+4
-1
frontend/Dockerfile
+4
-1
frontend/Dockerfile
+10
frontend/deno.lock
+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
+1
-1
frontend/flake.nix
+1
frontend/fly.toml
+1
frontend/fly.toml
-3
frontend/src/client.ts
-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
+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
-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
+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
+3
-4
frontend/src/lib/stores.ts
+2
-4
frontend/src/routes/pages.tsx
+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