my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1import { db } from "../db";
2import { validateProfileURL, verifyDomain } from "./indieauth";
3
4function getSessionUser(
5 req: Request,
6):
7 | { username: string; userId: number; is_admin: boolean; tier: string }
8 | Response {
9 const authHeader = req.headers.get("Authorization");
10
11 if (!authHeader || !authHeader.startsWith("Bearer ")) {
12 return Response.json({ error: "Unauthorized" }, { status: 401 });
13 }
14
15 const token = authHeader.substring(7);
16
17 // Look up session
18 const session = db
19 .query(
20 `SELECT s.expires_at, s.user_id, u.username, u.is_admin, u.tier, u.status
21 FROM sessions s
22 JOIN users u ON s.user_id = u.id
23 WHERE s.token = ?`,
24 )
25 .get(token) as
26 | {
27 expires_at: number;
28 user_id: number;
29 username: string;
30 is_admin: number;
31 tier: string;
32 status: string;
33 }
34 | undefined;
35
36 if (!session) {
37 return Response.json({ error: "Invalid session" }, { status: 401 });
38 }
39
40 const now = Math.floor(Date.now() / 1000);
41 if (session.expires_at < now) {
42 return Response.json({ error: "Session expired" }, { status: 401 });
43 }
44
45 if (session.status !== "active") {
46 return Response.json({ error: "Account is suspended" }, { status: 403 });
47 }
48
49 return {
50 username: session.username,
51 userId: session.user_id,
52 is_admin: session.is_admin === 1,
53 tier: session.tier,
54 };
55}
56
57export function hello(req: Request): Response {
58 const user = getSessionUser(req);
59 if (user instanceof Response) {
60 return user;
61 }
62
63 return Response.json({
64 message: `Hello ${user.username}! You're authenticated with passkeys.`,
65 id: user.userId,
66 username: user.username,
67 isAdmin: user.is_admin,
68 tier: user.tier,
69 });
70}
71
72export function listUsers(req: Request): Response {
73 const user = getSessionUser(req);
74 if (user instanceof Response) {
75 return user;
76 }
77
78 if (!user.is_admin) {
79 return Response.json({ error: "Admin access required" }, { status: 403 });
80 }
81
82 const users = db
83 .query(
84 `SELECT u.id, u.username, u.name, u.email, u.photo, u.status, u.role, u.tier, u.is_admin, u.created_at,
85 COUNT(c.id) as credential_count
86 FROM users u
87 LEFT JOIN credentials c ON u.id = c.user_id
88 GROUP BY u.id
89 ORDER BY u.created_at DESC`,
90 )
91 .all() as Array<{
92 id: number;
93 username: string;
94 name: string;
95 email: string | null;
96 photo: string | null;
97 status: string;
98 role: string;
99 tier: string;
100 is_admin: number;
101 created_at: number;
102 credential_count: number;
103 }>;
104
105 return Response.json({
106 users: users.map((u) => ({
107 id: u.id,
108 username: u.username,
109 name: u.name,
110 email: u.email,
111 photo: u.photo,
112 status: u.status,
113 role: u.role,
114 tier: u.tier,
115 isAdmin: u.is_admin === 1,
116 createdAt: u.created_at,
117 credentialCount: u.credential_count,
118 })),
119 });
120}
121
122export async function getProfile(req: Request): Promise<Response> {
123 const user = getSessionUser(req);
124 if (user instanceof Response) {
125 return user;
126 }
127
128 const profile = db
129 .query(
130 `SELECT id, username, name, email, photo, url, status, role, tier, is_admin, created_at
131 FROM users
132 WHERE username = ?`,
133 )
134 .get(user.username) as
135 | {
136 id: number;
137 username: string;
138 name: string;
139 email: string | null;
140 photo: string | null;
141 url: string | null;
142 status: string;
143 role: string;
144 tier: string;
145 is_admin: number;
146 created_at: number;
147 }
148 | undefined;
149
150 if (!profile) {
151 return Response.json({ error: "Profile not found" }, { status: 404 });
152 }
153
154 return Response.json({
155 id: profile.id,
156 username: profile.username,
157 name: profile.name,
158 email: profile.email,
159 photo: profile.photo,
160 url: profile.url,
161 status: profile.status,
162 role: profile.role,
163 tier: profile.tier,
164 isAdmin: profile.is_admin === 1,
165 createdAt: profile.created_at,
166 });
167}
168
169export async function updateProfile(req: Request): Promise<Response> {
170 const user = getSessionUser(req);
171 if (user instanceof Response) {
172 return user;
173 }
174
175 try {
176 const body = await req.json();
177 const { name, email, photo, url } = body;
178
179 if (!name || typeof name !== "string") {
180 return Response.json({ error: "Name is required" }, { status: 400 });
181 }
182
183 // If URL is being set, validate format and verify domain ownership
184 if (url && typeof url === "string") {
185 // 1. Validate URL format per IndieAuth spec
186 const validation = validateProfileURL(url);
187 if (!validation.valid) {
188 return Response.json(
189 { error: validation.error || "Invalid URL format" },
190 { status: 400 },
191 );
192 }
193
194 // 2. Verify domain has rel="me" link back to profile
195 const origin = process.env.ORIGIN || "http://localhost:3000";
196 const indikoProfileUrl = `${origin}/u/${user.username}`;
197
198 const verification = await verifyDomain(
199 validation.canonicalUrl!,
200 indikoProfileUrl,
201 );
202 if (!verification.success) {
203 return Response.json(
204 { error: verification.error || "Failed to verify domain" },
205 { status: 400 },
206 );
207 }
208 }
209
210 // Update profile
211 db.query(
212 "UPDATE users SET name = ?, email = ?, photo = ?, url = ? WHERE username = ?",
213 ).run(name, email || null, photo || null, url || null, user.username);
214
215 return Response.json({ success: true });
216 } catch (error) {
217 console.error("Update profile error:", error);
218 return Response.json(
219 { error: "Failed to update profile" },
220 { status: 500 },
221 );
222 }
223}
224
225export function getAuthorizedApps(req: Request): Response {
226 const user = getSessionUser(req);
227 if (user instanceof Response) {
228 return user;
229 }
230
231 const apps = db
232 .query(
233 `SELECT
234 a.client_id,
235 a.name,
236 a.first_seen,
237 a.last_used as app_last_used,
238 p.scopes,
239 p.granted_at,
240 p.last_used
241 FROM permissions p
242 JOIN apps a ON p.client_id = a.client_id
243 WHERE p.user_id = ?
244 ORDER BY p.last_used DESC`,
245 )
246 .all(user.userId) as Array<{
247 client_id: string;
248 name: string | null;
249 first_seen: number;
250 app_last_used: number;
251 scopes: string;
252 granted_at: number;
253 last_used: number;
254 }>;
255
256 return Response.json({
257 apps: apps.map((app) => {
258 let displayName = app.name || app.client_id;
259 // Try to extract hostname if client_id is a URL
260 if (!app.name) {
261 try {
262 displayName = new URL(app.client_id).hostname;
263 } catch {
264 // Not a URL, use client_id as-is
265 displayName = app.client_id;
266 }
267 }
268 return {
269 clientId: app.client_id,
270 name: displayName,
271 scopes: JSON.parse(app.scopes) as string[],
272 grantedAt: app.granted_at,
273 lastUsed: app.last_used,
274 };
275 }),
276 });
277}
278
279export function revokeApp(req: Request, clientId: string): Response {
280 const user = getSessionUser(req);
281 if (user instanceof Response) {
282 return user;
283 }
284
285 // Delete permission
286 const result = db
287 .query("DELETE FROM permissions WHERE user_id = ? AND client_id = ?")
288 .run(user.userId, clientId);
289
290 if (result.changes === 0) {
291 return Response.json({ error: "App not found" }, { status: 404 });
292 }
293
294 // Also delete any unused auth codes for this app
295 db.query(
296 "DELETE FROM authcodes WHERE user_id = ? AND client_id = ? AND used = 0",
297 ).run(user.userId, clientId);
298
299 return Response.json({ success: true });
300}
301
302export function listAllApps(req: Request): Response {
303 const user = getSessionUser(req);
304 if (user instanceof Response) {
305 return user;
306 }
307
308 if (!user.is_admin) {
309 return Response.json({ error: "Admin access required" }, { status: 403 });
310 }
311
312 const apps = db
313 .query(
314 `SELECT
315 a.client_id,
316 a.name,
317 a.first_seen,
318 a.last_used,
319 COUNT(DISTINCT p.user_id) as user_count
320 FROM apps a
321 LEFT JOIN permissions p ON a.client_id = p.client_id
322 GROUP BY a.client_id
323 ORDER BY a.last_used DESC`,
324 )
325 .all() as Array<{
326 client_id: string;
327 name: string | null;
328 first_seen: number;
329 last_used: number;
330 user_count: number;
331 }>;
332
333 return Response.json({
334 apps: apps.map((app) => ({
335 clientId: app.client_id,
336 name: app.name || new URL(app.client_id).hostname,
337 firstSeen: app.first_seen,
338 lastUsed: app.last_used,
339 userCount: app.user_count,
340 })),
341 });
342}
343
344export function getAppDetails(req: Request, clientId: string): Response {
345 const user = getSessionUser(req);
346 if (user instanceof Response) {
347 return user;
348 }
349
350 if (!user.is_admin) {
351 return Response.json({ error: "Admin access required" }, { status: 403 });
352 }
353
354 const app = db
355 .query(
356 `SELECT client_id, name, first_seen, last_used
357 FROM apps
358 WHERE client_id = ?`,
359 )
360 .get(clientId) as
361 | {
362 client_id: string;
363 name: string | null;
364 first_seen: number;
365 last_used: number;
366 }
367 | undefined;
368
369 if (!app) {
370 return Response.json({ error: "App not found" }, { status: 404 });
371 }
372
373 const permissions = db
374 .query(
375 `SELECT
376 u.username,
377 u.name,
378 p.scopes,
379 p.granted_at,
380 p.last_used
381 FROM permissions p
382 JOIN users u ON p.user_id = u.id
383 WHERE p.client_id = ?
384 ORDER BY p.last_used DESC`,
385 )
386 .all(clientId) as Array<{
387 username: string;
388 name: string;
389 scopes: string;
390 granted_at: number;
391 last_used: number;
392 }>;
393
394 return Response.json({
395 app: {
396 clientId: app.client_id,
397 name: app.name || new URL(app.client_id).hostname,
398 firstSeen: app.first_seen,
399 lastUsed: app.last_used,
400 },
401 permissions: permissions.map((p) => ({
402 username: p.username,
403 name: p.name,
404 scopes: JSON.parse(p.scopes) as string[],
405 grantedAt: p.granted_at,
406 lastUsed: p.last_used,
407 })),
408 });
409}
410
411export function revokeAppForUser(
412 req: Request,
413 clientId: string,
414 username: string,
415): Response {
416 const user = getSessionUser(req);
417 if (user instanceof Response) {
418 return user;
419 }
420
421 if (!user.is_admin) {
422 return Response.json({ error: "Admin access required" }, { status: 403 });
423 }
424
425 const targetUser = db
426 .query("SELECT id FROM users WHERE username = ?")
427 .get(username) as { id: number } | undefined;
428
429 if (!targetUser) {
430 return Response.json({ error: "User not found" }, { status: 404 });
431 }
432
433 const result = db
434 .query("DELETE FROM permissions WHERE user_id = ? AND client_id = ?")
435 .run(targetUser.id, clientId);
436
437 if (result.changes === 0) {
438 return Response.json({ error: "Permission not found" }, { status: 404 });
439 }
440
441 db.query(
442 "DELETE FROM authcodes WHERE user_id = ? AND client_id = ? AND used = 0",
443 ).run(targetUser.id, clientId);
444
445 return Response.json({ success: true });
446}
447
448export function disableUser(req: Request, userId: string): Response {
449 const user = getSessionUser(req);
450 if (user instanceof Response) {
451 return user;
452 }
453
454 if (!user.is_admin) {
455 return Response.json({ error: "Admin access required" }, { status: 403 });
456 }
457
458 const targetUserId = Number.parseInt(userId, 10);
459 if (Number.isNaN(targetUserId)) {
460 return Response.json({ error: "Invalid user ID" }, { status: 400 });
461 }
462
463 // Prevent disabling self
464 if (targetUserId === user.id) {
465 return Response.json(
466 { error: "Cannot disable your own account" },
467 { status: 400 },
468 );
469 }
470
471 const targetUser = db
472 .query("SELECT id, username FROM users WHERE id = ?")
473 .get(targetUserId) as { id: number; username: string } | undefined;
474
475 if (!targetUser) {
476 return Response.json({ error: "User not found" }, { status: 404 });
477 }
478
479 db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(
480 targetUserId,
481 );
482
483 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId);
484
485 return Response.json({ success: true });
486}
487
488export function enableUser(req: Request, userId: string): Response {
489 const user = getSessionUser(req);
490 if (user instanceof Response) {
491 return user;
492 }
493
494 if (!user.is_admin) {
495 return Response.json({ error: "Admin access required" }, { status: 403 });
496 }
497
498 const targetUserId = Number.parseInt(userId, 10);
499 if (Number.isNaN(targetUserId)) {
500 return Response.json({ error: "Invalid user ID" }, { status: 400 });
501 }
502
503 const targetUser = db
504 .query("SELECT id, username FROM users WHERE id = ?")
505 .get(targetUserId) as { id: number; username: string } | undefined;
506
507 if (!targetUser) {
508 return Response.json({ error: "User not found" }, { status: 404 });
509 }
510
511 db.query("UPDATE users SET status = 'active' WHERE id = ?").run(targetUserId);
512
513 return Response.json({ success: true });
514}
515
516export async function updateUserTier(
517 req: Request,
518 userId: string,
519): Promise<Response> {
520 const user = getSessionUser(req);
521 if (user instanceof Response) {
522 return user;
523 }
524
525 if (!user.is_admin) {
526 return Response.json({ error: "Admin access required" }, { status: 403 });
527 }
528
529 const targetUserId = Number.parseInt(userId, 10);
530 if (Number.isNaN(targetUserId)) {
531 return Response.json({ error: "Invalid user ID" }, { status: 400 });
532 }
533
534 try {
535 const body = await req.json();
536 const { tier } = body;
537
538 if (!tier || !["admin", "developer", "user"].includes(tier)) {
539 return Response.json(
540 { error: "Invalid tier. Must be 'admin', 'developer', or 'user'" },
541 { status: 400 },
542 );
543 }
544
545 const targetUser = db
546 .query("SELECT id, username, tier FROM users WHERE id = ?")
547 .get(targetUserId) as
548 | { id: number; username: string; tier: string }
549 | undefined;
550
551 if (!targetUser) {
552 return Response.json({ error: "User not found" }, { status: 404 });
553 }
554
555 // Prevent changing your own tier
556 if (targetUserId === user.userId) {
557 return Response.json(
558 { error: "Cannot change your own tier" },
559 { status: 400 },
560 );
561 }
562
563 // Update tier and is_admin flag
564 db.query("UPDATE users SET tier = ?, is_admin = ? WHERE id = ?").run(
565 tier,
566 tier === "admin" ? 1 : 0,
567 targetUserId,
568 );
569
570 return Response.json({ success: true, tier });
571 } catch (error) {
572 console.error("Update tier error:", error);
573 return Response.json({ error: "Invalid request body" }, { status: 400 });
574 }
575}
576
577export function deleteUser(req: Request, userId: string): Response {
578 const user = getSessionUser(req);
579 if (user instanceof Response) {
580 return user;
581 }
582
583 if (!user.is_admin) {
584 return Response.json({ error: "Admin access required" }, { status: 403 });
585 }
586
587 const targetUserId = Number.parseInt(userId, 10);
588 if (Number.isNaN(targetUserId)) {
589 return Response.json({ error: "Invalid user ID" }, { status: 400 });
590 }
591
592 if (targetUserId === user.userId) {
593 return Response.json(
594 { error: "Cannot delete your own account" },
595 { status: 400 },
596 );
597 }
598
599 const targetUser = db
600 .query("SELECT id, is_admin FROM users WHERE id = ?")
601 .get(targetUserId) as { id: number; is_admin: number } | undefined;
602
603 if (!targetUser) {
604 return Response.json({ error: "User not found" }, { status: 404 });
605 }
606
607 // Prevent admins from deleting other admin accounts
608 if (targetUser.is_admin === 1) {
609 return Response.json(
610 { error: "Cannot delete admin accounts" },
611 { status: 403 },
612 );
613 }
614
615 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId);
616 db.query("DELETE FROM credentials WHERE user_id = ?").run(targetUserId);
617 db.query("DELETE FROM permissions WHERE user_id = ?").run(targetUserId);
618 db.query("DELETE FROM authcodes WHERE user_id = ?").run(targetUserId);
619 db.query("DELETE FROM users WHERE id = ?").run(targetUserId);
620
621 return Response.json({ success: true });
622}
623
624export function deleteSelfAccount(req: Request): Response {
625 const user = getSessionUser(req);
626 if (user instanceof Response) {
627 return user;
628 }
629
630 // Prevent admins from deleting their own accounts
631 if (user.is_admin) {
632 return Response.json(
633 {
634 error:
635 "Admin accounts cannot be self-deleted. Contact another admin for account deletion.",
636 },
637 { status: 403 },
638 );
639 }
640
641 // Delete all user data
642 db.query("DELETE FROM sessions WHERE user_id = ?").run(user.userId);
643 db.query("DELETE FROM credentials WHERE user_id = ?").run(user.userId);
644 db.query("DELETE FROM permissions WHERE user_id = ?").run(user.userId);
645 db.query("DELETE FROM authcodes WHERE user_id = ?").run(user.userId);
646 db.query("DELETE FROM users WHERE id = ?").run(user.userId);
647
648 return Response.json({ success: true });
649}