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