Highly ambitious ATProtocol AppView service and sdks

add experimental valtown sqlite session/oauth adapters

Changed files
+338 -3
packages
oauth
session
+4 -1
packages/oauth/deno.json
··· 1 1 { 2 2 "name": "@slices/oauth", 3 - "version": "0.6.0", 3 + "version": "0.7.0-alpha.4", 4 4 "exports": { 5 5 ".": "./mod.ts" 6 + }, 7 + "imports": { 8 + "@libsql/client": "npm:@libsql/client@0.6.0" 6 9 }, 7 10 "compilerOptions": { 8 11 "lib": ["deno.ns", "deno.unstable", "dom", "dom.iterable", "esnext"]
+1
packages/oauth/mod.ts
··· 3 3 export { DeviceFlowClient } from "./src/device.ts"; 4 4 export { DenoKVOAuthStorage } from "./src/storage/deno-kv.ts"; 5 5 export { SQLiteOAuthStorage } from "./src/storage/sqlite.ts"; 6 + export { ValTownSQLiteOAuthStorage } from "./src/storage/valtown-sqlite.ts"; 6 7 export type { 7 8 OAuthConfig, 8 9 OAuthTokens,
+149
packages/oauth/src/storage/valtown-sqlite.ts
··· 1 + import type { OAuthStorage, OAuthTokens } from "../types.ts"; 2 + import type { InStatement, TransactionMode } from "@libsql/client"; 3 + 4 + // Val Town's SQLite ResultSet (doesn't have toJSON method) 5 + interface ValTownResultSet { 6 + columns: string[]; 7 + columnTypes: string[]; 8 + rows: unknown[][]; 9 + rowsAffected: number; 10 + lastInsertRowid?: bigint; 11 + } 12 + 13 + interface SQLiteInstance { 14 + execute(statement: InStatement): Promise<ValTownResultSet>; 15 + batch( 16 + statements: InStatement[], 17 + mode?: TransactionMode, 18 + ): Promise<ValTownResultSet[]>; 19 + } 20 + 21 + export class ValTownSQLiteOAuthStorage implements OAuthStorage { 22 + private sqlite: SQLiteInstance; 23 + 24 + constructor(sqlite: SQLiteInstance) { 25 + this.sqlite = sqlite; 26 + this.initTables(); 27 + } 28 + 29 + private async initTables(): Promise<void> { 30 + // Create tokens table 31 + await this.sqlite.execute(` 32 + CREATE TABLE IF NOT EXISTS oauth_tokens ( 33 + id INTEGER PRIMARY KEY, 34 + session_id TEXT, 35 + access_token TEXT NOT NULL, 36 + token_type TEXT NOT NULL, 37 + expires_at INTEGER, 38 + refresh_token TEXT, 39 + scope TEXT, 40 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), 41 + UNIQUE(session_id) 42 + ) 43 + `); 44 + 45 + // Create states table with automatic cleanup 46 + await this.sqlite.execute(` 47 + CREATE TABLE IF NOT EXISTS oauth_states ( 48 + state TEXT PRIMARY KEY, 49 + code_verifier TEXT NOT NULL, 50 + timestamp INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) 51 + ) 52 + `); 53 + 54 + // Create index for cleanup efficiency 55 + await this.sqlite.execute(` 56 + CREATE INDEX IF NOT EXISTS idx_oauth_states_timestamp ON oauth_states(timestamp) 57 + `); 58 + } 59 + 60 + async getTokens(sessionId: string): Promise<OAuthTokens | null> { 61 + const result = await this.sqlite.execute({ 62 + sql: `SELECT access_token, token_type, expires_at, refresh_token, scope 63 + FROM oauth_tokens 64 + WHERE session_id = ? 65 + LIMIT 1`, 66 + args: [sessionId], 67 + }); 68 + 69 + if (result.rows.length === 0) return null; 70 + 71 + const row = result.rows[0]; 72 + 73 + return { 74 + accessToken: row[0] as string, 75 + tokenType: row[1] as string, 76 + expiresAt: (row[2] as number | null) ?? undefined, 77 + refreshToken: (row[3] as string | null) ?? undefined, 78 + scope: (row[4] as string | null) ?? undefined, 79 + }; 80 + } 81 + 82 + async setTokens(tokens: OAuthTokens, sessionId: string): Promise<void> { 83 + await this.clearTokens(sessionId); 84 + 85 + await this.sqlite.execute({ 86 + sql: 87 + `INSERT INTO oauth_tokens (session_id, access_token, token_type, expires_at, refresh_token, scope) 88 + VALUES (?, ?, ?, ?, ?, ?)`, 89 + args: [ 90 + sessionId, 91 + tokens.accessToken, 92 + tokens.tokenType, 93 + tokens.expiresAt ?? null, 94 + tokens.refreshToken ?? null, 95 + tokens.scope ?? null, 96 + ], 97 + }); 98 + } 99 + 100 + async clearTokens(sessionId: string): Promise<void> { 101 + await this.sqlite.execute({ 102 + sql: "DELETE FROM oauth_tokens WHERE session_id = ?", 103 + args: [sessionId], 104 + }); 105 + } 106 + 107 + async getState(state: string): Promise<string | null> { 108 + const result = await this.sqlite.execute({ 109 + sql: "SELECT code_verifier FROM oauth_states WHERE state = ?", 110 + args: [state], 111 + }); 112 + 113 + if (result.rows.length === 0) return null; 114 + 115 + const codeVerifier = result.rows[0][0] as string; 116 + 117 + // Delete after use (one-time use) 118 + await this.clearState(state); 119 + 120 + return codeVerifier; 121 + } 122 + 123 + async setState(state: string, codeVerifier: string): Promise<void> { 124 + await this.sqlite.execute({ 125 + sql: 126 + `INSERT OR REPLACE INTO oauth_states (state, code_verifier, timestamp) 127 + VALUES (?, ?, ?)`, 128 + args: [state, codeVerifier, Date.now()], 129 + }); 130 + 131 + // Auto-cleanup expired states 132 + await this.cleanup(); 133 + } 134 + 135 + async clearState(state: string): Promise<void> { 136 + await this.sqlite.execute({ 137 + sql: "DELETE FROM oauth_states WHERE state = ?", 138 + args: [state], 139 + }); 140 + } 141 + 142 + private async cleanup(): Promise<void> { 143 + const cutoff = Date.now() - (10 * 60 * 1000); // 10 minutes ago 144 + await this.sqlite.execute({ 145 + sql: "DELETE FROM oauth_states WHERE timestamp < ?", 146 + args: [cutoff], 147 + }); 148 + } 149 + }
+3 -2
packages/session/deno.json
··· 1 1 { 2 2 "name": "@slices/session", 3 - "version": "0.3.0", 3 + "version": "0.4.0-alpha.4", 4 4 "exports": "./mod.ts", 5 5 "compilerOptions": { 6 6 "lib": ["deno.ns", "deno.unstable", "esnext", "dom"] ··· 10 10 "check": "deno check mod.ts" 11 11 }, 12 12 "imports": { 13 - "pg": "npm:pg@^8.16.3" 13 + "pg": "npm:pg@^8.16.3", 14 + "@libsql/client": "npm:@libsql/client@0.6.0" 14 15 } 15 16 }
+1
packages/session/mod.ts
··· 1 1 export { SessionStore } from "./src/store.ts"; 2 2 export { MemoryAdapter } from "./src/adapters/memory.ts"; 3 3 export { SQLiteAdapter } from "./src/adapters/sqlite.ts"; 4 + export { ValTownSQLiteAdapter } from "./src/adapters/valtown-sqlite.ts"; 4 5 export { PostgresAdapter } from "./src/adapters/postgres.ts"; 5 6 export { DenoKVAdapter } from "./src/adapters/deno-kv.ts"; 6 7 export { withOAuthSession } from "./src/oauth-integration.ts";
+180
packages/session/src/adapters/valtown-sqlite.ts
··· 1 + import type { SessionAdapter, SessionData } from "../types.ts"; 2 + import type { InStatement, InValue, TransactionMode } from "@libsql/client"; 3 + 4 + // Val Town's SQLite ResultSet (doesn't have toJSON method) 5 + interface ValTownResultSet { 6 + columns: string[]; 7 + columnTypes: string[]; 8 + rows: unknown[][]; 9 + rowsAffected: number; 10 + lastInsertRowid?: bigint; 11 + } 12 + 13 + interface SQLiteInstance { 14 + execute(statement: InStatement): Promise<ValTownResultSet>; 15 + batch(statements: InStatement[], mode?: TransactionMode): Promise<ValTownResultSet[]>; 16 + } 17 + 18 + export class ValTownSQLiteAdapter implements SessionAdapter { 19 + private sqlite: SQLiteInstance; 20 + 21 + constructor(sqlite: SQLiteInstance) { 22 + this.sqlite = sqlite; 23 + this.initializeDatabase(); 24 + } 25 + 26 + private async initializeDatabase(): Promise<void> { 27 + await this.sqlite.execute(` 28 + CREATE TABLE IF NOT EXISTS sessions ( 29 + session_id TEXT PRIMARY KEY, 30 + user_id TEXT NOT NULL, 31 + handle TEXT, 32 + is_authenticated INTEGER NOT NULL DEFAULT 1, 33 + data TEXT, -- JSON string 34 + created_at INTEGER NOT NULL, 35 + expires_at INTEGER NOT NULL, 36 + last_accessed_at INTEGER NOT NULL 37 + ) 38 + `); 39 + 40 + // Index for cleanup operations 41 + await this.sqlite.execute(` 42 + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at 43 + ON sessions(expires_at) 44 + `); 45 + 46 + // Index for user lookups 47 + await this.sqlite.execute(` 48 + CREATE INDEX IF NOT EXISTS idx_sessions_user_id 49 + ON sessions(user_id) 50 + `); 51 + } 52 + 53 + async get(sessionId: string): Promise<SessionData | null> { 54 + const result = await this.sqlite.execute({ 55 + sql: `SELECT session_id, user_id, handle, is_authenticated, data, created_at, expires_at, last_accessed_at 56 + FROM sessions 57 + WHERE session_id = ?`, 58 + args: [sessionId] 59 + }); 60 + 61 + if (result.rows.length === 0) return null; 62 + 63 + return this.rowToSessionData(result.rows[0]); 64 + } 65 + 66 + async set(sessionId: string, data: SessionData): Promise<void> { 67 + await this.sqlite.execute({ 68 + sql: `INSERT OR REPLACE INTO sessions 69 + (session_id, user_id, handle, is_authenticated, data, created_at, expires_at, last_accessed_at) 70 + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 71 + args: [ 72 + sessionId, 73 + data.userId, 74 + data.handle ?? null, 75 + data.isAuthenticated ? 1 : 0, 76 + data.data ? JSON.stringify(data.data) : null, 77 + data.createdAt, 78 + data.expiresAt, 79 + data.lastAccessedAt, 80 + ] 81 + }); 82 + } 83 + 84 + async update(sessionId: string, updates: Partial<SessionData>): Promise<boolean> { 85 + const setParts: string[] = []; 86 + const values: InValue[] = []; 87 + 88 + if (updates.userId !== undefined) { 89 + setParts.push("user_id = ?"); 90 + values.push(updates.userId); 91 + } 92 + 93 + if (updates.handle !== undefined) { 94 + setParts.push("handle = ?"); 95 + values.push(updates.handle ?? null); 96 + } 97 + 98 + if (updates.isAuthenticated !== undefined) { 99 + setParts.push("is_authenticated = ?"); 100 + values.push(updates.isAuthenticated ? 1 : 0); 101 + } 102 + 103 + if (updates.data !== undefined) { 104 + setParts.push("data = ?"); 105 + values.push(updates.data ? JSON.stringify(updates.data) : null); 106 + } 107 + 108 + if (updates.expiresAt !== undefined) { 109 + setParts.push("expires_at = ?"); 110 + values.push(updates.expiresAt); 111 + } 112 + 113 + if (updates.lastAccessedAt !== undefined) { 114 + setParts.push("last_accessed_at = ?"); 115 + values.push(updates.lastAccessedAt); 116 + } 117 + 118 + if (setParts.length === 0) return false; 119 + 120 + // Add sessionId as the last parameter for WHERE clause 121 + values.push(sessionId); 122 + 123 + const result = await this.sqlite.execute({ 124 + sql: `UPDATE sessions 125 + SET ${setParts.join(", ")} 126 + WHERE session_id = ?`, 127 + args: values 128 + }); 129 + 130 + return result.rowsAffected > 0; 131 + } 132 + 133 + async delete(sessionId: string): Promise<void> { 134 + await this.sqlite.execute({ 135 + sql: "DELETE FROM sessions WHERE session_id = ?", 136 + args: [sessionId] 137 + }); 138 + } 139 + 140 + async cleanup(expiresBeforeMs: number): Promise<number> { 141 + const result = await this.sqlite.execute({ 142 + sql: "DELETE FROM sessions WHERE expires_at < ?", 143 + args: [expiresBeforeMs] 144 + }); 145 + return result.rowsAffected; 146 + } 147 + 148 + async exists(sessionId: string): Promise<boolean> { 149 + const result = await this.sqlite.execute({ 150 + sql: "SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1", 151 + args: [sessionId] 152 + }); 153 + return result.rows.length > 0; 154 + } 155 + 156 + private rowToSessionData(row: unknown[]): SessionData { 157 + return { 158 + sessionId: row[0] as string, 159 + userId: row[1] as string, 160 + handle: (row[2] as string | null) ?? undefined, 161 + isAuthenticated: Boolean(row[3] as number), 162 + data: row[4] ? JSON.parse(row[4] as string) : undefined, 163 + createdAt: row[5] as number, 164 + expiresAt: row[6] as number, 165 + lastAccessedAt: row[7] as number, 166 + }; 167 + } 168 + 169 + // Val Town SQLite-specific methods 170 + async getSessionsByUser(userId: string): Promise<SessionData[]> { 171 + const result = await this.sqlite.execute({ 172 + sql: `SELECT session_id, user_id, handle, is_authenticated, data, created_at, expires_at, last_accessed_at 173 + FROM sessions 174 + WHERE user_id = ?`, 175 + args: [userId] 176 + }); 177 + 178 + return result.rows.map(row => this.rowToSessionData(row)); 179 + } 180 + }