+105
-58
Diff
round #0
+3
.gitignore
+3
.gitignore
+1
-1
packages/server/.env.example
+1
-1
packages/server/.env.example
+3
-8
packages/server/docker-compose.yml
+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
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
+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
+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
+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
+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
+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
+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(
History
1 round
0 comments
stevedylan.dev
submitted
#0
1 commit
expand
collapse
chore: refactored server package to use sqlite instead of redis
1/1 failed
expand
collapse
expand 0 comments
pull request successfully merged