my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: fix migrations

+159 -13
+3 -1
CRUSH.md
··· 59 59 - **`me` parameter delegation**: When a client passes `me=https://example.com` in the authorization request and it matches the user's website URL, the token response returns that URL instead of the canonical `/u/{username}` URL 60 60 61 61 ### Database Schema 62 - - **users**: username, name, email, photo, url, status, role, is_admin 62 + - **users**: username, name, email, photo, url, status, role, tier, is_admin 63 + - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 64 + - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) 63 65 - **credentials**: passkey credentials (credential_id, public_key, counter) 64 66 - **sessions**: user sessions with 24-hour expiry 65 67 - **challenges**: WebAuthn challenges (5-minute expiry)
+9
src/index.ts
··· 21 21 revokeApp, 22 22 revokeAppForUser, 23 23 updateProfile, 24 + updateUserTier, 24 25 } from "./routes/api"; 25 26 import { 26 27 canRegister, ··· 198 199 const url = new URL(req.url); 199 200 const userId = url.pathname.split("/")[4]; 200 201 return enableUser(req, userId); 202 + } 203 + return new Response("Method not allowed", { status: 405 }); 204 + }, 205 + "/api/admin/users/:id/tier": (req: Request) => { 206 + if (req.method === "PUT") { 207 + const url = new URL(req.url); 208 + const userId = url.pathname.split("/")[4]; 209 + return updateUserTier(req, userId); 201 210 } 202 211 return new Response("Method not allowed", { status: 405 }); 203 212 },
+24 -3
src/migrations/004_add_refresh_tokens.sql
··· 1 - -- Add refresh token support 2 - ALTER TABLE tokens ADD COLUMN refresh_token TEXT UNIQUE; 3 - ALTER TABLE tokens ADD COLUMN refresh_expires_at INTEGER; 1 + CREATE TABLE tokens_new ( 2 + id INTEGER PRIMARY KEY AUTOINCREMENT, 3 + token TEXT NOT NULL UNIQUE, 4 + user_id INTEGER NOT NULL, 5 + client_id TEXT NOT NULL, 6 + scope TEXT NOT NULL, 7 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 8 + expires_at INTEGER NOT NULL, 9 + revoked INTEGER NOT NULL DEFAULT 0, 10 + refresh_token TEXT UNIQUE, 11 + refresh_expires_at INTEGER, 12 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 13 + ); 14 + 15 + INSERT INTO tokens_new (id, token, user_id, client_id, scope, created_at, expires_at, revoked) 16 + SELECT id, token, user_id, client_id, scope, created_at, expires_at, revoked 17 + FROM tokens; 18 + 19 + DROP TABLE tokens; 20 + 21 + ALTER TABLE tokens_new RENAME TO tokens; 4 22 23 + CREATE INDEX idx_tokens_token ON tokens(token); 24 + CREATE INDEX idx_tokens_user_id ON tokens(user_id); 25 + CREATE INDEX idx_tokens_expires_at ON tokens(expires_at); 5 26 CREATE INDEX idx_tokens_refresh_token ON tokens(refresh_token);
+36
src/migrations/005_add_user_tier.sql
··· 1 + PRAGMA foreign_keys = OFF; 2 + 3 + CREATE TABLE users_new ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + username TEXT NOT NULL UNIQUE, 6 + name TEXT NOT NULL, 7 + email TEXT, 8 + photo TEXT, 9 + url TEXT, 10 + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'inactive')), 11 + role TEXT NOT NULL DEFAULT 'user', 12 + tier TEXT NOT NULL DEFAULT 'developer' CHECK(tier IN ('admin', 'developer', 'user')), 13 + is_admin INTEGER NOT NULL DEFAULT 0, 14 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 15 + ); 16 + 17 + INSERT INTO users_new (id, username, name, email, photo, url, status, role, tier, is_admin, created_at) 18 + SELECT 19 + id, 20 + username, 21 + name, 22 + email, 23 + photo, 24 + url, 25 + status, 26 + role, 27 + CASE WHEN is_admin = 1 THEN 'admin' ELSE 'developer' END, 28 + is_admin, 29 + created_at 30 + FROM users; 31 + 32 + DROP TABLE users; 33 + 34 + ALTER TABLE users_new RENAME TO users; 35 + 36 + PRAGMA foreign_keys = ON;
+67 -4
src/routes/api.ts
··· 3 3 4 4 function getSessionUser( 5 5 req: Request, 6 - ): { username: string; userId: number; is_admin: boolean } | Response { 6 + ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 7 7 const authHeader = req.headers.get("Authorization"); 8 8 9 9 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 15 15 // Look up session 16 16 const session = db 17 17 .query( 18 - `SELECT s.expires_at, s.user_id, u.username, u.is_admin, u.status 18 + `SELECT s.expires_at, s.user_id, u.username, u.is_admin, u.tier, u.status 19 19 FROM sessions s 20 20 JOIN users u ON s.user_id = u.id 21 21 WHERE s.token = ?`, ··· 26 26 user_id: number; 27 27 username: string; 28 28 is_admin: number; 29 + tier: string; 29 30 status: string; 30 31 } 31 32 | undefined; ··· 47 48 username: session.username, 48 49 userId: session.user_id, 49 50 is_admin: session.is_admin === 1, 51 + tier: session.tier, 50 52 }; 51 53 } 52 54 ··· 61 63 id: user.userId, 62 64 username: user.username, 63 65 isAdmin: user.is_admin, 66 + tier: user.tier, 64 67 }); 65 68 } 66 69 ··· 76 79 77 80 const users = db 78 81 .query( 79 - `SELECT u.id, u.username, u.name, u.email, u.photo, u.status, u.role, u.is_admin, u.created_at, 82 + `SELECT u.id, u.username, u.name, u.email, u.photo, u.status, u.role, u.tier, u.is_admin, u.created_at, 80 83 COUNT(c.id) as credential_count 81 84 FROM users u 82 85 LEFT JOIN credentials c ON u.id = c.user_id ··· 91 94 photo: string | null; 92 95 status: string; 93 96 role: string; 97 + tier: string; 94 98 is_admin: number; 95 99 created_at: number; 96 100 credential_count: number; ··· 105 109 photo: u.photo, 106 110 status: u.status, 107 111 role: u.role, 112 + tier: u.tier, 108 113 isAdmin: u.is_admin === 1, 109 114 createdAt: u.created_at, 110 115 credentialCount: u.credential_count, ··· 120 125 121 126 const profile = db 122 127 .query( 123 - `SELECT id, username, name, email, photo, url, status, role, is_admin, created_at 128 + `SELECT id, username, name, email, photo, url, status, role, tier, is_admin, created_at 124 129 FROM users 125 130 WHERE username = ?`, 126 131 ) ··· 134 139 url: string | null; 135 140 status: string; 136 141 role: string; 142 + tier: string; 137 143 is_admin: number; 138 144 created_at: number; 139 145 } ··· 152 158 url: profile.url, 153 159 status: profile.status, 154 160 role: profile.role, 161 + tier: profile.tier, 155 162 isAdmin: profile.is_admin === 1, 156 163 createdAt: profile.created_at, 157 164 }); ··· 499 506 db.query("UPDATE users SET status = 'active' WHERE id = ?").run(targetUserId); 500 507 501 508 return Response.json({ success: true }); 509 + } 510 + 511 + export async function updateUserTier(req: Request, userId: string): Promise<Response> { 512 + const user = getSessionUser(req); 513 + if (user instanceof Response) { 514 + return user; 515 + } 516 + 517 + if (!user.is_admin) { 518 + return Response.json({ error: "Admin access required" }, { status: 403 }); 519 + } 520 + 521 + const targetUserId = Number.parseInt(userId, 10); 522 + if (Number.isNaN(targetUserId)) { 523 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 524 + } 525 + 526 + try { 527 + const body = await req.json(); 528 + const { tier } = body; 529 + 530 + if (!tier || !["admin", "developer", "user"].includes(tier)) { 531 + return Response.json( 532 + { error: "Invalid tier. Must be 'admin', 'developer', or 'user'" }, 533 + { status: 400 }, 534 + ); 535 + } 536 + 537 + const targetUser = db 538 + .query("SELECT id, username, tier FROM users WHERE id = ?") 539 + .get(targetUserId) as { id: number; username: string; tier: string } | undefined; 540 + 541 + if (!targetUser) { 542 + return Response.json({ error: "User not found" }, { status: 404 }); 543 + } 544 + 545 + // Prevent changing your own tier 546 + if (targetUserId === user.userId) { 547 + return Response.json( 548 + { error: "Cannot change your own tier" }, 549 + { status: 400 }, 550 + ); 551 + } 552 + 553 + // Update tier and is_admin flag 554 + db.query("UPDATE users SET tier = ?, is_admin = ? WHERE id = ?").run( 555 + tier, 556 + tier === "admin" ? 1 : 0, 557 + targetUserId, 558 + ); 559 + 560 + return Response.json({ success: true, tier }); 561 + } catch (error) { 562 + console.error("Update tier error:", error); 563 + return Response.json({ error: "Invalid request body" }, { status: 400 }); 564 + } 502 565 } 503 566 504 567 export function deleteUser(req: Request, userId: string): Response {
+2 -1
src/routes/auth.ts
··· 255 255 256 256 // Create user (bootstrap is always admin, invited users are regular users) 257 257 const insertUser = db.query( 258 - "INSERT INTO users (username, name, is_admin, role) VALUES (?, ?, ?, ?) RETURNING id", 258 + "INSERT INTO users (username, name, is_admin, tier, role) VALUES (?, ?, ?, ?, ?) RETURNING id", 259 259 ); 260 260 const user = insertUser.get( 261 261 username, 262 262 username, 263 263 isBootstrap ? 1 : 0, 264 + isBootstrap ? "admin" : "user", 264 265 isBootstrap ? "admin" : "user", 265 266 ) as { 266 267 id: number;
+12 -2
src/routes/clients.ts
··· 16 16 17 17 function getSessionUser( 18 18 req: Request, 19 - ): { username: string; userId: number; is_admin: boolean } | Response { 19 + ): { username: string; userId: number; is_admin: boolean; tier: string } | Response { 20 20 const authHeader = req.headers.get("Authorization"); 21 21 22 22 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 27 27 28 28 const session = db 29 29 .query( 30 - `SELECT s.expires_at, s.user_id, u.username, u.is_admin, u.status 30 + `SELECT s.expires_at, s.user_id, u.username, u.is_admin, u.tier, u.status 31 31 FROM sessions s 32 32 JOIN users u ON s.user_id = u.id 33 33 WHERE s.token = ?`, ··· 38 38 user_id: number; 39 39 username: string; 40 40 is_admin: number; 41 + tier: string; 41 42 status: string; 42 43 } 43 44 | undefined; ··· 59 60 username: session.username, 60 61 userId: session.user_id, 61 62 is_admin: session.is_admin === 1, 63 + tier: session.tier, 62 64 }; 63 65 } 64 66 ··· 144 146 145 147 if (!user.is_admin) { 146 148 return Response.json({ error: "Admin access required" }, { status: 403 }); 149 + } 150 + 151 + // Only admin and developer tiers can create apps 152 + if (user.tier !== "admin" && user.tier !== "developer") { 153 + return Response.json( 154 + { error: "Developer or admin tier required to create apps" }, 155 + { status: 403 }, 156 + ); 147 157 } 148 158 149 159 try {
+6 -2
src/routes/indieauth.ts
··· 5 5 username: string; 6 6 userId: number; 7 7 isAdmin: boolean; 8 + tier: string; 8 9 } 9 10 10 11 // Helper to get authenticated user from session token ··· 19 20 20 21 const session = db 21 22 .query( 22 - `SELECT s.expires_at, u.id, u.username, u.is_admin, u.status 23 + `SELECT s.expires_at, u.id, u.username, u.is_admin, u.tier, u.status 23 24 FROM sessions s 24 25 JOIN users u ON s.user_id = u.id 25 26 WHERE s.token = ?`, ··· 30 31 id: number; 31 32 username: string; 32 33 is_admin: number; 34 + tier: string; 33 35 status: string; 34 36 } 35 37 | undefined; ··· 71 73 72 74 const session = db 73 75 .query( 74 - `SELECT s.expires_at, u.id, u.username, u.is_admin, u.status 76 + `SELECT s.expires_at, u.id, u.username, u.is_admin, u.tier, u.status 75 77 FROM sessions s 76 78 JOIN users u ON s.user_id = u.id 77 79 WHERE s.token = ?`, ··· 82 84 id: number; 83 85 username: string; 84 86 is_admin: number; 87 + tier: string; 85 88 status: string; 86 89 } 87 90 | undefined; ··· 97 100 username: session.username, 98 101 userId: session.id, 99 102 isAdmin: session.is_admin === 1, 103 + tier: session.tier, 100 104 }; 101 105 } 102 106