+2
-4
api/scripts/generate-typescript.ts
+2
-4
api/scripts/generate-typescript.ts
···
298
298
],
299
299
});
300
300
301
-
sourceFile.addInterface({
301
+
sourceFile.addTypeAlias({
302
302
name: "GetJobHistoryResponse",
303
303
isExported: true,
304
-
properties: [
305
-
{ name: "jobs", type: "JobStatus[]" },
306
-
],
304
+
type: "JobStatus[]",
307
305
});
308
306
309
307
sourceFile.addInterface({
+2
-2
frontend/deno.json
+2
-2
frontend/deno.json
···
15
15
},
16
16
"compilerOptions": {
17
17
"jsx": "precompile",
18
-
"jsxImportSource": "preact",
19
-
"types": ["./globals.d.ts"]
18
+
"jsxImportSource": "preact"
20
19
},
21
20
"imports": {
22
21
"@slices/oauth": "jsr:@slices/oauth@^0.3.0",
22
+
"@slices/session": "jsr:@slices/session@^0.1.0",
23
23
"@std/assert": "jsr:@std/assert@^1.0.14",
24
24
"preact": "npm:preact@^10.27.1",
25
25
"preact-render-to-string": "npm:preact-render-to-string@^6.5.13",
+79
frontend/deno.lock
+79
frontend/deno.lock
···
3
3
"specifiers": {
4
4
"jsr:@shikijs/shiki@*": "3.7.0",
5
5
"jsr:@slices/oauth@0.3": "0.3.0",
6
+
"jsr:@slices/session@0.1": "0.1.0",
6
7
"jsr:@std/assert@^1.0.14": "1.0.14",
7
8
"jsr:@std/cli@^1.0.21": "1.0.21",
8
9
"jsr:@std/encoding@^1.0.10": "1.0.10",
···
19
20
"npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0",
20
21
"npm:@shikijs/types@^3.7.0": "3.11.0",
21
22
"npm:@types/node@*": "22.15.15",
23
+
"npm:pg@^8.11.0": "8.16.3",
24
+
"npm:pg@^8.16.3": "8.16.3",
22
25
"npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1",
23
26
"npm:preact@^10.27.1": "10.27.1",
24
27
"npm:shiki@^3.7.0": "3.11.0",
28
+
"npm:typed-htmx@*": "0.3.1",
25
29
"npm:typed-htmx@~0.3.1": "0.3.1"
26
30
},
27
31
"jsr": {
···
35
39
},
36
40
"@slices/oauth@0.3.0": {
37
41
"integrity": "a6f3296e701291f14b4c8491a7f7a86bd3c8d5caf006eb1d371627558439e3b5"
42
+
},
43
+
"@slices/session@0.1.0": {
44
+
"integrity": "63a4e35d70dcb2bb58e6117fdccf308f4a86cd9d94cf99412a3de9d35862cabc",
45
+
"dependencies": [
46
+
"npm:pg@^8.16.3"
47
+
]
38
48
},
39
49
"@std/assert@1.0.14": {
40
50
"integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4",
···
256
266
"regex-recursion"
257
267
]
258
268
},
269
+
"pg-cloudflare@1.2.7": {
270
+
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="
271
+
},
272
+
"pg-connection-string@2.9.1": {
273
+
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="
274
+
},
275
+
"pg-int8@1.0.1": {
276
+
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
277
+
},
278
+
"pg-pool@3.10.1_pg@8.16.3": {
279
+
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
280
+
"dependencies": [
281
+
"pg"
282
+
]
283
+
},
284
+
"pg-protocol@1.10.3": {
285
+
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="
286
+
},
287
+
"pg-types@2.2.0": {
288
+
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
289
+
"dependencies": [
290
+
"pg-int8",
291
+
"postgres-array",
292
+
"postgres-bytea",
293
+
"postgres-date",
294
+
"postgres-interval"
295
+
]
296
+
},
297
+
"pg@8.16.3": {
298
+
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
299
+
"dependencies": [
300
+
"pg-connection-string",
301
+
"pg-pool",
302
+
"pg-protocol",
303
+
"pg-types",
304
+
"pgpass"
305
+
],
306
+
"optionalDependencies": [
307
+
"pg-cloudflare"
308
+
]
309
+
},
310
+
"pgpass@1.0.5": {
311
+
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
312
+
"dependencies": [
313
+
"split2"
314
+
]
315
+
},
316
+
"postgres-array@2.0.0": {
317
+
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
318
+
},
319
+
"postgres-bytea@1.0.0": {
320
+
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="
321
+
},
322
+
"postgres-date@1.0.7": {
323
+
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="
324
+
},
325
+
"postgres-interval@1.2.0": {
326
+
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
327
+
"dependencies": [
328
+
"xtend"
329
+
]
330
+
},
259
331
"preact-render-to-string@6.5.13_preact@10.27.1": {
260
332
"integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==",
261
333
"dependencies": [
···
298
370
},
299
371
"space-separated-tokens@2.0.2": {
300
372
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="
373
+
},
374
+
"split2@4.2.0": {
375
+
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
301
376
},
302
377
"stringify-entities@4.0.4": {
303
378
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
···
368
443
"vfile-message"
369
444
]
370
445
},
446
+
"xtend@4.0.2": {
447
+
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
448
+
},
371
449
"zwitch@2.0.4": {
372
450
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="
373
451
}
···
375
453
"workspace": {
376
454
"dependencies": [
377
455
"jsr:@slices/oauth@0.3",
456
+
"jsr:@slices/session@0.1",
378
457
"jsr:@std/assert@^1.0.14",
379
458
"jsr:@std/http@^1.0.20",
380
459
"npm:preact-render-to-string@^6.5.13",
+1
-3
frontend/globals.d.ts
+1
-3
frontend/globals.d.ts
+2
-4
frontend/src/client.ts
+2
-4
frontend/src/client.ts
···
1
1
// Generated TypeScript client for AT Protocol records
2
-
// Generated at: 2025-08-26 18:27:23 UTC
2
+
// Generated at: 2025-08-27 05:10:34 UTC
3
3
// Lexicons: 3
4
4
5
5
/**
···
128
128
limit?: number;
129
129
}
130
130
131
-
export interface GetJobHistoryResponse {
132
-
jobs: JobStatus[];
133
-
}
131
+
export type GetJobHistoryResponse = JobStatus[];
134
132
135
133
export interface CollectionStats {
136
134
collection: string;
+19
-1
frontend/src/config.ts
+19
-1
frontend/src/config.ts
···
1
1
import { AtProtoClient } from "./client.ts";
2
2
import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth";
3
+
import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session";
3
4
4
5
const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID");
5
6
const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET");
···
23
24
}
24
25
25
26
const DATABASE_URL = Deno.env.get("DATABASE_URL") || "slices.db";
27
+
28
+
// OAuth setup
26
29
const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL);
27
-
28
30
const oauthClient = new OAuthClient(
29
31
{
30
32
clientId: OAUTH_CLIENT_ID,
···
41
43
},
42
44
oauthStorage
43
45
);
46
+
47
+
// Session setup (shared database)
48
+
export const sessionStore = new SessionStore({
49
+
adapter: new SQLiteAdapter(DATABASE_URL),
50
+
cookieOptions: {
51
+
httpOnly: true,
52
+
secure: Deno.env.get("DENO_ENV") === "production",
53
+
sameSite: "lax",
54
+
path: "/",
55
+
},
56
+
});
57
+
58
+
// OAuth + Session integration
59
+
export const oauthSessions = withOAuthSession(sessionStore, oauthClient, {
60
+
autoRefresh: true,
61
+
});
44
62
45
63
export const atprotoClient = new AtProtoClient(API_URL, SLICE_URI, oauthClient);
-254
frontend/src/lib/session-store.ts
-254
frontend/src/lib/session-store.ts
···
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;
7
-
handle?: string;
8
-
isAuthenticated: boolean;
9
-
createdAt: number;
10
-
}
11
-
12
-
interface UserData {
13
-
handle?: string;
14
-
sub?: string;
15
-
isAuthenticated: boolean;
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 =
151
-
(await atprotoClient.oauth?.isAuthenticated()) || false;
152
-
153
-
if (userInfo && isAuthenticated) {
154
-
return {
155
-
handle: userInfo.name || undefined, // Use 'name' field which contains the handle
156
-
sub: userInfo.sub,
157
-
isAuthenticated: true,
158
-
};
159
-
}
160
-
return { isAuthenticated: false };
161
-
}
162
-
163
-
// Get current user from request
164
-
async getCurrentUser(
165
-
req: Request
166
-
): Promise<{ handle?: string; sub?: string; isAuthenticated: boolean }> {
167
-
const sessionId = getSessionIdFromRequest(req);
168
-
169
-
if (!sessionId) {
170
-
return { isAuthenticated: false };
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
-
202
-
return {
203
-
handle: sessionData.handle,
204
-
sub: sessionData.userDid,
205
-
isAuthenticated: true,
206
-
};
207
-
} catch (error) {
208
-
console.error("Session validation error:", error);
209
-
return { isAuthenticated: false };
210
-
}
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
-
}
230
-
231
-
// Utility function to extract session ID from request
232
-
export function getSessionIdFromRequest(request: Request): string | null {
233
-
const cookies = request.headers.get("cookie") || "";
234
-
const sessionCookie = cookies
235
-
.split("; ")
236
-
.find((row) => row.startsWith("session_id="));
237
-
238
-
if (!sessionCookie) {
239
-
return null;
240
-
}
241
-
242
-
return sessionCookie.split("=")[1];
243
-
}
244
-
245
-
// Utility function to create session cookie
246
-
export function createSessionCookie(sessionId: string): string {
247
-
// HttpOnly, Secure, SameSite=Strict cookie with 30 day expiration
248
-
return `session_id=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000`; // 30 days
249
-
}
250
-
251
-
// Utility function to clear session cookie
252
-
export function clearSessionCookie(): string {
253
-
return `session_id=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`;
254
-
}
-6
frontend/src/lib/stores.ts
-6
frontend/src/lib/stores.ts
+1
-1
frontend/src/routes/middleware.ts
+1
-1
frontend/src/routes/middleware.ts
+25
-16
frontend/src/routes/oauth.ts
+25
-16
frontend/src/routes/oauth.ts
···
1
1
import type { Route } from "@std/http/unstable-route";
2
-
import { atprotoClient } from "../config.ts";
3
-
import { sessionStore } from "../lib/stores.ts";
4
-
import {
5
-
createSessionCookie,
6
-
clearSessionCookie,
7
-
} from "../lib/session-store.ts";
2
+
import { atprotoClient, oauthSessions, sessionStore } from "../config.ts";
8
3
9
4
async function handleOAuthAuthorize(req: Request): Promise<Response> {
10
5
try {
···
72
67
state,
73
68
});
74
69
75
-
// Get user info from OAuth client
76
-
const userData = await sessionStore.getUserInfo();
70
+
// Create OAuth session with auto token management
71
+
const sessionId = await oauthSessions.createOAuthSession();
72
+
73
+
if (!sessionId) {
74
+
return Response.redirect(
75
+
new URL(
76
+
"/login?error=" + encodeURIComponent("Failed to create session"),
77
+
req.url
78
+
),
79
+
302
80
+
);
81
+
}
77
82
78
-
// Create new session and get session cookie
79
-
const sessionId = await sessionStore.createSessionFromUserData(userData);
80
-
const sessionCookie = createSessionCookie(sessionId);
83
+
// Create session cookie
84
+
const sessionCookie = sessionStore.createSessionCookie(sessionId);
81
85
82
86
return new Response(null, {
83
87
status: 302,
···
99
103
}
100
104
101
105
async function handleLogout(req: Request): Promise<Response> {
102
-
// Delete the session from KV store
103
-
await sessionStore.deleteSessionFromRequest(req);
106
+
// Get session from request
107
+
const session = await sessionStore.getSessionFromRequest(req);
108
+
109
+
if (session) {
110
+
// Use OAuth session manager to handle logout
111
+
await oauthSessions.logout(session.sessionId);
112
+
}
104
113
105
-
// Logout from OAuth client
106
-
await atprotoClient.oauth?.logout();
114
+
// Clear session cookie
115
+
const clearCookie = sessionStore.createLogoutCookie();
107
116
108
117
return new Response(null, {
109
118
status: 302,
110
119
headers: {
111
120
Location: new URL("/login", req.url).toString(),
112
-
"Set-Cookie": clearSessionCookie(),
121
+
"Set-Cookie": clearCookie,
113
122
},
114
123
});
115
124
}
+2
-1
frontend/src/routes/pages.tsx
+2
-1
frontend/src/routes/pages.tsx
···
369
369
return Response.redirect(new URL("/", req.url), 302);
370
370
}
371
371
372
-
// Get OAuth access token if available
372
+
// Get OAuth access token directly from OAuth client (clean separation)
373
373
let accessToken: string | undefined;
374
374
try {
375
+
// Tokens are managed by @slices/oauth, not stored in sessions
375
376
const tokens = await atprotoClient.oauth?.ensureValidToken();
376
377
accessToken = tokens?.accessToken;
377
378
} catch (error) {