A server-side link shortening service powered by Linkat
3
fork

Configure Feed

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

feat(identity): add handle resolution and update identity resolver

+157 -78
+44 -45
package-lock.json
··· 29 29 } 30 30 }, 31 31 "node_modules/@atproto/api": { 32 - "version": "0.18.3", 33 - "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.3.tgz", 34 - "integrity": "sha512-CBqyZfkcKYsr348KP4CKb9plMlZ5A96HwA/DnYscPBl6fvMZkAezAjniZX+xUILASHQJg5c+NaNw9xP8ZuyyDQ==", 32 + "version": "0.18.4", 33 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.18.4.tgz", 34 + "integrity": "sha512-+kSxto/GRFXRFFlGwfERrwEKnC6OqTgK34BUToer/Fv08q4WMR+GYPRabbWlnDoJWu3owcQfeYdcblQ88vi16g==", 35 35 "license": "MIT", 36 36 "dependencies": { 37 - "@atproto/common-web": "^0.4.5", 37 + "@atproto/common-web": "^0.4.6", 38 38 "@atproto/lexicon": "^0.5.2", 39 - "@atproto/syntax": "^0.4.1", 39 + "@atproto/syntax": "^0.4.2", 40 40 "@atproto/xrpc": "^0.7.6", 41 41 "await-lock": "^2.2.2", 42 42 "multiformats": "^9.9.0", ··· 45 45 } 46 46 }, 47 47 "node_modules/@atproto/common-web": { 48 - "version": "0.4.5", 49 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.5.tgz", 50 - "integrity": "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA==", 48 + "version": "0.4.6", 49 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.6.tgz", 50 + "integrity": "sha512-+2mG/1oBcB/ZmYIU1ltrFMIiuy9aByKAkb2Fos/0eTdczcLBaH17k0KoxMGvhfsujN2r62XlanOAMzysa7lv1g==", 51 51 "license": "MIT", 52 52 "dependencies": { 53 - "@atproto/lex-data": "0.0.1", 54 - "@atproto/lex-json": "0.0.1", 53 + "@atproto/lex-data": "0.0.2", 54 + "@atproto/lex-json": "0.0.2", 55 55 "zod": "^3.23.8" 56 56 } 57 57 }, 58 58 "node_modules/@atproto/lex-data": { 59 - "version": "0.0.1", 60 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.1.tgz", 61 - "integrity": "sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA==", 59 + "version": "0.0.2", 60 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.2.tgz", 61 + "integrity": "sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg==", 62 62 "license": "MIT", 63 63 "dependencies": { 64 - "@atproto/syntax": "0.4.1", 64 + "@atproto/syntax": "0.4.2", 65 65 "multiformats": "^9.9.0", 66 66 "tslib": "^2.8.1", 67 67 "uint8arrays": "3.0.0", ··· 69 69 } 70 70 }, 71 71 "node_modules/@atproto/lex-json": { 72 - "version": "0.0.1", 73 - "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.1.tgz", 74 - "integrity": "sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg==", 72 + "version": "0.0.2", 73 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.2.tgz", 74 + "integrity": "sha512-Pd72lO+l2rhOTutnf11omh9ZkoB/elbzE3HSmn2wuZlyH1mRhTYvoH8BOGokWQwbZkCE8LL3nOqMT3gHCD2l7g==", 75 75 "license": "MIT", 76 76 "dependencies": { 77 - "@atproto/lex-data": "0.0.1", 77 + "@atproto/lex-data": "0.0.2", 78 78 "tslib": "^2.8.1" 79 79 } 80 80 }, ··· 92 92 } 93 93 }, 94 94 "node_modules/@atproto/syntax": { 95 - "version": "0.4.1", 96 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", 97 - "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==", 95 + "version": "0.4.2", 96 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", 97 + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", 98 98 "license": "MIT" 99 99 }, 100 100 "node_modules/@atproto/xrpc": { ··· 926 926 "license": "MIT" 927 927 }, 928 928 "node_modules/@sveltejs/acorn-typescript": { 929 - "version": "1.0.7", 930 - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz", 931 - "integrity": "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q==", 929 + "version": "1.0.8", 930 + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", 931 + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", 932 932 "license": "MIT", 933 933 "peerDependencies": { 934 934 "acorn": "^8.9.0" ··· 945 945 } 946 946 }, 947 947 "node_modules/@sveltejs/kit": { 948 - "version": "2.49.0", 949 - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz", 950 - "integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==", 948 + "version": "2.49.1", 949 + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.1.tgz", 950 + "integrity": "sha512-vByReCTTdlNM80vva8alAQC80HcOiHLkd8XAxIiKghKSHcqeNfyhp3VsYAV8VSiPKu4Jc8wWCfsZNAIvd1uCqA==", 951 951 "dev": true, 952 952 "license": "MIT", 953 953 "peer": true, ··· 1315 1315 "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", 1316 1316 "dev": true, 1317 1317 "license": "MIT", 1318 - "peer": true, 1319 1318 "dependencies": { 1320 1319 "undici-types": "~7.16.0" 1321 1320 } ··· 1515 1514 "version": "5.5.0", 1516 1515 "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz", 1517 1516 "integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==", 1518 - "dev": true, 1519 1517 "license": "MIT" 1520 1518 }, 1521 1519 "node_modules/dijkstrajs": { ··· 1593 1591 "license": "MIT" 1594 1592 }, 1595 1593 "node_modules/esrap": { 1596 - "version": "2.1.3", 1597 - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.3.tgz", 1598 - "integrity": "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg==", 1594 + "version": "2.2.1", 1595 + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", 1596 + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", 1599 1597 "license": "MIT", 1600 1598 "dependencies": { 1601 1599 "@jridgewell/sourcemap-codec": "^1.4.15" ··· 2152 2150 } 2153 2151 }, 2154 2152 "node_modules/prettier": { 2155 - "version": "3.6.2", 2156 - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", 2157 - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", 2153 + "version": "3.7.4", 2154 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", 2155 + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", 2158 2156 "dev": true, 2159 2157 "license": "MIT", 2160 2158 "peer": true, ··· 2345 2343 } 2346 2344 }, 2347 2345 "node_modules/svelte": { 2348 - "version": "5.43.15", 2349 - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.15.tgz", 2350 - "integrity": "sha512-FYlfm3oyLBNUy2NGqaWfKPiGOamS6YB8BJwAcF9xSXVFUjfcl9Ded1YSMu1vXEf0y0lcmBj45UgnOY2ZxhW0Cw==", 2346 + "version": "5.45.6", 2347 + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz", 2348 + "integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==", 2351 2349 "license": "MIT", 2352 2350 "peer": true, 2353 2351 "dependencies": { ··· 2359 2357 "aria-query": "^5.3.1", 2360 2358 "axobject-query": "^4.1.0", 2361 2359 "clsx": "^2.1.1", 2360 + "devalue": "^5.5.0", 2362 2361 "esm-env": "^1.2.1", 2363 - "esrap": "^2.1.0", 2362 + "esrap": "^2.2.1", 2364 2363 "is-reference": "^3.0.3", 2365 2364 "locate-character": "^3.0.0", 2366 2365 "magic-string": "^0.30.11", ··· 2507 2506 "license": "MIT" 2508 2507 }, 2509 2508 "node_modules/unicode-segmenter": { 2510 - "version": "0.14.0", 2511 - "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz", 2512 - "integrity": "sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg==", 2509 + "version": "0.14.1", 2510 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.1.tgz", 2511 + "integrity": "sha512-yHedxlEpUyD+u1UE8qAuCMXVdMLn7yUdlmd8WN7FGmO1ICnpE7LJfnmuXBB+T0zkie3qHsy8fSucqceI/MylOg==", 2513 2512 "license": "MIT" 2514 2513 }, 2515 2514 "node_modules/vite": { 2516 - "version": "7.2.4", 2517 - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", 2518 - "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", 2515 + "version": "7.2.6", 2516 + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", 2517 + "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", 2519 2518 "dev": true, 2520 2519 "license": "MIT", 2521 2520 "peer": true,
+37 -24
src/lib/components/QRCodeModal.svelte
··· 1 1 <script lang="ts"> 2 2 import { X } from '@lucide/svelte'; 3 - import { onMount } from 'svelte'; 4 3 5 4 interface Props { 6 5 url: string; ··· 10 9 11 10 let { url, isOpen, onClose }: Props = $props(); 12 11 13 - let qrCodeContainer: HTMLDivElement; 12 + let qrCodeContainer = $state<HTMLDivElement | undefined>(); 13 + 14 + // Handle keyboard events for accessibility 15 + function handleBackdropKeydown(e: KeyboardEvent) { 16 + if (e.key === 'Escape') { 17 + onClose(); 18 + } 19 + } 14 20 15 21 $effect(() => { 16 22 if (isOpen && qrCodeContainer && typeof window !== 'undefined') { 17 23 import('qrcode').then(({ default: QRCode }) => { 24 + if (!qrCodeContainer) return; 25 + 18 26 qrCodeContainer.innerHTML = ''; 19 - QRCode.toCanvas(url, { 20 - errorCorrectionLevel: 'H', 21 - margin: 2, 22 - width: 256, 23 - color: { 24 - dark: '#000000', 25 - light: '#FFFFFF' 26 - } 27 - }, (error: Error | null | undefined, canvas: HTMLCanvasElement) => { 28 - if (error) { 29 - console.error('QR Code generation error:', error); 30 - return; 31 - } 32 - if (qrCodeContainer) { 33 - qrCodeContainer.appendChild(canvas); 27 + QRCode.toCanvas( 28 + url, 29 + { 30 + errorCorrectionLevel: 'H', 31 + margin: 2, 32 + width: 256, 33 + color: { 34 + dark: '#000000', 35 + light: '#FFFFFF' 36 + } 37 + }, 38 + (error: Error | null | undefined, canvas: HTMLCanvasElement) => { 39 + if (error) { 40 + console.error('QR Code generation error:', error); 41 + return; 42 + } 43 + if (qrCodeContainer) { 44 + qrCodeContainer.appendChild(canvas); 45 + } 34 46 } 35 - }); 47 + ); 36 48 }); 37 49 } 38 50 }); 39 51 </script> 40 52 41 53 {#if isOpen} 54 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 42 55 <div 43 56 style=" 44 57 position: fixed; ··· 56 69 z-index: 9999; 57 70 " 58 71 onclick={onClose} 72 + onkeydown={handleBackdropKeydown} 59 73 role="dialog" 60 74 aria-modal="true" 61 75 aria-labelledby="qr-modal-title" 76 + tabindex="-1" 62 77 > 78 + <!-- svelte-ignore a11y_no_static_element_interactions --> 63 79 <div 64 80 class="relative w-full max-w-md rounded-lg p-8 shadow-2xl" 65 81 style="background-color: rgb(var(--color-surface)); color: rgb(var(--color-text-primary))" 66 82 onclick={(e) => e.stopPropagation()} 67 - role="document" 83 + onkeydown={(e) => e.stopPropagation()} 68 84 > 69 85 <button 70 86 onclick={onClose} ··· 90 106 </h2> 91 107 92 108 <div class="flex flex-col items-center gap-4"> 93 - <div 94 - bind:this={qrCodeContainer} 95 - class="rounded-lg p-4" 96 - style="background-color: white;" 97 - ></div> 109 + <div bind:this={qrCodeContainer} class="rounded-lg p-4" style="background-color: white;"> 110 + </div> 98 111 <p 99 112 class="max-w-xs break-all text-center text-sm" 100 113 style="color: rgb(var(--color-text-secondary))"
+4 -2
src/lib/components/ShortLinkItem.svelte
··· 15 15 let isHovered = $state(false); 16 16 let showQRModal = $state(false); 17 17 18 - // Get the full URL for copying 19 - const fullUrl = typeof window !== 'undefined' ? `${window.location.origin}/${shortcode}` : ''; 18 + // Use $derived to make this reactive and avoid capturing initial value 19 + const fullUrl = $derived( 20 + typeof window !== 'undefined' ? `${window.location.origin}/${shortcode}` : '' 21 + ); 20 22 </script> 21 23 22 24 <li>
+3 -2
src/lib/components/StatusCard.svelte
··· 21 21 } 22 22 }; 23 23 24 - const config = styles[type]; 25 - const Icon = config.icon; 24 + // Use $derived to make this reactive 25 + const config = $derived(styles[type]); 26 + const Icon = $derived(config.icon); 26 27 </script> 27 28 28 29 <div
+51 -1
src/lib/services/atproto/identity-resolver.ts
··· 6 6 export interface ResolvedIdentity { 7 7 did: string; 8 8 pds: string; 9 + handle?: string; 9 10 } 10 11 11 12 /** ··· 51 52 throw new Error('Invalid response from identity resolver'); 52 53 } 53 54 54 - return data; 55 + return { 56 + did: data.did, 57 + pds: data.pds, 58 + handle: data.handle 59 + }; 60 + } 61 + 62 + /** 63 + * Resolves a DID to get the user's handle 64 + * 65 + * @param did - The DID to resolve 66 + * @param fetchFn - Optional custom fetch function 67 + * @returns The user's handle 68 + * @throws Error if resolution fails 69 + */ 70 + export async function resolveHandle( 71 + did: string, 72 + fetchFn?: typeof fetch 73 + ): Promise<string> { 74 + console.info(`[Identity] Resolving handle for DID: ${did}`); 75 + 76 + const identity = await resolveIdentity(did, fetchFn); 77 + 78 + if (identity.handle) { 79 + console.info(`[Identity] Found handle from Slingshot: ${identity.handle}`); 80 + return identity.handle; 81 + } 82 + 83 + // Fallback: fetch profile from public API if handle not in identity response 84 + console.info('[Identity] Handle not in Slingshot response, fetching from public API'); 85 + const _fetch = fetchFn ?? globalThis.fetch; 86 + const response = await _fetch( 87 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 88 + ); 89 + 90 + if (!response.ok) { 91 + console.error(`[Identity] Profile fetch failed: ${response.status} ${response.statusText}`); 92 + throw new Error( 93 + `Failed to fetch profile from Bluesky API: ${response.status} ${response.statusText}` 94 + ); 95 + } 96 + 97 + const profile = await response.json(); 98 + 99 + if (!profile.handle) { 100 + throw new Error('No handle found in profile response'); 101 + } 102 + 103 + console.info(`[Identity] Resolved handle from API: ${profile.handle}`); 104 + return profile.handle; 55 105 }
+1 -1
src/lib/services/atproto/index.ts
··· 6 6 */ 7 7 8 8 export { createAgent } from './agent-factory'; 9 - export { resolveIdentity, type ResolvedIdentity } from './identity-resolver'; 9 + export { resolveIdentity, resolveHandle, type ResolvedIdentity } from './identity-resolver'; 10 10 export { 11 11 defaultAgent, 12 12 getPublicAgent,
+14 -1
src/routes/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 2 import { ATPROTO_DID } from '$env/static/private'; 3 3 import { getShortLinks } from '$lib/services/linkat'; 4 + import { resolveHandle } from '$lib/services/atproto'; 4 5 import type { ShortLink } from '$lib/services/types'; 5 6 6 - export const load: PageServerLoad = async () => { 7 + export const load: PageServerLoad = async ({ fetch }) => { 7 8 // Check if DID is configured 8 9 if (!ATPROTO_DID || ATPROTO_DID === '') { 9 10 console.error('[Homepage] ATPROTO_DID not configured'); 10 11 return { 11 12 did: 'NOT_CONFIGURED', 13 + handle: null, 12 14 linkCount: 0, 13 15 links: [], 14 16 error: 'ATPROTO_DID environment variable is not configured. Please add it to your .env file.' ··· 16 18 } 17 19 18 20 try { 21 + // Resolve handle from DID 22 + let handle: string | null = null; 23 + try { 24 + handle = await resolveHandle(ATPROTO_DID, fetch); 25 + console.info(`[Homepage] Resolved handle: ${handle}`); 26 + } catch (error) { 27 + console.warn('[Homepage] Failed to resolve handle, will display DID instead:', error); 28 + } 29 + 19 30 const links = await getShortLinks(); 20 31 21 32 return { 22 33 did: ATPROTO_DID, 34 + handle, 23 35 linkCount: links.length, 24 36 links: links.map((link: ShortLink) => ({ 25 37 shortcode: link.shortcode, ··· 31 43 console.error('[Homepage] Error loading links:', error); 32 44 return { 33 45 did: ATPROTO_DID, 46 + handle: null, 34 47 linkCount: 0, 35 48 links: [], 36 49 error: 'Failed to load links from AT Protocol. Please check your DID and network connection.'
+3 -2
src/routes/+page.svelte
··· 61 61 <StatusCard type="success"> 62 62 <div class="space-y-1" style="color: rgb(var(--color-text-primary))"> 63 63 <p>Service is running</p> 64 - <p class="flex items-center gap-2"> 65 - Configured DID: <CodeBlock>{data.did}</CodeBlock> 64 + <p class="flex items-center gap-2 flex-wrap"> 65 + Configured {data.handle ? 'handle' : 'DID'}: 66 + <CodeBlock>{data.handle || data.did}</CodeBlock> 66 67 </p> 67 68 <p>Active links: {data.linkCount}</p> 68 69 </div>