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.

feat: add multiple passkey support

+663 -1
+236
src/client/index.ts
··· 1 + import { 2 + startRegistration, 3 + } from "@simplewebauthn/browser"; 4 + 1 5 const token = localStorage.getItem("indiko_session"); 2 6 const footer = document.getElementById("footer") as HTMLElement; 3 7 const welcome = document.getElementById("welcome") as HTMLElement; 4 8 const subtitle = document.getElementById("subtitle") as HTMLElement; 5 9 const recentApps = document.getElementById("recentApps") as HTMLElement; 10 + const passkeysList = document.getElementById("passkeysList") as HTMLElement; 11 + const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; 6 12 const toast = document.getElementById("toast") as HTMLElement; 7 13 8 14 // Profile form elements ··· 40 46 photo: string | null; 41 47 url: string | null; 42 48 isAdmin?: boolean; 49 + } 50 + 51 + interface Passkey { 52 + id: number; 53 + name: string; 54 + created_at: number; 43 55 } 44 56 45 57 function showToast(message: string, type: "success" | "error" = "success") { ··· 112 124 // Load profile and apps 113 125 loadProfile(); 114 126 loadRecentApps(); 127 + loadPasskeys(); 115 128 } catch (error) { 116 129 console.error("Auth check failed:", error); 117 130 footer.textContent = "error loading user info"; ··· 288 301 showToast((error as Error).message || "Failed to delete account", "error"); 289 302 deleteAccountBtn.disabled = false; 290 303 deleteAccountBtn.textContent = "delete my account"; 304 + } 305 + }); 306 + 307 + async function loadPasskeys() { 308 + try { 309 + const response = await fetch("/api/passkeys", { 310 + headers: { 311 + Authorization: `Bearer ${token}`, 312 + }, 313 + }); 314 + 315 + if (!response.ok) { 316 + throw new Error("Failed to load passkeys"); 317 + } 318 + 319 + const data = await response.json(); 320 + const passkeys = data.passkeys as Passkey[]; 321 + 322 + if (passkeys.length === 0) { 323 + passkeysList.innerHTML = '<div class="empty">No passkeys registered</div>'; 324 + return; 325 + } 326 + 327 + passkeysList.innerHTML = passkeys 328 + .map((passkey) => { 329 + const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); 330 + 331 + return ` 332 + <div class="passkey-item" data-passkey-id="${passkey.id}"> 333 + <div class="passkey-info"> 334 + <div class="passkey-name">${passkey.name}</div> 335 + <div class="passkey-date">added ${createdDate}</div> 336 + </div> 337 + <div class="passkey-actions"> 338 + <button type="button" class="rename-passkey-btn" data-passkey-id="${passkey.id}">rename</button> 339 + ${passkeys.length > 1 ? `<button type="button" class="delete-passkey-btn" data-passkey-id="${passkey.id}">delete</button>` : ''} 340 + </div> 341 + </div> 342 + `; 343 + }) 344 + .join(""); 345 + 346 + // Add event listeners for rename buttons 347 + document.querySelectorAll(".rename-passkey-btn").forEach((btn) => { 348 + btn.addEventListener("click", () => { 349 + const passkeyId = btn.getAttribute("data-passkey-id"); 350 + showRenameForm(Number(passkeyId)); 351 + }); 352 + }); 353 + 354 + // Add event listeners for delete buttons 355 + document.querySelectorAll(".delete-passkey-btn").forEach((btn) => { 356 + btn.addEventListener("click", async () => { 357 + const passkeyId = btn.getAttribute("data-passkey-id"); 358 + await deletePasskeyHandler(Number(passkeyId)); 359 + }); 360 + }); 361 + } catch (error) { 362 + console.error("Failed to load passkeys:", error); 363 + passkeysList.innerHTML = '<div class="empty">Failed to load passkeys</div>'; 364 + } 365 + } 366 + 367 + function showRenameForm(passkeyId: number) { 368 + const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); 369 + if (!passkeyItem) return; 370 + 371 + const infoDiv = passkeyItem.querySelector(".passkey-info"); 372 + const nameDiv = infoDiv?.querySelector(".passkey-name"); 373 + if (!nameDiv) return; 374 + 375 + const currentName = nameDiv.textContent || ""; 376 + 377 + // Replace the info div with a rename form 378 + if (infoDiv) { 379 + infoDiv.innerHTML = ` 380 + <div class="rename-form"> 381 + <input type="text" value="${currentName}" class="rename-input" data-passkey-id="${passkeyId}" /> 382 + <button type="button" class="save-rename-btn" data-passkey-id="${passkeyId}">save</button> 383 + <button type="button" class="cancel-rename-btn" data-passkey-id="${passkeyId}">cancel</button> 384 + </div> 385 + `; 386 + 387 + const input = infoDiv.querySelector(".rename-input") as HTMLInputElement; 388 + input.focus(); 389 + input.select(); 390 + 391 + // Save button 392 + infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { 393 + await renamePasskeyHandler(passkeyId, input.value); 394 + }); 395 + 396 + // Cancel button 397 + infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { 398 + loadPasskeys(); 399 + }); 400 + 401 + // Enter to save 402 + input.addEventListener("keypress", async (e) => { 403 + if (e.key === "Enter") { 404 + await renamePasskeyHandler(passkeyId, input.value); 405 + } 406 + }); 407 + 408 + // Escape to cancel 409 + input.addEventListener("keydown", (e) => { 410 + if (e.key === "Escape") { 411 + loadPasskeys(); 412 + } 413 + }); 414 + } 415 + } 416 + 417 + async function renamePasskeyHandler(passkeyId: number, newName: string) { 418 + if (!newName.trim()) { 419 + showToast("Passkey name cannot be empty", "error"); 420 + return; 421 + } 422 + 423 + try { 424 + const response = await fetch(`/api/passkeys/${passkeyId}`, { 425 + method: "PATCH", 426 + headers: { 427 + Authorization: `Bearer ${token}`, 428 + "Content-Type": "application/json", 429 + }, 430 + body: JSON.stringify({ name: newName }), 431 + }); 432 + 433 + if (!response.ok) { 434 + const error = await response.json(); 435 + throw new Error(error.error || "Failed to rename passkey"); 436 + } 437 + 438 + showToast("Passkey renamed successfully!", "success"); 439 + loadPasskeys(); 440 + } catch (error) { 441 + showToast((error as Error).message || "Failed to rename passkey", "error"); 442 + } 443 + } 444 + 445 + async function deletePasskeyHandler(passkeyId: number) { 446 + if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) { 447 + return; 448 + } 449 + 450 + try { 451 + const response = await fetch(`/api/passkeys/${passkeyId}`, { 452 + method: "DELETE", 453 + headers: { 454 + Authorization: `Bearer ${token}`, 455 + }, 456 + }); 457 + 458 + if (!response.ok) { 459 + const error = await response.json(); 460 + throw new Error(error.error || "Failed to delete passkey"); 461 + } 462 + 463 + showToast("Passkey deleted successfully!", "success"); 464 + loadPasskeys(); 465 + } catch (error) { 466 + showToast((error as Error).message || "Failed to delete passkey", "error"); 467 + } 468 + } 469 + 470 + // Add passkey button handler 471 + addPasskeyBtn.addEventListener("click", async () => { 472 + addPasskeyBtn.disabled = true; 473 + addPasskeyBtn.textContent = "preparing..."; 474 + 475 + try { 476 + // Get registration options 477 + const optionsRes = await fetch("/api/passkeys/add/options", { 478 + method: "POST", 479 + headers: { 480 + Authorization: `Bearer ${token}`, 481 + }, 482 + }); 483 + 484 + if (!optionsRes.ok) { 485 + const error = await optionsRes.json(); 486 + throw new Error(error.error || "Failed to get passkey options"); 487 + } 488 + 489 + const options = await optionsRes.json(); 490 + 491 + addPasskeyBtn.textContent = "create your passkey..."; 492 + 493 + // Start registration 494 + const regResponse = await startRegistration(options); 495 + 496 + addPasskeyBtn.textContent = "verifying..."; 497 + 498 + // Ask for a name 499 + const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); 500 + 501 + // Verify registration 502 + const verifyRes = await fetch("/api/passkeys/add/verify", { 503 + method: "POST", 504 + headers: { 505 + Authorization: `Bearer ${token}`, 506 + "Content-Type": "application/json", 507 + }, 508 + body: JSON.stringify({ 509 + response: regResponse, 510 + challenge: options.challenge, 511 + name: name || undefined, 512 + }), 513 + }); 514 + 515 + if (!verifyRes.ok) { 516 + const error = await verifyRes.json(); 517 + throw new Error(error.error || "Failed to add passkey"); 518 + } 519 + 520 + showToast("Passkey added successfully!", "success"); 521 + loadPasskeys(); 522 + } catch (error) { 523 + showToast((error as Error).message || "Failed to add passkey", "error"); 524 + } finally { 525 + addPasskeyBtn.disabled = false; 526 + addPasskeyBtn.textContent = "add new passkey"; 291 527 } 292 528 }); 293 529
+68
src/html/index.html
··· 196 196 .toast.success { 197 197 border-color: var(--berry-crush); 198 198 } 199 + 200 + .passkey-item { 201 + padding: 0.75rem; 202 + background: rgba(12, 23, 19, 0.6); 203 + border: 1px solid var(--rosewood); 204 + display: flex; 205 + justify-content: space-between; 206 + align-items: center; 207 + gap: 1rem; 208 + } 209 + 210 + .passkey-info { 211 + flex: 1; 212 + } 213 + 214 + .passkey-name { 215 + font-weight: 500; 216 + color: var(--lavender); 217 + margin-bottom: 0.25rem; 218 + } 219 + 220 + .passkey-date { 221 + font-size: 0.75rem; 222 + color: var(--old-rose); 223 + } 224 + 225 + .passkey-actions { 226 + display: flex; 227 + gap: 0.5rem; 228 + } 229 + 230 + .passkey-actions button { 231 + padding: 0.375rem 0.75rem; 232 + font-size: 0.75rem; 233 + margin: 0; 234 + } 235 + 236 + .rename-form { 237 + display: flex; 238 + gap: 0.5rem; 239 + width: 100%; 240 + } 241 + 242 + .rename-form input { 243 + flex: 1; 244 + margin: 0; 245 + padding: 0.375rem 0.75rem; 246 + font-size: 0.875rem; 247 + } 248 + 249 + .rename-form button { 250 + padding: 0.375rem 0.75rem; 251 + font-size: 0.75rem; 252 + margin: 0; 253 + } 199 254 </style> 200 255 </head> 201 256 ··· 211 266 <div id="recentApps" class="apps-preview"> 212 267 <div class="loading">loading...</div> 213 268 </div> 269 + </div> 270 + 271 + <div class="profile-section"> 272 + <h2 class="section-title">passkeys</h2> 273 + <p style="color: var(--old-rose); margin-bottom: 1.5rem; line-height: 1.6;"> 274 + Manage your passkeys for secure, password-free authentication 275 + </p> 276 + <div id="passkeysList" class="apps-preview"> 277 + <div class="loading">loading...</div> 278 + </div> 279 + <button type="button" id="addPasskeyBtn" style="margin-top: 1rem;"> 280 + add new passkey 281 + </button> 214 282 </div> 215 283 216 284 <div class="profile-section">
+25
src/index.ts
··· 31 31 registerVerify, 32 32 } from "./routes/auth"; 33 33 import { 34 + addPasskeyOptions, 35 + addPasskeyVerify, 36 + deletePasskey, 37 + listPasskeys, 38 + renamePasskey, 39 + } from "./routes/passkeys"; 40 + import { 34 41 createClient, 35 42 deleteClient, 36 43 getClient, ··· 246 253 "/auth/register/verify": registerVerify, 247 254 "/auth/login/options": loginOptions, 248 255 "/auth/login/verify": loginVerify, 256 + // Passkey management endpoints 257 + "/api/passkeys": (req: Request) => { 258 + if (req.method === "GET") return listPasskeys(req); 259 + return new Response("Method not allowed", { status: 405 }); 260 + }, 261 + "/api/passkeys/add/options": (req: Request) => { 262 + if (req.method === "POST") return addPasskeyOptions(req); 263 + return new Response("Method not allowed", { status: 405 }); 264 + }, 265 + "/api/passkeys/add/verify": (req: Request) => { 266 + if (req.method === "POST") return addPasskeyVerify(req); 267 + return new Response("Method not allowed", { status: 405 }); 268 + }, 269 + "/api/passkeys/:id": (req: Request) => { 270 + if (req.method === "DELETE") return deletePasskey(req); 271 + if (req.method === "PATCH") return renamePasskey(req); 272 + return new Response("Method not allowed", { status: 405 }); 273 + }, 249 274 // Dynamic routes with Bun's :param syntax 250 275 "/u/:username": userProfile, 251 276 "/api/apps/:clientId": (req) => {
+5
src/migrations/006_add_passkey_names.sql
··· 1 + -- Add name column to credentials table for multiple passkey support 2 + ALTER TABLE credentials ADD COLUMN name TEXT; 3 + 4 + -- Update existing credentials with a default name 5 + UPDATE credentials SET name = 'Passkey ' || id WHERE name IS NULL;
+2 -1
src/routes/auth.ts
··· 270 270 // Store credential 271 271 // credential.id is a Uint8Array, convert to Buffer for storage 272 272 db.query( 273 - "INSERT INTO credentials (user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?)", 273 + "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)", 274 274 ).run( 275 275 user.id, 276 276 Buffer.from(credential.id), 277 277 Buffer.from(credential.publicKey), 278 278 credential.counter, 279 + "Primary Passkey", 279 280 ); 280 281 281 282 // Mark invite as used if applicable
+327
src/routes/passkeys.ts
··· 1 + import { 2 + type RegistrationResponseJSON, 3 + generateRegistrationOptions, 4 + type VerifiedRegistrationResponse, 5 + verifyRegistrationResponse, 6 + } from "@simplewebauthn/server"; 7 + import { db } from "../db"; 8 + 9 + const RP_NAME = "Indiko"; 10 + 11 + // Get all passkeys for current user 12 + export function listPasskeys(req: Request): Response { 13 + const sessionToken = 14 + req.headers.get("Authorization")?.replace("Bearer ", "") || 15 + req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1]; 16 + 17 + if (!sessionToken) { 18 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 19 + } 20 + 21 + const session = db 22 + .query( 23 + "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')", 24 + ) 25 + .get(sessionToken) as { user_id: number; expires_at: number } | undefined; 26 + 27 + if (!session) { 28 + return Response.json({ error: "Invalid session" }, { status: 401 }); 29 + } 30 + 31 + const passkeys = db 32 + .query( 33 + "SELECT id, name, created_at FROM credentials WHERE user_id = ? ORDER BY created_at DESC", 34 + ) 35 + .all(session.user_id) as Array<{ 36 + id: number; 37 + name: string; 38 + created_at: number; 39 + }>; 40 + 41 + return Response.json({ passkeys }); 42 + } 43 + 44 + // Generate options for adding a new passkey 45 + export async function addPasskeyOptions(req: Request): Promise<Response> { 46 + const sessionToken = 47 + req.headers.get("Authorization")?.replace("Bearer ", "") || 48 + req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1]; 49 + 50 + if (!sessionToken) { 51 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 52 + } 53 + 54 + const session = db 55 + .query( 56 + "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')", 57 + ) 58 + .get(sessionToken) as { user_id: number; expires_at: number } | undefined; 59 + 60 + if (!session) { 61 + return Response.json({ error: "Invalid session" }, { status: 401 }); 62 + } 63 + 64 + const user = db 65 + .query("SELECT username FROM users WHERE id = ?") 66 + .get(session.user_id) as { username: string } | undefined; 67 + 68 + if (!user) { 69 + return Response.json({ error: "User not found" }, { status: 404 }); 70 + } 71 + 72 + // Get existing credentials to exclude them 73 + const existingCredentials = db 74 + .query("SELECT credential_id FROM credentials WHERE user_id = ?") 75 + .all(session.user_id) as Array<{ credential_id: Buffer }>; 76 + 77 + const excludeCredentials = existingCredentials.map((cred) => ({ 78 + id: cred.credential_id, 79 + type: "public-key" as const, 80 + })); 81 + 82 + // Generate WebAuthn registration options 83 + const options = await generateRegistrationOptions({ 84 + rpName: RP_NAME, 85 + rpID: process.env.RP_ID!, 86 + userName: user.username, 87 + userDisplayName: user.username, 88 + attestationType: "none", 89 + excludeCredentials, 90 + authenticatorSelection: { 91 + residentKey: "required", 92 + userVerification: "required", 93 + authenticatorAttachment: "platform", 94 + }, 95 + }); 96 + 97 + // Store challenge 98 + const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes 99 + db.query( 100 + "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'passkey_add', ?)", 101 + ).run(options.challenge, user.username, expiresAt); 102 + 103 + return Response.json(options); 104 + } 105 + 106 + // Verify and add new passkey 107 + export async function addPasskeyVerify(req: Request): Promise<Response> { 108 + try { 109 + const sessionToken = 110 + req.headers.get("Authorization")?.replace("Bearer ", "") || 111 + req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1]; 112 + 113 + if (!sessionToken) { 114 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 115 + } 116 + 117 + const session = db 118 + .query( 119 + "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')", 120 + ) 121 + .get(sessionToken) as { user_id: number; expires_at: number } | undefined; 122 + 123 + if (!session) { 124 + return Response.json({ error: "Invalid session" }, { status: 401 }); 125 + } 126 + 127 + const user = db 128 + .query("SELECT username FROM users WHERE id = ?") 129 + .get(session.user_id) as { username: string } | undefined; 130 + 131 + if (!user) { 132 + return Response.json({ error: "User not found" }, { status: 404 }); 133 + } 134 + 135 + const body = await req.json(); 136 + const { response, challenge: expectedChallenge, name } = body as { 137 + response: RegistrationResponseJSON; 138 + challenge: string; 139 + name?: string; 140 + }; 141 + 142 + if (!response) { 143 + return Response.json({ error: "Response required" }, { status: 400 }); 144 + } 145 + 146 + // Verify challenge exists and is valid 147 + const challenge = db 148 + .query( 149 + "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'passkey_add'", 150 + ) 151 + .get(expectedChallenge, user.username) as 152 + | { challenge: string; expires_at: number } 153 + | undefined; 154 + 155 + if (!challenge) { 156 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 157 + } 158 + 159 + const now = Math.floor(Date.now() / 1000); 160 + if (challenge.expires_at < now) { 161 + return Response.json({ error: "Challenge expired" }, { status: 400 }); 162 + } 163 + 164 + // Verify WebAuthn response 165 + let verification: VerifiedRegistrationResponse; 166 + try { 167 + verification = await verifyRegistrationResponse({ 168 + response, 169 + expectedChallenge: challenge.challenge, 170 + expectedOrigin: process.env.ORIGIN!, 171 + expectedRPID: process.env.RP_ID!, 172 + }); 173 + } catch (error) { 174 + console.error("WebAuthn verification failed:", error); 175 + return Response.json({ error: "Verification failed" }, { status: 400 }); 176 + } 177 + 178 + if (!verification.verified || !verification.registrationInfo) { 179 + return Response.json({ error: "Verification failed" }, { status: 400 }); 180 + } 181 + 182 + const { credential } = verification.registrationInfo; 183 + 184 + // Generate default name if not provided 185 + const passkeyCount = db 186 + .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") 187 + .get(session.user_id) as { count: number }; 188 + 189 + const passkeyName = name || `Passkey ${passkeyCount.count + 1}`; 190 + 191 + // Store credential 192 + const result = db 193 + .query( 194 + "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?) RETURNING id", 195 + ) 196 + .get( 197 + session.user_id, 198 + Buffer.from(credential.id), 199 + Buffer.from(credential.publicKey), 200 + credential.counter, 201 + passkeyName, 202 + ) as { id: number }; 203 + 204 + // Delete challenge 205 + db.query("DELETE FROM challenges WHERE challenge = ?").run( 206 + challenge.challenge, 207 + ); 208 + 209 + return Response.json({ 210 + success: true, 211 + passkey: { 212 + id: result.id, 213 + name: passkeyName, 214 + created_at: Math.floor(Date.now() / 1000), 215 + }, 216 + }); 217 + } catch (error) { 218 + console.error("Add passkey verify error:", error); 219 + return Response.json({ error: "Internal server error" }, { status: 500 }); 220 + } 221 + } 222 + 223 + // Delete a passkey 224 + export function deletePasskey(req: Request): Response { 225 + const sessionToken = 226 + req.headers.get("Authorization")?.replace("Bearer ", "") || 227 + req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1]; 228 + 229 + if (!sessionToken) { 230 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 231 + } 232 + 233 + const session = db 234 + .query( 235 + "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')", 236 + ) 237 + .get(sessionToken) as { user_id: number; expires_at: number } | undefined; 238 + 239 + if (!session) { 240 + return Response.json({ error: "Invalid session" }, { status: 401 }); 241 + } 242 + 243 + const url = new URL(req.url); 244 + const passkeyId = url.pathname.split("/").pop(); 245 + 246 + if (!passkeyId) { 247 + return Response.json({ error: "Passkey ID required" }, { status: 400 }); 248 + } 249 + 250 + // Check if this is the user's passkey 251 + const passkey = db 252 + .query("SELECT user_id FROM credentials WHERE id = ?") 253 + .get(Number(passkeyId)) as { user_id: number } | undefined; 254 + 255 + if (!passkey || passkey.user_id !== session.user_id) { 256 + return Response.json({ error: "Passkey not found" }, { status: 404 }); 257 + } 258 + 259 + // Check if this is the last passkey 260 + const passkeyCount = db 261 + .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") 262 + .get(session.user_id) as { count: number }; 263 + 264 + if (passkeyCount.count <= 1) { 265 + return Response.json( 266 + { error: "Cannot delete last passkey" }, 267 + { status: 400 }, 268 + ); 269 + } 270 + 271 + // Delete the passkey 272 + db.query("DELETE FROM credentials WHERE id = ?").run(Number(passkeyId)); 273 + 274 + return Response.json({ success: true }); 275 + } 276 + 277 + // Rename a passkey 278 + export async function renamePasskey(req: Request): Promise<Response> { 279 + const sessionToken = 280 + req.headers.get("Authorization")?.replace("Bearer ", "") || 281 + req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1]; 282 + 283 + if (!sessionToken) { 284 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 285 + } 286 + 287 + const session = db 288 + .query( 289 + "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')", 290 + ) 291 + .get(sessionToken) as { user_id: number; expires_at: number } | undefined; 292 + 293 + if (!session) { 294 + return Response.json({ error: "Invalid session" }, { status: 401 }); 295 + } 296 + 297 + const url = new URL(req.url); 298 + const passkeyId = url.pathname.split("/").pop(); 299 + 300 + if (!passkeyId) { 301 + return Response.json({ error: "Passkey ID required" }, { status: 400 }); 302 + } 303 + 304 + const body = await req.json(); 305 + const { name } = body as { name?: string }; 306 + 307 + if (!name || typeof name !== "string" || name.trim().length === 0) { 308 + return Response.json({ error: "Name required" }, { status: 400 }); 309 + } 310 + 311 + // Check if this is the user's passkey 312 + const passkey = db 313 + .query("SELECT user_id FROM credentials WHERE id = ?") 314 + .get(Number(passkeyId)) as { user_id: number } | undefined; 315 + 316 + if (!passkey || passkey.user_id !== session.user_id) { 317 + return Response.json({ error: "Passkey not found" }, { status: 404 }); 318 + } 319 + 320 + // Update the name 321 + db.query("UPDATE credentials SET name = ? WHERE id = ?").run( 322 + name.trim(), 323 + Number(passkeyId), 324 + ); 325 + 326 + return Response.json({ success: true, name: name.trim() }); 327 + }