A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

chore: refactored server package to use sqlite instead of redis

authored by stevedylan.dev and committed by tangled.org 7348ada4 594cd435

+105 -58
+3
.gitignore
··· 33 33 # Finder (MacOS) folder config 34 34 .DS_Store 35 35 36 + # SQLite data 37 + data/ 38 + 36 39 # Bun lockfile - keep but binary cache 37 40 bun.lockb 38 41 plans/
+1 -1
packages/server/.env.example
··· 1 1 CLIENT_URL=https://your-domain.com 2 2 CLIENT_NAME=Sequoia 3 3 PORT=3000 4 - REDIS_URL=redis://redis:6379 4 + DATABASE_PATH=./data/sequoia.db 5 5 6 6 # Theme overrides (optional) 7 7 # THEME_ACCENT_COLOR=#3A5A40
+3 -8
packages/server/docker-compose.yml
··· 7 7 - CLIENT_URL=${CLIENT_URL} 8 8 - CLIENT_NAME=${CLIENT_NAME:-Sequoia} 9 9 - PORT=${PORT:-3000} 10 - - REDIS_URL=redis://redis:6379 10 + - DATABASE_PATH=${DATABASE_PATH:-/app/data/sequoia.db} 11 11 - THEME_ACCENT_COLOR=${THEME_ACCENT_COLOR:-} 12 12 - THEME_BG_COLOR=${THEME_BG_COLOR:-} 13 13 - THEME_FG_COLOR=${THEME_FG_COLOR:-} ··· 20 20 - THEME_DARK_BORDER_COLOR=${THEME_DARK_BORDER_COLOR:-} 21 21 - THEME_DARK_ERROR_COLOR=${THEME_DARK_ERROR_COLOR:-} 22 22 - THEME_CSS_PATH=${THEME_CSS_PATH:-} 23 - depends_on: 24 - - redis 25 - 26 - redis: 27 - image: redis:7 28 23 volumes: 29 - - redis-data:/data 24 + - sequoia-data:/app/data 30 25 31 26 volumes: 32 - redis-data: 27 + sequoia-data:
+2 -2
packages/server/src/env.ts
··· 2 2 CLIENT_URL: string; 3 3 CLIENT_NAME: string; 4 4 PORT: number; 5 - REDIS_URL: string; 5 + DATABASE_PATH: string; 6 6 } 7 7 8 8 export function loadEnv(): Env { ··· 15 15 CLIENT_URL: CLIENT_URL.replace(/\/+$/, ""), 16 16 CLIENT_NAME: process.env.CLIENT_NAME || "Sequoia", 17 17 PORT: Number(process.env.PORT) || 3000, 18 - REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379", 18 + DATABASE_PATH: process.env.DATABASE_PATH || "./data/sequoia.db", 19 19 }; 20 20 }
+5 -5
packages/server/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { cors } from "hono/cors"; 3 - import { RedisClient } from "bun"; 4 3 import { loadEnv } from "./env"; 5 4 import type { Env } from "./env"; 5 + import { openDatabase } from "./lib/db"; 6 6 import auth from "./routes/auth"; 7 7 import subscribe from "./routes/subscribe"; 8 8 9 9 const env = loadEnv(); 10 10 11 - const redis = new RedisClient(env.REDIS_URL); 11 + const db = openDatabase(env.DATABASE_PATH); 12 12 13 - type Variables = { env: Env; redis: typeof redis }; 13 + type Variables = { env: Env; db: typeof db }; 14 14 15 15 const app = new Hono<{ Variables: Variables }>(); 16 16 17 - // Inject env and redis into all routes 17 + // Inject env and db into all routes 18 18 app.use("*", async (c, next) => { 19 19 c.set("env", env); 20 - c.set("redis", redis); 20 + c.set("db", db); 21 21 await next(); 22 22 }); 23 23
+53
packages/server/src/lib/db.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { mkdirSync } from "node:fs"; 3 + import { dirname } from "node:path"; 4 + 5 + export function openDatabase(path: string): Database { 6 + mkdirSync(dirname(path), { recursive: true }); 7 + 8 + const db = new Database(path); 9 + db.run("PRAGMA journal_mode = WAL"); 10 + db.run(` 11 + CREATE TABLE IF NOT EXISTS kv ( 12 + key TEXT PRIMARY KEY, 13 + value TEXT NOT NULL, 14 + expires_at INTEGER 15 + ) 16 + `); 17 + return db; 18 + } 19 + 20 + export function kvGet(db: Database, key: string): string | undefined { 21 + const row = db 22 + .query<{ value: string; expires_at: number | null }, [string]>( 23 + "SELECT value, expires_at FROM kv WHERE key = ?", 24 + ) 25 + .get(key); 26 + 27 + if (!row) return undefined; 28 + 29 + if (row.expires_at !== null && row.expires_at <= Date.now()) { 30 + db.run("DELETE FROM kv WHERE key = ?", [key]); 31 + return undefined; 32 + } 33 + 34 + return row.value; 35 + } 36 + 37 + export function kvSet( 38 + db: Database, 39 + key: string, 40 + value: string, 41 + ttlSeconds?: number, 42 + ): void { 43 + const expiresAt = 44 + ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null; 45 + db.run( 46 + "INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", 47 + [key, value, expiresAt], 48 + ); 49 + } 50 + 51 + export function kvDel(db: Database, key: string): void { 52 + db.run("DELETE FROM kv WHERE key = ?", [key]); 53 + }
+5 -5
packages/server/src/lib/oauth-client.ts
··· 1 1 import { JoseKey } from "@atproto/jwk-jose"; 2 2 import { OAuthClient } from "@atproto/oauth-client"; 3 3 import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; 4 - import type { RedisClient } from "bun"; 5 - import { createStateStore, createSessionStore } from "./redis-stores"; 4 + import type { Database } from "bun:sqlite"; 5 + import { createStateStore, createSessionStore } from "./stores"; 6 6 7 7 export const OAUTH_SCOPE = 8 8 "atproto repo:site.standard.graph.subscription?action=create&action=delete"; 9 9 10 10 export function createOAuthClient( 11 - redis: RedisClient, 11 + db: Database, 12 12 clientUrl: string, 13 13 clientName = "Sequoia", 14 14 ) { ··· 47 47 }, 48 48 requestLock: <T>(_name: string, fn: () => T | PromiseLike<T>) => fn(), 49 49 }, 50 - stateStore: createStateStore(redis), 51 - sessionStore: createSessionStore(redis), 50 + stateStore: createStateStore(db), 51 + sessionStore: createSessionStore(db), 52 52 }); 53 53 }
+10 -13
packages/server/src/lib/redis-stores.ts packages/server/src/lib/stores.ts
··· 5 5 SessionStore, 6 6 StateStore, 7 7 } from "@atproto/oauth-client"; 8 - import { RedisClient } from "bun"; 8 + import type { Database } from "bun:sqlite"; 9 + import { kvGet, kvSet, kvDel } from "./db"; 9 10 10 11 type SerializedStateData = Omit<InternalStateData, "dpopKey"> & { 11 12 dpopJwk: Record<string, unknown>; ··· 25 26 return JoseKey.fromJWK(jwk) as unknown as Key; 26 27 } 27 28 28 - export function createStateStore(redis: RedisClient, ttl = 600): StateStore { 29 + export function createStateStore(db: Database, ttl = 600): StateStore { 29 30 return { 30 31 async set(key, { dpopKey, ...rest }) { 31 32 const data: SerializedStateData = { 32 33 ...rest, 33 34 dpopJwk: serializeKey(dpopKey), 34 35 }; 35 - const redisKey = `oauth_state:${key}`; 36 - await redis.set(redisKey, JSON.stringify(data)); 37 - await redis.expire(redisKey, ttl); 36 + kvSet(db, `oauth_state:${key}`, JSON.stringify(data), ttl); 38 37 }, 39 38 async get(key) { 40 - const raw = await redis.get(`oauth_state:${key}`); 39 + const raw = kvGet(db, `oauth_state:${key}`); 41 40 if (!raw) return undefined; 42 41 const { dpopJwk, ...rest }: SerializedStateData = JSON.parse(raw); 43 42 const dpopKey = await deserializeKey(dpopJwk); 44 43 return { ...rest, dpopKey }; 45 44 }, 46 45 async del(key) { 47 - await redis.del(`oauth_state:${key}`); 46 + kvDel(db, `oauth_state:${key}`); 48 47 }, 49 48 }; 50 49 } 51 50 52 51 export function createSessionStore( 53 - redis: RedisClient, 52 + db: Database, 54 53 ttl = 60 * 60 * 24 * 14, 55 54 ): SessionStore { 56 55 return { ··· 59 58 ...rest, 60 59 dpopJwk: serializeKey(dpopKey), 61 60 }; 62 - const redisKey = `oauth_session:${sub}`; 63 - await redis.set(redisKey, JSON.stringify(data)); 64 - await redis.expire(redisKey, ttl); 61 + kvSet(db, `oauth_session:${sub}`, JSON.stringify(data), ttl); 65 62 }, 66 63 async get(sub) { 67 - const raw = await redis.get(`oauth_session:${sub}`); 64 + const raw = kvGet(db, `oauth_session:${sub}`); 68 65 if (!raw) return undefined; 69 66 const { dpopJwk, ...rest }: SerializedSession = JSON.parse(raw); 70 67 const dpopKey = await deserializeKey(dpopJwk); 71 68 return { ...rest, dpopKey }; 72 69 }, 73 70 async del(sub) { 74 - await redis.del(`oauth_session:${sub}`); 71 + kvDel(db, `oauth_session:${sub}`); 75 72 }, 76 73 }; 77 74 }
+15 -16
packages/server/src/routes/auth.ts
··· 1 1 import { Hono } from "hono"; 2 - import type { RedisClient } from "bun"; 2 + import type { Database } from "bun:sqlite"; 3 3 import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; 4 + import { kvGet, kvSet, kvDel } from "../lib/db"; 4 5 import { 5 6 getSessionDid, 6 7 setSessionCookie, ··· 10 11 } from "../lib/session"; 11 12 import type { Env } from "../env"; 12 13 13 - type Variables = { env: Env; redis: RedisClient }; 14 + type Variables = { env: Env; db: Database }; 14 15 15 16 const auth = new Hono<{ Variables: Variables }>(); 16 17 ··· 37 38 // Start OAuth login flow 38 39 auth.get("/login", async (c) => { 39 40 const env = c.get("env"); 40 - const redis = c.get("redis"); 41 + const db = c.get("db"); 41 42 42 43 try { 43 44 const handle = c.req.query("handle"); ··· 45 46 return c.redirect(`${env.CLIENT_URL}/?error=missing_handle`); 46 47 } 47 48 48 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 49 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 49 50 const authUrl = await client.authorize(handle, { 50 51 scope: OAUTH_SCOPE, 51 52 }); ··· 60 61 // OAuth callback handler 61 62 auth.get("/callback", async (c) => { 62 63 const env = c.get("env"); 63 - const redis = c.get("redis"); 64 + const db = c.get("db"); 64 65 65 66 try { 66 67 const params = new URLSearchParams(c.req.url.split("?")[1] || ""); ··· 73 74 ); 74 75 } 75 76 76 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 77 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 77 78 const { session } = await client.callback(params); 78 79 79 80 // Resolve handle from DID ··· 85 86 // Handle resolution is best-effort 86 87 } 87 88 88 - // Store handle in Redis alongside the session for quick lookup 89 + // Store handle alongside the session for quick lookup 89 90 if (handle) { 90 - const key = `oauth_handle:${session.did}`; 91 - await redis.set(key, handle); 92 - await redis.expire(key, 60 * 60 * 24 * 14); 91 + kvSet(db, `oauth_handle:${session.did}`, handle, 60 * 60 * 24 * 14); 93 92 } 94 93 95 94 setSessionCookie(c, session.did, env.CLIENT_URL); ··· 108 107 // Logout endpoint 109 108 auth.post("/logout", async (c) => { 110 109 const env = c.get("env"); 111 - const redis = c.get("redis"); 110 + const db = c.get("db"); 112 111 const did = getSessionDid(c); 113 112 114 113 if (did) { 115 114 try { 116 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 115 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 117 116 await client.revoke(did); 118 117 } catch (error) { 119 118 console.error("Revoke error:", error); 120 119 } 121 - await redis.del(`oauth_handle:${did}`); 120 + kvDel(db, `oauth_handle:${did}`); 122 121 } 123 122 124 123 clearSessionCookie(c, env.CLIENT_URL); ··· 128 127 // Check auth status 129 128 auth.get("/status", async (c) => { 130 129 const env = c.get("env"); 131 - const redis = c.get("redis"); 130 + const db = c.get("db"); 132 131 const did = getSessionDid(c); 133 132 134 133 if (!did) { ··· 136 135 } 137 136 138 137 try { 139 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 138 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 140 139 const session = await client.restore(did); 141 140 142 - const handle = await redis.get(`oauth_handle:${session.did}`); 141 + const handle = kvGet(db, `oauth_handle:${session.did}`); 143 142 144 143 return c.json({ 145 144 authenticated: true,
+8 -8
packages/server/src/routes/subscribe.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 2 import { Hono } from "hono"; 3 - import type { RedisClient } from "bun"; 3 + import type { Database } from "bun:sqlite"; 4 4 import { createOAuthClient } from "../lib/oauth-client"; 5 5 import { getSessionDid, setReturnToCookie } from "../lib/session"; 6 6 import { page, escapeHtml } from "../lib/theme"; 7 7 import type { Env } from "../env"; 8 8 9 - type Variables = { env: Env; redis: RedisClient }; 9 + type Variables = { env: Env; db: Database }; 10 10 11 11 const subscribe = new Hono<{ Variables: Variables }>(); 12 12 ··· 66 66 67 67 subscribe.post("/", async (c) => { 68 68 const env = c.get("env"); 69 - const redis = c.get("redis"); 69 + const db = c.get("db"); 70 70 71 71 let publicationUri: string; 72 72 try { ··· 87 87 } 88 88 89 89 try { 90 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 90 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 91 91 const session = await client.restore(did); 92 92 const agent = new Agent(session); 93 93 ··· 131 131 132 132 subscribe.get("/", async (c) => { 133 133 const env = c.get("env"); 134 - const redis = c.get("redis"); 134 + const db = c.get("db"); 135 135 136 136 const publicationUri = c.req.query("publicationUri"); 137 137 const action = c.req.query("action"); ··· 157 157 } 158 158 159 159 try { 160 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 160 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 161 161 const session = await client.restore(did); 162 162 const agent = new Agent(session); 163 163 ··· 256 256 257 257 subscribe.get("/check", async (c) => { 258 258 const env = c.get("env"); 259 - const redis = c.get("redis"); 259 + const db = c.get("db"); 260 260 261 261 const publicationUri = c.req.query("publicationUri"); 262 262 ··· 270 270 } 271 271 272 272 try { 273 - const client = createOAuthClient(redis, env.CLIENT_URL, env.CLIENT_NAME); 273 + const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME); 274 274 const session = await client.restore(did); 275 275 const agent = new Agent(session); 276 276 const recordUri = await findExistingSubscription(