this repo has no description
0
fork

Configure Feed

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

Start to switch into just a runner and keytrace

+1602 -4110
+2 -1
.gitignore
··· 22 22 23 23 # Session data 24 24 .data 25 - .turbo 25 + .turbo 26 + packages/runner/dist
+2 -1
.oxfmtrc.json
··· 1 1 { 2 2 "$schema": "./node_modules/oxfmt/configuration_schema.json", 3 3 "ignorePatterns": [], 4 - "semicolons": false 4 + "semicolons": false, 5 + "printWidth": 180 5 6 }
+4 -1
.vscode/settings.json
··· 25 25 "titleBar.inactiveBackground": "#cfd99a99", 26 26 "titleBar.inactiveForeground": "#15202b99" 27 27 }, 28 - "peacock.color": "#cfd99a" 28 + "peacock.color": "#cfd99a", 29 + "[typescript]": { 30 + "editor.defaultFormatter": "oxc.oxc-vscode" 31 + } 29 32 }
+4 -4
apps/keytrace.dev/app.vue
··· 5 5 </template> 6 6 7 7 <script setup lang="ts"> 8 - const { colorMode, init } = useColorMode() 8 + const { colorMode, init } = useColorMode(); 9 9 10 10 // Set html class during SSR and client to avoid hydration mismatch 11 11 useHead({ 12 12 htmlAttrs: { 13 13 class: computed(() => colorMode.value), 14 14 }, 15 - }) 15 + }); 16 16 17 17 onMounted(() => { 18 - init() 19 - }) 18 + init(); 19 + }); 20 20 </script>
+59 -46
apps/keytrace.dev/assets/css/main.css
··· 4 4 5 5 /* ─── Dark mode (default) ─── */ 6 6 :root { 7 - --kt-bg-root: #0C0A13; 8 - --kt-bg-surface: #13111C; 9 - --kt-bg-elevated: #1C1929; 10 - --kt-bg-inset: #0A0910; 7 + --kt-bg-root: #0c0a13; 8 + --kt-bg-surface: #13111c; 9 + --kt-bg-elevated: #1c1929; 10 + --kt-bg-inset: #0a0910; 11 11 12 - --kt-text-primary: #F4F4F5; 13 - --kt-text-secondary: #A1A1AA; 14 - --kt-text-tertiary: #71717A; 15 - --kt-text-link: #A78BFA; 12 + --kt-text-primary: #f4f4f5; 13 + --kt-text-secondary: #a1a1aa; 14 + --kt-text-tertiary: #71717a; 15 + --kt-text-link: #a78bfa; 16 16 17 - --kt-border-default: #27272A; 18 - --kt-border-muted: #18181B; 19 - --kt-border-emphasis: #3F3F46; 17 + --kt-border-default: #27272a; 18 + --kt-border-muted: #18181b; 19 + --kt-border-emphasis: #3f3f46; 20 20 21 - --kt-verified: #22C55E; 22 - --kt-verified-muted: #22C55E20; 23 - --kt-verified-text: #4ADE80; 24 - --kt-verified-border: #22C55E40; 21 + --kt-verified: #22c55e; 22 + --kt-verified-muted: #22c55e20; 23 + --kt-verified-text: #4ade80; 24 + --kt-verified-border: #22c55e40; 25 25 26 - --kt-pending: #F59E0B; 27 - --kt-pending-muted: #F59E0B20; 28 - --kt-pending-text: #FBBF24; 29 - --kt-pending-border: #F59E0B40; 26 + --kt-pending: #f59e0b; 27 + --kt-pending-muted: #f59e0b20; 28 + --kt-pending-text: #fbbf24; 29 + --kt-pending-border: #f59e0b40; 30 30 31 - --kt-failed: #EF4444; 32 - --kt-failed-muted: #EF444420; 33 - --kt-failed-text: #F87171; 34 - --kt-failed-border: #EF444440; 31 + --kt-failed: #ef4444; 32 + --kt-failed-muted: #ef444420; 33 + --kt-failed-text: #f87171; 34 + --kt-failed-border: #ef444440; 35 35 36 - --kt-unverified: #71717A; 37 - --kt-unverified-muted: #71717A20; 38 - --kt-unverified-text: #A1A1AA; 39 - --kt-unverified-border: #71717A40; 36 + --kt-unverified: #71717a; 37 + --kt-unverified-muted: #71717a20; 38 + --kt-unverified-text: #a1a1aa; 39 + --kt-unverified-border: #71717a40; 40 40 } 41 41 42 42 /* ─── Light mode ─── */ 43 43 :root.light { 44 - --kt-bg-root: #FAFAFA; 45 - --kt-bg-surface: #FFFFFF; 46 - --kt-bg-elevated: #FFFFFF; 47 - --kt-bg-inset: #F4F4F5; 44 + --kt-bg-root: #fafafa; 45 + --kt-bg-surface: #ffffff; 46 + --kt-bg-elevated: #ffffff; 47 + --kt-bg-inset: #f4f4f5; 48 48 49 - --kt-text-primary: #18181B; 50 - --kt-text-secondary: #71717A; 51 - --kt-text-tertiary: #A1A1AA; 52 - --kt-text-link: #7C3AED; 49 + --kt-text-primary: #18181b; 50 + --kt-text-secondary: #71717a; 51 + --kt-text-tertiary: #a1a1aa; 52 + --kt-text-link: #7c3aed; 53 53 54 - --kt-border-default: #E4E4E7; 55 - --kt-border-muted: #F4F4F5; 56 - --kt-border-emphasis: #D4D4D8; 54 + --kt-border-default: #e4e4e7; 55 + --kt-border-muted: #f4f4f5; 56 + --kt-border-emphasis: #d4d4d8; 57 57 } 58 58 59 59 /* ─── Base styles ─── */ ··· 72 72 @layer utilities { 73 73 .kt-gradient-hero { 74 74 background: 75 - radial-gradient(ellipse at 20% 50%, rgba(139, 92, 246, 0.15) 0%, transparent 50%), 76 - radial-gradient(ellipse at 80% 20%, rgba(34, 197, 94, 0.08) 0%, transparent 40%), 77 - radial-gradient(ellipse at 50% 100%, rgba(59, 130, 246, 0.10) 0%, transparent 50%); 75 + radial-gradient(ellipse at 20% 50%, rgba(139, 92, 246, 0.15) 0%, transparent 50%), radial-gradient(ellipse at 80% 20%, rgba(34, 197, 94, 0.08) 0%, transparent 40%), 76 + radial-gradient(ellipse at 50% 100%, rgba(59, 130, 246, 0.1) 0%, transparent 50%); 78 77 } 79 78 80 79 .kt-glow-card:hover { ··· 84 83 85 84 /* ─── Animations ─── */ 86 85 @keyframes verify-check { 87 - 0% { transform: scale(0); opacity: 0; } 88 - 50% { transform: scale(1.2); } 89 - 100% { transform: scale(1); opacity: 1; } 86 + 0% { 87 + transform: scale(0); 88 + opacity: 0; 89 + } 90 + 50% { 91 + transform: scale(1.2); 92 + } 93 + 100% { 94 + transform: scale(1); 95 + opacity: 1; 96 + } 90 97 } 91 98 92 99 @keyframes verify-ring { 93 - 0% { transform: scale(0.8); opacity: 0; } 94 - 100% { transform: scale(1); opacity: 1; } 100 + 0% { 101 + transform: scale(0.8); 102 + opacity: 0; 103 + } 104 + 100% { 105 + transform: scale(1); 106 + opacity: 1; 107 + } 95 108 } 96 109 97 110 .animate-verify-check {
+32 -49
apps/keytrace.dev/components/ui/ClaimCard.vue
··· 1 1 <template> 2 - <div 3 - class="group rounded-xl border transition-all duration-200" 4 - :class="statusClasses" 5 - > 2 + <div class="group rounded-xl border transition-all duration-200" :class="statusClasses"> 6 3 <!-- Header row --> 7 4 <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800/50"> 8 5 <div class="flex items-center gap-2.5"> ··· 21 18 22 19 <!-- Body --> 23 20 <div class="px-4 py-3 space-y-1.5"> 24 - <a 25 - v-if="claim.subject" 26 - :href="claim.subject" 27 - target="_blank" 28 - rel="noopener noreferrer" 29 - class="text-sm text-violet-400 hover:text-violet-300 font-mono transition-colors" 30 - > 21 + <a v-if="claim.subject" :href="claim.subject" target="_blank" rel="noopener noreferrer" class="text-sm text-violet-400 hover:text-violet-300 font-mono transition-colors"> 31 22 {{ claim.subject }} 32 23 </a> 33 24 ··· 41 32 42 33 <!-- Trust chain (expandable) --> 43 34 <details v-if="claim.status === 'verified' && claim.attestation" class="mt-2"> 44 - <summary class="text-xs text-zinc-600 hover:text-zinc-400 cursor-pointer transition-colors"> 45 - View attestation details 46 - </summary> 35 + <summary class="text-xs text-zinc-600 hover:text-zinc-400 cursor-pointer transition-colors">View attestation details</summary> 47 36 <div class="mt-2 p-3 rounded-lg bg-kt-inset font-mono text-xs text-zinc-500 space-y-1"> 48 - <div v-if="claim.attestation.signingKey?.uri"> 49 - Key: {{ truncate(claim.attestation.signingKey.uri) }} 50 - </div> 51 - <div v-if="claim.attestation.sig"> 52 - Sig: {{ truncate(claim.attestation.sig, 40) }} 53 - </div> 54 - <div v-if="claim.recipe?.cid"> 55 - Recipe CID: {{ truncate(claim.recipe.cid) }} 56 - </div> 37 + <div v-if="claim.attestation.signingKey?.uri">Key: {{ truncate(claim.attestation.signingKey.uri) }}</div> 38 + <div v-if="claim.attestation.sig">Sig: {{ truncate(claim.attestation.sig, 40) }}</div> 39 + <div v-if="claim.recipe?.cid">Recipe CID: {{ truncate(claim.recipe.cid) }}</div> 57 40 </div> 58 41 </details> 59 42 </div> ··· 61 44 </template> 62 45 63 46 <script setup lang="ts"> 64 - import { computed } from "vue" 65 - import { Github, Globe, AtSign, Key } from "lucide-vue-next" 47 + import { computed } from "vue"; 48 + import { Github, Globe, AtSign, Key } from "lucide-vue-next"; 66 49 67 50 export interface ClaimData { 68 - displayName: string 69 - status: "verified" | "pending" | "failed" | "unverified" 70 - serviceType?: string 71 - subject?: string 72 - recipeName?: string 51 + displayName: string; 52 + status: "verified" | "pending" | "failed" | "unverified"; 53 + serviceType?: string; 54 + subject?: string; 55 + recipeName?: string; 73 56 attestation?: { 74 - signedAt?: string 75 - signingKey?: { uri: string } 76 - sig?: string 77 - } 57 + signedAt?: string; 58 + signingKey?: { uri: string }; 59 + sig?: string; 60 + }; 78 61 recipe?: { 79 - cid?: string 80 - } 62 + cid?: string; 63 + }; 81 64 } 82 65 83 66 const props = defineProps<{ 84 - claim: ClaimData 85 - }>() 67 + claim: ClaimData; 68 + }>(); 86 69 87 70 const serviceIcons: Record<string, any> = { 88 71 github: Github, ··· 90 73 dns: Globe, 91 74 mastodon: AtSign, 92 75 fediverse: AtSign, 93 - } 76 + }; 94 77 95 - const serviceIcon = computed(() => serviceIcons[props.claim.serviceType ?? ""] ?? Key) 78 + const serviceIcon = computed(() => serviceIcons[props.claim.serviceType ?? ""] ?? Key); 96 79 97 80 const statusClasses = computed(() => { 98 81 switch (props.claim.status) { 99 82 case "verified": 100 - return "border-verified/20 bg-kt-surface hover:border-verified/40 hover:shadow-glow-verified" 83 + return "border-verified/20 bg-kt-surface hover:border-verified/40 hover:shadow-glow-verified"; 101 84 case "pending": 102 - return "border-pending/20 bg-kt-surface hover:border-pending/40" 85 + return "border-pending/20 bg-kt-surface hover:border-pending/40"; 103 86 case "failed": 104 - return "border-failed/20 bg-kt-surface hover:border-failed/40" 87 + return "border-failed/20 bg-kt-surface hover:border-failed/40"; 105 88 default: 106 - return "border-zinc-800 bg-kt-surface hover:border-zinc-700" 89 + return "border-zinc-800 bg-kt-surface hover:border-zinc-700"; 107 90 } 108 - }) 91 + }); 109 92 110 93 function truncate(str?: string, len = 24) { 111 - if (!str) return "" 112 - return str.length > len ? str.slice(0, len) + "..." : str 94 + if (!str) return ""; 95 + return str.length > len ? str.slice(0, len) + "..." : str; 113 96 } 114 97 115 98 function formatDate(dateStr?: string) { 116 - if (!dateStr) return "" 99 + if (!dateStr) return ""; 117 100 return new Date(dateStr).toLocaleDateString("en-US", { 118 101 month: "short", 119 102 day: "numeric", 120 103 year: "numeric", 121 - }) 104 + }); 122 105 } 123 106 </script>
+10 -13
apps/keytrace.dev/components/ui/CopyButton.vue
··· 1 1 <template> 2 - <button 3 - class="inline-flex items-center gap-1 text-zinc-500 hover:text-zinc-300 transition-colors" 4 - @click="copy" 5 - > 2 + <button class="inline-flex items-center gap-1 text-zinc-500 hover:text-zinc-300 transition-colors" @click="copy"> 6 3 <Transition name="fade" mode="out-in"> 7 4 <CheckIcon v-if="copied" key="check" class="w-3.5 h-3.5 text-verified" /> 8 5 <ClipboardIcon v-else key="copy" class="w-3.5 h-3.5" /> ··· 12 9 </template> 13 10 14 11 <script setup lang="ts"> 15 - import { ref } from "vue" 16 - import { Check as CheckIcon, Clipboard as ClipboardIcon } from "lucide-vue-next" 12 + import { ref } from "vue"; 13 + import { Check as CheckIcon, Clipboard as ClipboardIcon } from "lucide-vue-next"; 17 14 18 15 const props = defineProps<{ 19 - value: string 20 - }>() 16 + value: string; 17 + }>(); 21 18 22 - const copied = ref(false) 19 + const copied = ref(false); 23 20 24 21 async function copy() { 25 22 try { 26 - await navigator.clipboard.writeText(props.value) 27 - copied.value = true 23 + await navigator.clipboard.writeText(props.value); 24 + copied.value = true; 28 25 setTimeout(() => { 29 - copied.value = false 30 - }, 2000) 26 + copied.value = false; 27 + }, 2000); 31 28 } catch { 32 29 // Clipboard API may not be available 33 30 }
+9 -9
apps/keytrace.dev/components/ui/KtInput.vue
··· 15 15 </template> 16 16 17 17 <script setup lang="ts"> 18 - import { useId } from "vue" 18 + import { useId } from "vue"; 19 19 20 20 defineProps<{ 21 - modelValue?: string 22 - label?: string 23 - placeholder?: string 24 - type?: string 25 - }>() 21 + modelValue?: string; 22 + label?: string; 23 + placeholder?: string; 24 + type?: string; 25 + }>(); 26 26 27 27 defineEmits<{ 28 - "update:modelValue": [value: string] 29 - }>() 28 + "update:modelValue": [value: string]; 29 + }>(); 30 30 31 - const inputId = useId() 31 + const inputId = useId(); 32 32 </script>
+17 -44
apps/keytrace.dev/components/ui/MobileProfileCard.vue
··· 5 5 6 6 <!-- Avatar overlapping header --> 7 7 <div class="px-4 -mt-8"> 8 - <img 9 - v-if="avatar" 10 - :src="avatar" 11 - :alt="displayName" 12 - class="w-16 h-16 rounded-full ring-4 ring-kt-surface" 13 - /> 8 + <img v-if="avatar" :src="avatar" :alt="displayName" class="w-16 h-16 rounded-full ring-4 ring-kt-surface" /> 14 9 <div v-else class="w-16 h-16 rounded-full ring-4 ring-kt-surface bg-zinc-800 flex items-center justify-center"> 15 10 <UserIcon class="w-6 h-6 text-zinc-600" /> 16 11 </div> ··· 23 18 24 19 <!-- Compact claim list --> 25 20 <div v-if="claims?.length" class="mt-3 space-y-1.5"> 26 - <div 27 - v-for="claim in claims" 28 - :key="claim.subject" 29 - class="flex items-center gap-2 text-sm" 30 - > 31 - <CheckCircleIcon 32 - v-if="claim.status === 'verified'" 33 - class="w-4 h-4 text-verified flex-shrink-0" 34 - /> 35 - <ClockIcon 36 - v-else-if="claim.status === 'pending'" 37 - class="w-4 h-4 text-pending flex-shrink-0" 38 - /> 39 - <XCircleIcon 40 - v-else-if="claim.status === 'failed'" 41 - class="w-4 h-4 text-failed flex-shrink-0" 42 - /> 43 - <MinusCircleIcon 44 - v-else 45 - class="w-4 h-4 text-zinc-500 flex-shrink-0" 46 - /> 21 + <div v-for="claim in claims" :key="claim.subject" class="flex items-center gap-2 text-sm"> 22 + <CheckCircleIcon v-if="claim.status === 'verified'" class="w-4 h-4 text-verified flex-shrink-0" /> 23 + <ClockIcon v-else-if="claim.status === 'pending'" class="w-4 h-4 text-pending flex-shrink-0" /> 24 + <XCircleIcon v-else-if="claim.status === 'failed'" class="w-4 h-4 text-failed flex-shrink-0" /> 25 + <MinusCircleIcon v-else class="w-4 h-4 text-zinc-500 flex-shrink-0" /> 47 26 <span class="text-zinc-300 truncate">{{ claim.subject }}</span> 48 27 </div> 49 28 </div> ··· 60 39 </template> 61 40 62 41 <script setup lang="ts"> 63 - import { 64 - CheckCircle as CheckCircleIcon, 65 - Clock as ClockIcon, 66 - XCircle as XCircleIcon, 67 - MinusCircle as MinusCircleIcon, 68 - User as UserIcon, 69 - } from "lucide-vue-next" 42 + import { CheckCircle as CheckCircleIcon, Clock as ClockIcon, XCircle as XCircleIcon, MinusCircle as MinusCircleIcon, User as UserIcon } from "lucide-vue-next"; 70 43 71 44 export interface MobileClaim { 72 - subject: string 73 - status: "verified" | "pending" | "failed" | "unverified" 45 + subject: string; 46 + status: "verified" | "pending" | "failed" | "unverified"; 74 47 } 75 48 76 49 defineProps<{ 77 - avatar?: string 78 - displayName: string 79 - handle: string 80 - did: string 81 - claims?: MobileClaim[] 82 - }>() 50 + avatar?: string; 51 + displayName: string; 52 + handle: string; 53 + did: string; 54 + claims?: MobileClaim[]; 55 + }>(); 83 56 84 57 function truncateDid(did: string) { 85 - if (did.length <= 20) return did 86 - return did.slice(0, 12) + "..." + did.slice(-6) 58 + if (did.length <= 20) return did; 59 + return did.slice(0, 12) + "..." + did.slice(-6); 87 60 } 88 61 </script>
+6 -12
apps/keytrace.dev/components/ui/NavBar.vue
··· 6 6 <div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-violet-600/15 border border-violet-500/20"> 7 7 <span class="font-mono text-sm font-bold text-violet-400">kt</span> 8 8 </div> 9 - <span class="font-semibold text-zinc-200 text-sm hidden sm:block"> 10 - keytrace 11 - </span> 9 + <span class="font-semibold text-zinc-200 text-sm hidden sm:block"> keytrace </span> 12 10 </NuxtLink> 13 11 14 12 <!-- Mobile: icon buttons. Desktop: full nav --> 15 13 <div class="flex items-center gap-2"> 16 - <NuxtLink 17 - v-if="showAddClaim" 18 - to="/add" 19 - class="sm:px-3 sm:py-1.5 p-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" 20 - > 14 + <NuxtLink v-if="showAddClaim" to="/add" class="sm:px-3 sm:py-1.5 p-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors"> 21 15 <PlusIcon class="w-5 h-5 sm:hidden" /> 22 16 <span class="hidden sm:inline">Add claim</span> 23 17 </NuxtLink> ··· 35 29 </template> 36 30 37 31 <script setup lang="ts"> 38 - import { Plus as PlusIcon, User as UserIcon } from "lucide-vue-next" 32 + import { Plus as PlusIcon, User as UserIcon } from "lucide-vue-next"; 39 33 40 34 defineProps<{ 41 - avatarUrl?: string 42 - showAddClaim?: boolean 43 - }>() 35 + avatarUrl?: string; 36 + showAddClaim?: boolean; 37 + }>(); 44 38 </script>
+16 -29
apps/keytrace.dev/components/ui/ProfileHeader.vue
··· 1 1 <template> 2 2 <div class="flex items-start gap-5 p-6"> 3 3 <!-- Avatar --> 4 - <img 5 - v-if="profile.avatar" 6 - :src="profile.avatar" 7 - :alt="profile.displayName" 8 - class="w-20 h-20 rounded-full ring-2 ring-zinc-800 bg-zinc-900" 9 - /> 4 + <img v-if="profile.avatar" :src="profile.avatar" :alt="profile.displayName" class="w-20 h-20 rounded-full ring-2 ring-zinc-800 bg-zinc-900" /> 10 5 <div v-else class="w-20 h-20 rounded-full ring-2 ring-zinc-800 bg-zinc-900 flex items-center justify-center"> 11 6 <UserIcon class="w-8 h-8 text-zinc-600" /> 12 7 </div> ··· 17 12 {{ profile.displayName }} 18 13 </h1> 19 14 20 - <p class="text-zinc-500 mt-0.5"> 21 - <span class="text-zinc-600">@</span>{{ profile.handle }} 22 - </p> 15 + <p class="text-zinc-500 mt-0.5"><span class="text-zinc-600">@</span>{{ profile.handle }}</p> 23 16 24 17 <div class="flex items-center gap-2 mt-1"> 25 18 <code class="text-xs font-mono text-zinc-500 truncate max-w-[280px]"> ··· 30 23 31 24 <!-- Summary badges --> 32 25 <div class="flex items-center gap-2 mt-3"> 33 - <span 34 - v-if="verifiedCount > 0" 35 - class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-verified/10 text-verified text-xs font-medium" 36 - > 26 + <span v-if="verifiedCount > 0" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-verified/10 text-verified text-xs font-medium"> 37 27 <CheckCircleIcon class="w-3.5 h-3.5" /> 38 28 {{ verifiedCount }} verified 39 29 </span> 40 - <span 41 - v-if="pendingCount > 0" 42 - class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-pending/10 text-pending text-xs font-medium" 43 - > 30 + <span v-if="pendingCount > 0" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-pending/10 text-pending text-xs font-medium"> 44 31 <ClockIcon class="w-3.5 h-3.5" /> 45 32 {{ pendingCount }} pending 46 33 </span> ··· 55 42 </template> 56 43 57 44 <script setup lang="ts"> 58 - import { computed } from "vue" 59 - import { CheckCircle as CheckCircleIcon, Clock as ClockIcon, User as UserIcon } from "lucide-vue-next" 45 + import { computed } from "vue"; 46 + import { CheckCircle as CheckCircleIcon, Clock as ClockIcon, User as UserIcon } from "lucide-vue-next"; 60 47 61 48 export interface ProfileData { 62 - avatar?: string 63 - displayName: string 64 - handle: string 65 - did: string 49 + avatar?: string; 50 + displayName: string; 51 + handle: string; 52 + did: string; 66 53 } 67 54 68 55 export interface Claim { 69 - status: "verified" | "pending" | "failed" | "unverified" 56 + status: "verified" | "pending" | "failed" | "unverified"; 70 57 } 71 58 72 59 const props = defineProps<{ 73 - profile: ProfileData 74 - claims?: Claim[] 75 - }>() 60 + profile: ProfileData; 61 + claims?: Claim[]; 62 + }>(); 76 63 77 - const verifiedCount = computed(() => props.claims?.filter((c) => c.status === "verified").length ?? 0) 78 - const pendingCount = computed(() => props.claims?.filter((c) => c.status === "pending").length ?? 0) 64 + const verifiedCount = computed(() => props.claims?.filter((c) => c.status === "verified").length ?? 0); 65 + const pendingCount = computed(() => props.claims?.filter((c) => c.status === "pending").length ?? 0); 79 66 </script>
+20 -28
apps/keytrace.dev/components/ui/RecentClaimRow.vue
··· 1 1 <template> 2 2 <div class="flex items-center gap-3 px-4 py-3 rounded-lg bg-kt-surface border border-zinc-800/50 hover:border-zinc-700/50 transition-colors group"> 3 3 <!-- Avatar --> 4 - <img 5 - v-if="claim.avatar" 6 - :src="claim.avatar" 7 - :alt="claim.handle" 8 - class="w-8 h-8 rounded-full bg-zinc-800" 9 - /> 4 + <img v-if="claim.avatar" :src="claim.avatar" :alt="claim.handle" class="w-8 h-8 rounded-full bg-zinc-800" /> 10 5 <div v-else class="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center"> 11 6 <UserIcon class="w-4 h-4 text-zinc-600" /> 12 7 </div> 13 8 14 9 <!-- Info --> 15 10 <div class="flex-1 min-w-0"> 16 - <NuxtLink 17 - :to="`/@${claim.handle}`" 18 - class="text-sm text-zinc-200 hover:text-white font-medium transition-colors" 19 - > 11 + <NuxtLink :to="`/@${claim.handle}`" class="text-sm text-zinc-200 hover:text-white font-medium transition-colors"> 20 12 {{ claim.handle }} 21 13 </NuxtLink> 22 14 <span class="text-zinc-600 text-sm mx-1.5">verified</span> ··· 34 26 </template> 35 27 36 28 <script setup lang="ts"> 37 - import { computed } from "vue" 38 - import { Github, Globe, AtSign, Key, User as UserIcon } from "lucide-vue-next" 29 + import { computed } from "vue"; 30 + import { Github, Globe, AtSign, Key, User as UserIcon } from "lucide-vue-next"; 39 31 40 32 export interface RecentClaim { 41 - handle: string 42 - avatar?: string 43 - displayName: string 44 - serviceType?: string 45 - createdAt?: string 33 + handle: string; 34 + avatar?: string; 35 + displayName: string; 36 + serviceType?: string; 37 + createdAt?: string; 46 38 } 47 39 48 40 const props = defineProps<{ 49 - claim: RecentClaim 50 - }>() 41 + claim: RecentClaim; 42 + }>(); 51 43 52 44 const serviceIcons: Record<string, any> = { 53 45 github: Github, ··· 55 47 dns: Globe, 56 48 mastodon: AtSign, 57 49 fediverse: AtSign, 58 - } 50 + }; 59 51 60 - const serviceIcon = computed(() => serviceIcons[props.claim.serviceType ?? ""] ?? Key) 52 + const serviceIcon = computed(() => serviceIcons[props.claim.serviceType ?? ""] ?? Key); 61 53 62 54 function relativeTime(dateStr: string) { 63 - const date = new Date(dateStr) 64 - const now = new Date() 65 - const seconds = Math.floor((now.getTime() - date.getTime()) / 1000) 55 + const date = new Date(dateStr); 56 + const now = new Date(); 57 + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 66 58 67 - if (seconds < 60) return "just now" 68 - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` 69 - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` 70 - return `${Math.floor(seconds / 86400)}d ago` 59 + if (seconds < 60) return "just now"; 60 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 61 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 62 + return `${Math.floor(seconds / 86400)}d ago`; 71 63 } 72 64 </script>
+9 -9
apps/keytrace.dev/components/ui/ServicePicker.vue
··· 18 18 </template> 19 19 20 20 <script setup lang="ts"> 21 - import type { Component } from "vue" 21 + import type { Component } from "vue"; 22 22 23 23 export interface ServiceOption { 24 - id: string 25 - name: string 26 - description: string 27 - icon: Component 24 + id: string; 25 + name: string; 26 + description: string; 27 + icon: Component; 28 28 } 29 29 30 30 defineProps<{ 31 - services: ServiceOption[] 32 - }>() 31 + services: ServiceOption[]; 32 + }>(); 33 33 34 34 defineEmits<{ 35 - select: [service: ServiceOption] 36 - }>() 35 + select: [service: ServiceOption]; 36 + }>(); 37 37 </script>
+2 -2
apps/keytrace.dev/components/ui/SkeletonLoader.vue
··· 36 36 37 37 <script setup lang="ts"> 38 38 defineProps<{ 39 - variant?: "profile" | "card" | "row" 40 - }>() 39 + variant?: "profile" | "card" | "row"; 40 + }>(); 41 41 </script>
+7 -10
apps/keytrace.dev/components/ui/StatusBadge.vue
··· 1 1 <template> 2 - <span 3 - class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium" 4 - :class="config.classes" 5 - > 2 + <span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium" :class="config.classes"> 6 3 <component :is="config.icon" class="w-3 h-3" /> 7 4 {{ config.label }} 8 5 </span> 9 6 </template> 10 7 11 8 <script setup lang="ts"> 12 - import { computed } from "vue" 13 - import { CheckCircle, Clock, XCircle, MinusCircle } from "lucide-vue-next" 9 + import { computed } from "vue"; 10 + import { CheckCircle, Clock, XCircle, MinusCircle } from "lucide-vue-next"; 14 11 15 12 const props = defineProps<{ 16 - status: "verified" | "pending" | "failed" | "unverified" 17 - }>() 13 + status: "verified" | "pending" | "failed" | "unverified"; 14 + }>(); 18 15 19 16 const configs = { 20 17 verified: { ··· 37 34 icon: MinusCircle, 38 35 label: "Unverified", 39 36 }, 40 - } as const 37 + } as const; 41 38 42 - const config = computed(() => configs[props.status]) 39 + const config = computed(() => configs[props.status]); 43 40 </script>
+13 -31
apps/keytrace.dev/components/ui/VerificationLog.vue
··· 1 1 <template> 2 2 <div class="rounded-xl border border-zinc-800 bg-kt-surface overflow-hidden"> 3 - <div 4 - v-for="(step, i) in steps" 5 - :key="i" 6 - class="flex items-start gap-3 px-4 py-3 border-b border-zinc-800/50 last:border-b-0" 7 - > 3 + <div v-for="(step, i) in steps" :key="i" class="flex items-start gap-3 px-4 py-3 border-b border-zinc-800/50 last:border-b-0"> 8 4 <!-- Status indicator --> 9 5 <div class="mt-0.5"> 10 - <CheckCircleIcon 11 - v-if="step.status === 'success'" 12 - class="w-4 h-4 text-verified" 13 - /> 14 - <XCircleIcon 15 - v-else-if="step.status === 'error'" 16 - class="w-4 h-4 text-failed" 17 - /> 18 - <div 19 - v-else-if="step.status === 'running'" 20 - class="w-4 h-4 border-2 border-violet-500 border-t-transparent rounded-full animate-spin" 21 - /> 22 - <div 23 - v-else 24 - class="w-4 h-4 rounded-full border border-zinc-700" 25 - /> 6 + <CheckCircleIcon v-if="step.status === 'success'" class="w-4 h-4 text-verified" /> 7 + <XCircleIcon v-else-if="step.status === 'error'" class="w-4 h-4 text-failed" /> 8 + <div v-else-if="step.status === 'running'" class="w-4 h-4 border-2 border-violet-500 border-t-transparent rounded-full animate-spin" /> 9 + <div v-else class="w-4 h-4 rounded-full border border-zinc-700" /> 26 10 </div> 27 11 28 12 <!-- Step info --> ··· 44 28 </div> 45 29 46 30 <!-- Timing --> 47 - <span v-if="step.duration" class="text-xs text-zinc-600 font-mono"> 48 - {{ step.duration }}ms 49 - </span> 31 + <span v-if="step.duration" class="text-xs text-zinc-600 font-mono"> {{ step.duration }}ms </span> 50 32 </div> 51 33 </div> 52 34 </template> 53 35 54 36 <script setup lang="ts"> 55 - import { CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next" 37 + import { CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 56 38 57 39 export interface VerificationStep { 58 - action: string 59 - detail?: string 60 - status: "pending" | "running" | "success" | "error" 61 - duration?: number 40 + action: string; 41 + detail?: string; 42 + status: "pending" | "running" | "success" | "error"; 43 + duration?: number; 62 44 } 63 45 64 46 defineProps<{ 65 - steps: VerificationStep[] 66 - }>() 47 + steps: VerificationStep[]; 48 + }>(); 67 49 </script>
+9 -16
apps/keytrace.dev/components/ui/WizardProgress.vue
··· 1 1 <template> 2 2 <div class="flex items-center gap-2"> 3 3 <template v-for="(step, i) in steps" :key="i"> 4 - <div 5 - class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-all" 6 - :class="stepClass(i)" 7 - > 4 + <div class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-medium transition-all" :class="stepClass(i)"> 8 5 <CheckIcon v-if="i < currentStep" class="w-4 h-4" /> 9 6 <span v-else>{{ i + 1 }}</span> 10 7 </div> 11 - <div 12 - v-if="i < steps.length - 1" 13 - class="flex-1 h-px transition-colors" 14 - :class="i < currentStep ? 'bg-violet-500' : 'bg-zinc-800'" 15 - /> 8 + <div v-if="i < steps.length - 1" class="flex-1 h-px transition-colors" :class="i < currentStep ? 'bg-violet-500' : 'bg-zinc-800'" /> 16 9 </template> 17 10 </div> 18 11 </template> 19 12 20 13 <script setup lang="ts"> 21 - import { Check as CheckIcon } from "lucide-vue-next" 14 + import { Check as CheckIcon } from "lucide-vue-next"; 22 15 23 16 const props = defineProps<{ 24 - steps: string[] 25 - currentStep: number 26 - }>() 17 + steps: string[]; 18 + currentStep: number; 19 + }>(); 27 20 28 21 function stepClass(i: number) { 29 - if (i < props.currentStep) return "bg-violet-600 text-white" 30 - if (i === props.currentStep) return "bg-violet-600/20 text-violet-400 ring-1 ring-violet-500" 31 - return "bg-zinc-800 text-zinc-500" 22 + if (i < props.currentStep) return "bg-violet-600 text-white"; 23 + if (i === props.currentStep) return "bg-violet-600/20 text-violet-400 ring-1 ring-violet-500"; 24 + return "bg-zinc-800 text-zinc-500"; 32 25 } 33 26 </script>
+8 -8
apps/keytrace.dev/composables/useColorMode.ts
··· 1 - const colorMode = ref<"dark" | "light">("dark") 1 + const colorMode = ref<"dark" | "light">("dark"); 2 2 3 3 export function useColorMode() { 4 4 function apply(mode: "dark" | "light") { 5 - colorMode.value = mode 5 + colorMode.value = mode; 6 6 if (import.meta.client) { 7 - localStorage.setItem("kt-theme", mode) 7 + localStorage.setItem("kt-theme", mode); 8 8 } 9 9 } 10 10 11 11 function toggle() { 12 - apply(colorMode.value === "dark" ? "light" : "dark") 12 + apply(colorMode.value === "dark" ? "light" : "dark"); 13 13 } 14 14 15 15 function init() { 16 - if (!import.meta.client) return 17 - const stored = localStorage.getItem("kt-theme") as "dark" | "light" | null 16 + if (!import.meta.client) return; 17 + const stored = localStorage.getItem("kt-theme") as "dark" | "light" | null; 18 18 // Dark-first: only use light if explicitly chosen by the user 19 - apply(stored ?? "dark") 19 + apply(stored ?? "dark"); 20 20 } 21 21 22 22 return { 23 23 colorMode: readonly(colorMode), 24 24 toggle, 25 25 init, 26 - } 26 + }; 27 27 }
+22 -22
apps/keytrace.dev/composables/useSession.ts
··· 1 1 interface Session { 2 - authenticated: boolean 3 - did?: string 4 - handle?: string 5 - displayName?: string 6 - avatar?: string 2 + authenticated: boolean; 3 + did?: string; 4 + handle?: string; 5 + displayName?: string; 6 + avatar?: string; 7 7 } 8 8 9 - const sessionState = ref<Session | null>(null) 10 - const sessionLoading = ref(false) 11 - const sessionFetched = ref(false) 9 + const sessionState = ref<Session | null>(null); 10 + const sessionLoading = ref(false); 11 + const sessionFetched = ref(false); 12 12 13 13 export function useSession() { 14 14 async function fetchSession() { 15 - if (sessionFetched.value) return 16 - sessionLoading.value = true 15 + if (sessionFetched.value) return; 16 + sessionLoading.value = true; 17 17 try { 18 - const data = await $fetch<Session>("/api/oauth/session") 19 - sessionState.value = data 18 + const data = await $fetch<Session>("/api/oauth/session"); 19 + sessionState.value = data; 20 20 } catch { 21 - sessionState.value = { authenticated: false } 21 + sessionState.value = { authenticated: false }; 22 22 } finally { 23 - sessionLoading.value = false 24 - sessionFetched.value = true 23 + sessionLoading.value = false; 24 + sessionFetched.value = true; 25 25 } 26 26 } 27 27 28 28 async function refresh() { 29 - sessionFetched.value = false 30 - await fetchSession() 29 + sessionFetched.value = false; 30 + await fetchSession(); 31 31 } 32 32 33 33 async function logout() { 34 - await $fetch("/api/oauth/logout", { method: "POST" }) 35 - sessionState.value = { authenticated: false } 34 + await $fetch("/api/oauth/logout", { method: "POST" }); 35 + sessionState.value = { authenticated: false }; 36 36 } 37 37 38 38 function login(handle: string) { 39 39 if (handle) { 40 40 navigateTo(`/oauth/login?handle=${encodeURIComponent(handle)}`, { 41 41 external: true, 42 - }) 42 + }); 43 43 } 44 44 } 45 45 46 46 // Auto-fetch on first use 47 47 if (!sessionFetched.value && !sessionLoading.value) { 48 - fetchSession() 48 + fetchSession(); 49 49 } 50 50 51 51 return { ··· 54 54 refresh, 55 55 logout, 56 56 login, 57 - } 57 + }; 58 58 }
+27 -48
apps/keytrace.dev/layouts/default.vue
··· 1 1 <template> 2 2 <div class="min-h-screen bg-kt-root"> 3 - <NavBar 4 - :avatar-url="session?.avatar" 5 - :show-add-claim="session?.authenticated" 6 - > 3 + <NavBar :avatar-url="session?.avatar" :show-add-claim="session?.authenticated"> 7 4 <template #user> 8 5 <div v-if="session?.authenticated" class="relative" ref="menuRef"> 9 - <button 10 - class="w-8 h-8 rounded-full bg-zinc-800 overflow-hidden flex items-center justify-center cursor-pointer" 11 - @click="menuOpen = !menuOpen" 12 - > 13 - <img 14 - v-if="session.avatar" 15 - :src="session.avatar" 16 - class="w-full h-full object-cover" 17 - /> 6 + <button class="w-8 h-8 rounded-full bg-zinc-800 overflow-hidden flex items-center justify-center cursor-pointer" @click="menuOpen = !menuOpen"> 7 + <img v-if="session.avatar" :src="session.avatar" class="w-full h-full object-cover" /> 18 8 <UserIcon v-else class="w-4 h-4 text-zinc-500" /> 19 9 </button> 20 10 <Transition ··· 25 15 leave-from-class="opacity-100 scale-100" 26 16 leave-to-class="opacity-0 scale-95" 27 17 > 28 - <div 29 - v-if="menuOpen" 30 - class="absolute right-0 mt-2 min-w-[180px] rounded-lg bg-zinc-900 border border-zinc-800 shadow-xl py-1 z-50 origin-top-right" 31 - > 32 - <NuxtLink 33 - :to="`/${session.handle}`" 34 - class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" 35 - @click="menuOpen = false" 36 - > 37 - <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> 18 + <div v-if="menuOpen" class="absolute right-0 mt-2 min-w-[180px] rounded-lg bg-zinc-900 border border-zinc-800 shadow-xl py-1 z-50 origin-top-right"> 19 + <NuxtLink :to="`/${session.handle}`" class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" @click="menuOpen = false"> 20 + <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 21 + <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /> 22 + <circle cx="12" cy="7" r="4" /> 23 + </svg> 38 24 Profile 39 25 </NuxtLink> 40 26 <div class="h-px bg-zinc-800 my-1" /> 41 - <button 42 - class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 w-full" 43 - @click="handleLogout" 44 - > 45 - <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> 27 + <button class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 w-full" @click="handleLogout"> 28 + <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 29 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> 30 + <polyline points="16 17 21 12 16 7" /> 31 + <line x1="21" y1="12" x2="9" y2="12" /> 32 + </svg> 46 33 Sign out 47 34 </button> 48 35 </div> 49 36 </Transition> 50 37 </div> 51 - <NuxtLink 52 - v-else 53 - to="/" 54 - class="px-3 py-1.5 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" 55 - > 56 - Sign in 57 - </NuxtLink> 38 + <NuxtLink v-else to="/" class="px-3 py-1.5 text-sm text-zinc-400 hover:text-zinc-200 transition-colors"> Sign in </NuxtLink> 58 39 </template> 59 40 </NavBar> 60 41 ··· 70 51 </div> 71 52 <span class="text-xs text-zinc-500">keytrace.dev</span> 72 53 </div> 73 - <span class="text-xs text-zinc-600"> 74 - Identity verification for ATProto 75 - </span> 54 + <span class="text-xs text-zinc-600"> Identity verification for ATProto </span> 76 55 </div> 77 56 </footer> 78 57 </div> 79 58 </template> 80 59 81 60 <script setup lang="ts"> 82 - import { User as UserIcon } from "lucide-vue-next" 61 + import { User as UserIcon } from "lucide-vue-next"; 83 62 84 - const { session, logout } = useSession() 85 - const menuOpen = ref(false) 86 - const menuRef = ref<HTMLElement | null>(null) 63 + const { session, logout } = useSession(); 64 + const menuOpen = ref(false); 65 + const menuRef = ref<HTMLElement | null>(null); 87 66 88 67 function onClickOutside(e: MouseEvent) { 89 68 if (menuRef.value && !menuRef.value.contains(e.target as Node)) { 90 - menuOpen.value = false 69 + menuOpen.value = false; 91 70 } 92 71 } 93 72 94 - onMounted(() => document.addEventListener("click", onClickOutside)) 95 - onUnmounted(() => document.removeEventListener("click", onClickOutside)) 73 + onMounted(() => document.addEventListener("click", onClickOutside)); 74 + onUnmounted(() => document.removeEventListener("click", onClickOutside)); 96 75 97 76 async function handleLogout() { 98 - menuOpen.value = false 99 - await logout() 100 - navigateTo("/") 77 + menuOpen.value = false; 78 + await logout(); 79 + navigateTo("/"); 101 80 } 102 81 </script>
+1 -1
apps/keytrace.dev/nuxt.config.ts
··· 56 56 keytraceDid: "", 57 57 keytraceAppPassword: "", 58 58 }, 59 - }) 59 + });
+40 -78
apps/keytrace.dev/pages/[handle].vue
··· 11 11 </template> 12 12 13 13 <!-- Error state --> 14 - <div 15 - v-else-if="error" 16 - class="text-center py-20" 17 - > 14 + <div v-else-if="error" class="text-center py-20"> 18 15 <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-failed/10 mb-4"> 19 16 <AlertCircleIcon class="w-8 h-8 text-failed" /> 20 17 </div> 21 18 <h2 class="text-xl font-semibold text-zinc-100 mb-2"> 22 - {{ error.statusCode === 404 ? 'Profile not found' : 'Something went wrong' }} 19 + {{ error.statusCode === 404 ? "Profile not found" : "Something went wrong" }} 23 20 </h2> 24 21 <p class="text-sm text-zinc-400 mb-6"> 25 - {{ error.statusCode === 404 26 - ? `We couldn't find a profile for "${cleanHandle}". Check the handle and try again.` 27 - : 'There was a problem loading this profile. Please try again later.' 22 + {{ 23 + error.statusCode === 404 24 + ? `We couldn't find a profile for "${cleanHandle}". Check the handle and try again.` 25 + : "There was a problem loading this profile. Please try again later." 28 26 }} 29 27 </p> 30 - <NuxtLink 31 - to="/" 32 - class="px-4 py-2 text-sm text-violet-400 hover:text-violet-300 transition-colors" 33 - > 34 - &larr; Back to home 35 - </NuxtLink> 28 + <NuxtLink to="/" class="px-4 py-2 text-sm text-violet-400 hover:text-violet-300 transition-colors"> &larr; Back to home </NuxtLink> 36 29 </div> 37 30 38 31 <!-- Profile content --> ··· 58 51 </ProfileHeader> 59 52 60 53 <!-- Claims list --> 61 - <div 62 - v-if="profile.claims && profile.claims.length > 0" 63 - class="mt-8 space-y-3" 64 - > 65 - <ClaimCard 66 - v-for="claim in profile.claims" 67 - :key="claim.uri" 68 - :claim="mapClaim(claim)" 69 - /> 54 + <div v-if="profile.claims && profile.claims.length > 0" class="mt-8 space-y-3"> 55 + <ClaimCard v-for="claim in profile.claims" :key="claim.uri" :claim="mapClaim(claim)" /> 70 56 </div> 71 57 72 58 <!-- Empty state --> 73 - <div 74 - v-else 75 - class="mt-12 text-center py-16 rounded-xl border border-zinc-800/50 bg-kt-surface" 76 - > 59 + <div v-else class="mt-12 text-center py-16 rounded-xl border border-zinc-800/50 bg-kt-surface"> 77 60 <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-zinc-800 mb-4"> 78 61 <LinkIcon class="w-8 h-8 text-zinc-600" /> 79 62 </div> 80 - <h3 class="text-lg font-semibold text-zinc-200 mb-2"> 81 - No linked accounts yet 82 - </h3> 83 - <p class="text-sm text-zinc-500 max-w-sm mx-auto"> 84 - This user hasn't linked any external accounts to their ATProto identity yet. 85 - </p> 63 + <h3 class="text-lg font-semibold text-zinc-200 mb-2">No linked accounts yet</h3> 64 + <p class="text-sm text-zinc-500 max-w-sm mx-auto">This user hasn't linked any external accounts to their ATProto identity yet.</p> 86 65 </div> 87 66 88 67 <!-- Technical details (progressive disclosure) --> 89 - <details 90 - v-if="profile.did" 91 - class="mt-8" 92 - > 93 - <summary class="text-xs text-zinc-600 hover:text-zinc-400 cursor-pointer transition-colors"> 94 - Technical details 95 - </summary> 68 + <details v-if="profile.did" class="mt-8"> 69 + <summary class="text-xs text-zinc-600 hover:text-zinc-400 cursor-pointer transition-colors">Technical details</summary> 96 70 <div class="mt-3 p-4 rounded-lg bg-kt-inset border border-zinc-800 font-mono text-xs text-zinc-500 space-y-1"> 97 71 <div>DID: {{ profile.did }}</div> 98 72 <div>Handle: {{ profile.handle }}</div> 99 - <div v-if="profile.summary"> 100 - Claims: {{ profile.summary.total }} total, {{ profile.summary.verified }} linked 101 - </div> 73 + <div v-if="profile.summary">Claims: {{ profile.summary.total }} total, {{ profile.summary.verified }} linked</div> 102 74 </div> 103 75 </details> 104 76 </template> ··· 106 78 </template> 107 79 108 80 <script setup lang="ts"> 109 - import { AlertCircle as AlertCircleIcon, Share2 as ShareIcon, Link as LinkIcon } from "lucide-vue-next" 81 + import { AlertCircle as AlertCircleIcon, Share2 as ShareIcon, Link as LinkIcon } from "lucide-vue-next"; 110 82 111 - const route = useRoute() 83 + const route = useRoute(); 112 84 113 85 const rawHandle = computed(() => { 114 - const param = route.params.handle as string 115 - return param 116 - }) 86 + const param = route.params.handle as string; 87 + return param; 88 + }); 117 89 118 90 const cleanHandle = computed(() => { 119 91 // Strip leading @ if present 120 - return rawHandle.value.replace(/^@/, "") 121 - }) 92 + return rawHandle.value.replace(/^@/, ""); 93 + }); 122 94 123 - const { data: profile, pending, error } = await useFetch( 124 - () => `/api/profile/${encodeURIComponent(cleanHandle.value)}`, 125 - ) 95 + const { data: profile, pending, error } = await useFetch(() => `/api/profile/${encodeURIComponent(cleanHandle.value)}`); 126 96 127 97 // Map API claims to the shape ProfileHeader expects 128 - const profileClaims = computed(() => 129 - (profile.value?.claims ?? []).map((c: any) => ({ status: c.status })), 130 - ) 98 + const profileClaims = computed(() => (profile.value?.claims ?? []).map((c: any) => ({ status: c.status }))); 131 99 132 100 // Map API claim to ClaimCard data shape 133 101 function mapClaim(claim: any) { 134 - const match = claim.matches?.[0] 102 + const match = claim.matches?.[0]; 135 103 return { 136 104 displayName: match?.providerName ?? guessDisplayName(claim.uri), 137 105 status: claim.status, ··· 140 108 recipeName: match?.provider, 141 109 attestation: undefined, 142 110 recipe: undefined, 143 - } 111 + }; 144 112 } 145 113 146 114 function guessDisplayName(uri: string) { 147 - if (uri.includes("github.com")) return "GitHub Account" 148 - if (uri.startsWith("dns:")) return "Domain" 149 - if (uri.includes("mastodon")) return "Mastodon Account" 150 - return "Identity Claim" 115 + if (uri.includes("github.com")) return "GitHub Account"; 116 + if (uri.startsWith("dns:")) return "Domain"; 117 + if (uri.includes("mastodon")) return "Mastodon Account"; 118 + return "Identity Claim"; 151 119 } 152 120 153 121 function guessServiceType(uri: string) { 154 - if (uri.includes("github.com")) return "github" 155 - if (uri.startsWith("dns:")) return "dns" 156 - if (uri.includes("mastodon")) return "mastodon" 157 - return "" 122 + if (uri.includes("github.com")) return "github"; 123 + if (uri.startsWith("dns:")) return "dns"; 124 + if (uri.includes("mastodon")) return "mastodon"; 125 + return ""; 158 126 } 159 127 160 128 function shareProfile() { 161 - const url = window.location.href 129 + const url = window.location.href; 162 130 if (navigator.share) { 163 - navigator.share({ title: `${profile.value?.displayName} on Keytrace`, url }) 131 + navigator.share({ title: `${profile.value?.displayName} on Keytrace`, url }); 164 132 } else if (navigator.clipboard) { 165 - navigator.clipboard.writeText(url) 133 + navigator.clipboard.writeText(url); 166 134 } 167 135 } 168 136 169 137 // OG meta tags 170 138 useHead({ 171 - title: computed(() => 172 - profile.value 173 - ? `${profile.value.displayName || profile.value.handle} - Keytrace` 174 - : "Profile - Keytrace", 175 - ), 139 + title: computed(() => (profile.value ? `${profile.value.displayName || profile.value.handle} - Keytrace` : "Profile - Keytrace")), 176 140 meta: [ 177 141 { 178 142 name: "description", 179 143 content: computed(() => 180 - profile.value 181 - ? `View ${profile.value.displayName || profile.value.handle}'s verified identities on Keytrace` 182 - : "View verified identities on Keytrace", 144 + profile.value ? `View ${profile.value.displayName || profile.value.handle}'s verified identities on Keytrace` : "View verified identities on Keytrace", 183 145 ), 184 146 }, 185 147 ], 186 - }) 148 + }); 187 149 </script>
+90 -147
apps/keytrace.dev/pages/add.vue
··· 1 1 <template> 2 2 <div class="max-w-2xl mx-auto px-6 py-12"> 3 3 <!-- Progress indicator --> 4 - <WizardProgress 5 - :steps="stepLabels" 6 - :current-step="currentStep" 7 - class="mb-10" 8 - /> 4 + <WizardProgress :steps="stepLabels" :current-step="currentStep" class="mb-10" /> 9 5 10 6 <!-- Step 1: Choose Service --> 11 7 <Transition name="slide" mode="out-in"> 12 8 <div v-if="currentStep === 0" key="step-0"> 13 - <h2 class="text-2xl font-semibold text-zinc-100 tracking-tight"> 14 - What would you like to link? 15 - </h2> 16 - <p class="mt-2 text-zinc-400 text-sm"> 17 - Choose an account or service to link to your identity. 18 - </p> 9 + <h2 class="text-2xl font-semibold text-zinc-100 tracking-tight">What would you like to link?</h2> 10 + <p class="mt-2 text-zinc-400 text-sm">Choose an account or service to link to your identity.</p> 19 11 20 12 <div class="mt-8"> 21 - <ServicePicker 22 - :services="services" 23 - @select="selectService" 24 - /> 13 + <ServicePicker :services="services" @select="selectService" /> 25 14 </div> 26 15 </div> 27 16 28 17 <!-- Step 2: Instructions + Proof --> 29 18 <div v-else-if="currentStep === 1" key="step-1"> 30 - <h2 class="text-2xl font-semibold text-zinc-100 tracking-tight"> 31 - Create your proof 32 - </h2> 19 + <h2 class="text-2xl font-semibold text-zinc-100 tracking-tight">Create your proof</h2> 33 20 34 21 <ol class="mt-6 space-y-4"> 35 - <li 36 - v-for="(instruction, i) in selectedInstructions" 37 - :key="i" 38 - class="flex gap-3" 39 - > 22 + <li v-for="(instruction, i) in selectedInstructions" :key="i" class="flex gap-3"> 40 23 <span class="flex-shrink-0 w-6 h-6 rounded-full bg-zinc-800 text-zinc-500 text-xs flex items-center justify-center font-mono"> 41 24 {{ i + 1 }} 42 25 </span> ··· 54 37 55 38 <!-- Claim URI input --> 56 39 <div class="mt-6"> 57 - <KtInput 58 - v-model="claimUri" 59 - :label="selectedService?.inputLabel ?? 'Claim URL'" 60 - :placeholder="selectedService?.inputPlaceholder ?? 'https://...'" 61 - /> 40 + <KtInput v-model="claimUri" :label="selectedService?.inputLabel ?? 'Claim URL'" :placeholder="selectedService?.inputPlaceholder ?? 'https://...'" /> 62 41 <p v-if="claimUriError" class="mt-1.5 text-xs text-failed"> 63 42 {{ claimUriError }} 64 43 </p> 65 44 </div> 66 45 67 46 <div class="mt-8 flex items-center gap-3"> 68 - <button 69 - class="px-4 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" 70 - @click="currentStep = 0" 71 - > 72 - &larr; Back 73 - </button> 47 + <button class="px-4 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" @click="currentStep = 0">&larr; Back</button> 74 48 <button 75 49 class="px-6 py-2.5 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all hover:shadow-glow-brand disabled:opacity-50 disabled:cursor-not-allowed" 76 50 :disabled="!claimUri" ··· 83 57 84 58 <!-- Step 3: Verification --> 85 59 <div v-else-if="currentStep === 2" key="step-2"> 86 - <h2 class="text-2xl font-semibold text-zinc-100 tracking-tight mb-6"> 87 - Verifying your claim 88 - </h2> 60 + <h2 class="text-2xl font-semibold text-zinc-100 tracking-tight mb-6">Verifying your claim</h2> 89 61 90 62 <VerificationLog :steps="verificationSteps" /> 91 63 ··· 95 67 <CheckCircleIcon class="w-8 h-8 text-verified animate-verify-check" /> 96 68 </div> 97 69 <h3 class="text-xl font-semibold text-zinc-100">Claim linked</h3> 98 - <p class="text-sm text-zinc-400 mt-2"> 99 - Your identity proof has been verified and stored in your ATProto repo. 100 - </p> 70 + <p class="text-sm text-zinc-400 mt-2">Your identity proof has been verified and stored in your ATProto repo.</p> 101 71 <div class="mt-6 flex items-center justify-center gap-3"> 102 - <NuxtLink 103 - to="/dashboard" 104 - class="px-5 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all" 105 - > 106 - Go to Dashboard 107 - </NuxtLink> 108 - <button 109 - class="px-5 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" 110 - @click="reset" 111 - > 112 - Add another 113 - </button> 72 + <NuxtLink to="/dashboard" class="px-5 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all"> Go to Dashboard </NuxtLink> 73 + <button class="px-5 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" @click="reset">Add another</button> 114 74 </div> 115 75 </div> 116 76 ··· 121 81 </div> 122 82 <h3 class="text-xl font-semibold text-zinc-100">Verification failed</h3> 123 83 <p class="text-sm text-zinc-400 mt-2 max-w-sm mx-auto"> 124 - {{ verificationError || 'We could not verify your proof. Please check your setup and try again.' }} 84 + {{ verificationError || "We could not verify your proof. Please check your setup and try again." }} 125 85 </p> 126 86 <div class="mt-6 flex items-center justify-center gap-3"> 127 - <button 128 - class="px-5 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all" 129 - @click="currentStep = 1" 130 - > 131 - Try again 132 - </button> 133 - <button 134 - class="px-5 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" 135 - @click="reset" 136 - > 137 - Start over 138 - </button> 87 + <button class="px-5 py-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all" @click="currentStep = 1">Try again</button> 88 + <button class="px-5 py-2 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" @click="reset">Start over</button> 139 89 </div> 140 90 </div> 141 91 </div> ··· 144 94 </template> 145 95 146 96 <script setup lang="ts"> 147 - import { 148 - Github, 149 - Globe, 150 - CheckCircle as CheckCircleIcon, 151 - XCircle as XCircleIcon, 152 - } from "lucide-vue-next" 153 - import type { ServiceOption } from "~/components/ui/ServicePicker.vue" 154 - import type { VerificationStep } from "~/components/ui/VerificationLog.vue" 97 + import { Github, Globe, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 98 + import type { ServiceOption } from "~/components/ui/ServicePicker.vue"; 99 + import type { VerificationStep } from "~/components/ui/VerificationLog.vue"; 155 100 156 - const { session } = useSession() 101 + const { session } = useSession(); 157 102 158 103 // Redirect to home if not authenticated 159 104 watch( 160 105 () => session.value, 161 106 (s) => { 162 107 if (s && !s.authenticated) { 163 - navigateTo("/") 108 + navigateTo("/"); 164 109 } 165 110 }, 166 111 { immediate: true }, 167 - ) 112 + ); 168 113 169 - const currentStep = ref(0) 170 - const selectedService = ref<(ServiceOption & { inputLabel?: string; inputPlaceholder?: string; instructions?: string[]; proofTemplate?: string }) | null>(null) 171 - const claimUri = ref("") 172 - const claimUriError = ref("") 173 - const claimId = ref("") 114 + const currentStep = ref(0); 115 + const selectedService = ref<(ServiceOption & { inputLabel?: string; inputPlaceholder?: string; instructions?: string[]; proofTemplate?: string }) | null>(null); 116 + const claimUri = ref(""); 117 + const claimUriError = ref(""); 118 + const claimId = ref(""); 174 119 175 - const stepLabels = ["Choose service", "Create proof", "Verify"] 120 + const stepLabels = ["Choose service", "Create proof", "Verify"]; 176 121 177 122 const services: (ServiceOption & { inputLabel: string; inputPlaceholder: string; instructions: string[]; proofTemplate: string })[] = [ 178 123 { ··· 207 152 ], 208 153 proofTemplate: "keytrace-did={did}", 209 154 }, 210 - ] 155 + ]; 211 156 212 157 const proofContent = computed(() => { 213 - const template = selectedService.value?.proofTemplate ?? "" 214 - return template 215 - .replace(/\{claimId\}/g, claimId.value) 216 - .replace(/\{did\}/g, session.value?.did ?? "did:plc:...") 217 - }) 158 + const template = selectedService.value?.proofTemplate ?? ""; 159 + return template.replace(/\{claimId\}/g, claimId.value).replace(/\{did\}/g, session.value?.did ?? "did:plc:..."); 160 + }); 218 161 219 - const selectedInstructions = computed(() => selectedService.value?.instructions ?? []) 162 + const selectedInstructions = computed(() => selectedService.value?.instructions ?? []); 220 163 221 164 function generateClaimId() { 222 - const bytes = new Uint8Array(8) 223 - crypto.getRandomValues(bytes) 224 - return "kt-" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("") 165 + const bytes = new Uint8Array(8); 166 + crypto.getRandomValues(bytes); 167 + return "kt-" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); 225 168 } 226 169 227 170 function selectService(service: ServiceOption) { 228 - selectedService.value = services.find((s) => s.id === service.id) ?? null 229 - claimUri.value = "" 230 - claimUriError.value = "" 231 - claimId.value = generateClaimId() 232 - currentStep.value = 1 171 + selectedService.value = services.find((s) => s.id === service.id) ?? null; 172 + claimUri.value = ""; 173 + claimUriError.value = ""; 174 + claimId.value = generateClaimId(); 175 + currentStep.value = 1; 233 176 } 234 177 235 178 // Verification state 236 - const verificationSteps = ref<VerificationStep[]>([]) 237 - const verificationComplete = ref(false) 238 - const verificationSuccess = ref(false) 239 - const verificationError = ref("") 179 + const verificationSteps = ref<VerificationStep[]>([]); 180 + const verificationComplete = ref(false); 181 + const verificationSuccess = ref(false); 182 + const verificationError = ref(""); 240 183 241 184 async function startVerification() { 242 185 if (!claimUri.value) { 243 - claimUriError.value = "Please enter a URL or domain" 244 - return 186 + claimUriError.value = "Please enter a URL or domain"; 187 + return; 245 188 } 246 - claimUriError.value = "" 247 - currentStep.value = 2 248 - verificationComplete.value = false 249 - verificationSuccess.value = false 250 - verificationError.value = "" 189 + claimUriError.value = ""; 190 + currentStep.value = 2; 191 + verificationComplete.value = false; 192 + verificationSuccess.value = false; 193 + verificationError.value = ""; 251 194 252 195 // Build the claim URI for the API 253 - let apiClaimUri = claimUri.value 196 + let apiClaimUri = claimUri.value; 254 197 if (selectedService.value?.id === "dns-txt") { 255 - apiClaimUri = `dns:${claimUri.value.replace(/^(https?:\/\/)?/, "").replace(/\/.*$/, "")}` 198 + apiClaimUri = `dns:${claimUri.value.replace(/^(https?:\/\/)?/, "").replace(/\/.*$/, "")}`; 256 199 } 257 200 258 201 // Set up verification steps ··· 261 204 { action: "Fetching proof content", status: "pending" }, 262 205 { action: "Verifying identity match", status: "pending" }, 263 206 { action: "Creating claim record", status: "pending" }, 264 - ] 207 + ]; 265 208 266 209 try { 267 210 // Step 1: Resolve 268 - const startTime = Date.now() 269 - await new Promise((r) => setTimeout(r, 500)) 211 + const startTime = Date.now(); 212 + await new Promise((r) => setTimeout(r, 500)); 270 213 verificationSteps.value[0] = { 271 214 ...verificationSteps.value[0], 272 215 status: "success", 273 216 detail: apiClaimUri, 274 217 duration: Date.now() - startTime, 275 - } 218 + }; 276 219 277 220 // Step 2: Verify via API 278 - verificationSteps.value[1] = { ...verificationSteps.value[1], status: "running" } 279 - const step2Start = Date.now() 221 + verificationSteps.value[1] = { ...verificationSteps.value[1], status: "running" }; 222 + const step2Start = Date.now(); 280 223 281 224 const result = await $fetch("/api/verify", { 282 225 method: "POST", ··· 284 227 claimUri: apiClaimUri, 285 228 did: session.value?.did, 286 229 }, 287 - }) 230 + }); 288 231 289 232 verificationSteps.value[1] = { 290 233 ...verificationSteps.value[1], 291 234 status: "success", 292 235 detail: "Proof content retrieved", 293 236 duration: Date.now() - step2Start, 294 - } 237 + }; 295 238 296 239 // Step 3: Check result 297 - verificationSteps.value[2] = { ...verificationSteps.value[2], status: "running" } 298 - const step3Start = Date.now() 299 - await new Promise((r) => setTimeout(r, 300)) 240 + verificationSteps.value[2] = { ...verificationSteps.value[2], status: "running" }; 241 + const step3Start = Date.now(); 242 + await new Promise((r) => setTimeout(r, 300)); 300 243 301 244 if (result.status === "verified") { 302 245 verificationSteps.value[2] = { ··· 304 247 status: "success", 305 248 detail: "Identity confirmed", 306 249 duration: Date.now() - step3Start, 307 - } 250 + }; 308 251 } else { 309 252 verificationSteps.value[2] = { 310 253 ...verificationSteps.value[2], 311 254 status: "error", 312 255 detail: result.errors?.join(", ") || "Proof not found", 313 256 duration: Date.now() - step3Start, 314 - } 315 - verificationComplete.value = true 316 - verificationSuccess.value = false 317 - verificationError.value = "Could not find your identity proof. Make sure you followed the instructions correctly." 318 - return 257 + }; 258 + verificationComplete.value = true; 259 + verificationSuccess.value = false; 260 + verificationError.value = "Could not find your identity proof. Make sure you followed the instructions correctly."; 261 + return; 319 262 } 320 263 321 264 // Step 4: Create claim record 322 - verificationSteps.value[3] = { ...verificationSteps.value[3], status: "running" } 323 - const step4Start = Date.now() 265 + verificationSteps.value[3] = { ...verificationSteps.value[3], status: "running" }; 266 + const step4Start = Date.now(); 324 267 325 268 await $fetch("/api/claims", { 326 269 method: "POST", 327 270 body: { claimUri: apiClaimUri }, 328 - }) 271 + }); 329 272 330 273 verificationSteps.value[3] = { 331 274 ...verificationSteps.value[3], 332 275 status: "success", 333 276 detail: "Record stored in your ATProto repo", 334 277 duration: Date.now() - step4Start, 335 - } 278 + }; 336 279 337 - verificationComplete.value = true 338 - verificationSuccess.value = true 280 + verificationComplete.value = true; 281 + verificationSuccess.value = true; 339 282 } catch (err: any) { 340 283 // Mark current running step as error 341 - const runningIdx = verificationSteps.value.findIndex((s) => s.status === "running") 284 + const runningIdx = verificationSteps.value.findIndex((s) => s.status === "running"); 342 285 if (runningIdx >= 0) { 343 286 verificationSteps.value[runningIdx] = { 344 287 ...verificationSteps.value[runningIdx], 345 288 status: "error", 346 289 detail: err?.data?.statusMessage || "Request failed", 347 - } 290 + }; 348 291 } 349 - verificationComplete.value = true 350 - verificationSuccess.value = false 351 - verificationError.value = err?.data?.statusMessage || "Verification failed. Please try again." 292 + verificationComplete.value = true; 293 + verificationSuccess.value = false; 294 + verificationError.value = err?.data?.statusMessage || "Verification failed. Please try again."; 352 295 } 353 296 } 354 297 355 298 function reset() { 356 - currentStep.value = 0 357 - selectedService.value = null 358 - claimUri.value = "" 359 - claimUriError.value = "" 360 - claimId.value = "" 361 - verificationSteps.value = [] 362 - verificationComplete.value = false 363 - verificationSuccess.value = false 364 - verificationError.value = "" 299 + currentStep.value = 0; 300 + selectedService.value = null; 301 + claimUri.value = ""; 302 + claimUriError.value = ""; 303 + claimId.value = ""; 304 + verificationSteps.value = []; 305 + verificationComplete.value = false; 306 + verificationSuccess.value = false; 307 + verificationError.value = ""; 365 308 } 366 309 </script>
+30 -75
apps/keytrace.dev/pages/dashboard.vue
··· 3 3 <!-- Page header --> 4 4 <div class="flex items-center justify-between mb-8"> 5 5 <div> 6 - <h1 class="text-2xl font-semibold text-zinc-100 tracking-tight"> 7 - Your Claims 8 - </h1> 9 - <p class="text-sm text-zinc-500 mt-1"> 10 - Manage your linked accounts and identity proofs. 11 - </p> 6 + <h1 class="text-2xl font-semibold text-zinc-100 tracking-tight">Your Claims</h1> 7 + <p class="text-sm text-zinc-500 mt-1">Manage your linked accounts and identity proofs.</p> 12 8 </div> 13 9 <NuxtLink 14 10 to="/add" ··· 26 22 </div> 27 23 28 24 <!-- Error state --> 29 - <div 30 - v-else-if="error" 31 - class="text-center py-16 rounded-xl border border-zinc-800/50 bg-kt-surface" 32 - > 25 + <div v-else-if="error" class="text-center py-16 rounded-xl border border-zinc-800/50 bg-kt-surface"> 33 26 <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-failed/10 mb-4"> 34 27 <AlertCircleIcon class="w-8 h-8 text-failed" /> 35 28 </div> 36 - <h3 class="text-lg font-semibold text-zinc-200 mb-2"> 37 - Failed to load claims 38 - </h3> 39 - <p class="text-sm text-zinc-500 mb-6"> 40 - Something went wrong while fetching your claims. Please try again. 41 - </p> 42 - <button 43 - class="px-4 py-2 text-sm text-violet-400 hover:text-violet-300 transition-colors" 44 - @click="refresh()" 45 - > 46 - Try again 47 - </button> 29 + <h3 class="text-lg font-semibold text-zinc-200 mb-2">Failed to load claims</h3> 30 + <p class="text-sm text-zinc-500 mb-6">Something went wrong while fetching your claims. Please try again.</p> 31 + <button class="px-4 py-2 text-sm text-violet-400 hover:text-violet-300 transition-colors" @click="refresh()">Try again</button> 48 32 </div> 49 33 50 34 <!-- Claims list --> 51 35 <template v-else-if="claims"> 52 36 <div v-if="claims.claims && claims.claims.length > 0" class="space-y-3"> 53 - <div 54 - v-for="claim in claims.claims" 55 - :key="claim.uri" 56 - class="group rounded-xl border border-zinc-800 bg-kt-surface hover:border-zinc-700 transition-all" 57 - > 37 + <div v-for="claim in claims.claims" :key="claim.uri" class="group rounded-xl border border-zinc-800 bg-kt-surface hover:border-zinc-700 transition-all"> 58 38 <div class="flex items-center justify-between px-4 py-3"> 59 39 <div class="flex items-center gap-3 min-w-0"> 60 40 <div class="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center"> ··· 71 51 </div> 72 52 73 53 <div class="flex items-center gap-2 ml-4"> 74 - <button 75 - class="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors" 76 - title="Re-verify" 77 - @click="reverify(claim)" 78 - > 54 + <button class="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors" title="Re-verify" @click="reverify(claim)"> 79 55 <RefreshCwIcon class="w-4 h-4" /> 80 56 </button> 81 - <button 82 - class="p-2 rounded-lg text-zinc-500 hover:text-failed hover:bg-failed/10 transition-colors" 83 - title="Delete claim" 84 - @click="deleteClaim(claim)" 85 - > 57 + <button class="p-2 rounded-lg text-zinc-500 hover:text-failed hover:bg-failed/10 transition-colors" title="Delete claim" @click="deleteClaim(claim)"> 86 58 <Trash2Icon class="w-4 h-4" /> 87 59 </button> 88 60 </div> ··· 91 63 </div> 92 64 93 65 <!-- Empty state --> 94 - <div 95 - v-else 96 - class="text-center py-16 rounded-xl border border-zinc-800/50 bg-kt-surface" 97 - > 66 + <div v-else class="text-center py-16 rounded-xl border border-zinc-800/50 bg-kt-surface"> 98 67 <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-violet-600/10 mb-4"> 99 68 <LinkIcon class="w-8 h-8 text-violet-400" /> 100 69 </div> 101 - <h3 class="text-lg font-semibold text-zinc-200 mb-2"> 102 - No linked accounts yet 103 - </h3> 104 - <p class="text-sm text-zinc-500 max-w-sm mx-auto mb-6"> 105 - Start by connecting your GitHub account or verifying a domain you own. 106 - </p> 70 + <h3 class="text-lg font-semibold text-zinc-200 mb-2">No linked accounts yet</h3> 71 + <p class="text-sm text-zinc-500 max-w-sm mx-auto mb-6">Start by connecting your GitHub account or verifying a domain you own.</p> 107 72 <NuxtLink 108 73 to="/add" 109 74 class="inline-flex items-center gap-2 px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all hover:shadow-glow-brand" ··· 117 82 </template> 118 83 119 84 <script setup lang="ts"> 120 - import { 121 - Plus as PlusIcon, 122 - AlertCircle as AlertCircleIcon, 123 - Link as LinkIcon, 124 - RefreshCw as RefreshCwIcon, 125 - Trash2 as Trash2Icon, 126 - Github, 127 - Globe, 128 - AtSign, 129 - Key, 130 - } from "lucide-vue-next" 131 - import type { Component } from "vue" 85 + import { Plus as PlusIcon, AlertCircle as AlertCircleIcon, Link as LinkIcon, RefreshCw as RefreshCwIcon, Trash2 as Trash2Icon, Github, Globe, AtSign, Key } from "lucide-vue-next"; 86 + import type { Component } from "vue"; 132 87 133 - const { session } = useSession() 88 + const { session } = useSession(); 134 89 135 90 // Redirect to home if not authenticated 136 91 watch( 137 92 () => session.value, 138 93 (s) => { 139 94 if (s && !s.authenticated) { 140 - navigateTo("/") 95 + navigateTo("/"); 141 96 } 142 97 }, 143 98 { immediate: true }, 144 - ) 99 + ); 145 100 146 - const { data: claims, pending, error, refresh } = await useFetch("/api/claims") 101 + const { data: claims, pending, error, refresh } = await useFetch("/api/claims"); 147 102 148 103 const serviceIcons: Record<string, Component> = { 149 104 github: Github, 150 105 domain: Globe, 151 106 dns: Globe, 152 107 mastodon: AtSign, 153 - } 108 + }; 154 109 155 110 function getServiceIcon(uri: string): Component { 156 - if (uri.includes("github.com")) return serviceIcons.github 157 - if (uri.startsWith("dns:")) return serviceIcons.dns 158 - if (uri.includes("mastodon")) return serviceIcons.mastodon 159 - return Key 111 + if (uri.includes("github.com")) return serviceIcons.github; 112 + if (uri.startsWith("dns:")) return serviceIcons.dns; 113 + if (uri.includes("mastodon")) return serviceIcons.mastodon; 114 + return Key; 160 115 } 161 116 162 117 async function reverify(claim: any) { ··· 167 122 claimUri: claim.claimUri, 168 123 did: claims.value?.did, 169 124 }, 170 - }) 171 - await refresh() 125 + }); 126 + await refresh(); 172 127 } catch { 173 128 // Error handling could be improved with toast notifications 174 129 } ··· 176 131 177 132 async function deleteClaim(claim: any) { 178 133 // Extract rkey from the AT URI (at://did/collection/rkey) 179 - const parts = claim.uri?.split("/") 180 - const rkey = parts?.[parts.length - 1] 181 - if (!rkey) return 134 + const parts = claim.uri?.split("/"); 135 + const rkey = parts?.[parts.length - 1]; 136 + if (!rkey) return; 182 137 183 138 try { 184 - await $fetch(`/api/claims/${rkey}`, { method: "DELETE" }) 185 - await refresh() 139 + await $fetch(`/api/claims/${rkey}`, { method: "DELETE" }); 140 + await refresh(); 186 141 } catch { 187 142 // Error handling could be improved with toast notifications 188 143 }
+24 -66
apps/keytrace.dev/pages/index.vue
··· 16 16 17 17 <div class="relative z-10 max-w-3xl mx-auto text-center px-6"> 18 18 <!-- Tagline pill --> 19 - <div 20 - class="mb-8 inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-zinc-800 bg-zinc-900/50 text-xs text-zinc-400" 21 - > 19 + <div class="mb-8 inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-zinc-800 bg-zinc-900/50 text-xs text-zinc-400"> 22 20 <span class="w-2 h-2 rounded-full bg-verified animate-pulse" /> 23 21 Identity verification for ATProto 24 22 </div> 25 23 26 - <h1 27 - class="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-zinc-100 leading-[1.1]" 28 - > 24 + <h1 class="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-zinc-100 leading-[1.1]"> 29 25 Prove who you are,<br /> 30 - <span 31 - class="text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-emerald-400" 32 - > 33 - everywhere. 34 - </span> 26 + <span class="text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-emerald-400"> everywhere. </span> 35 27 </h1> 36 28 37 29 <p class="mt-6 text-lg text-zinc-400 max-w-xl mx-auto leading-relaxed"> 38 - Link your GitHub, domain, and other accounts to your Bluesky identity. 39 - Cryptographically signed, user-owned, and portable. 30 + Link your GitHub, domain, and other accounts to your Bluesky identity. Cryptographically signed, user-owned, and portable. 40 31 </p> 41 32 42 33 <!-- CTA group --> ··· 56 47 Get Started 57 48 </button> 58 49 </template> 59 - <NuxtLink 60 - to="/@orta.bsky.social" 61 - class="px-6 py-2.5 text-zinc-400 hover:text-zinc-200 text-sm font-medium transition-colors" 62 - > 63 - View example profile &rarr; 64 - </NuxtLink> 50 + <NuxtLink to="/@orta.bsky.social" class="px-6 py-2.5 text-zinc-400 hover:text-zinc-200 text-sm font-medium transition-colors"> View example profile &rarr; </NuxtLink> 65 51 </div> 66 52 67 53 <!-- Inline login form --> 68 54 <Transition name="slide"> 69 - <div 70 - v-if="showLogin && !session?.authenticated" 71 - class="mt-8 max-w-sm mx-auto" 72 - > 73 - <form 74 - class="flex gap-2" 75 - @submit.prevent="handleLogin" 76 - > 55 + <div v-if="showLogin && !session?.authenticated" class="mt-8 max-w-sm mx-auto"> 56 + <form class="flex gap-2" @submit.prevent="handleLogin"> 77 57 <input 78 58 v-model="handle" 79 59 type="text" 80 60 placeholder="you.bsky.social" 81 61 class="flex-1 px-4 py-2.5 rounded-lg bg-kt-inset border border-zinc-800 text-sm text-zinc-200 font-mono placeholder:text-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all" 82 62 /> 83 - <button 84 - type="submit" 85 - class="px-4 py-2.5 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all" 86 - > 87 - Sign in 88 - </button> 63 + <button type="submit" class="px-4 py-2.5 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all">Sign in</button> 89 64 </form> 90 65 </div> 91 66 </Transition> ··· 94 69 95 70 <!-- How it works --> 96 71 <section class="max-w-4xl mx-auto px-6 py-16"> 97 - <h2 class="text-lg font-semibold text-zinc-300 mb-8 text-center"> 98 - How it works 99 - </h2> 72 + <h2 class="text-lg font-semibold text-zinc-300 mb-8 text-center">How it works</h2> 100 73 101 74 <div class="grid grid-cols-1 sm:grid-cols-3 gap-6"> 102 - <div 103 - v-for="step in howItWorks" 104 - :key="step.number" 105 - class="text-center" 106 - > 75 + <div v-for="step in howItWorks" :key="step.number" class="text-center"> 107 76 <div 108 77 class="w-12 h-12 mx-auto mb-4 rounded-full flex items-center justify-center text-sm font-mono font-bold" 109 - :class="step.number === 3 110 - ? 'bg-verified/10 text-verified' 111 - : 'bg-violet-600/10 text-violet-400'" 78 + :class="step.number === 3 ? 'bg-verified/10 text-verified' : 'bg-violet-600/10 text-violet-400'" 112 79 > 113 80 {{ step.number }} 114 81 </div> ··· 124 91 125 92 <!-- Recent verifications feed --> 126 93 <section class="max-w-4xl mx-auto px-6 py-16"> 127 - <h2 class="text-lg font-semibold text-zinc-300 mb-6"> 128 - Recent verifications 129 - </h2> 94 + <h2 class="text-lg font-semibold text-zinc-300 mb-6">Recent verifications</h2> 130 95 131 96 <div class="space-y-2"> 132 - <RecentClaimRow 133 - v-for="claim in recentClaims" 134 - :key="claim.handle + claim.displayName" 135 - :claim="claim" 136 - /> 97 + <RecentClaimRow v-for="claim in recentClaims" :key="claim.handle + claim.displayName" :claim="claim" /> 137 98 </div> 138 99 </section> 139 100 </div> 140 101 </template> 141 102 142 103 <script setup lang="ts"> 143 - const { session } = useSession() 104 + const { session } = useSession(); 144 105 145 - const showLogin = ref(false) 146 - const handle = ref("") 106 + const showLogin = ref(false); 107 + const handle = ref(""); 147 108 148 109 function handleLogin() { 149 - const { login } = useSession() 150 - login(handle.value) 110 + const { login } = useSession(); 111 + login(handle.value); 151 112 } 152 113 153 - const gridSvg = `<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><path d="M32 0H0v32" fill="none" stroke="white" stroke-width="0.5"/></svg>` 114 + const gridSvg = `<svg width="32" height="32" xmlns="http://www.w3.org/2000/svg"><path d="M32 0H0v32" fill="none" stroke="white" stroke-width="0.5"/></svg>`; 154 115 155 116 const howItWorks = [ 156 117 { 157 118 number: 1, 158 119 title: "Sign in with Bluesky", 159 - description: 160 - "Authenticate with your ATProto identity. No new accounts, no key generation.", 120 + description: "Authenticate with your ATProto identity. No new accounts, no key generation.", 161 121 }, 162 122 { 163 123 number: 2, 164 124 title: "Add your proof", 165 - description: 166 - "Post a small verification token to your GitHub, domain DNS, or other account.", 125 + description: "Post a small verification token to your GitHub, domain DNS, or other account.", 167 126 }, 168 127 { 169 128 number: 3, 170 129 title: "Get linked", 171 - description: 172 - "Keytrace verifies the proof and signs an attestation stored in your ATProto repo.", 130 + description: "Keytrace verifies the proof and signs an attestation stored in your ATProto repo.", 173 131 }, 174 - ] 132 + ]; 175 133 176 134 // Placeholder data for recent verifications 177 135 const recentClaims = [ ··· 203 161 serviceType: "mastodon", 204 162 createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), 205 163 }, 206 - ] 164 + ]; 207 165 </script>
+27 -30
apps/keytrace.dev/server/api/attest.post.ts
··· 1 - import { getSessionAgent } from "../utils/session" 2 - import { createAttestation, buildClaimRecord } from "../utils/attestation" 3 - import { addRecentClaim } from "../utils/recent-claims" 4 - import { getKeytraceAgent } from "../utils/keytrace-agent" 1 + import { getSessionAgent } from "../utils/session"; 2 + import { createAttestation, buildClaimRecord } from "../utils/attestation"; 3 + import { addRecentClaim } from "../utils/recent-claims"; 4 + import { getKeytraceAgent } from "../utils/keytrace-agent"; 5 5 6 6 /** 7 7 * POST /api/attest ··· 16 16 * Requires authentication. Returns the created record URI and attestation. 17 17 */ 18 18 export default defineEventHandler(async (event) => { 19 - const { did, agent } = await getSessionAgent(event) 19 + const { did, agent } = await getSessionAgent(event); 20 20 21 - const body = await readBody(event) 21 + const body = await readBody(event); 22 22 if (!body?.type || !body?.subject || !body?.recipeId) { 23 23 throw createError({ 24 24 statusCode: 400, 25 - statusMessage: 26 - "Missing required fields: type, subject, recipeId", 27 - }) 25 + statusMessage: "Missing required fields: type, subject, recipeId", 26 + }); 28 27 } 29 28 30 29 const { type, subject, recipeId } = body as { 31 - type: string 32 - subject: string 33 - recipeId: string 34 - } 30 + type: string; 31 + subject: string; 32 + recipeId: string; 33 + }; 35 34 36 35 // Get the recipe strong ref from keytrace's repo 37 - const config = useRuntimeConfig() 38 - const keytraceAgent = await getKeytraceAgent() 39 - let recipeRef: { uri: string; cid: string } 36 + const config = useRuntimeConfig(); 37 + const keytraceAgent = await getKeytraceAgent(); 38 + let recipeRef: { uri: string; cid: string }; 40 39 try { 41 40 const recipeRecord = await keytraceAgent.com.atproto.repo.getRecord({ 42 41 repo: config.keytraceDid, 43 42 collection: "dev.keytrace.recipe", 44 43 rkey: recipeId, 45 - }) 44 + }); 46 45 recipeRef = { 47 46 uri: recipeRecord.data.uri, 48 47 cid: recipeRecord.data.cid!, 49 - } 48 + }; 50 49 } catch { 51 50 throw createError({ 52 51 statusCode: 400, 53 52 statusMessage: `Recipe not found: ${recipeId}`, 54 - }) 53 + }); 55 54 } 56 55 57 56 // Create the attestation (signs with today's key) 58 - const attestation = await createAttestation(did, type, subject) 57 + const attestation = await createAttestation(did, type, subject); 59 58 60 59 // Build the full claim record 61 - const record = buildClaimRecord(type, subject, recipeRef, attestation) 60 + const record = buildClaimRecord(type, subject, recipeRef, attestation); 62 61 63 62 // Write to the user's ATProto repo 64 63 const response = await agent.com.atproto.repo.createRecord({ 65 64 repo: did, 66 65 collection: "dev.keytrace.claim", 67 66 record, 68 - }) 67 + }); 69 68 70 69 // Add to recent claims feed (best-effort, don't fail the request) 71 70 try { 72 - const profile = await agent.getProfile({ actor: did }).catch(() => null) 71 + const profile = await agent.getProfile({ actor: did }).catch(() => null); 73 72 await addRecentClaim({ 74 73 did, 75 74 handle: profile?.data.handle ?? did, 76 75 avatar: profile?.data.avatar, 77 76 type, 78 77 subject, 79 - displayName: type 80 - .replace(/-/g, " ") 81 - .replace(/\b\w/g, (c) => c.toUpperCase()), 78 + displayName: type.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), 82 79 createdAt: new Date().toISOString(), 83 - }) 80 + }); 84 81 } catch (error) { 85 - console.error("[attest] Failed to update recent claims feed:", error) 82 + console.error("[attest] Failed to update recent claims feed:", error); 86 83 } 87 84 88 85 return { 89 86 uri: response.data.uri, 90 87 cid: response.data.cid, 91 88 attestation, 92 - } 93 - }) 89 + }; 90 + });
+10 -10
apps/keytrace.dev/server/api/claims/[rkey].delete.ts
··· 5 5 * Requires authentication. 6 6 */ 7 7 8 - import { COLLECTION_NSID } from "@keytrace/doip" 9 - import { getSessionAgent } from "~/server/utils/session" 8 + import { COLLECTION_NSID } from "@keytrace/runner"; 9 + import { getSessionAgent } from "~/server/utils/session"; 10 10 11 11 export default defineEventHandler(async (event) => { 12 - const { did, agent } = await getSessionAgent(event) 12 + const { did, agent } = await getSessionAgent(event); 13 13 14 - const rkey = getRouterParam(event, "rkey") 14 + const rkey = getRouterParam(event, "rkey"); 15 15 if (!rkey) { 16 - throw createError({ statusCode: 400, statusMessage: "Missing rkey parameter" }) 16 + throw createError({ statusCode: 400, statusMessage: "Missing rkey parameter" }); 17 17 } 18 18 19 19 // Validate rkey format (ATProto record keys are TIDs or other safe strings) 20 20 if (!/^[a-zA-Z0-9._~-]+$/.test(rkey)) { 21 - throw createError({ statusCode: 400, statusMessage: "Invalid rkey format" }) 21 + throw createError({ statusCode: 400, statusMessage: "Invalid rkey format" }); 22 22 } 23 23 24 24 try { ··· 26 26 repo: did, 27 27 collection: COLLECTION_NSID, 28 28 rkey, 29 - }) 29 + }); 30 30 31 - return { success: true } 31 + return { success: true }; 32 32 } catch { 33 33 throw createError({ 34 34 statusCode: 500, 35 35 statusMessage: "Failed to delete claim record", 36 - }) 36 + }); 37 37 } 38 - }) 38 + });
+8 -8
apps/keytrace.dev/server/api/claims/index.get.ts
··· 5 5 * Requires authentication (signed DID cookie). 6 6 */ 7 7 8 - import { Profile } from "@keytrace/doip" 9 - import { requireAuth } from "~/server/utils/session" 8 + import { fetchProfile } from "@keytrace/runner"; 9 + import { requireAuth } from "~/server/utils/session"; 10 10 11 11 export default defineEventHandler(async (event) => { 12 - const did = requireAuth(event) 12 + const did = requireAuth(event); 13 13 14 14 try { 15 - const profile = await Profile.fetch(did) 15 + const profile = await fetchProfile(did); 16 16 17 17 return { 18 18 did: profile.did, 19 19 handle: profile.handle, 20 - claims: profile.claimRecords, 21 - } 20 + claims: profile.claims, 21 + }; 22 22 } catch { 23 23 throw createError({ 24 24 statusCode: 500, 25 25 statusMessage: "Failed to fetch claims", 26 - }) 26 + }); 27 27 } 28 - }) 28 + });
+21 -21
apps/keytrace.dev/server/api/claims/index.post.ts
··· 5 5 * Requires authentication. Body: { claimUri: string, comment?: string } 6 6 */ 7 7 8 - import { COLLECTION_NSID, serviceProviders } from "@keytrace/doip" 9 - import { getSessionAgent } from "~/server/utils/session" 8 + import { COLLECTION_NSID, serviceProviders } from "@keytrace/runner"; 9 + import { getSessionAgent } from "~/server/utils/session"; 10 10 11 11 export default defineEventHandler(async (event) => { 12 - const { did, agent } = await getSessionAgent(event) 12 + const { did, agent } = await getSessionAgent(event); 13 13 14 14 const body = await readBody<{ 15 - claimUri?: string 16 - comment?: string 17 - }>(event) 15 + claimUri?: string; 16 + comment?: string; 17 + }>(event); 18 18 19 19 if (!body?.claimUri || typeof body.claimUri !== "string") { 20 - throw createError({ statusCode: 400, statusMessage: "Missing claimUri" }) 20 + throw createError({ statusCode: 400, statusMessage: "Missing claimUri" }); 21 21 } 22 22 23 23 // Validate that the URI matches a known service provider 24 - const matches = serviceProviders.matchUri(body.claimUri) 24 + const matches = serviceProviders.matchUri(body.claimUri); 25 25 if (matches.length === 0) { 26 26 throw createError({ 27 27 statusCode: 400, 28 28 statusMessage: "Claim URI does not match any known service provider", 29 - }) 29 + }); 30 30 } 31 31 32 32 try { ··· 35 35 claimUri: body.claimUri, 36 36 comment: body.comment, 37 37 createdAt: new Date().toISOString(), 38 - } 38 + }; 39 39 40 - console.log(`[claims] Creating record: repo=${did} collection=${COLLECTION_NSID}`) 41 - console.log(`[claims] Record:`, JSON.stringify(record)) 40 + console.log(`[claims] Creating record: repo=${did} collection=${COLLECTION_NSID}`); 41 + console.log(`[claims] Record:`, JSON.stringify(record)); 42 42 43 43 const result = await agent.com.atproto.repo.createRecord({ 44 44 repo: did, 45 45 collection: COLLECTION_NSID, 46 46 record, 47 - }) 47 + }); 48 48 49 - console.log(`[claims] Success: uri=${result.data.uri} cid=${result.data.cid}`) 49 + console.log(`[claims] Success: uri=${result.data.uri} cid=${result.data.cid}`); 50 50 51 51 return { 52 52 uri: result.data.uri, 53 53 cid: result.data.cid, 54 54 record, 55 - } 55 + }; 56 56 } catch (err: unknown) { 57 - console.error(`[claims] Failed to create claim record:`, err) 57 + console.error(`[claims] Failed to create claim record:`, err); 58 58 if (err && typeof err === "object" && "status" in err) { 59 - console.error(`[claims] HTTP status: ${(err as any).status}`) 59 + console.error(`[claims] HTTP status: ${(err as any).status}`); 60 60 } 61 61 if (err && typeof err === "object" && "headers" in err) { 62 - const wwwAuth = (err as any).headers?.get?.("www-authenticate") ?? (err as any).headers?.["www-authenticate"] 63 - if (wwwAuth) console.error(`[claims] WWW-Authenticate: ${wwwAuth}`) 62 + const wwwAuth = (err as any).headers?.get?.("www-authenticate") ?? (err as any).headers?.["www-authenticate"]; 63 + if (wwwAuth) console.error(`[claims] WWW-Authenticate: ${wwwAuth}`); 64 64 } 65 65 throw createError({ 66 66 statusCode: 500, 67 67 statusMessage: "Failed to create claim record", 68 - }) 68 + }); 69 69 } 70 - }) 70 + });
+8 -8
apps/keytrace.dev/server/api/oauth/logout.post.ts
··· 1 - import { getOAuthClient, verifySignedDid } from "~/server/utils/oauth" 1 + import { getOAuthClient, verifySignedDid } from "~/server/utils/oauth"; 2 2 3 3 export default defineEventHandler(async (event) => { 4 - const cookie = getCookie(event, "did") 4 + const cookie = getCookie(event, "did"); 5 5 6 6 if (cookie) { 7 - const did = verifySignedDid(cookie) 7 + const did = verifySignedDid(cookie); 8 8 if (did) { 9 9 try { 10 - const client = getOAuthClient() 11 - await client.revoke(did) 10 + const client = getOAuthClient(); 11 + await client.revoke(did); 12 12 } catch { 13 13 // Ignore revocation errors 14 14 } 15 15 } 16 16 } 17 17 18 - deleteCookie(event, "did", { path: "/" }) 19 - return { success: true } 20 - }) 18 + deleteCookie(event, "did", { path: "/" }); 19 + return { success: true }; 20 + });
+15 -17
apps/keytrace.dev/server/api/oauth/session.get.ts
··· 1 - import { getOAuthClient, verifySignedDid } from "~/server/utils/oauth" 1 + import { getOAuthClient, verifySignedDid } from "~/server/utils/oauth"; 2 2 3 3 export default defineEventHandler(async (event) => { 4 - const cookie = getCookie(event, "did") 4 + const cookie = getCookie(event, "did"); 5 5 6 6 if (!cookie) { 7 - return { authenticated: false } 7 + return { authenticated: false }; 8 8 } 9 9 10 10 // Verify the cookie signature (SEC-03) 11 - const did = verifySignedDid(cookie) 11 + const did = verifySignedDid(cookie); 12 12 if (!did) { 13 13 // Invalid signature -- clear the tampered cookie 14 - deleteCookie(event, "did", { path: "/" }) 15 - return { authenticated: false } 14 + deleteCookie(event, "did", { path: "/" }); 15 + return { authenticated: false }; 16 16 } 17 17 18 18 // Validate that the OAuth session is still active (SEC-05) 19 19 try { 20 - const client = getOAuthClient() 21 - await client.restore(did) 20 + const client = getOAuthClient(); 21 + await client.restore(did); 22 22 } catch { 23 23 // OAuth session expired or revoked -- clear cookie 24 - deleteCookie(event, "did", { path: "/" }) 25 - return { authenticated: false } 24 + deleteCookie(event, "did", { path: "/" }); 25 + return { authenticated: false }; 26 26 } 27 27 28 28 try { 29 - const response = await fetch( 30 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 31 - ) 32 - const profile = await response.json() 29 + const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`); 30 + const profile = await response.json(); 33 31 34 32 return { 35 33 authenticated: true, ··· 37 35 handle: profile.handle, 38 36 displayName: profile.displayName, 39 37 avatar: profile.avatar, 40 - } 38 + }; 41 39 } catch { 42 40 return { 43 41 authenticated: true, 44 42 did, 45 - } 43 + }; 46 44 } 47 - }) 45 + });
+13 -13
apps/keytrace.dev/server/api/profile/[handleOrDid].get.ts
··· 5 5 * Resolves handles to DIDs, fetches Bluesky profile info, and lists claims. 6 6 */ 7 7 8 - import { Profile } from "@keytrace/doip" 8 + import { fetchProfile, verifyAllClaims, getProfileSummary } from "@keytrace/runner"; 9 9 10 10 export default defineEventHandler(async (event) => { 11 - const handleOrDid = getRouterParam(event, "handleOrDid") 11 + const handleOrDid = getRouterParam(event, "handleOrDid"); 12 12 13 13 if (!handleOrDid) { 14 - throw createError({ statusCode: 400, statusMessage: "Missing handleOrDid parameter" }) 14 + throw createError({ statusCode: 400, statusMessage: "Missing handleOrDid parameter" }); 15 15 } 16 16 17 - const query = getQuery(event) 18 - const shouldVerify = query.verify === "true" 17 + const query = getQuery(event); 18 + const shouldVerify = query.verify === "true"; 19 19 20 20 try { 21 - const profile = await Profile.fetch(handleOrDid) 21 + const profile = await fetchProfile(handleOrDid); 22 22 23 23 // Only verify claims when explicitly requested (prevents DoS via expensive verification) 24 24 if (shouldVerify) { 25 - await profile.verifyAll({ timeout: 10_000 }) 25 + await verifyAllClaims(profile, { timeout: 10_000 }); 26 26 } 27 27 28 28 return { ··· 30 30 handle: profile.handle, 31 31 displayName: profile.displayName, 32 32 avatar: profile.avatar, 33 - claims: profile.claims.map((claim) => ({ 33 + claims: profile.claimInstances.map((claim) => ({ 34 34 uri: claim.uri, 35 35 did: claim.did, 36 36 status: claim.status, ··· 41 41 })), 42 42 errors: claim.errors, 43 43 })), 44 - summary: profile.getSummary(), 45 - } 44 + summary: getProfileSummary(profile), 45 + }; 46 46 } catch (err: unknown) { 47 47 if (err instanceof Error && err.message.includes("resolve")) { 48 48 throw createError({ 49 49 statusCode: 404, 50 50 statusMessage: "Profile not found", 51 - }) 51 + }); 52 52 } 53 53 throw createError({ 54 54 statusCode: 500, 55 55 statusMessage: "Failed to fetch profile", 56 - }) 56 + }); 57 57 } 58 - }) 58 + });
+14 -15
apps/keytrace.dev/server/api/proxy/dns.get.ts
··· 4 4 * DNS TXT record lookup proxy for browser-based claim verification. 5 5 */ 6 6 7 - import dns from "node:dns" 7 + import dns from "node:dns"; 8 8 9 - const DOMAIN_REGEX = 10 - /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/ 9 + const DOMAIN_REGEX = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; 11 10 12 11 export default defineEventHandler(async (event) => { 13 - const query = getQuery(event) 14 - const domain = query.domain as string | undefined 12 + const query = getQuery(event); 13 + const domain = query.domain as string | undefined; 15 14 16 15 if (!domain || typeof domain !== "string") { 17 - throw createError({ statusCode: 400, statusMessage: "Missing domain query parameter" }) 16 + throw createError({ statusCode: 400, statusMessage: "Missing domain query parameter" }); 18 17 } 19 18 20 19 if (!DOMAIN_REGEX.test(domain)) { 21 - throw createError({ statusCode: 400, statusMessage: "Invalid domain format" }) 20 + throw createError({ statusCode: 400, statusMessage: "Invalid domain format" }); 22 21 } 23 22 24 23 // Block lookups for private/internal domains 25 - const blockedSuffixes = [".local", ".internal", ".corp", ".lan", ".home.arpa", ".intranet"] 24 + const blockedSuffixes = [".local", ".internal", ".corp", ".lan", ".home.arpa", ".intranet"]; 26 25 if (domain === "localhost" || blockedSuffixes.some((s) => domain.endsWith(s))) { 27 - throw createError({ statusCode: 400, statusMessage: "Internal domains are not allowed" }) 26 + throw createError({ statusCode: 400, statusMessage: "Internal domains are not allowed" }); 28 27 } 29 28 30 29 try { 31 - const records = await dns.promises.resolveTxt(domain) 30 + const records = await dns.promises.resolveTxt(domain); 32 31 return { 33 32 domain, 34 33 records: { 35 34 txt: records.flat(), 36 35 }, 37 - } 36 + }; 38 37 } catch (err: unknown) { 39 38 // NXDOMAIN or other lookup failures 40 - const code = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined 39 + const code = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined; 41 40 if (code === "ENOTFOUND" || code === "ENODATA") { 42 41 return { 43 42 domain, 44 43 records: { txt: [] }, 45 - } 44 + }; 46 45 } 47 46 throw createError({ 48 47 statusCode: 502, 49 48 statusMessage: "DNS lookup failed", 50 - }) 49 + }); 51 50 } 52 - }) 51 + });
+28 -39
apps/keytrace.dev/server/api/proxy/http.post.ts
··· 19 19 "infosec.exchange", 20 20 "fosstodon.org", 21 21 "techhub.social", 22 - ]) 22 + ]); 23 23 24 - const PRIVATE_IP_RANGES = [ 25 - /^127\./, 26 - /^10\./, 27 - /^172\.(1[6-9]|2\d|3[01])\./, 28 - /^192\.168\./, 29 - /^169\.254\./, 30 - /^0\./, 31 - /^::1$/, 32 - /^fc00:/, 33 - /^fe80:/, 34 - /^fd/, 35 - ] 24 + const PRIVATE_IP_RANGES = [/^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^169\.254\./, /^0\./, /^::1$/, /^fc00:/, /^fe80:/, /^fd/]; 36 25 37 26 function isPrivateIp(hostname: string): boolean { 38 - return PRIVATE_IP_RANGES.some((re) => re.test(hostname)) 27 + return PRIVATE_IP_RANGES.some((re) => re.test(hostname)); 39 28 } 40 29 41 30 export default defineEventHandler(async (event) => { 42 31 const body = await readBody<{ 43 - url?: string 44 - method?: string 45 - headers?: Record<string, string> 46 - }>(event) 32 + url?: string; 33 + method?: string; 34 + headers?: Record<string, string>; 35 + }>(event); 47 36 48 37 if (!body?.url || typeof body.url !== "string") { 49 - throw createError({ statusCode: 400, statusMessage: "Missing url in request body" }) 38 + throw createError({ statusCode: 400, statusMessage: "Missing url in request body" }); 50 39 } 51 40 52 - let parsed: URL 41 + let parsed: URL; 53 42 try { 54 - parsed = new URL(body.url) 43 + parsed = new URL(body.url); 55 44 } catch { 56 - throw createError({ statusCode: 400, statusMessage: "Invalid URL" }) 45 + throw createError({ statusCode: 400, statusMessage: "Invalid URL" }); 57 46 } 58 47 59 48 // Only allow https 60 49 if (parsed.protocol !== "https:") { 61 - throw createError({ statusCode: 400, statusMessage: "Only HTTPS URLs are allowed" }) 50 + throw createError({ statusCode: 400, statusMessage: "Only HTTPS URLs are allowed" }); 62 51 } 63 52 64 53 // Domain allowlist check ··· 66 55 throw createError({ 67 56 statusCode: 403, 68 57 statusMessage: `Domain not allowed: ${parsed.hostname}`, 69 - }) 58 + }); 70 59 } 71 60 72 61 // Block private IPs 73 62 if (isPrivateIp(parsed.hostname)) { 74 - throw createError({ statusCode: 403, statusMessage: "Private IP addresses are not allowed" }) 63 + throw createError({ statusCode: 403, statusMessage: "Private IP addresses are not allowed" }); 75 64 } 76 65 77 - const method = (body.method ?? "GET").toUpperCase() 66 + const method = (body.method ?? "GET").toUpperCase(); 78 67 if (!["GET", "HEAD"].includes(method)) { 79 - throw createError({ statusCode: 400, statusMessage: "Only GET and HEAD methods are allowed" }) 68 + throw createError({ statusCode: 400, statusMessage: "Only GET and HEAD methods are allowed" }); 80 69 } 81 70 82 71 try { 83 - const controller = new AbortController() 84 - const timeoutId = setTimeout(() => controller.abort(), 10_000) 72 + const controller = new AbortController(); 73 + const timeoutId = setTimeout(() => controller.abort(), 10_000); 85 74 86 75 const response = await globalThis.fetch(parsed.toString(), { 87 76 method, ··· 91 80 }, 92 81 signal: controller.signal, 93 82 redirect: "manual", 94 - }) 83 + }); 95 84 96 - clearTimeout(timeoutId) 85 + clearTimeout(timeoutId); 97 86 98 87 if (!response.ok) { 99 88 throw createError({ 100 89 statusCode: 502, 101 90 statusMessage: `Upstream returned ${response.status}`, 102 - }) 91 + }); 103 92 } 104 93 105 - const contentType = response.headers.get("content-type") ?? "" 106 - const text = await response.text() 94 + const contentType = response.headers.get("content-type") ?? ""; 95 + const text = await response.text(); 107 96 108 97 // Return as JSON if the upstream content is JSON 109 98 if (contentType.includes("application/json")) { 110 99 try { 111 - return JSON.parse(text) 100 + return JSON.parse(text); 112 101 } catch { 113 102 // Fall through to text return 114 103 } 115 104 } 116 105 117 - return { body: text, contentType } 106 + return { body: text, contentType }; 118 107 } catch (err: unknown) { 119 - if (err && typeof err === "object" && "statusCode" in err) throw err 108 + if (err && typeof err === "object" && "statusCode" in err) throw err; 120 109 throw createError({ 121 110 statusCode: 502, 122 111 statusMessage: "Proxy fetch failed", 123 - }) 112 + }); 124 113 } 125 - }) 114 + });
+5 -5
apps/keytrace.dev/server/api/recent-claims.get.ts
··· 1 - import { getRecentClaims } from "../utils/recent-claims" 1 + import { getRecentClaims } from "../utils/recent-claims"; 2 2 3 3 /** 4 4 * GET /api/recent-claims ··· 7 7 * Cached for 60 seconds to reduce storage reads. 8 8 */ 9 9 export default defineEventHandler(async (event) => { 10 - setResponseHeader(event, "Cache-Control", "public, max-age=60") 10 + setResponseHeader(event, "Cache-Control", "public, max-age=60"); 11 11 12 - const claims = await getRecentClaims() 13 - return claims 14 - }) 12 + const claims = await getRecentClaims(); 13 + return claims; 14 + });
+14 -14
apps/keytrace.dev/server/api/verify.post.ts
··· 5 5 * Body: { claimUri: string, did: string } 6 6 */ 7 7 8 - import { Claim, isValidDid } from "@keytrace/doip" 8 + import { createClaim, verifyClaim, isValidDid } from "@keytrace/runner"; 9 9 10 10 export default defineEventHandler(async (event) => { 11 - const body = await readBody<{ claimUri?: string; did?: string }>(event) 11 + const body = await readBody<{ claimUri?: string; did?: string }>(event); 12 12 13 13 if (!body?.claimUri || typeof body.claimUri !== "string") { 14 - throw createError({ statusCode: 400, statusMessage: "Missing claimUri" }) 14 + throw createError({ statusCode: 400, statusMessage: "Missing claimUri" }); 15 15 } 16 16 17 17 if (!body?.did || typeof body.did !== "string") { 18 - throw createError({ statusCode: 400, statusMessage: "Missing did" }) 18 + throw createError({ statusCode: 400, statusMessage: "Missing did" }); 19 19 } 20 20 21 21 if (!isValidDid(body.did)) { 22 - throw createError({ statusCode: 400, statusMessage: "Invalid DID format" }) 22 + throw createError({ statusCode: 400, statusMessage: "Invalid DID format" }); 23 23 } 24 24 25 25 try { 26 - console.log(`[verify] Starting verification: uri=${body.claimUri} did=${body.did}`) 27 - const claim = new Claim(body.claimUri, body.did) 28 - const result = await claim.verify({ timeout: 10_000 }) 26 + console.log(`[verify] Starting verification: uri=${body.claimUri} did=${body.did}`); 27 + const claim = createClaim(body.claimUri, body.did); 28 + const result = await verifyClaim(claim, { timeout: 10_000 }); 29 29 30 - console.log(`[verify] Result: status=${result.status} errors=${JSON.stringify(result.errors)} matches=${JSON.stringify(claim.matches.map((m) => m.provider.id))}`) 30 + console.log(`[verify] Result: status=${result.status} errors=${JSON.stringify(result.errors)} matches=${JSON.stringify(claim.matches.map((m) => m.provider.id))}`); 31 31 32 32 return { 33 33 uri: claim.uri, ··· 39 39 providerName: m.provider.name, 40 40 isAmbiguous: m.isAmbiguous, 41 41 })), 42 - } 42 + }; 43 43 } catch (err: unknown) { 44 - console.error(`[verify] Error:`, err) 44 + console.error(`[verify] Error:`, err); 45 45 if (err instanceof Error && err.message.includes("Invalid DID")) { 46 - throw createError({ statusCode: 400, statusMessage: "Invalid DID format" }) 46 + throw createError({ statusCode: 400, statusMessage: "Invalid DID format" }); 47 47 } 48 48 throw createError({ 49 49 statusCode: 500, 50 50 statusMessage: "Verification failed", 51 - }) 51 + }); 52 52 } 53 - }) 53 + });
+24 -24
apps/keytrace.dev/server/middleware/rate-limit.ts
··· 6 6 */ 7 7 8 8 interface RateLimitEntry { 9 - count: number 10 - resetAt: number 9 + count: number; 10 + resetAt: number; 11 11 } 12 12 13 - const store = new Map<string, RateLimitEntry>() 13 + const store = new Map<string, RateLimitEntry>(); 14 14 15 - const WINDOW_MS = 60_000 // 1 minute window 16 - const MAX_REQUESTS = 60 // requests per window per IP 17 - const PRUNE_INTERVAL_MS = 5 * 60_000 // prune stale entries every 5 minutes 15 + const WINDOW_MS = 60_000; // 1 minute window 16 + const MAX_REQUESTS = 60; // requests per window per IP 17 + const PRUNE_INTERVAL_MS = 5 * 60_000; // prune stale entries every 5 minutes 18 18 19 - let lastPrune = Date.now() 19 + let lastPrune = Date.now(); 20 20 21 21 function pruneStaleEntries(): void { 22 - const now = Date.now() 23 - if (now - lastPrune < PRUNE_INTERVAL_MS) return 24 - lastPrune = now 22 + const now = Date.now(); 23 + if (now - lastPrune < PRUNE_INTERVAL_MS) return; 24 + lastPrune = now; 25 25 for (const [key, entry] of store) { 26 26 if (now > entry.resetAt) { 27 - store.delete(key) 27 + store.delete(key); 28 28 } 29 29 } 30 30 } 31 31 32 32 function getClientIp(event: any): string { 33 - const forwarded = getHeader(event, "x-forwarded-for") 33 + const forwarded = getHeader(event, "x-forwarded-for"); 34 34 if (forwarded) { 35 - return forwarded.split(",")[0].trim() 35 + return forwarded.split(",")[0].trim(); 36 36 } 37 - return getHeader(event, "x-real-ip") || "unknown" 37 + return getHeader(event, "x-real-ip") || "unknown"; 38 38 } 39 39 40 40 export default defineEventHandler((event) => { 41 - pruneStaleEntries() 41 + pruneStaleEntries(); 42 42 43 - const ip = getClientIp(event) 44 - const now = Date.now() 43 + const ip = getClientIp(event); 44 + const now = Date.now(); 45 45 46 - let entry = store.get(ip) 46 + let entry = store.get(ip); 47 47 if (!entry || now > entry.resetAt) { 48 - entry = { count: 0, resetAt: now + WINDOW_MS } 49 - store.set(ip, entry) 48 + entry = { count: 0, resetAt: now + WINDOW_MS }; 49 + store.set(ip, entry); 50 50 } 51 51 52 - entry.count++ 52 + entry.count++; 53 53 54 54 if (entry.count > MAX_REQUESTS) { 55 - setResponseHeader(event, "Retry-After", Math.ceil((entry.resetAt - now) / 1000)) 55 + setResponseHeader(event, "Retry-After", Math.ceil((entry.resetAt - now) / 1000)); 56 56 throw createError({ 57 57 statusCode: 429, 58 58 statusMessage: "Too Many Requests", 59 - }) 59 + }); 60 60 } 61 - }) 61 + });
+3 -3
apps/keytrace.dev/server/routes/.well-known/oauth-client-metadata.json.get.ts
··· 1 - import { getClientMetadata } from "~/server/utils/oauth" 1 + import { getClientMetadata } from "~/server/utils/oauth"; 2 2 3 3 export default defineEventHandler(() => { 4 - return getClientMetadata() 5 - }) 4 + return getClientMetadata(); 5 + });
+11 -11
apps/keytrace.dev/server/routes/oauth/callback.get.ts
··· 1 - import { getOAuthClient, getPublicUrl, signDid } from "~/server/utils/oauth" 1 + import { getOAuthClient, getPublicUrl, signDid } from "~/server/utils/oauth"; 2 2 3 3 export default defineEventHandler(async (event) => { 4 4 try { 5 - const url = getRequestURL(event) 6 - const params = new URLSearchParams(url.search) 7 - const client = getOAuthClient() 5 + const url = getRequestURL(event); 6 + const params = new URLSearchParams(url.search); 7 + const client = getOAuthClient(); 8 8 9 - const { session } = await client.callback(params) 10 - const did = session.did 9 + const { session } = await client.callback(params); 10 + const did = session.did; 11 11 12 12 // Store signed DID in a cookie (SEC-03) 13 13 setCookie(event, "did", signDid(did), { ··· 16 16 secure: getPublicUrl().startsWith("https"), 17 17 maxAge: 60 * 60 * 24, // 24 hours 18 18 path: "/", 19 - }) 19 + }); 20 20 21 - return sendRedirect(event, "/") 21 + return sendRedirect(event, "/"); 22 22 } catch (error) { 23 - console.error("OAuth callback error:", error) 24 - return sendRedirect(event, "/?error=auth_failed") 23 + console.error("OAuth callback error:", error); 24 + return sendRedirect(event, "/?error=auth_failed"); 25 25 } 26 - }) 26 + });
+11 -12
apps/keytrace.dev/server/routes/oauth/login.get.ts
··· 1 - import { getOAuthClient } from "~/server/utils/oauth" 1 + import { getOAuthClient } from "~/server/utils/oauth"; 2 2 3 3 export default defineEventHandler(async (event) => { 4 - const query = getQuery(event) 5 - const handle = query.handle as string 4 + const query = getQuery(event); 5 + const handle = query.handle as string; 6 6 7 7 if (!handle) { 8 - throw createError({ statusCode: 400, statusMessage: "Missing handle parameter" }) 8 + throw createError({ statusCode: 400, statusMessage: "Missing handle parameter" }); 9 9 } 10 10 11 11 try { 12 - const client = getOAuthClient() 12 + const client = getOAuthClient(); 13 13 const url = await client.authorize(handle, { 14 - scope: "atproto repo:dev.keytrace.claim?action=create repo:dev.keytrace.claim?action=delete", 15 - 16 - }) 14 + scope: "atproto repo:dev.keytrace.claim?action=create repo:dev.keytrace.claim?action=delete", 15 + }); 17 16 18 - return sendRedirect(event, url.toString()) 17 + return sendRedirect(event, url.toString()); 19 18 } catch (error) { 20 - console.error("OAuth login error:", error) 21 - return sendRedirect(event, "/?error=auth_failed") 19 + console.error("OAuth login error:", error); 20 + return sendRedirect(event, "/?error=auth_failed"); 22 21 } 23 - }) 22 + });
+24 -33
apps/keytrace.dev/server/utils/attestation.ts
··· 1 - import { getOrCreateTodaysKey, getTodaysKeyRef } from "./keys" 2 - import { signClaim, canonicalize } from "./signing" 1 + import { getOrCreateTodaysKey, getTodaysKeyRef } from "./keys"; 2 + import { signClaim, canonicalize } from "./signing"; 3 3 4 4 export interface ClaimData { 5 - type: string 6 - subject: string 7 - did: string 8 - verifiedAt: string 5 + type: string; 6 + subject: string; 7 + did: string; 8 + verifiedAt: string; 9 9 } 10 10 11 11 export interface Attestation { 12 - sig: string 13 - signingKey: { uri: string; cid: string } 14 - signedAt: string 12 + sig: string; 13 + signingKey: { uri: string; cid: string }; 14 + signedAt: string; 15 15 } 16 16 17 17 export interface ClaimRecord { 18 - $type: "dev.keytrace.claim" 19 - type: string 20 - subject: string 21 - recipe: { uri: string; cid: string } 22 - attestation: Attestation 23 - createdAt: string 18 + $type: "dev.keytrace.claim"; 19 + type: string; 20 + subject: string; 21 + recipe: { uri: string; cid: string }; 22 + attestation: Attestation; 23 + createdAt: string; 24 24 } 25 25 26 26 /** ··· 31 31 * 3. Signs with ES256 32 32 * 4. Returns the attestation (sig + key ref + timestamp) 33 33 */ 34 - export async function createAttestation( 35 - did: string, 36 - type: string, 37 - subject: string, 38 - ): Promise<Attestation> { 39 - const keyPair = await getOrCreateTodaysKey() 40 - const now = new Date().toISOString() 34 + export async function createAttestation(did: string, type: string, subject: string): Promise<Attestation> { 35 + const keyPair = await getOrCreateTodaysKey(); 36 + const now = new Date().toISOString(); 41 37 42 38 const claimData: ClaimData = { 43 39 did, 44 40 subject, 45 41 type, 46 42 verifiedAt: now, 47 - } 43 + }; 48 44 49 - const sig = signClaim(claimData, keyPair.privateKey) 50 - const keyRef = await getTodaysKeyRef() 45 + const sig = signClaim(claimData, keyPair.privateKey); 46 + const keyRef = await getTodaysKeyRef(); 51 47 52 48 return { 53 49 sig, 54 50 signingKey: keyRef, 55 51 signedAt: now, 56 - } 52 + }; 57 53 } 58 54 59 55 /** 60 56 * Build a complete dev.keytrace.claim record ready to write to a user's ATProto repo. 61 57 */ 62 - export function buildClaimRecord( 63 - type: string, 64 - subject: string, 65 - recipeRef: { uri: string; cid: string }, 66 - attestation: Attestation, 67 - ): ClaimRecord { 58 + export function buildClaimRecord(type: string, subject: string, recipeRef: { uri: string; cid: string }, attestation: Attestation): ClaimRecord { 68 59 return { 69 60 $type: "dev.keytrace.claim", 70 61 type, ··· 72 63 recipe: recipeRef, 73 64 attestation, 74 65 createdAt: new Date().toISOString(), 75 - } 66 + }; 76 67 }
+67 -75
apps/keytrace.dev/server/utils/keys.ts
··· 1 - import crypto from "node:crypto" 2 - import fs from "node:fs" 3 - import path from "node:path" 4 - import { 5 - S3Client, 6 - GetObjectCommand, 7 - PutObjectCommand, 8 - } from "@aws-sdk/client-s3" 9 - import { getKeytraceAgent } from "./keytrace-agent" 1 + import crypto from "node:crypto"; 2 + import fs from "node:fs"; 3 + import path from "node:path"; 4 + import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; 5 + import { getKeytraceAgent } from "./keytrace-agent"; 10 6 11 7 export interface KeyPair { 12 - privateKey: crypto.KeyObject 13 - publicKey: crypto.KeyObject 14 - publicJwk: JsonWebKey 15 - privateJwk: JsonWebKey 8 + privateKey: crypto.KeyObject; 9 + publicKey: crypto.KeyObject; 10 + publicJwk: JsonWebKey; 11 + privateJwk: JsonWebKey; 16 12 } 17 13 18 14 /** In-memory cache for the current day's key to avoid repeated S3 lookups. */ 19 - let _cachedKey: { date: string; keyPair: KeyPair } | null = null 15 + let _cachedKey: { date: string; keyPair: KeyPair } | null = null; 20 16 21 17 /** 22 18 * Get or create today's signing key. ··· 24 20 * and the public key is published to keytrace's ATProto repo. 25 21 */ 26 22 export async function getOrCreateTodaysKey(): Promise<KeyPair> { 27 - const today = new Date().toISOString().split("T")[0] // "YYYY-MM-DD" 23 + const today = new Date().toISOString().split("T")[0]; // "YYYY-MM-DD" 28 24 29 25 // Fast path: in-memory cache 30 26 if (_cachedKey && _cachedKey.date === today) { 31 - return _cachedKey.keyPair 27 + return _cachedKey.keyPair; 32 28 } 33 29 34 30 // Try loading from storage 35 - const stored = await loadKeyFromStorage(`keys/${today}.jwk`) 31 + const stored = await loadKeyFromStorage(`keys/${today}.jwk`); 36 32 if (stored) { 37 - const keyPair = jwkToKeyPair(stored) 38 - _cachedKey = { date: today, keyPair } 39 - return keyPair 33 + const keyPair = jwkToKeyPair(stored); 34 + _cachedKey = { date: today, keyPair }; 35 + return keyPair; 40 36 } 41 37 42 38 // Generate new key pair for today 43 - const keyPair = generateES256KeyPair() 39 + const keyPair = generateES256KeyPair(); 44 40 45 41 // Save private key to storage 46 - await saveKeyToStorage(`keys/${today}.jwk`, keyPair.privateJwk) 42 + await saveKeyToStorage(`keys/${today}.jwk`, keyPair.privateJwk); 47 43 48 44 // Publish public key to ATProto 49 - await publishKeyToATProto(today, keyPair.publicJwk) 45 + await publishKeyToATProto(today, keyPair.publicJwk); 50 46 51 - _cachedKey = { date: today, keyPair } 52 - return keyPair 47 + _cachedKey = { date: today, keyPair }; 48 + return keyPair; 53 49 } 54 50 55 51 /** ··· 58 54 export function generateES256KeyPair(): KeyPair { 59 55 const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { 60 56 namedCurve: "P-256", 61 - }) 57 + }); 62 58 63 - const privateJwk = privateKey.export({ format: "jwk" }) 64 - const publicJwk = publicKey.export({ format: "jwk" }) 59 + const privateJwk = privateKey.export({ format: "jwk" }); 60 + const publicJwk = publicKey.export({ format: "jwk" }); 65 61 66 - return { privateKey, publicKey, publicJwk, privateJwk } 62 + return { privateKey, publicKey, publicJwk, privateJwk }; 67 63 } 68 64 69 65 /** 70 66 * Convert a stored JWK (private key with d parameter) back into a KeyPair. 71 67 */ 72 68 function jwkToKeyPair(jwk: JsonWebKey): KeyPair { 73 - const privateKey = crypto.createPrivateKey({ key: jwk as any, format: "jwk" }) 74 - const publicKey = crypto.createPublicKey(privateKey) 75 - const publicJwk = publicKey.export({ format: "jwk" }) 69 + const privateKey = crypto.createPrivateKey({ key: jwk as any, format: "jwk" }); 70 + const publicKey = crypto.createPublicKey(privateKey); 71 + const publicJwk = publicKey.export({ format: "jwk" }); 76 72 77 - return { privateKey, publicKey, publicJwk, privateJwk: jwk } 73 + return { privateKey, publicKey, publicJwk, privateJwk: jwk }; 78 74 } 79 75 80 76 /** 81 77 * Publish a public key to keytrace's ATProto repo as a dev.keytrace.key record. 82 78 * Record key = date (YYYY-MM-DD). 83 79 */ 84 - async function publishKeyToATProto( 85 - date: string, 86 - publicJwk: JsonWebKey, 87 - ): Promise<void> { 80 + async function publishKeyToATProto(date: string, publicJwk: JsonWebKey): Promise<void> { 88 81 try { 89 - const agent = await getKeytraceAgent() 90 - const config = useRuntimeConfig() 82 + const agent = await getKeytraceAgent(); 83 + const config = useRuntimeConfig(); 91 84 92 85 await agent.com.atproto.repo.putRecord({ 93 86 repo: config.keytraceDid, ··· 99 92 validFrom: `${date}T00:00:00.000Z`, 100 93 validUntil: `${date}T23:59:59.999Z`, 101 94 }, 102 - }) 95 + }); 103 96 104 - console.log(`[keys] Published signing key for ${date} to ATProto`) 97 + console.log(`[keys] Published signing key for ${date} to ATProto`); 105 98 } catch (error) { 106 - console.error(`[keys] Failed to publish key to ATProto:`, error) 99 + console.error(`[keys] Failed to publish key to ATProto:`, error); 107 100 // Don't throw - key is still usable locally even if ATProto publish fails 108 101 } 109 102 } ··· 112 105 * Get the strong ref (URI + CID) for today's key record. 113 106 */ 114 107 export async function getTodaysKeyRef(): Promise<{ 115 - uri: string 116 - cid: string 108 + uri: string; 109 + cid: string; 117 110 }> { 118 - const today = new Date().toISOString().split("T")[0] 119 - const config = useRuntimeConfig() 120 - const agent = await getKeytraceAgent() 111 + const today = new Date().toISOString().split("T")[0]; 112 + const config = useRuntimeConfig(); 113 + const agent = await getKeytraceAgent(); 121 114 122 115 const response = await agent.com.atproto.repo.getRecord({ 123 116 repo: config.keytraceDid, 124 117 collection: "dev.keytrace.key", 125 118 rkey: today, 126 - }) 119 + }); 127 120 128 121 return { 129 122 uri: response.data.uri, 130 123 cid: response.data.cid!, 131 - } 124 + }; 132 125 } 133 126 134 127 // --- Storage helpers --- 135 128 136 129 function useS3(): boolean { 137 - return Boolean(useRuntimeConfig().s3Bucket) 130 + return Boolean(useRuntimeConfig().s3Bucket); 138 131 } 139 132 140 133 function getS3Client(): S3Client { 141 - const config = useRuntimeConfig() 134 + const config = useRuntimeConfig(); 142 135 return new S3Client({ 143 136 region: config.s3Region || "fr-par", 144 - endpoint: 145 - config.s3Endpoint || `https://s3.${config.s3Region || "fr-par"}.scw.cloud`, 137 + endpoint: config.s3Endpoint || `https://s3.${config.s3Region || "fr-par"}.scw.cloud`, 146 138 credentials: { 147 139 accessKeyId: config.s3AccessKeyId, 148 140 secretAccessKey: config.s3SecretAccessKey, 149 141 }, 150 142 forcePathStyle: true, 151 - }) 143 + }); 152 144 } 153 145 154 146 async function loadKeyFromStorage(key: string): Promise<JsonWebKey | null> { 155 147 if (useS3()) { 156 - return loadKeyFromS3(key) 148 + return loadKeyFromS3(key); 157 149 } 158 - return loadKeyFromFile(key) 150 + return loadKeyFromFile(key); 159 151 } 160 152 161 153 async function saveKeyToStorage(key: string, jwk: JsonWebKey): Promise<void> { 162 154 if (useS3()) { 163 - return saveKeyToS3(key, jwk) 155 + return saveKeyToS3(key, jwk); 164 156 } 165 - return saveKeyToFile(key, jwk) 157 + return saveKeyToFile(key, jwk); 166 158 } 167 159 168 160 async function loadKeyFromS3(key: string): Promise<JsonWebKey | null> { 169 161 try { 170 - const config = useRuntimeConfig() 162 + const config = useRuntimeConfig(); 171 163 const response = await getS3Client().send( 172 164 new GetObjectCommand({ 173 165 Bucket: config.s3Bucket, 174 166 Key: key, 175 167 }), 176 - ) 177 - const body = await response.Body?.transformToString() 178 - return body ? JSON.parse(body) : null 168 + ); 169 + const body = await response.Body?.transformToString(); 170 + return body ? JSON.parse(body) : null; 179 171 } catch (e: any) { 180 - if (e.name === "NoSuchKey") return null 181 - throw e 172 + if (e.name === "NoSuchKey") return null; 173 + throw e; 182 174 } 183 175 } 184 176 185 177 async function saveKeyToS3(key: string, jwk: JsonWebKey): Promise<void> { 186 - const config = useRuntimeConfig() 178 + const config = useRuntimeConfig(); 187 179 await getS3Client().send( 188 180 new PutObjectCommand({ 189 181 Bucket: config.s3Bucket, ··· 191 183 Body: JSON.stringify(jwk), 192 184 ContentType: "application/json", 193 185 }), 194 - ) 186 + ); 195 187 } 196 188 197 - const DATA_DIR = path.join(process.cwd(), ".data") 189 + const DATA_DIR = path.join(process.cwd(), ".data"); 198 190 199 191 function loadKeyFromFile(key: string): JsonWebKey | null { 200 - const filePath = path.join(DATA_DIR, key) 192 + const filePath = path.join(DATA_DIR, key); 201 193 try { 202 - const content = fs.readFileSync(filePath, "utf-8") 203 - return JSON.parse(content) 194 + const content = fs.readFileSync(filePath, "utf-8"); 195 + return JSON.parse(content); 204 196 } catch { 205 - return null 197 + return null; 206 198 } 207 199 } 208 200 209 201 function saveKeyToFile(key: string, jwk: JsonWebKey): void { 210 - const filePath = path.join(DATA_DIR, key) 211 - const dir = path.dirname(filePath) 202 + const filePath = path.join(DATA_DIR, key); 203 + const dir = path.dirname(filePath); 212 204 if (!fs.existsSync(dir)) { 213 - fs.mkdirSync(dir, { recursive: true }) 205 + fs.mkdirSync(dir, { recursive: true }); 214 206 } 215 - fs.writeFileSync(filePath, JSON.stringify(jwk), "utf-8") 207 + fs.writeFileSync(filePath, JSON.stringify(jwk), "utf-8"); 216 208 }
+7 -9
apps/keytrace.dev/server/utils/keytrace-agent.ts
··· 1 - import { AtpAgent } from "@atproto/api" 1 + import { AtpAgent } from "@atproto/api"; 2 2 3 - let _agent: AtpAgent | null = null 3 + let _agent: AtpAgent | null = null; 4 4 5 5 /** 6 6 * Get a singleton ATProto agent authenticated as the keytrace service account. ··· 8 8 */ 9 9 export async function getKeytraceAgent(): Promise<AtpAgent> { 10 10 if (!_agent) { 11 - const config = useRuntimeConfig() 11 + const config = useRuntimeConfig(); 12 12 if (!config.keytraceDid || !config.keytraceAppPassword) { 13 - throw new Error( 14 - "Missing NUXT_KEYTRACE_DID or NUXT_KEYTRACE_APP_PASSWORD environment variables", 15 - ) 13 + throw new Error("Missing NUXT_KEYTRACE_DID or NUXT_KEYTRACE_APP_PASSWORD environment variables"); 16 14 } 17 - _agent = new AtpAgent({ service: "https://bsky.social" }) 15 + _agent = new AtpAgent({ service: "https://bsky.social" }); 18 16 await _agent.login({ 19 17 identifier: config.keytraceDid, 20 18 password: config.keytraceAppPassword, 21 - }) 19 + }); 22 20 } 23 - return _agent 21 + return _agent; 24 22 }
+29 -45
apps/keytrace.dev/server/utils/oauth.ts
··· 1 - import { createHmac } from "node:crypto" 2 - import { NodeOAuthClient } from "@atproto/oauth-client-node" 3 - import { createSessionStore, createStateStore } from "./storage" 1 + import { createHmac } from "node:crypto"; 2 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 3 + import { createSessionStore, createStateStore } from "./storage"; 4 4 5 - let _oauthClient: NodeOAuthClient | null = null 5 + let _oauthClient: NodeOAuthClient | null = null; 6 6 7 7 // --- Session cookie signing (SEC-02, SEC-03) --- 8 8 9 9 function getSessionSecret(): string { 10 - return useRuntimeConfig().sessionSecret 10 + return useRuntimeConfig().sessionSecret; 11 11 } 12 12 13 13 /** Produce an HMAC-SHA256 hex signature for a DID string. */ 14 14 function hmacSign(did: string): string { 15 - return createHmac("sha256", getSessionSecret()).update(did).digest("hex") 15 + return createHmac("sha256", getSessionSecret()).update(did).digest("hex"); 16 16 } 17 17 18 18 /** ··· 20 20 * The signature prevents clients from forging arbitrary DIDs. 21 21 */ 22 22 export function signDid(did: string): string { 23 - return `${did}.${hmacSign(did)}` 23 + return `${did}.${hmacSign(did)}`; 24 24 } 25 25 26 26 /** 27 27 * Verify a signed cookie value and return the DID, or `null` if invalid. 28 28 */ 29 29 export function verifySignedDid(cookie: string): string | null { 30 - const lastDot = cookie.lastIndexOf(".") 31 - if (lastDot === -1) return null 30 + const lastDot = cookie.lastIndexOf("."); 31 + if (lastDot === -1) return null; 32 32 33 - const did = cookie.substring(0, lastDot) 34 - const sig = cookie.substring(lastDot + 1) 33 + const did = cookie.substring(0, lastDot); 34 + const sig = cookie.substring(lastDot + 1); 35 35 36 36 // Constant-time comparison to prevent timing attacks 37 - const expected = hmacSign(did) 38 - if (sig.length !== expected.length) return null 37 + const expected = hmacSign(did); 38 + if (sig.length !== expected.length) return null; 39 39 40 - let mismatch = 0 40 + let mismatch = 0; 41 41 for (let i = 0; i < sig.length; i++) { 42 - mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i) 42 + mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i); 43 43 } 44 - return mismatch === 0 ? did : null 44 + return mismatch === 0 ? did : null; 45 45 } 46 46 47 47 /** ··· 50 50 */ 51 51 function checkSessionSecret(): void { 52 52 if (getSessionSecret() === "dev-secret-change-in-production") { 53 - console.warn( 54 - "[SECURITY] sessionSecret is set to the default value. " + 55 - "Set NUXT_SESSION_SECRET to a strong random string before deploying to production.", 56 - ) 53 + console.warn("[SECURITY] sessionSecret is set to the default value. " + "Set NUXT_SESSION_SECRET to a strong random string before deploying to production."); 57 54 } 58 55 } 59 56 60 57 export function getPublicUrl(): string { 61 - const config = useRuntimeConfig() 62 - return (config.public.publicUrl || "http://127.0.0.1:3000").replace( 63 - /\/$/, 64 - "", 65 - ) 58 + const config = useRuntimeConfig(); 59 + return (config.public.publicUrl || "http://127.0.0.1:3000").replace(/\/$/, ""); 66 60 } 67 61 68 62 function isLoopback(url: string): boolean { 69 - return ( 70 - url.startsWith("http://localhost") || 71 - url.startsWith("http://127.0.0.1") 72 - ) 63 + return url.startsWith("http://localhost") || url.startsWith("http://127.0.0.1"); 73 64 } 74 65 75 66 export function getClientMetadata() { 76 - const publicUrl = getPublicUrl() 67 + const publicUrl = getPublicUrl(); 77 68 78 69 // ATProto OAuth has different rules for dev vs production: 79 70 // - client_id must use http://localhost (not IP) for loopback dev 80 71 // - redirect_uris must use http://127.0.0.1 (not localhost) per RFC 8252 81 72 // - production requires HTTPS for both 82 - const loopback = isLoopback(publicUrl) 73 + const loopback = isLoopback(publicUrl); 83 74 84 - const clientIdBase = loopback 85 - ? "http://localhost" 86 - : publicUrl 87 - const redirectBase = loopback 88 - ? publicUrl.replace("http://localhost", "http://127.0.0.1") 89 - : publicUrl 75 + const clientIdBase = loopback ? "http://localhost" : publicUrl; 76 + const redirectBase = loopback ? publicUrl.replace("http://localhost", "http://127.0.0.1") : publicUrl; 90 77 91 78 return { 92 79 client_id: `${clientIdBase}/.well-known/oauth-client-metadata.json`, 93 80 client_name: "keytrace.dev", 94 81 client_uri: publicUrl, 95 82 redirect_uris: [`${redirectBase}/oauth/callback`] as [string], 96 - grant_types: ["authorization_code", "refresh_token"] as [ 97 - "authorization_code", 98 - "refresh_token", 99 - ], 83 + grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 100 84 response_types: ["code"] as ["code"], 101 85 scope: "atproto repo:dev.keytrace.claim?action=create repo:dev.keytrace.claim?action=delete", 102 86 token_endpoint_auth_method: "none" as const, 103 87 application_type: "web" as const, 104 88 dpop_bound_access_tokens: true, 105 - } 89 + }; 106 90 } 107 91 108 92 export function getOAuthClient(): NodeOAuthClient { 109 93 if (!_oauthClient) { 110 - checkSessionSecret() 94 + checkSessionSecret(); 111 95 _oauthClient = new NodeOAuthClient({ 112 96 clientMetadata: getClientMetadata(), 113 97 stateStore: createStateStore(), 114 98 sessionStore: createSessionStore(), 115 - }) 99 + }); 116 100 } 117 - return _oauthClient 101 + return _oauthClient; 118 102 }
+40 -45
apps/keytrace.dev/server/utils/recent-claims.ts
··· 1 - import fs from "node:fs" 2 - import path from "node:path" 3 - import { 4 - S3Client, 5 - GetObjectCommand, 6 - PutObjectCommand, 7 - } from "@aws-sdk/client-s3" 1 + import fs from "node:fs"; 2 + import path from "node:path"; 3 + import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; 8 4 9 5 export interface RecentClaim { 10 - did: string 11 - handle: string 12 - avatar?: string 13 - type: string 14 - subject: string 15 - displayName: string 16 - createdAt: string 6 + did: string; 7 + handle: string; 8 + avatar?: string; 9 + type: string; 10 + subject: string; 11 + displayName: string; 12 + createdAt: string; 17 13 } 18 14 19 - const FEED_KEY = "recent-claims.json" 20 - const MAX_ITEMS = 50 15 + const FEED_KEY = "recent-claims.json"; 16 + const MAX_ITEMS = 50; 21 17 22 18 /** 23 19 * Add a claim to the recent claims feed. 24 20 * Prepends to the list, trims to 50 items, and saves. 25 21 */ 26 22 export async function addRecentClaim(claim: RecentClaim): Promise<void> { 27 - const feed = await getRecentClaims() 28 - feed.unshift(claim) 29 - if (feed.length > MAX_ITEMS) feed.length = MAX_ITEMS 30 - await saveRecentClaims(feed) 23 + const feed = await getRecentClaims(); 24 + feed.unshift(claim); 25 + if (feed.length > MAX_ITEMS) feed.length = MAX_ITEMS; 26 + await saveRecentClaims(feed); 31 27 } 32 28 33 29 /** ··· 35 31 */ 36 32 export async function getRecentClaims(): Promise<RecentClaim[]> { 37 33 if (useS3Storage()) { 38 - return loadFromS3() 34 + return loadFromS3(); 39 35 } 40 - return loadFromFile() 36 + return loadFromFile(); 41 37 } 42 38 43 39 // --- Internal helpers --- 44 40 45 41 function useS3Storage(): boolean { 46 - return Boolean(useRuntimeConfig().s3Bucket) 42 + return Boolean(useRuntimeConfig().s3Bucket); 47 43 } 48 44 49 45 function getS3Client(): S3Client { 50 - const config = useRuntimeConfig() 46 + const config = useRuntimeConfig(); 51 47 return new S3Client({ 52 48 region: config.s3Region || "fr-par", 53 - endpoint: 54 - config.s3Endpoint || `https://s3.${config.s3Region || "fr-par"}.scw.cloud`, 49 + endpoint: config.s3Endpoint || `https://s3.${config.s3Region || "fr-par"}.scw.cloud`, 55 50 credentials: { 56 51 accessKeyId: config.s3AccessKeyId, 57 52 secretAccessKey: config.s3SecretAccessKey, 58 53 }, 59 54 forcePathStyle: true, 60 - }) 55 + }); 61 56 } 62 57 63 58 async function saveRecentClaims(feed: RecentClaim[]): Promise<void> { 64 59 if (useS3Storage()) { 65 - return saveToS3(feed) 60 + return saveToS3(feed); 66 61 } 67 - return saveToFile(feed) 62 + return saveToFile(feed); 68 63 } 69 64 70 65 async function loadFromS3(): Promise<RecentClaim[]> { 71 66 try { 72 - const config = useRuntimeConfig() 67 + const config = useRuntimeConfig(); 73 68 const response = await getS3Client().send( 74 69 new GetObjectCommand({ 75 70 Bucket: config.s3Bucket, 76 71 Key: FEED_KEY, 77 72 }), 78 - ) 79 - const body = await response.Body?.transformToString() 80 - return body ? JSON.parse(body) : [] 73 + ); 74 + const body = await response.Body?.transformToString(); 75 + return body ? JSON.parse(body) : []; 81 76 } catch (e: any) { 82 - if (e.name === "NoSuchKey") return [] 83 - throw e 77 + if (e.name === "NoSuchKey") return []; 78 + throw e; 84 79 } 85 80 } 86 81 87 82 async function saveToS3(feed: RecentClaim[]): Promise<void> { 88 - const config = useRuntimeConfig() 83 + const config = useRuntimeConfig(); 89 84 await getS3Client().send( 90 85 new PutObjectCommand({ 91 86 Bucket: config.s3Bucket, ··· 93 88 Body: JSON.stringify(feed), 94 89 ContentType: "application/json", 95 90 }), 96 - ) 91 + ); 97 92 } 98 93 99 - const DATA_DIR = path.join(process.cwd(), ".data") 94 + const DATA_DIR = path.join(process.cwd(), ".data"); 100 95 101 96 function loadFromFile(): RecentClaim[] { 102 - const filePath = path.join(DATA_DIR, FEED_KEY) 97 + const filePath = path.join(DATA_DIR, FEED_KEY); 103 98 try { 104 - const content = fs.readFileSync(filePath, "utf-8") 105 - return JSON.parse(content) 99 + const content = fs.readFileSync(filePath, "utf-8"); 100 + return JSON.parse(content); 106 101 } catch { 107 - return [] 102 + return []; 108 103 } 109 104 } 110 105 111 106 function saveToFile(feed: RecentClaim[]): void { 112 107 if (!fs.existsSync(DATA_DIR)) { 113 - fs.mkdirSync(DATA_DIR, { recursive: true }) 108 + fs.mkdirSync(DATA_DIR, { recursive: true }); 114 109 } 115 - const filePath = path.join(DATA_DIR, FEED_KEY) 116 - fs.writeFileSync(filePath, JSON.stringify(feed, null, 2), "utf-8") 110 + const filePath = path.join(DATA_DIR, FEED_KEY); 111 + fs.writeFileSync(filePath, JSON.stringify(feed, null, 2), "utf-8"); 117 112 }
+18 -18
apps/keytrace.dev/server/utils/session.ts
··· 1 - import type { H3Event } from "h3" 2 - import { Agent } from "@atproto/api" 3 - import { verifySignedDid, getOAuthClient } from "./oauth" 1 + import type { H3Event } from "h3"; 2 + import { Agent } from "@atproto/api"; 3 + import { verifySignedDid, getOAuthClient } from "./oauth"; 4 4 5 5 /** 6 6 * Read the authenticated DID from the signed session cookie. 7 7 * Returns the DID string or null if not authenticated / signature invalid. 8 8 */ 9 9 export function getAuthenticatedDid(event: H3Event): string | null { 10 - const raw = getCookie(event, "did") 11 - if (!raw) return null 12 - return verifySignedDid(raw) 10 + const raw = getCookie(event, "did"); 11 + if (!raw) return null; 12 + return verifySignedDid(raw); 13 13 } 14 14 15 15 /** ··· 17 17 * Returns the verified DID. 18 18 */ 19 19 export function requireAuth(event: H3Event): string { 20 - const did = getAuthenticatedDid(event) 20 + const did = getAuthenticatedDid(event); 21 21 if (!did) { 22 - throw createError({ statusCode: 401, statusMessage: "Not authenticated" }) 22 + throw createError({ statusCode: 401, statusMessage: "Not authenticated" }); 23 23 } 24 - return did 24 + return did; 25 25 } 26 26 27 27 /** ··· 29 29 * Throws 401 if not authenticated, 500 if session cannot be restored. 30 30 */ 31 31 export async function getSessionAgent(event: H3Event) { 32 - const did = requireAuth(event) 33 - const client = getOAuthClient() 32 + const did = requireAuth(event); 33 + const client = getOAuthClient(); 34 34 try { 35 - console.log(`[session] Restoring OAuth session for ${did}`) 36 - const oauthSession = await client.restore(did) 37 - const agent = new Agent(oauthSession) 38 - console.log(`[session] Session restored successfully`) 39 - return { did, agent } 35 + console.log(`[session] Restoring OAuth session for ${did}`); 36 + const oauthSession = await client.restore(did); 37 + const agent = new Agent(oauthSession); 38 + console.log(`[session] Session restored successfully`); 39 + return { did, agent }; 40 40 } catch (err) { 41 - console.error(`[session] Failed to restore OAuth session:`, err) 41 + console.error(`[session] Failed to restore OAuth session:`, err); 42 42 throw createError({ 43 43 statusCode: 500, 44 44 statusMessage: "Failed to restore OAuth session", 45 - }) 45 + }); 46 46 } 47 47 }
+59 -76
apps/keytrace.dev/server/utils/signing.ts
··· 1 - import crypto from "node:crypto" 1 + import crypto from "node:crypto"; 2 2 3 3 /** 4 4 * Canonicalize an object for signing: sort keys recursively and JSON.stringify. 5 5 * This ensures the same data always produces the same bytes for signing. 6 6 */ 7 7 export function canonicalize(data: Record<string, unknown>): string { 8 - return JSON.stringify(sortKeys(data)) 8 + return JSON.stringify(sortKeys(data)); 9 9 } 10 10 11 11 function sortKeys(obj: unknown): unknown { 12 - if (obj === null || typeof obj !== "object") return obj 13 - if (Array.isArray(obj)) return obj.map(sortKeys) 12 + if (obj === null || typeof obj !== "object") return obj; 13 + if (Array.isArray(obj)) return obj.map(sortKeys); 14 14 15 - const sorted: Record<string, unknown> = {} 15 + const sorted: Record<string, unknown> = {}; 16 16 for (const key of Object.keys(obj as Record<string, unknown>).sort()) { 17 - sorted[key] = sortKeys((obj as Record<string, unknown>)[key]) 17 + sorted[key] = sortKeys((obj as Record<string, unknown>)[key]); 18 18 } 19 - return sorted 19 + return sorted; 20 20 } 21 21 22 22 /** 23 23 * Base64url encode a buffer (no padding). 24 24 */ 25 25 function base64urlEncode(buffer: Buffer): string { 26 - return buffer.toString("base64url") 26 + return buffer.toString("base64url"); 27 27 } 28 28 29 29 /** 30 30 * Base64url decode a string. 31 31 */ 32 32 function base64urlDecode(str: string): Buffer { 33 - return Buffer.from(str, "base64url") 33 + return Buffer.from(str, "base64url"); 34 34 } 35 35 36 36 /** ··· 38 38 * 39 39 * Format: base64url(header).base64url(payload).base64url(signature) 40 40 */ 41 - export function signClaim( 42 - claimData: Record<string, unknown>, 43 - privateKey: crypto.KeyObject, 44 - ): string { 45 - const header = { alg: "ES256", typ: "JWT" } 46 - const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))) 41 + export function signClaim(claimData: Record<string, unknown>, privateKey: crypto.KeyObject): string { 42 + const header = { alg: "ES256", typ: "JWT" }; 43 + const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); 47 44 48 - const payload = canonicalize(claimData) 49 - const payloadB64 = base64urlEncode(Buffer.from(payload)) 45 + const payload = canonicalize(claimData); 46 + const payloadB64 = base64urlEncode(Buffer.from(payload)); 50 47 51 - const signingInput = `${headerB64}.${payloadB64}` 48 + const signingInput = `${headerB64}.${payloadB64}`; 52 49 53 50 // Node crypto.sign with ECDSA produces a DER-encoded signature. 54 51 // JWS ES256 requires the raw R||S format (64 bytes for P-256). 55 - const derSig = crypto.sign("SHA256", Buffer.from(signingInput), privateKey) 56 - const rawSig = derToRaw(derSig) 52 + const derSig = crypto.sign("SHA256", Buffer.from(signingInput), privateKey); 53 + const rawSig = derToRaw(derSig); 57 54 58 - const signatureB64 = base64urlEncode(rawSig) 55 + const signatureB64 = base64urlEncode(rawSig); 59 56 60 - return `${headerB64}.${payloadB64}.${signatureB64}` 57 + return `${headerB64}.${payloadB64}.${signatureB64}`; 61 58 } 62 59 63 60 /** 64 61 * Verify a JWS compact signature over claim data. 65 62 * Returns true if the signature is valid. 66 63 */ 67 - export function verifyClaim( 68 - claimData: Record<string, unknown>, 69 - jws: string, 70 - publicKey: crypto.KeyObject, 71 - ): boolean { 72 - const parts = jws.split(".") 73 - if (parts.length !== 3) return false 64 + export function verifyClaim(claimData: Record<string, unknown>, jws: string, publicKey: crypto.KeyObject): boolean { 65 + const parts = jws.split("."); 66 + if (parts.length !== 3) return false; 74 67 75 - const [headerB64, payloadB64, signatureB64] = parts 68 + const [headerB64, payloadB64, signatureB64] = parts; 76 69 77 70 // Verify the payload matches the expected canonical claim data 78 - const expectedPayload = canonicalize(claimData) 79 - const actualPayload = base64urlDecode(payloadB64).toString("utf-8") 80 - if (actualPayload !== expectedPayload) return false 71 + const expectedPayload = canonicalize(claimData); 72 + const actualPayload = base64urlDecode(payloadB64).toString("utf-8"); 73 + if (actualPayload !== expectedPayload) return false; 81 74 82 - const signingInput = `${headerB64}.${payloadB64}` 83 - const rawSig = base64urlDecode(signatureB64) 75 + const signingInput = `${headerB64}.${payloadB64}`; 76 + const rawSig = base64urlDecode(signatureB64); 84 77 85 78 // Convert raw R||S back to DER for Node crypto.verify 86 - const derSig = rawToDer(rawSig) 79 + const derSig = rawToDer(rawSig); 87 80 88 - return crypto.verify( 89 - "SHA256", 90 - Buffer.from(signingInput), 91 - publicKey, 92 - derSig, 93 - ) 81 + return crypto.verify("SHA256", Buffer.from(signingInput), publicKey, derSig); 94 82 } 95 83 96 84 /** ··· 99 87 */ 100 88 function derToRaw(derSig: Buffer): Buffer { 101 89 // DER format: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s] 102 - let offset = 2 // skip 0x30 and total length 90 + let offset = 2; // skip 0x30 and total length 103 91 104 92 // Read R 105 - if (derSig[offset] !== 0x02) throw new Error("Invalid DER signature") 106 - offset++ 107 - const rLen = derSig[offset] 108 - offset++ 109 - let r = derSig.subarray(offset, offset + rLen) 110 - offset += rLen 93 + if (derSig[offset] !== 0x02) throw new Error("Invalid DER signature"); 94 + offset++; 95 + const rLen = derSig[offset]; 96 + offset++; 97 + let r = derSig.subarray(offset, offset + rLen); 98 + offset += rLen; 111 99 112 100 // Read S 113 - if (derSig[offset] !== 0x02) throw new Error("Invalid DER signature") 114 - offset++ 115 - const sLen = derSig[offset] 116 - offset++ 117 - let s = derSig.subarray(offset, offset + sLen) 101 + if (derSig[offset] !== 0x02) throw new Error("Invalid DER signature"); 102 + offset++; 103 + const sLen = derSig[offset]; 104 + offset++; 105 + let s = derSig.subarray(offset, offset + sLen); 118 106 119 107 // Trim leading zero bytes (DER uses signed integers) 120 - if (r.length > 32) r = r.subarray(r.length - 32) 121 - if (s.length > 32) s = s.subarray(s.length - 32) 108 + if (r.length > 32) r = r.subarray(r.length - 32); 109 + if (s.length > 32) s = s.subarray(s.length - 32); 122 110 123 111 // Pad to 32 bytes if shorter 124 - const raw = Buffer.alloc(64) 125 - r.copy(raw, 32 - r.length) 126 - s.copy(raw, 64 - s.length) 112 + const raw = Buffer.alloc(64); 113 + r.copy(raw, 32 - r.length); 114 + s.copy(raw, 64 - s.length); 127 115 128 - return raw 116 + return raw; 129 117 } 130 118 131 119 /** 132 120 * Convert a raw R||S ECDSA signature to DER format. 133 121 */ 134 122 function rawToDer(raw: Buffer): Buffer { 135 - if (raw.length !== 64) throw new Error("Expected 64-byte raw signature") 123 + if (raw.length !== 64) throw new Error("Expected 64-byte raw signature"); 136 124 137 - let r = raw.subarray(0, 32) 138 - let s = raw.subarray(32, 64) 125 + let r = raw.subarray(0, 32); 126 + let s = raw.subarray(32, 64); 139 127 140 128 // Remove leading zeros but keep at least one byte 141 - while (r.length > 1 && r[0] === 0) r = r.subarray(1) 142 - while (s.length > 1 && s[0] === 0) s = s.subarray(1) 129 + while (r.length > 1 && r[0] === 0) r = r.subarray(1); 130 + while (s.length > 1 && s[0] === 0) s = s.subarray(1); 143 131 144 132 // Add leading zero if high bit is set (DER signed integer encoding) 145 - if (r[0] & 0x80) r = Buffer.concat([Buffer.from([0x00]), r]) 146 - if (s[0] & 0x80) s = Buffer.concat([Buffer.from([0x00]), s]) 133 + if (r[0] & 0x80) r = Buffer.concat([Buffer.from([0x00]), r]); 134 + if (s[0] & 0x80) s = Buffer.concat([Buffer.from([0x00]), s]); 147 135 148 - const rLen = r.length 149 - const sLen = s.length 150 - const totalLen = 2 + rLen + 2 + sLen 136 + const rLen = r.length; 137 + const sLen = s.length; 138 + const totalLen = 2 + rLen + 2 + sLen; 151 139 152 - return Buffer.concat([ 153 - Buffer.from([0x30, totalLen, 0x02, rLen]), 154 - r, 155 - Buffer.from([0x02, sLen]), 156 - s, 157 - ]) 140 + return Buffer.concat([Buffer.from([0x30, totalLen, 0x02, rLen]), r, Buffer.from([0x02, sLen]), s]); 158 141 }
+57 -72
apps/keytrace.dev/server/utils/storage.ts
··· 1 - import fs from "node:fs" 2 - import path from "node:path" 3 - import { 4 - S3Client, 5 - GetObjectCommand, 6 - PutObjectCommand, 7 - DeleteObjectCommand, 8 - } from "@aws-sdk/client-s3" 9 - import type { 10 - NodeSavedSession, 11 - NodeSavedSessionStore, 12 - NodeSavedState, 13 - NodeSavedStateStore, 14 - } from "@atproto/oauth-client-node" 1 + import fs from "node:fs"; 2 + import path from "node:path"; 3 + import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; 4 + import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from "@atproto/oauth-client-node"; 15 5 16 6 function getS3Config() { 17 - const config = useRuntimeConfig() 7 + const config = useRuntimeConfig(); 18 8 return { 19 9 bucket: config.s3Bucket, 20 10 region: config.s3Region || "fr-par", 21 11 accessKeyId: config.s3AccessKeyId, 22 12 secretAccessKey: config.s3SecretAccessKey, 23 13 endpoint: config.s3Endpoint, 24 - } 14 + }; 25 15 } 26 16 27 - let _s3Client: S3Client | null = null 17 + let _s3Client: S3Client | null = null; 28 18 29 19 function getS3Client(): S3Client { 30 20 if (!_s3Client) { 31 - const config = getS3Config() 21 + const config = getS3Config(); 32 22 _s3Client = new S3Client({ 33 23 region: config.region, 34 - endpoint: 35 - config.endpoint || `https://s3.${config.region}.scw.cloud`, 24 + endpoint: config.endpoint || `https://s3.${config.region}.scw.cloud`, 36 25 credentials: { 37 26 accessKeyId: config.accessKeyId, 38 27 secretAccessKey: config.secretAccessKey, 39 28 }, 40 29 forcePathStyle: true, 41 - }) 30 + }); 42 31 } 43 - return _s3Client 32 + return _s3Client; 44 33 } 45 34 46 35 // S3-based storage for production 47 36 class S3SessionStore implements NodeSavedSessionStore { 48 - private prefix = "sessions/" 37 + private prefix = "sessions/"; 49 38 50 39 async get(key: string): Promise<NodeSavedSession | undefined> { 51 40 try { ··· 54 43 Bucket: getS3Config().bucket, 55 44 Key: `${this.prefix}${key}.json`, 56 45 }), 57 - ) 58 - const body = await response.Body?.transformToString() 59 - return body ? JSON.parse(body) : undefined 46 + ); 47 + const body = await response.Body?.transformToString(); 48 + return body ? JSON.parse(body) : undefined; 60 49 } catch (e: any) { 61 - if (e.name === "NoSuchKey") return undefined 62 - throw e 50 + if (e.name === "NoSuchKey") return undefined; 51 + throw e; 63 52 } 64 53 } 65 54 ··· 71 60 Body: JSON.stringify(value), 72 61 ContentType: "application/json", 73 62 }), 74 - ) 63 + ); 75 64 } 76 65 77 66 async del(key: string): Promise<void> { ··· 80 69 Bucket: getS3Config().bucket, 81 70 Key: `${this.prefix}${key}.json`, 82 71 }), 83 - ) 72 + ); 84 73 } 85 74 } 86 75 87 76 class S3StateStore implements NodeSavedStateStore { 88 - private prefix = "states/" 77 + private prefix = "states/"; 89 78 90 79 async get(key: string): Promise<NodeSavedState | undefined> { 91 80 try { ··· 94 83 Bucket: getS3Config().bucket, 95 84 Key: `${this.prefix}${key}.json`, 96 85 }), 97 - ) 98 - const body = await response.Body?.transformToString() 99 - return body ? JSON.parse(body) : undefined 86 + ); 87 + const body = await response.Body?.transformToString(); 88 + return body ? JSON.parse(body) : undefined; 100 89 } catch (e: any) { 101 - if (e.name === "NoSuchKey") return undefined 102 - throw e 90 + if (e.name === "NoSuchKey") return undefined; 91 + throw e; 103 92 } 104 93 } 105 94 ··· 111 100 Body: JSON.stringify(value), 112 101 ContentType: "application/json", 113 102 }), 114 - ) 103 + ); 115 104 } 116 105 117 106 async del(key: string): Promise<void> { ··· 120 109 Bucket: getS3Config().bucket, 121 110 Key: `${this.prefix}${key}.json`, 122 111 }), 123 - ) 112 + ); 124 113 } 125 114 } 126 115 127 116 // File-based storage for local development 128 - const DATA_DIR = path.join(process.cwd(), ".data") 117 + const DATA_DIR = path.join(process.cwd(), ".data"); 129 118 130 119 class FileSessionStore implements NodeSavedSessionStore { 131 - private file = path.join(DATA_DIR, "sessions.json") 120 + private file = path.join(DATA_DIR, "sessions.json"); 132 121 133 122 constructor() { 134 123 if (!fs.existsSync(DATA_DIR)) { 135 - fs.mkdirSync(DATA_DIR, { recursive: true }) 124 + fs.mkdirSync(DATA_DIR, { recursive: true }); 136 125 } 137 126 } 138 127 139 128 private read(): Record<string, NodeSavedSession> { 140 129 try { 141 - return JSON.parse(fs.readFileSync(this.file, "utf-8")) 130 + return JSON.parse(fs.readFileSync(this.file, "utf-8")); 142 131 } catch { 143 - return {} 132 + return {}; 144 133 } 145 134 } 146 135 147 136 private write(data: Record<string, NodeSavedSession>): void { 148 - fs.writeFileSync(this.file, JSON.stringify(data, null, 2)) 137 + fs.writeFileSync(this.file, JSON.stringify(data, null, 2)); 149 138 } 150 139 151 140 async get(key: string): Promise<NodeSavedSession | undefined> { 152 - return this.read()[key] 141 + return this.read()[key]; 153 142 } 154 143 155 144 async set(key: string, value: NodeSavedSession): Promise<void> { 156 - const data = this.read() 157 - data[key] = value 158 - this.write(data) 145 + const data = this.read(); 146 + data[key] = value; 147 + this.write(data); 159 148 } 160 149 161 150 async del(key: string): Promise<void> { 162 - const data = this.read() 163 - delete data[key] 164 - this.write(data) 151 + const data = this.read(); 152 + delete data[key]; 153 + this.write(data); 165 154 } 166 155 } 167 156 168 157 class FileStateStore implements NodeSavedStateStore { 169 - private file = path.join(DATA_DIR, "states.json") 158 + private file = path.join(DATA_DIR, "states.json"); 170 159 171 160 constructor() { 172 161 if (!fs.existsSync(DATA_DIR)) { 173 - fs.mkdirSync(DATA_DIR, { recursive: true }) 162 + fs.mkdirSync(DATA_DIR, { recursive: true }); 174 163 } 175 164 } 176 165 177 166 private read(): Record<string, NodeSavedState> { 178 167 try { 179 - return JSON.parse(fs.readFileSync(this.file, "utf-8")) 168 + return JSON.parse(fs.readFileSync(this.file, "utf-8")); 180 169 } catch { 181 - return {} 170 + return {}; 182 171 } 183 172 } 184 173 185 174 private write(data: Record<string, NodeSavedState>): void { 186 - fs.writeFileSync(this.file, JSON.stringify(data, null, 2)) 175 + fs.writeFileSync(this.file, JSON.stringify(data, null, 2)); 187 176 } 188 177 189 178 async get(key: string): Promise<NodeSavedState | undefined> { 190 - return this.read()[key] 179 + return this.read()[key]; 191 180 } 192 181 193 182 async set(key: string, value: NodeSavedState): Promise<void> { 194 - const data = this.read() 195 - data[key] = value 196 - this.write(data) 183 + const data = this.read(); 184 + data[key] = value; 185 + this.write(data); 197 186 } 198 187 199 188 async del(key: string): Promise<void> { 200 - const data = this.read() 201 - delete data[key] 202 - this.write(data) 189 + const data = this.read(); 190 + delete data[key]; 191 + this.write(data); 203 192 } 204 193 } 205 194 206 195 export function createSessionStore(): NodeSavedSessionStore { 207 - const useS3 = Boolean(useRuntimeConfig().s3Bucket) 208 - console.log( 209 - useS3 210 - ? `Session storage: S3 (${useRuntimeConfig().s3Bucket})` 211 - : "Session storage: File (.data/)", 212 - ) 213 - return useS3 ? new S3SessionStore() : new FileSessionStore() 196 + const useS3 = Boolean(useRuntimeConfig().s3Bucket); 197 + console.log(useS3 ? `Session storage: S3 (${useRuntimeConfig().s3Bucket})` : "Session storage: File (.data/)"); 198 + return useS3 ? new S3SessionStore() : new FileSessionStore(); 214 199 } 215 200 216 201 export function createStateStore(): NodeSavedStateStore { 217 - const useS3 = Boolean(useRuntimeConfig().s3Bucket) 218 - return useS3 ? new S3StateStore() : new FileStateStore() 202 + const useS3 = Boolean(useRuntimeConfig().s3Bucket); 203 + return useS3 ? new S3StateStore() : new FileStateStore(); 219 204 }
+3 -8
apps/keytrace.dev/tailwind.config.ts
··· 1 - import type { Config } from "tailwindcss" 1 + import type { Config } from "tailwindcss"; 2 2 3 3 export default { 4 4 darkMode: "class", 5 - content: [ 6 - "./components/**/*.{vue,ts}", 7 - "./layouts/**/*.vue", 8 - "./pages/**/*.vue", 9 - "./app.vue", 10 - ], 5 + content: ["./components/**/*.{vue,ts}", "./layouts/**/*.vue", "./pages/**/*.vue", "./app.vue"], 11 6 theme: { 12 7 extend: { 13 8 colors: { ··· 34 29 }, 35 30 }, 36 31 }, 37 - } satisfies Config 32 + } satisfies Config;
+71 -64
keytrace-plan.md
··· 155 155 // packages/keytrace-runner/src/claim.ts 156 156 157 157 export interface Claim { 158 - uri: string 159 - did: string 160 - status: ClaimStatus 161 - matches: ServiceProviderMatch[] 158 + uri: string; 159 + did: string; 160 + status: ClaimStatus; 161 + matches: ServiceProviderMatch[]; 162 162 } 163 163 164 164 export function createClaim(uri: string, did: string): Claim { 165 165 if (!did.startsWith("did:")) { 166 - throw new Error("Invalid DID format") 166 + throw new Error("Invalid DID format"); 167 167 } 168 - return { uri, did, status: "pending", matches: [] } 168 + return { uri, did, status: "pending", matches: [] }; 169 169 } 170 170 171 171 export function matchClaim(claim: Claim): Claim { 172 172 // find matching service providers, return updated claim 173 - const matches = findMatchingProviders(claim.uri) 174 - return { ...claim, matches } 173 + const matches = findMatchingProviders(claim.uri); 174 + return { ...claim, matches }; 175 175 } 176 176 177 177 export async function verifyClaim(claim: Claim, opts?: VerifyOptions): Promise<Claim> { 178 178 // For each matched service provider: 179 179 // 1. Fetch proof location 180 180 // 2. Look for DID (or handle, or profile URL) in response 181 - const patterns = generateProofPatterns(claim.did) 181 + const patterns = generateProofPatterns(claim.did); 182 182 // ... verification logic 183 - return { ...claim, status: "verified" } 183 + return { ...claim, status: "verified" }; 184 184 } 185 185 186 186 function generateProofPatterns(did: string): string[] { 187 187 return [ 188 - did, // did:plc:xxx 189 - did.replace("did:plc:", ""), // just xxx 190 - `https://[SITE_DOMAIN]/${did}`, // profile URL 191 - ] 188 + did, // did:plc:xxx 189 + did.replace("did:plc:", ""), // just xxx 190 + `https://[SITE_DOMAIN]/${did}`, // profile URL 191 + ]; 192 192 } 193 193 ``` 194 194 ··· 272 272 273 273 ```typescript 274 274 interface RecentClaim { 275 - did: string 276 - handle: string 277 - avatar?: string 278 - type: string // e.g., "github-gist" 279 - subject: string // e.g., "github:octocat" 280 - displayName: string // e.g., "GitHub Account" 281 - createdAt: string 275 + did: string; 276 + handle: string; 277 + avatar?: string; 278 + type: string; // e.g., "github-gist" 279 + subject: string; // e.g., "github:octocat" 280 + displayName: string; // e.g., "GitHub Account" 281 + createdAt: string; 282 282 } 283 283 284 284 // Array of last 50 claims, newest first 285 - type RecentClaimsFeed = RecentClaim[] 285 + type RecentClaimsFeed = RecentClaim[]; 286 286 ``` 287 287 288 288 **Update flow:** ··· 294 294 ```typescript 295 295 // server/utils/recent-claims.ts 296 296 async function addRecentClaim(claim: RecentClaim): Promise<void> { 297 - const feed = await getRecentClaimsFromS3() ?? [] 298 - feed.unshift(claim) 299 - if (feed.length > 50) feed.length = 50 300 - await saveRecentClaimsToS3(feed) 297 + const feed = (await getRecentClaimsFromS3()) ?? []; 298 + feed.unshift(claim); 299 + if (feed.length > 50) feed.length = 50; 300 + await saveRecentClaimsToS3(feed); 301 301 } 302 302 ``` 303 303 ··· 330 330 ``` 331 331 332 332 **Browser usage:** The keytrace-runner in browser mode uses these endpoints: 333 + 333 334 ```typescript 334 335 const runner = createRunner({ 335 - fetch: (url, init) => fetch('/api/proxy/http', { 336 - method: 'POST', 337 - body: JSON.stringify({ url, ...init }) 338 - }).then(r => r.json()) 339 - }) 336 + fetch: (url, init) => 337 + fetch("/api/proxy/http", { 338 + method: "POST", 339 + body: JSON.stringify({ url, ...init }), 340 + }).then((r) => r.json()), 341 + }); 340 342 ``` 341 343 342 344 ## Implementation Phases ··· 436 438 - **Public auditability** - anyone can fetch the key to verify signatures 437 439 438 440 **Lexicon: `dev.keytrace.key`** 441 + 439 442 ```json 440 443 { 441 444 "lexicon": 1, ··· 471 474 Keys are stored at: `at://did:plc:hcwfdlmprcc335oixyfsw7u3/dev.keytrace.key/2026-02-08` 472 475 473 476 **Key Storage:** 477 + 474 478 - **Public keys**: Stored in keytrace's ATProto repo (publicly discoverable) 475 479 - **Private keys**: Stored in S3 at `s3://{bucket}/keys/{date}.jwk` (e.g., `keys/2026-02-08.jwk`) 476 480 ··· 481 485 ```typescript 482 486 // server/utils/keys.ts 483 487 async function getOrCreateTodaysKey(): Promise<JWK> { 484 - const today = new Date().toISOString().split('T')[0] // "2026-02-08" 488 + const today = new Date().toISOString().split("T")[0]; // "2026-02-08" 485 489 486 490 // Try S3 first (fast path) 487 - let privateKey = await getKeyFromS3(`keys/${today}.jwk`) 488 - if (privateKey) return privateKey 491 + let privateKey = await getKeyFromS3(`keys/${today}.jwk`); 492 + if (privateKey) return privateKey; 489 493 490 494 // Generate new key pair for today 491 - privateKey = await generateES256KeyPair() 495 + privateKey = await generateES256KeyPair(); 492 496 493 497 // Save private key to S3 494 - await saveKeyToS3(`keys/${today}.jwk`, privateKey) 498 + await saveKeyToS3(`keys/${today}.jwk`, privateKey); 495 499 496 500 // Publish public key to ATProto 497 - await publishKeyToATProto(today, privateKey) 501 + await publishKeyToATProto(today, privateKey); 498 502 499 - return privateKey 503 + return privateKey; 500 504 } 501 505 ``` 502 506 503 507 This approach: 508 + 504 509 - No separate cron service needed 505 510 - Keys created on-demand when first attestation is requested 506 511 - S3 acts as cache to avoid regenerating on each request ··· 517 522 518 523 ```typescript 519 524 // server/utils/keytrace-agent.ts 520 - import { Agent } from "@atproto/api" 525 + import { Agent } from "@atproto/api"; 521 526 522 - let _agent: Agent | null = null 527 + let _agent: Agent | null = null; 523 528 524 529 export async function getKeytraceAgent(): Promise<Agent> { 525 530 if (!_agent) { 526 - const config = useRuntimeConfig() 527 - _agent = new Agent({ service: "https://bsky.social" }) 531 + const config = useRuntimeConfig(); 532 + _agent = new Agent({ service: "https://bsky.social" }); 528 533 await _agent.login({ 529 534 identifier: config.keytraceDid, 530 535 password: config.keytraceAppPassword, 531 - }) 536 + }); 532 537 } 533 - return _agent 538 + return _agent; 534 539 } 535 540 ``` 536 541 ··· 539 544 Recipes are public, version-controlled instructions for how to verify a specific claim type. They're stored in keytrace's ATProto repo and referenced by CID for integrity. 540 545 541 546 **Lexicon: `dev.keytrace.recipe`** 547 + 542 548 ```json 543 549 { 544 550 "lexicon": 1, ··· 681 687 ``` 682 688 683 689 **Example Recipe: GitHub Gist** 690 + 684 691 ```json 685 692 { 686 693 "$type": "dev.keytrace.recipe", ··· 736 743 When a user's claim passes verification, keytrace creates a signed attestation record in the USER's repo. 737 744 738 745 **Lexicon: `dev.keytrace.claim`** 746 + 739 747 ```json 740 748 { 741 749 "lexicon": 1, ··· 904 912 // packages/keytrace-runner/src/index.ts 905 913 906 914 /** Injected fetch function - allows caller to provide proxy, auth, etc. */ 907 - export type FetchFn = (url: string, init?: RequestInit) => Promise<Response> 915 + export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>; 908 916 909 917 export interface RunnerConfig { 910 918 /** Custom fetch function (defaults to global fetch) */ 911 - fetch?: FetchFn 919 + fetch?: FetchFn; 912 920 /** Request timeout in ms */ 913 - timeout?: number 921 + timeout?: number; 914 922 } 915 923 916 924 export interface ClaimContext { 917 925 /** Unique claim ID for this verification attempt */ 918 - claimId: string 926 + claimId: string; 919 927 /** User's ATProto DID */ 920 - did: string 928 + did: string; 921 929 /** User's ATProto handle */ 922 - handle: string 930 + handle: string; 923 931 /** User-provided params from recipe (e.g., { gistUrl: "..." }) */ 924 - params: Record<string, string> 932 + params: Record<string, string>; 925 933 } 926 934 927 935 export interface VerificationResult { 928 - success: boolean 929 - steps: StepResult[] 936 + success: boolean; 937 + steps: StepResult[]; 930 938 /** Extracted subject from params (e.g., "github:octocat") */ 931 - subject?: string 932 - error?: string 939 + subject?: string; 940 + error?: string; 933 941 } 934 942 935 943 export interface StepResult { 936 - action: string 937 - success: boolean 938 - data?: unknown 939 - error?: string 944 + action: string; 945 + success: boolean; 946 + data?: unknown; 947 + error?: string; 940 948 } 941 949 942 - export async function runRecipe( 943 - recipe: Recipe, 944 - context: ClaimContext, 945 - config?: RunnerConfig 946 - ): Promise<VerificationResult> 950 + export async function runRecipe(recipe: Recipe, context: ClaimContext, config?: RunnerConfig): Promise<VerificationResult>; 947 951 ``` 948 952 949 953 **Environment support:** 954 + 950 955 - **Browser**: Uses native `fetch`, `DOMParser` for HTML parsing 951 956 - **Node**: Uses `fetch` (Node 18+), `linkedom` for HTML parsing 952 957 953 958 **Built-in actions:** 959 + 954 960 - `http-get` - Fetch URL using injected fetch function 955 961 - `css-select` - Parse HTML and run CSS selectors 956 962 - `json-path` - Extract data from JSON ··· 959 965 960 966 **Template interpolation:** 961 967 All URL and pattern strings support `{variable}` interpolation from context: 968 + 962 969 - `{claimId}`, `{did}`, `{handle}` - from ClaimContext 963 970 - `{paramKey}` - from user-provided params 964 971
packages/doip/.turbo/turbo-build.log

This is a binary file and will not be displayed.

-12
packages/doip/.turbo/turbo-test.log
··· 1 - 2 - RUN v2.1.9 /home/orta/dev/keytrace/packages/doip 3 - 4 - ✓ tests/serviceProviders/dns.test.ts (11 tests) 4ms 5 - ✓ tests/serviceProviders/github.test.ts (9 tests) 5ms 6 - ✓ tests/claim.test.ts (14 tests) 7ms 7 - 8 - Test Files 3 passed (3) 9 - Tests 34 passed (34) 10 - Start at 09:07:40 11 - Duration 486ms (transform 97ms, setup 0ms, collect 247ms, tests 16ms, environment 0ms, prepare 204ms) 12 -
-51
packages/doip/dist/claim.d.ts
··· 1 - import { ClaimStatus } from "./types.js"; 2 - import { type ServiceProviderMatch } from "./serviceProviders/index.js"; 3 - import type { VerifyOptions, ClaimVerificationResult } from "./types.js"; 4 - /** 5 - * Validate a DID string. Accepts did:plc and did:web formats. 6 - */ 7 - export declare function isValidDid(did: string): boolean; 8 - /** 9 - * Represents a single identity claim linking a DID to an external account 10 - */ 11 - export declare class Claim { 12 - private _uri; 13 - private _did; 14 - private _status; 15 - private _matches; 16 - private _errors; 17 - constructor(uri: string, did: string); 18 - get uri(): string; 19 - get did(): string; 20 - get status(): ClaimStatus; 21 - get matches(): ServiceProviderMatch[]; 22 - get errors(): string[]; 23 - /** 24 - * Match the claim URI against known service providers 25 - */ 26 - match(): void; 27 - /** 28 - * Check if the claim is ambiguous (matches multiple providers) 29 - */ 30 - isAmbiguous(): boolean; 31 - /** 32 - * Get the matched service provider (first unambiguous match, or first match) 33 - */ 34 - getMatchedProvider(): ServiceProviderMatch | undefined; 35 - /** 36 - * Verify the claim by fetching proof and checking for DID 37 - */ 38 - verify(opts?: VerifyOptions): Promise<ClaimVerificationResult>; 39 - private fetchProof; 40 - private checkProof; 41 - private generateProofPatterns; 42 - private extractValues; 43 - private extractValuesRecursive; 44 - private matchesPattern; 45 - toJSON(): object; 46 - static fromJSON(data: { 47 - uri: string; 48 - did: string; 49 - }): Claim; 50 - } 51 - //# sourceMappingURL=claim.d.ts.map
-1
packages/doip/dist/claim.d.ts.map
··· 1 - {"version":3,"file":"claim.d.ts","sourceRoot":"","sources":["../src/claim.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEzC,OAAO,EAEL,KAAK,oBAAoB,EAG1B,MAAM,6BAA6B,CAAC;AAErC,OAAO,KAAK,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAOzE;;GAEG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED;;GAEG;AACH,qBAAa,KAAK;IAChB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,OAAO,CAAiC;IAChD,OAAO,CAAC,QAAQ,CAA8B;IAC9C,OAAO,CAAC,OAAO,CAAgB;gBAEnB,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM;IAUpC,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,MAAM,IAAI,WAAW,CAExB;IAED,IAAI,OAAO,IAAI,oBAAoB,EAAE,CAEpC;IAED,IAAI,MAAM,IAAI,MAAM,EAAE,CAErB;IAED;;OAEG;IACH,KAAK,IAAI,IAAI;IASb;;OAEG;IACH,WAAW,IAAI,OAAO;IAItB;;OAEG;IACH,kBAAkB,IAAI,oBAAoB,GAAG,SAAS;IAItD;;OAEG;IACG,MAAM,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,uBAAuB,CAAC;YA6C1D,UAAU;IAkBxB,OAAO,CAAC,UAAU;IAmBlB,OAAO,CAAC,qBAAqB;IAc7B,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,sBAAsB;IAoC9B,OAAO,CAAC,cAAc;IAqBtB,MAAM,IAAI,MAAM;IAahB,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK;CAG3D"}
-226
packages/doip/dist/claim.js
··· 1 - import { ClaimStatus } from "./types.js"; 2 - import { DEFAULT_TIMEOUT } from "./constants.js"; 3 - import { matchUri, } from "./serviceProviders/index.js"; 4 - import * as fetchers from "./fetchers/index.js"; 5 - // did:plc identifiers are base32-encoded, lowercase 6 - const DID_PLC_RE = /^did:plc:[a-z2-7]{24}$/; 7 - // did:web uses a domain name (with optional port and path segments encoded as colons) 8 - const DID_WEB_RE = /^did:web:[a-zA-Z0-9._:%-]+$/; 9 - /** 10 - * Validate a DID string. Accepts did:plc and did:web formats. 11 - */ 12 - export function isValidDid(did) { 13 - return DID_PLC_RE.test(did) || DID_WEB_RE.test(did); 14 - } 15 - /** 16 - * Represents a single identity claim linking a DID to an external account 17 - */ 18 - export class Claim { 19 - _uri; 20 - _did; 21 - _status = ClaimStatus.INIT; 22 - _matches = []; 23 - _errors = []; 24 - constructor(uri, did) { 25 - this._uri = uri; 26 - // Validate DID format: must be did:plc:<id> or did:web:<host> 27 - if (!isValidDid(did)) { 28 - throw new Error(`Invalid DID format: ${did}`); 29 - } 30 - this._did = did; 31 - } 32 - get uri() { 33 - return this._uri; 34 - } 35 - get did() { 36 - return this._did; 37 - } 38 - get status() { 39 - return this._status; 40 - } 41 - get matches() { 42 - return this._matches; 43 - } 44 - get errors() { 45 - return this._errors; 46 - } 47 - /** 48 - * Match the claim URI against known service providers 49 - */ 50 - match() { 51 - this._matches = matchUri(this._uri); 52 - this._status = this._matches.length > 0 ? ClaimStatus.MATCHED : ClaimStatus.ERROR; 53 - if (this._matches.length === 0) { 54 - this._errors.push(`No service provider matched URI: ${this._uri}`); 55 - } 56 - } 57 - /** 58 - * Check if the claim is ambiguous (matches multiple providers) 59 - */ 60 - isAmbiguous() { 61 - return this._matches.length > 1 || (this._matches.length === 1 && this._matches[0].isAmbiguous); 62 - } 63 - /** 64 - * Get the matched service provider (first unambiguous match, or first match) 65 - */ 66 - getMatchedProvider() { 67 - return this._matches[0]; 68 - } 69 - /** 70 - * Verify the claim by fetching proof and checking for DID 71 - */ 72 - async verify(opts = {}) { 73 - if (this._status === ClaimStatus.INIT) { 74 - this.match(); 75 - } 76 - if (this._matches.length === 0) { 77 - return { 78 - status: ClaimStatus.ERROR, 79 - errors: this._errors, 80 - timestamp: new Date(), 81 - }; 82 - } 83 - // Try each matched provider until one succeeds 84 - for (const match of this._matches) { 85 - try { 86 - const config = match.provider.processURI(this._uri, match.match); 87 - const proofData = await this.fetchProof(config.proof.request, opts); 88 - if (this.checkProof(proofData, config.proof.target)) { 89 - this._status = ClaimStatus.VERIFIED; 90 - return { 91 - status: ClaimStatus.VERIFIED, 92 - errors: [], 93 - timestamp: new Date(), 94 - }; 95 - } 96 - } 97 - catch (err) { 98 - this._errors.push(`${match.provider.id}: ${err instanceof Error ? err.message : "Unknown error"}`); 99 - } 100 - // Stop on unambiguous match 101 - if (!match.isAmbiguous) 102 - break; 103 - } 104 - this._status = ClaimStatus.FAILED; 105 - return { 106 - status: ClaimStatus.FAILED, 107 - errors: this._errors, 108 - timestamp: new Date(), 109 - }; 110 - } 111 - async fetchProof(request, opts) { 112 - const fetcher = fetchers.get(request.fetcher); 113 - if (!fetcher) { 114 - throw new Error(`Unknown fetcher: ${request.fetcher}`); 115 - } 116 - console.log(`[doip] Fetching proof: ${request.fetcher} ${request.uri} (format: ${request.format})`); 117 - const data = await fetcher.fetch(request.uri, { 118 - format: request.format, 119 - timeout: opts.timeout ?? DEFAULT_TIMEOUT, 120 - headers: request.options?.headers, 121 - }); 122 - const fileKeys = data && typeof data === "object" && "files" in data 123 - ? Object.keys(data.files) 124 - : []; 125 - console.log(`[doip] Fetched proof, files: ${JSON.stringify(fileKeys)}`); 126 - return data; 127 - } 128 - checkProof(data, targets) { 129 - const proofPatterns = this.generateProofPatterns(); 130 - console.log(`[doip] Checking proof for DID ${this._did}, patterns: ${JSON.stringify(proofPatterns)}`); 131 - console.log(`[doip] Proof targets: ${JSON.stringify(targets.map((t) => t.path.join(".")))}`); 132 - for (const target of targets) { 133 - const values = this.extractValues(data, target.path); 134 - console.log(`[doip] Target ${target.path.join(".")}: found ${values.length} value(s)${values.length > 0 ? `: ${JSON.stringify(values.map((v) => v.slice(0, 100)))}` : ""}`); 135 - for (const value of values) { 136 - if (this.matchesPattern(value, proofPatterns, target.relation)) { 137 - console.log(`[doip] Match found at ${target.path.join(".")} (relation: ${target.relation})`); 138 - return true; 139 - } 140 - } 141 - } 142 - console.log(`[doip] No match found in any target`); 143 - return false; 144 - } 145 - generateProofPatterns() { 146 - // Patterns to search for in proof locations 147 - const patterns = [ 148 - this._did, // did:plc:xxx 149 - ]; 150 - // Also add the short form for did:plc DIDs 151 - if (this._did.startsWith("did:plc:")) { 152 - patterns.push(this._did.replace("did:plc:", "")); 153 - } 154 - return patterns; 155 - } 156 - extractValues(data, path) { 157 - const results = []; 158 - this.extractValuesRecursive(data, path, 0, results); 159 - return results; 160 - } 161 - extractValuesRecursive(data, path, index, results) { 162 - if (data === null || data === undefined) 163 - return; 164 - if (index >= path.length) { 165 - // Reached the end of the path 166 - if (typeof data === "string") { 167 - results.push(data); 168 - } 169 - else if (Array.isArray(data)) { 170 - // If it's an array of strings, add them all 171 - for (const item of data) { 172 - if (typeof item === "string") { 173 - results.push(item); 174 - } 175 - } 176 - } 177 - return; 178 - } 179 - const key = path[index]; 180 - if (key === "*" && Array.isArray(data)) { 181 - // Wildcard: recurse into all array elements 182 - for (const item of data) { 183 - this.extractValuesRecursive(item, path, index + 1, results); 184 - } 185 - } 186 - else if (typeof data === "object" && data !== null) { 187 - const record = data; 188 - this.extractValuesRecursive(record[key], path, index + 1, results); 189 - } 190 - } 191 - matchesPattern(value, patterns, relation) { 192 - for (const pattern of patterns) { 193 - switch (relation) { 194 - case "contains": 195 - if (value.includes(pattern)) 196 - return true; 197 - break; 198 - case "equals": 199 - if (value === pattern) 200 - return true; 201 - break; 202 - case "startsWith": 203 - if (value.startsWith(pattern)) 204 - return true; 205 - break; 206 - } 207 - } 208 - return false; 209 - } 210 - toJSON() { 211 - return { 212 - uri: this._uri, 213 - did: this._did, 214 - status: this._status, 215 - matches: this._matches.map((m) => ({ 216 - provider: m.provider.id, 217 - isAmbiguous: m.isAmbiguous, 218 - })), 219 - errors: this._errors, 220 - }; 221 - } 222 - static fromJSON(data) { 223 - return new Claim(data.uri, data.did); 224 - } 225 - } 226 - //# sourceMappingURL=claim.js.map
-1
packages/doip/dist/claim.js.map
··· 1 - {"version":3,"file":"claim.js","sourceRoot":"","sources":["../src/claim.ts"],"names":[],"mappings":"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"}
-17
packages/doip/dist/constants.d.ts
··· 1 - /** 2 - * The ATProto collection NSID for identity claims 3 - */ 4 - export declare const COLLECTION_NSID = "dev.keytrace.claim"; 5 - /** 6 - * Default timeout for fetcher operations in milliseconds 7 - */ 8 - export declare const DEFAULT_TIMEOUT = 5000; 9 - /** 10 - * PLC directory URL for resolving did:plc DIDs 11 - */ 12 - export declare const PLC_DIRECTORY_URL = "https://plc.directory"; 13 - /** 14 - * Fallback public ATProto API URL (used only when PDS resolution fails) 15 - */ 16 - export declare const PUBLIC_API_URL = "https://public.api.bsky.app"; 17 - //# sourceMappingURL=constants.d.ts.map
-1
packages/doip/dist/constants.d.ts.map
··· 1 - {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,eAAe,uBAAuB,CAAC;AAEpD;;GAEG;AACH,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC;;GAEG;AACH,eAAO,MAAM,iBAAiB,0BAA0B,CAAC;AAEzD;;GAEG;AACH,eAAO,MAAM,cAAc,gCAAgC,CAAC"}
-17
packages/doip/dist/constants.js
··· 1 - /** 2 - * The ATProto collection NSID for identity claims 3 - */ 4 - export const COLLECTION_NSID = "dev.keytrace.claim"; 5 - /** 6 - * Default timeout for fetcher operations in milliseconds 7 - */ 8 - export const DEFAULT_TIMEOUT = 5000; 9 - /** 10 - * PLC directory URL for resolving did:plc DIDs 11 - */ 12 - export const PLC_DIRECTORY_URL = "https://plc.directory"; 13 - /** 14 - * Fallback public ATProto API URL (used only when PDS resolution fails) 15 - */ 16 - export const PUBLIC_API_URL = "https://public.api.bsky.app"; 17 - //# sourceMappingURL=constants.js.map
-1
packages/doip/dist/constants.js.map
··· 1 - {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,oBAAoB,CAAC;AAEpD;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC;AAEpC;;GAEG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,uBAAuB,CAAC;AAEzD;;GAEG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,6BAA6B,CAAC"}
-25
packages/doip/dist/fetchers/activitypub.d.ts
··· 1 - export interface ActivityPubActor { 2 - id: string; 3 - type: string; 4 - preferredUsername?: string; 5 - name?: string; 6 - summary?: string; 7 - attachment?: Array<{ 8 - type: string; 9 - name?: string; 10 - value?: string; 11 - }>; 12 - attributedTo?: string; 13 - } 14 - export interface ActivityPubFetchOptions { 15 - timeout?: number; 16 - } 17 - /** 18 - * Fetch an ActivityPub actor document 19 - */ 20 - export declare function fetchActor(uri: string, options?: ActivityPubFetchOptions): Promise<ActivityPubActor>; 21 - /** 22 - * Fetch data from an ActivityPub URL (alias for http fetch with AP headers) 23 - */ 24 - export declare function fetch(uri: string, options?: ActivityPubFetchOptions): Promise<unknown>; 25 - //# sourceMappingURL=activitypub.d.ts.map
-1
packages/doip/dist/fetchers/activitypub.d.ts.map
··· 1 - {"version":3,"file":"activitypub.d.ts","sourceRoot":"","sources":["../../src/fetchers/activitypub.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,KAAK,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC,CAAC;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,gBAAgB,CAAC,CAuB3B;AAED;;GAEG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,uBAA4B,GAAG,OAAO,CAAC,OAAO,CAAC,CAEhG"}
-32
packages/doip/dist/fetchers/activitypub.js
··· 1 - import { DEFAULT_TIMEOUT } from "../constants.js"; 2 - /** 3 - * Fetch an ActivityPub actor document 4 - */ 5 - export async function fetchActor(uri, options = {}) { 6 - const timeout = options.timeout ?? DEFAULT_TIMEOUT; 7 - const controller = new AbortController(); 8 - const timeoutId = setTimeout(() => controller.abort(), timeout); 9 - try { 10 - const response = await globalThis.fetch(uri, { 11 - headers: { 12 - Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 13 - "User-Agent": "keytrace-doip/1.0", 14 - }, 15 - signal: controller.signal, 16 - }); 17 - if (!response.ok) { 18 - throw new Error(`HTTP ${response.status}: ${response.statusText}`); 19 - } 20 - return (await response.json()); 21 - } 22 - finally { 23 - clearTimeout(timeoutId); 24 - } 25 - } 26 - /** 27 - * Fetch data from an ActivityPub URL (alias for http fetch with AP headers) 28 - */ 29 - export async function fetch(uri, options = {}) { 30 - return fetchActor(uri, options); 31 - } 32 - //# sourceMappingURL=activitypub.js.map
-1
packages/doip/dist/fetchers/activitypub.js.map
··· 1 - {"version":3,"file":"activitypub.js","sourceRoot":"","sources":["../../src/fetchers/activitypub.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAoBlD;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,GAAW,EACX,UAAmC,EAAE;IAErC,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,CAAC;IACnD,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,SAAS,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,OAAO,CAAC,CAAC;IAEhE,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE;YAC3C,OAAO,EAAE;gBACP,MAAM,EACJ,iGAAiG;gBACnG,YAAY,EAAE,mBAAmB;aAClC;YACD,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CAAC,CAAC;QAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,QAAQ,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;QACrE,CAAC;QAED,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAqB,CAAC;IACrD,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,SAAS,CAAC,CAAC;IAC1B,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,GAAW,EAAE,UAAmC,EAAE;IAC5E,OAAO,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;AAClC,CAAC"}
-15
packages/doip/dist/fetchers/dns.d.ts
··· 1 - export interface DnsFetchResult { 2 - domain: string; 3 - records: { 4 - txt: string[]; 5 - }; 6 - } 7 - export interface DnsFetchOptions { 8 - timeout?: number; 9 - } 10 - /** 11 - * Fetch DNS TXT records for a domain. 12 - * Returns null in environments where DNS resolution is not available. 13 - */ 14 - export declare function fetch(domain: string, options?: DnsFetchOptions): Promise<DnsFetchResult | null>; 15 - //# sourceMappingURL=dns.d.ts.map
-1
packages/doip/dist/fetchers/dns.d.ts.map
··· 1 - {"version":3,"file":"dns.d.ts","sourceRoot":"","sources":["../../src/fetchers/dns.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE;QACP,GAAG,EAAE,MAAM,EAAE,CAAC;KACf,CAAC;CACH;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAgBD;;;GAGG;AACH,wBAAsB,KAAK,CACzB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAgChC"}
-49
packages/doip/dist/fetchers/dns.js
··· 1 - import { DEFAULT_TIMEOUT } from "../constants.js"; 2 - /** 3 - * Check if the Node.js `dns` module is available. 4 - * This is more reliable than checking `typeof window` since SSR frameworks 5 - * and edge runtimes can have `window` defined or `dns` unavailable. 6 - */ 7 - async function hasDnsModule() { 8 - try { 9 - await import("dns"); 10 - return true; 11 - } 12 - catch { 13 - return false; 14 - } 15 - } 16 - /** 17 - * Fetch DNS TXT records for a domain. 18 - * Returns null in environments where DNS resolution is not available. 19 - */ 20 - export async function fetch(domain, options = {}) { 21 - if (!(await hasDnsModule())) { 22 - console.debug("DNS fetching is not available in this environment"); 23 - return null; 24 - } 25 - const timeout = options.timeout ?? DEFAULT_TIMEOUT; 26 - try { 27 - const dns = await import("dns"); 28 - const dnsPromises = dns.promises; 29 - const timeoutPromise = new Promise((_, reject) => { 30 - setTimeout(() => reject(new Error("DNS timeout")), timeout); 31 - }); 32 - const fetchPromise = dnsPromises.resolveTxt(domain).then((records) => ({ 33 - domain, 34 - records: { 35 - txt: records.flat(), 36 - }, 37 - })); 38 - return await Promise.race([fetchPromise, timeoutPromise]); 39 - } 40 - catch (error) { 41 - if (error instanceof Error && error.message === "DNS timeout") { 42 - throw error; 43 - } 44 - // DNS lookup failed (NXDOMAIN, etc.) 45 - console.debug(`DNS lookup failed for ${domain}: ${error instanceof Error ? error.message : "Unknown error"}`); 46 - return null; 47 - } 48 - } 49 - //# sourceMappingURL=dns.js.map
-1
packages/doip/dist/fetchers/dns.js.map
··· 1 - {"version":3,"file":"dns.js","sourceRoot":"","sources":["../../src/fetchers/dns.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAalD;;;;GAIG;AACH,KAAK,UAAU,YAAY;IACzB,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CACzB,MAAc,EACd,UAA2B,EAAE;IAE7B,IAAI,CAAC,CAAC,MAAM,YAAY,EAAE,CAAC,EAAE,CAAC;QAC5B,OAAO,CAAC,KAAK,CAAC,mDAAmD,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,CAAC;IAEnD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC;QAChC,MAAM,WAAW,GAAG,GAAG,CAAC,QAAQ,CAAC;QAEjC,MAAM,cAAc,GAAG,IAAI,OAAO,CAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE;YACtD,UAAU,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,aAAa,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;QAC9D,CAAC,CAAC,CAAC;QAEH,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;YACrE,MAAM;YACN,OAAO,EAAE;gBACP,GAAG,EAAE,OAAO,CAAC,IAAI,EAAE;aACpB;SACF,CAAC,CAAC,CAAC;QAEJ,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,YAAY,EAAE,cAAc,CAAC,CAAC,CAAC;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,KAAK,YAAY,KAAK,IAAI,KAAK,CAAC,OAAO,KAAK,aAAa,EAAE,CAAC;YAC9D,MAAM,KAAK,CAAC;QACd,CAAC;QACD,qCAAqC;QACrC,OAAO,CAAC,KAAK,CAAC,yBAAyB,MAAM,KAAK,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC,CAAC;QAC9G,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
-10
packages/doip/dist/fetchers/http.d.ts
··· 1 - export interface HttpFetchOptions { 2 - format: "json" | "text"; 3 - headers?: Record<string, string>; 4 - timeout?: number; 5 - } 6 - /** 7 - * Fetch data from an HTTP/HTTPS URL 8 - */ 9 - export declare function fetch(url: string, options: HttpFetchOptions): Promise<unknown>; 10 - //# sourceMappingURL=http.d.ts.map
-1
packages/doip/dist/fetchers/http.d.ts.map
··· 1 - {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/fetchers/http.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,wBAAsB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CA0BpF"}
-30
packages/doip/dist/fetchers/http.js
··· 1 - import { DEFAULT_TIMEOUT } from "../constants.js"; 2 - /** 3 - * Fetch data from an HTTP/HTTPS URL 4 - */ 5 - export async function fetch(url, options) { 6 - const timeout = options.timeout ?? DEFAULT_TIMEOUT; 7 - const controller = new AbortController(); 8 - const timeoutId = setTimeout(() => controller.abort(), timeout); 9 - try { 10 - const response = await globalThis.fetch(url, { 11 - headers: { 12 - "User-Agent": "keytrace-doip/1.0", 13 - Accept: options.format === "json" ? "application/json" : "text/plain", 14 - ...options.headers, 15 - }, 16 - signal: controller.signal, 17 - }); 18 - if (!response.ok) { 19 - throw new Error(`HTTP ${response.status}: ${response.statusText}`); 20 - } 21 - if (options.format === "json") { 22 - return await response.json(); 23 - } 24 - return await response.text(); 25 - } 26 - finally { 27 - clearTimeout(timeoutId); 28 - } 29 - } 30 - //# sourceMappingURL=http.js.map
-1
packages/doip/dist/fetchers/http.js.map
··· 1 - {"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/fetchers/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAQlD;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,GAAW,EAAE,OAAyB;IAChE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,eAAe,CAAC;IACnD,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,MAAM,SAAS,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,OAAO,CAAC,CAAC;IAEhE,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE;YAC3C,OAAO,EAAE;gBACP,YAAY,EAAE,mBAAmB;gBACjC,MAAM,EAAE,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,YAAY;gBACrE,GAAG,OAAO,CAAC,OAAO;aACnB;YACD,MAAM,EAAE,UAAU,CAAC,MAAM;SAC1B,CAAC,CAAC;QAEH,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,QAAQ,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;QACrE,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;YAC9B,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QAC/B,CAAC;QACD,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC/B,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,SAAS,CAAC,CAAC;IAC1B,CAAC;AACH,CAAC"}
-16
packages/doip/dist/fetchers/index.d.ts
··· 1 - import * as http from "./http.js"; 2 - import * as dns from "./dns.js"; 3 - import * as activitypub from "./activitypub.js"; 4 - export interface Fetcher { 5 - fetch: (uri: string, options?: any) => Promise<unknown>; 6 - } 7 - /** 8 - * Get a fetcher by name 9 - */ 10 - export declare function get(name: string): Fetcher | undefined; 11 - /** 12 - * Get all available fetchers 13 - */ 14 - export declare function getAll(): Record<string, Fetcher>; 15 - export { http, dns, activitypub }; 16 - //# sourceMappingURL=index.d.ts.map
-1
packages/doip/dist/fetchers/index.d.ts.map
··· 1 - {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fetchers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAChC,OAAO,KAAK,WAAW,MAAM,kBAAkB,CAAC;AAGhD,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACzD;AAQD;;GAEG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAErD;AAED;;GAEG;AACH,wBAAgB,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEhD;AAED,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,WAAW,EAAE,CAAC"}
-22
packages/doip/dist/fetchers/index.js
··· 1 - import * as http from "./http.js"; 2 - import * as dns from "./dns.js"; 3 - import * as activitypub from "./activitypub.js"; 4 - const fetchers = { 5 - http, 6 - dns, 7 - activitypub, 8 - }; 9 - /** 10 - * Get a fetcher by name 11 - */ 12 - export function get(name) { 13 - return fetchers[name]; 14 - } 15 - /** 16 - * Get all available fetchers 17 - */ 18 - export function getAll() { 19 - return { ...fetchers }; 20 - } 21 - export { http, dns, activitypub }; 22 - //# sourceMappingURL=index.js.map
-1
packages/doip/dist/fetchers/index.js.map
··· 1 - {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/fetchers/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAChC,OAAO,KAAK,WAAW,MAAM,kBAAkB,CAAC;AAOhD,MAAM,QAAQ,GAA4B;IACxC,IAAI;IACJ,GAAG;IACH,WAAW;CACZ,CAAC;AAEF;;GAEG;AACH,MAAM,UAAU,GAAG,CAAC,IAAY;IAC9B,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC;AACxB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,MAAM;IACpB,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC;AACzB,CAAC;AAED,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,WAAW,EAAE,CAAC"}
-9
packages/doip/dist/index.d.ts
··· 1 - export { Claim, isValidDid } from "./claim.js"; 2 - export { Profile, resolvePds } from "./profile.js"; 3 - export { ClaimStatus } from "./types.js"; 4 - export type { ClaimVerificationResult, ProfileData, ClaimData, VerifyOptions } from "./types.js"; 5 - export { COLLECTION_NSID, DEFAULT_TIMEOUT, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 6 - export * as serviceProviders from "./serviceProviders/index.js"; 7 - export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI, } from "./serviceProviders/types.js"; 8 - export * as fetchers from "./fetchers/index.js"; 9 - //# sourceMappingURL=index.d.ts.map
-1
packages/doip/dist/index.d.ts.map
··· 1 - {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAGnD,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,YAAY,EAAE,uBAAuB,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGjG,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAGrG,OAAO,KAAK,gBAAgB,MAAM,6BAA6B,CAAC;AAChE,YAAY,EACV,eAAe,EACf,oBAAoB,EACpB,WAAW,EACX,YAAY,EACZ,YAAY,GACb,MAAM,6BAA6B,CAAC;AAGrC,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC"}
-12
packages/doip/dist/index.js
··· 1 - // Main exports 2 - export { Claim, isValidDid } from "./claim.js"; 3 - export { Profile, resolvePds } from "./profile.js"; 4 - // Types 5 - export { ClaimStatus } from "./types.js"; 6 - // Constants 7 - export { COLLECTION_NSID, DEFAULT_TIMEOUT, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 8 - // Service providers 9 - export * as serviceProviders from "./serviceProviders/index.js"; 10 - // Fetchers 11 - export * as fetchers from "./fetchers/index.js"; 12 - //# sourceMappingURL=index.js.map
-1
packages/doip/dist/index.js.map
··· 1 - {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAe;AACf,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAEnD,QAAQ;AACR,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAGzC,YAAY;AACZ,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAErG,oBAAoB;AACpB,OAAO,KAAK,gBAAgB,MAAM,6BAA6B,CAAC;AAShE,WAAW;AACX,OAAO,KAAK,QAAQ,MAAM,qBAAqB,CAAC"}
-58
packages/doip/dist/profile.d.ts
··· 1 - import { Claim } from "./claim.js"; 2 - import type { ProfileData, ClaimData, VerifyOptions } from "./types.js"; 3 - /** 4 - * Resolve the PDS endpoint from a DID document. 5 - * For did:plc, fetches from plc.directory. 6 - * For did:web, fetches from the well-known DID path. 7 - * Falls back to PUBLIC_API_URL on failure. 8 - */ 9 - export declare function resolvePds(did: string): Promise<string>; 10 - /** 11 - * Represents a user profile with identity claims from ATProto 12 - */ 13 - export declare class Profile { 14 - private _did; 15 - private _handle; 16 - private _displayName?; 17 - private _avatar?; 18 - private _claims; 19 - private _claimRecords; 20 - private constructor(); 21 - get did(): string; 22 - get handle(): string; 23 - get displayName(): string | undefined; 24 - get avatar(): string | undefined; 25 - get claims(): Claim[]; 26 - get claimRecords(): ClaimData[]; 27 - /** 28 - * Fetch a profile from ATProto by DID or handle 29 - */ 30 - static fetch(didOrHandle: string, serviceUrl?: string): Promise<Profile>; 31 - /** 32 - * Internal: fetch profile data using an already-configured agent 33 - */ 34 - private static _fetchWithAgent; 35 - /** 36 - * Verify all claims in this profile 37 - */ 38 - verifyAll(opts?: VerifyOptions): Promise<void>; 39 - /** 40 - * Get verification summary 41 - */ 42 - getSummary(): { 43 - total: number; 44 - verified: number; 45 - failed: number; 46 - pending: number; 47 - }; 48 - /** 49 - * Get claims grouped by status 50 - */ 51 - getClaimsByStatus(): { 52 - verified: Claim[]; 53 - failed: Claim[]; 54 - pending: Claim[]; 55 - }; 56 - toJSON(): ProfileData; 57 - } 58 - //# sourceMappingURL=profile.d.ts.map
-1
packages/doip/dist/profile.d.ts.map
··· 1 - {"version":3,"file":"profile.d.ts","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAGnC,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAmBxE;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA6B7D;AAWD;;GAEG;AACH,qBAAa,OAAO;IAClB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,OAAO,CAAe;IAC9B,OAAO,CAAC,aAAa,CAAmB;IAExC,OAAO;IASP,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,SAAS,CAEpC;IAED,IAAI,MAAM,IAAI,MAAM,GAAG,SAAS,CAE/B;IAED,IAAI,MAAM,IAAI,KAAK,EAAE,CAEpB;IAED,IAAI,YAAY,IAAI,SAAS,EAAE,CAE9B;IAED;;OAEG;WACU,KAAK,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAoC9E;;OAEG;mBACkB,eAAe;IAmEpC;;OAEG;IACG,SAAS,CAAC,IAAI,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpD;;OAEG;IACH,UAAU,IAAI;QACZ,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;KACjB;IAaD;;OAEG;IACH,iBAAiB,IAAI;QACnB,QAAQ,EAAE,KAAK,EAAE,CAAC;QAClB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,OAAO,EAAE,KAAK,EAAE,CAAC;KAClB;IAYD,MAAM,IAAI,WAAW;CAStB"}
-217
packages/doip/dist/profile.js
··· 1 - import { AtpAgent } from "@atproto/api"; 2 - import { Claim } from "./claim.js"; 3 - import { ClaimStatus } from "./types.js"; 4 - import { COLLECTION_NSID, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 5 - /** 6 - * Resolve the PDS endpoint from a DID document. 7 - * For did:plc, fetches from plc.directory. 8 - * For did:web, fetches from the well-known DID path. 9 - * Falls back to PUBLIC_API_URL on failure. 10 - */ 11 - export async function resolvePds(did) { 12 - try { 13 - let url; 14 - if (did.startsWith("did:plc:")) { 15 - url = `${PLC_DIRECTORY_URL}/${did}`; 16 - } 17 - else if (did.startsWith("did:web:")) { 18 - const host = did.replace("did:web:", "").replaceAll(":", "/"); 19 - url = `https://${host}/.well-known/did.json`; 20 - } 21 - else { 22 - return PUBLIC_API_URL; 23 - } 24 - const response = await globalThis.fetch(url, { 25 - headers: { Accept: "application/json" }, 26 - }); 27 - if (!response.ok) { 28 - return PUBLIC_API_URL; 29 - } 30 - const doc = (await response.json()); 31 - const pdsService = doc.service?.find((s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"); 32 - return pdsService?.serviceEndpoint ?? PUBLIC_API_URL; 33 - } 34 - catch { 35 - return PUBLIC_API_URL; 36 - } 37 - } 38 - /** 39 - * Parse an AT URI and extract the rkey (record key). 40 - * AT URIs have the format: at://did/collection/rkey 41 - */ 42 - function parseAtUriRkey(atUri) { 43 - const match = atUri.match(/^at:\/\/[^/]+\/[^/]+\/(.+)$/); 44 - return match?.[1] ?? ""; 45 - } 46 - /** 47 - * Represents a user profile with identity claims from ATProto 48 - */ 49 - export class Profile { 50 - _did; 51 - _handle; 52 - _displayName; 53 - _avatar; 54 - _claims = []; 55 - _claimRecords = []; 56 - constructor(data) { 57 - this._did = data.did; 58 - this._handle = data.handle; 59 - this._displayName = data.displayName; 60 - this._avatar = data.avatar; 61 - this._claimRecords = data.claims; 62 - this._claims = data.claims.map((c) => new Claim(c.uri, data.did)); 63 - } 64 - get did() { 65 - return this._did; 66 - } 67 - get handle() { 68 - return this._handle; 69 - } 70 - get displayName() { 71 - return this._displayName; 72 - } 73 - get avatar() { 74 - return this._avatar; 75 - } 76 - get claims() { 77 - return this._claims; 78 - } 79 - get claimRecords() { 80 - return this._claimRecords; 81 - } 82 - /** 83 - * Fetch a profile from ATProto by DID or handle 84 - */ 85 - static async fetch(didOrHandle, serviceUrl) { 86 - // Resolve PDS from DID document unless an explicit serviceUrl was provided 87 - let resolvedServiceUrl; 88 - let did = didOrHandle; 89 - if (serviceUrl) { 90 - resolvedServiceUrl = serviceUrl; 91 - } 92 - else if (didOrHandle.startsWith("did:")) { 93 - resolvedServiceUrl = await resolvePds(didOrHandle); 94 - } 95 - else { 96 - // Handle - we need to resolve via the public API first, then resolve PDS 97 - resolvedServiceUrl = PUBLIC_API_URL; 98 - } 99 - const agent = new AtpAgent({ service: resolvedServiceUrl }); 100 - // Resolve handle to DID if needed 101 - if (!didOrHandle.startsWith("did:")) { 102 - const resolved = await agent.resolveHandle({ handle: didOrHandle }); 103 - did = resolved.data.did; 104 - // Now that we have the DID, resolve the actual PDS if no explicit serviceUrl 105 - if (!serviceUrl) { 106 - const pdsUrl = await resolvePds(did); 107 - if (pdsUrl !== resolvedServiceUrl) { 108 - resolvedServiceUrl = pdsUrl; 109 - // Re-create agent pointed at the user's actual PDS 110 - const pdsAgent = new AtpAgent({ service: pdsUrl }); 111 - return Profile._fetchWithAgent(pdsAgent, did); 112 - } 113 - } 114 - } 115 - return Profile._fetchWithAgent(agent, did); 116 - } 117 - /** 118 - * Internal: fetch profile data using an already-configured agent 119 - */ 120 - static async _fetchWithAgent(agent, did) { 121 - // Fetch Bluesky profile for display info via public API (not PDS) 122 - // The PDS doesn't serve app.bsky.actor.getProfile - only the AppView does 123 - let bskyProfile = null; 124 - try { 125 - const publicAgent = new AtpAgent({ service: PUBLIC_API_URL }); 126 - const profileRes = await publicAgent.getProfile({ actor: did }); 127 - bskyProfile = { 128 - handle: profileRes.data.handle, 129 - displayName: profileRes.data.displayName, 130 - avatar: profileRes.data.avatar, 131 - }; 132 - } 133 - catch (err) { 134 - // Profile fetch is optional - user may not have a Bluesky profile 135 - // 404 is expected; log other errors at debug level 136 - if (err instanceof Error && !err.message.includes("404")) { 137 - console.debug(`Failed to fetch profile for ${did}: ${err.message}`); 138 - } 139 - } 140 - // List all claim records with cursor-based pagination 141 - const claims = []; 142 - try { 143 - let cursor; 144 - do { 145 - const records = await agent.com.atproto.repo.listRecords({ 146 - repo: did, 147 - collection: COLLECTION_NSID, 148 - limit: 100, 149 - cursor, 150 - }); 151 - for (const record of records.data.records) { 152 - const value = record.value; 153 - if (value.claimUri) { 154 - claims.push({ 155 - uri: value.claimUri, 156 - did, 157 - comment: value.comment, 158 - createdAt: value.createdAt ?? new Date().toISOString(), 159 - rkey: parseAtUriRkey(record.uri), 160 - }); 161 - } 162 - } 163 - cursor = records.data.cursor; 164 - } while (cursor); 165 - } 166 - catch (err) { 167 - // 404 means no records yet; log other errors 168 - if (err instanceof Error && !err.message.includes("404")) { 169 - console.debug(`Failed to list claim records for ${did}: ${err.message}`); 170 - } 171 - } 172 - return new Profile({ 173 - did, 174 - handle: bskyProfile?.handle ?? did, 175 - displayName: bskyProfile?.displayName, 176 - avatar: bskyProfile?.avatar, 177 - claims, 178 - }); 179 - } 180 - /** 181 - * Verify all claims in this profile 182 - */ 183 - async verifyAll(opts) { 184 - await Promise.all(this._claims.map((claim) => claim.verify(opts))); 185 - } 186 - /** 187 - * Get verification summary 188 - */ 189 - getSummary() { 190 - return { 191 - total: this._claims.length, 192 - verified: this._claims.filter((c) => c.status === ClaimStatus.VERIFIED).length, 193 - failed: this._claims.filter((c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR).length, 194 - pending: this._claims.filter((c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED).length, 195 - }; 196 - } 197 - /** 198 - * Get claims grouped by status 199 - */ 200 - getClaimsByStatus() { 201 - return { 202 - verified: this._claims.filter((c) => c.status === ClaimStatus.VERIFIED), 203 - failed: this._claims.filter((c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR), 204 - pending: this._claims.filter((c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED), 205 - }; 206 - } 207 - toJSON() { 208 - return { 209 - did: this._did, 210 - handle: this._handle, 211 - displayName: this._displayName, 212 - avatar: this._avatar, 213 - claims: this._claimRecords, 214 - }; 215 - } 216 - } 217 - //# sourceMappingURL=profile.js.map
-1
packages/doip/dist/profile.js.map
··· 1 - {"version":3,"file":"profile.js","sourceRoot":"","sources":["../src/profile.ts"],"names":[],"mappings":"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"}
-10
packages/doip/dist/serviceProviders/activitypub.d.ts
··· 1 - import type { ServiceProvider } from "./types.js"; 2 - /** 3 - * ActivityPub (Mastodon/Fediverse) service provider 4 - * 5 - * Users prove ownership by adding their DID to their profile bio or fields. 6 - * The claim URI is the profile URL (e.g., https://mastodon.social/@username) 7 - */ 8 - declare const activitypub: ServiceProvider; 9 - export default activitypub; 10 - //# sourceMappingURL=activitypub.d.ts.map
-1
packages/doip/dist/serviceProviders/activitypub.d.ts.map
··· 1 - {"version":3,"file":"activitypub.d.ts","sourceRoot":"","sources":["../../src/serviceProviders/activitypub.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;GAKG;AACH,QAAA,MAAM,WAAW,EAAE,eAsDlB,CAAC;AAEF,eAAe,WAAW,CAAC"}
-56
packages/doip/dist/serviceProviders/activitypub.js
··· 1 - /** 2 - * ActivityPub (Mastodon/Fediverse) service provider 3 - * 4 - * Users prove ownership by adding their DID to their profile bio or fields. 5 - * The claim URI is the profile URL (e.g., https://mastodon.social/@username) 6 - */ 7 - const activitypub = { 8 - id: "activitypub", 9 - name: "ActivityPub", 10 - homepage: "", 11 - // Match Mastodon-style profile URLs: https://instance/@username 12 - reUri: /^https:\/\/([^/]+)\/@([^/]+)\/?$/, 13 - // Could match other ActivityPub software with same URL pattern 14 - isAmbiguous: true, 15 - processURI(uri, match) { 16 - const [, domain, username] = match; 17 - return { 18 - profile: { 19 - display: `@${username}@${domain}`, 20 - uri, 21 - qrcode: true, 22 - }, 23 - proof: { 24 - request: { 25 - uri, 26 - fetcher: "activitypub", 27 - format: "json", 28 - }, 29 - target: [ 30 - // Check profile bio/summary (HTML content) 31 - { path: ["summary"], relation: "contains", format: "text" }, 32 - // Check profile fields (Mastodon-style verification fields) 33 - { path: ["attachment", "*", "value"], relation: "contains", format: "text" }, 34 - ], 35 - }, 36 - }; 37 - }, 38 - postprocess(data) { 39 - const actor = data; 40 - return { 41 - display: actor.name || actor.preferredUsername, 42 - }; 43 - }, 44 - getProofText(did) { 45 - return did; 46 - }, 47 - tests: [ 48 - { uri: "https://mastodon.social/@alice", shouldMatch: true }, 49 - { uri: "https://fosstodon.org/@bob/", shouldMatch: true }, 50 - { uri: "https://hachyderm.io/@user", shouldMatch: true }, 51 - { uri: "https://twitter.com/alice", shouldMatch: false }, 52 - { uri: "https://mastodon.social/alice", shouldMatch: false }, 53 - ], 54 - }; 55 - export default activitypub; 56 - //# sourceMappingURL=activitypub.js.map
-1
packages/doip/dist/serviceProviders/activitypub.js.map
··· 1 - {"version":3,"file":"activitypub.js","sourceRoot":"","sources":["../../src/serviceProviders/activitypub.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,WAAW,GAAoB;IACnC,EAAE,EAAE,aAAa;IACjB,IAAI,EAAE,aAAa;IACnB,QAAQ,EAAE,EAAE;IAEZ,gEAAgE;IAChE,KAAK,EAAE,kCAAkC;IAEzC,+DAA+D;IAC/D,WAAW,EAAE,IAAI;IAEjB,UAAU,CAAC,GAAG,EAAE,KAAK;QACnB,MAAM,CAAC,EAAE,MAAM,EAAE,QAAQ,CAAC,GAAG,KAAK,CAAC;QAEnC,OAAO;YACL,OAAO,EAAE;gBACP,OAAO,EAAE,IAAI,QAAQ,IAAI,MAAM,EAAE;gBACjC,GAAG;gBACH,MAAM,EAAE,IAAI;aACb;YACD,KAAK,EAAE;gBACL,OAAO,EAAE;oBACP,GAAG;oBACH,OAAO,EAAE,aAAa;oBACtB,MAAM,EAAE,MAAM;iBACf;gBACD,MAAM,EAAE;oBACN,2CAA2C;oBAC3C,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE;oBAC3D,4DAA4D;oBAC5D,EAAE,IAAI,EAAE,CAAC,YAAY,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE;iBAC7E;aACF;SACF,CAAC;IACJ,CAAC;IAED,WAAW,CAAC,IAAI;QACd,MAAM,KAAK,GAAG,IAAqD,CAAC;QACpE,OAAO;YACL,OAAO,EAAE,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,iBAAiB;SAC/C,CAAC;IACJ,CAAC;IAED,YAAY,CAAC,GAAG;QACd,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,EAAE;QACL,EAAE,GAAG,EAAE,gCAAgC,EAAE,WAAW,EAAE,IAAI,EAAE;QAC5D,EAAE,GAAG,EAAE,6BAA6B,EAAE,WAAW,EAAE,IAAI,EAAE;QACzD,EAAE,GAAG,EAAE,4BAA4B,EAAE,WAAW,EAAE,IAAI,EAAE;QACxD,EAAE,GAAG,EAAE,2BAA2B,EAAE,WAAW,EAAE,KAAK,EAAE;QACxD,EAAE,GAAG,EAAE,+BAA+B,EAAE,WAAW,EAAE,KAAK,EAAE;KAC7D;CACF,CAAC;AAEF,eAAe,WAAW,CAAC"}
-10
packages/doip/dist/serviceProviders/bsky.d.ts
··· 1 - import type { ServiceProvider } from "./types.js"; 2 - /** 3 - * Bluesky service provider 4 - * 5 - * Users prove ownership of another Bluesky account by adding their DID to the profile bio. 6 - * The claim URI is the bsky.app profile URL. 7 - */ 8 - declare const bsky: ServiceProvider; 9 - export default bsky; 10 - //# sourceMappingURL=bsky.d.ts.map
-1
packages/doip/dist/serviceProviders/bsky.d.ts.map
··· 1 - {"version":3,"file":"bsky.d.ts","sourceRoot":"","sources":["../../src/serviceProviders/bsky.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;GAKG;AACH,QAAA,MAAM,IAAI,EAAE,eA4CX,CAAC;AAEF,eAAe,IAAI,CAAC"}
-47
packages/doip/dist/serviceProviders/bsky.js
··· 1 - /** 2 - * Bluesky service provider 3 - * 4 - * Users prove ownership of another Bluesky account by adding their DID to the profile bio. 5 - * The claim URI is the bsky.app profile URL. 6 - */ 7 - const bsky = { 8 - id: "bsky", 9 - name: "Bluesky", 10 - homepage: "https://bsky.app", 11 - // Match Bluesky profile URLs: https://bsky.app/profile/handle or did 12 - reUri: /^https:\/\/bsky\.app\/profile\/([^/]+)\/?$/, 13 - isAmbiguous: false, 14 - processURI(uri, match) { 15 - const [, handle] = match; 16 - return { 17 - profile: { 18 - display: handle.startsWith("did:") ? handle : `@${handle}`, 19 - uri, 20 - qrcode: true, 21 - }, 22 - proof: { 23 - request: { 24 - uri: `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 25 - fetcher: "http", 26 - format: "json", 27 - }, 28 - target: [ 29 - // Check profile description/bio 30 - { path: ["description"], relation: "contains", format: "text" }, 31 - ], 32 - }, 33 - }; 34 - }, 35 - getProofText(did) { 36 - return did; 37 - }, 38 - tests: [ 39 - { uri: "https://bsky.app/profile/alice.bsky.social", shouldMatch: true }, 40 - { uri: "https://bsky.app/profile/did:plc:abc123", shouldMatch: true }, 41 - { uri: "https://bsky.app/profile/alice.bsky.social/", shouldMatch: true }, 42 - { uri: "https://bsky.app/profile/alice/post/123", shouldMatch: false }, 43 - { uri: "https://bsky.social/profile/alice", shouldMatch: false }, 44 - ], 45 - }; 46 - export default bsky; 47 - //# sourceMappingURL=bsky.js.map
-1
packages/doip/dist/serviceProviders/bsky.js.map
··· 1 - {"version":3,"file":"bsky.js","sourceRoot":"","sources":["../../src/serviceProviders/bsky.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,IAAI,GAAoB;IAC5B,EAAE,EAAE,MAAM;IACV,IAAI,EAAE,SAAS;IACf,QAAQ,EAAE,kBAAkB;IAE5B,qEAAqE;IACrE,KAAK,EAAE,4CAA4C;IAEnD,WAAW,EAAE,KAAK;IAElB,UAAU,CAAC,GAAG,EAAE,KAAK;QACnB,MAAM,CAAC,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC;QAEzB,OAAO;YACL,OAAO,EAAE;gBACP,OAAO,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,MAAM,EAAE;gBAC1D,GAAG;gBACH,MAAM,EAAE,IAAI;aACb;YACD,KAAK,EAAE;gBACL,OAAO,EAAE;oBACP,GAAG,EAAE,oEAAoE,kBAAkB,CAAC,MAAM,CAAC,EAAE;oBACrG,OAAO,EAAE,MAAM;oBACf,MAAM,EAAE,MAAM;iBACf;gBACD,MAAM,EAAE;oBACN,gCAAgC;oBAChC,EAAE,IAAI,EAAE,CAAC,aAAa,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE;iBAChE;aACF;SACF,CAAC;IACJ,CAAC;IAED,YAAY,CAAC,GAAG;QACd,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,EAAE;QACL,EAAE,GAAG,EAAE,4CAA4C,EAAE,WAAW,EAAE,IAAI,EAAE;QACxE,EAAE,GAAG,EAAE,yCAAyC,EAAE,WAAW,EAAE,IAAI,EAAE;QACrE,EAAE,GAAG,EAAE,6CAA6C,EAAE,WAAW,EAAE,IAAI,EAAE;QACzE,EAAE,GAAG,EAAE,yCAAyC,EAAE,WAAW,EAAE,KAAK,EAAE;QACtE,EAAE,GAAG,EAAE,mCAAmC,EAAE,WAAW,EAAE,KAAK,EAAE;KACjE;CACF,CAAC;AAEF,eAAe,IAAI,CAAC"}
-10
packages/doip/dist/serviceProviders/dns.d.ts
··· 1 - import type { ServiceProvider } from "./types.js"; 2 - /** 3 - * DNS TXT record service provider 4 - * 5 - * Users prove domain ownership by adding a TXT record containing their DID. 6 - * The claim URI format is: dns:example.com 7 - */ 8 - declare const dns: ServiceProvider; 9 - export default dns; 10 - //# sourceMappingURL=dns.d.ts.map
-1
packages/doip/dist/serviceProviders/dns.d.ts.map
··· 1 - {"version":3,"file":"dns.d.ts","sourceRoot":"","sources":["../../src/serviceProviders/dns.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;GAKG;AACH,QAAA,MAAM,GAAG,EAAE,eA8CV,CAAC;AAEF,eAAe,GAAG,CAAC"}
-48
packages/doip/dist/serviceProviders/dns.js
··· 1 - /** 2 - * DNS TXT record service provider 3 - * 4 - * Users prove domain ownership by adding a TXT record containing their DID. 5 - * The claim URI format is: dns:example.com 6 - */ 7 - const dns = { 8 - id: "dns", 9 - name: "DNS", 10 - homepage: "", 11 - // Match dns:domain.tld URIs (must contain at least one dot) 12 - reUri: /^dns:([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+)$/, 13 - isAmbiguous: false, 14 - processURI(uri, match) { 15 - const [, domain] = match; 16 - return { 17 - profile: { 18 - display: domain, 19 - uri: `https://${domain}`, 20 - qrcode: false, 21 - }, 22 - proof: { 23 - request: { 24 - uri: domain, 25 - fetcher: "dns", 26 - format: "json", 27 - }, 28 - target: [ 29 - // Look for DID in any TXT record 30 - { path: ["records", "txt"], relation: "contains", format: "text" }, 31 - ], 32 - }, 33 - }; 34 - }, 35 - getProofText(did) { 36 - return `keytrace-verification=${did}`; 37 - }, 38 - tests: [ 39 - { uri: "dns:example.com", shouldMatch: true }, 40 - { uri: "dns:sub.example.com", shouldMatch: true }, 41 - { uri: "dns:a.b.c.example.com", shouldMatch: true }, 42 - { uri: "dns:example", shouldMatch: false }, 43 - { uri: "dns:-invalid.com", shouldMatch: false }, 44 - { uri: "https://example.com", shouldMatch: false }, 45 - ], 46 - }; 47 - export default dns; 48 - //# sourceMappingURL=dns.js.map
-1
packages/doip/dist/serviceProviders/dns.js.map
··· 1 - {"version":3,"file":"dns.js","sourceRoot":"","sources":["../../src/serviceProviders/dns.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,GAAG,GAAoB;IAC3B,EAAE,EAAE,KAAK;IACT,IAAI,EAAE,KAAK;IACX,QAAQ,EAAE,EAAE;IAEZ,4DAA4D;IAC5D,KAAK,EACH,iGAAiG;IAEnG,WAAW,EAAE,KAAK;IAElB,UAAU,CAAC,GAAG,EAAE,KAAK;QACnB,MAAM,CAAC,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC;QAEzB,OAAO;YACL,OAAO,EAAE;gBACP,OAAO,EAAE,MAAM;gBACf,GAAG,EAAE,WAAW,MAAM,EAAE;gBACxB,MAAM,EAAE,KAAK;aACd;YACD,KAAK,EAAE;gBACL,OAAO,EAAE;oBACP,GAAG,EAAE,MAAM;oBACX,OAAO,EAAE,KAAK;oBACd,MAAM,EAAE,MAAM;iBACf;gBACD,MAAM,EAAE;oBACN,iCAAiC;oBACjC,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,KAAK,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE;iBACnE;aACF;SACF,CAAC;IACJ,CAAC;IAED,YAAY,CAAC,GAAG;QACd,OAAO,yBAAyB,GAAG,EAAE,CAAC;IACxC,CAAC;IAED,KAAK,EAAE;QACL,EAAE,GAAG,EAAE,iBAAiB,EAAE,WAAW,EAAE,IAAI,EAAE;QAC7C,EAAE,GAAG,EAAE,qBAAqB,EAAE,WAAW,EAAE,IAAI,EAAE;QACjD,EAAE,GAAG,EAAE,uBAAuB,EAAE,WAAW,EAAE,IAAI,EAAE;QACnD,EAAE,GAAG,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,EAAE;QAC1C,EAAE,GAAG,EAAE,kBAAkB,EAAE,WAAW,EAAE,KAAK,EAAE;QAC/C,EAAE,GAAG,EAAE,qBAAqB,EAAE,WAAW,EAAE,KAAK,EAAE;KACnD;CACF,CAAC;AAEF,eAAe,GAAG,CAAC"}
-10
packages/doip/dist/serviceProviders/github.d.ts
··· 1 - import type { ServiceProvider } from "./types.js"; 2 - /** 3 - * GitHub Gist service provider 4 - * 5 - * Users prove ownership by creating a public gist containing their DID. 6 - * The gist URL is used as the claim URI. 7 - */ 8 - declare const github: ServiceProvider; 9 - export default github; 10 - //# sourceMappingURL=github.d.ts.map
-1
packages/doip/dist/serviceProviders/github.d.ts.map
··· 1 - {"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../../src/serviceProviders/github.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;GAKG;AACH,QAAA,MAAM,MAAM,EAAE,eAwEb,CAAC;AAEF,eAAe,MAAM,CAAC"}
-75
packages/doip/dist/serviceProviders/github.js
··· 1 - /** 2 - * GitHub Gist service provider 3 - * 4 - * Users prove ownership by creating a public gist containing their DID. 5 - * The gist URL is used as the claim URI. 6 - */ 7 - const github = { 8 - id: "github", 9 - name: "GitHub", 10 - homepage: "https://github.com", 11 - // Match GitHub Gist URLs: https://gist.github.com/username/gistid 12 - reUri: /^https:\/\/gist\.github\.com\/([^/]+)\/([a-f0-9]+)\/?$/, 13 - isAmbiguous: false, 14 - processURI(uri, match) { 15 - const [, username, gistId] = match; 16 - return { 17 - profile: { 18 - display: `@${username}`, 19 - uri: `https://github.com/${username}`, 20 - qrcode: true, 21 - }, 22 - proof: { 23 - request: { 24 - uri: `https://api.github.com/gists/${gistId}`, 25 - fetcher: "http", 26 - format: "json", 27 - options: { 28 - headers: { 29 - Accept: "application/vnd.github.v3+json", 30 - }, 31 - }, 32 - }, 33 - target: [ 34 - // Check keytrace.json file content 35 - { 36 - path: ["files", "keytrace.json", "content"], 37 - relation: "contains", 38 - format: "text", 39 - }, 40 - // Check proof.md file content 41 - { 42 - path: ["files", "proof.md", "content"], 43 - relation: "contains", 44 - format: "text", 45 - }, 46 - // Check keytrace.md file content 47 - { 48 - path: ["files", "keytrace.md", "content"], 49 - relation: "contains", 50 - format: "text", 51 - }, 52 - // Check openpgp.md for backwards compatibility with Keyoxide 53 - { 54 - path: ["files", "openpgp.md", "content"], 55 - relation: "contains", 56 - format: "text", 57 - }, 58 - // Check gist description 59 - { path: ["description"], relation: "contains", format: "text" }, 60 - ], 61 - }, 62 - }; 63 - }, 64 - getProofText(did) { 65 - return `Verifying my identity on keytrace: ${did}`; 66 - }, 67 - tests: [ 68 - { uri: "https://gist.github.com/alice/abc123def456", shouldMatch: true }, 69 - { uri: "https://gist.github.com/alice/abc123def456/", shouldMatch: true }, 70 - { uri: "https://github.com/alice", shouldMatch: false }, 71 - { uri: "https://gist.gitlab.com/alice/abc123", shouldMatch: false }, 72 - ], 73 - }; 74 - export default github; 75 - //# sourceMappingURL=github.js.map
-1
packages/doip/dist/serviceProviders/github.js.map
··· 1 - {"version":3,"file":"github.js","sourceRoot":"","sources":["../../src/serviceProviders/github.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,MAAM,GAAoB;IAC9B,EAAE,EAAE,QAAQ;IACZ,IAAI,EAAE,QAAQ;IACd,QAAQ,EAAE,oBAAoB;IAE9B,kEAAkE;IAClE,KAAK,EAAE,wDAAwD;IAE/D,WAAW,EAAE,KAAK;IAElB,UAAU,CAAC,GAAG,EAAE,KAAK;QACnB,MAAM,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC;QAEnC,OAAO;YACL,OAAO,EAAE;gBACP,OAAO,EAAE,IAAI,QAAQ,EAAE;gBACvB,GAAG,EAAE,sBAAsB,QAAQ,EAAE;gBACrC,MAAM,EAAE,IAAI;aACb;YACD,KAAK,EAAE;gBACL,OAAO,EAAE;oBACP,GAAG,EAAE,gCAAgC,MAAM,EAAE;oBAC7C,OAAO,EAAE,MAAM;oBACf,MAAM,EAAE,MAAM;oBACd,OAAO,EAAE;wBACP,OAAO,EAAE;4BACP,MAAM,EAAE,gCAAgC;yBACzC;qBACF;iBACF;gBACD,MAAM,EAAE;oBACN,mCAAmC;oBACnC;wBACE,IAAI,EAAE,CAAC,OAAO,EAAE,eAAe,EAAE,SAAS,CAAC;wBAC3C,QAAQ,EAAE,UAAU;wBACpB,MAAM,EAAE,MAAM;qBACf;oBACD,8BAA8B;oBAC9B;wBACE,IAAI,EAAE,CAAC,OAAO,EAAE,UAAU,EAAE,SAAS,CAAC;wBACtC,QAAQ,EAAE,UAAU;wBACpB,MAAM,EAAE,MAAM;qBACf;oBACD,iCAAiC;oBACjC;wBACE,IAAI,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,SAAS,CAAC;wBACzC,QAAQ,EAAE,UAAU;wBACpB,MAAM,EAAE,MAAM;qBACf;oBACD,6DAA6D;oBAC7D;wBACE,IAAI,EAAE,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,CAAC;wBACxC,QAAQ,EAAE,UAAU;wBACpB,MAAM,EAAE,MAAM;qBACf;oBACD,yBAAyB;oBACzB,EAAE,IAAI,EAAE,CAAC,aAAa,CAAC,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE;iBAChE;aACF;SACF,CAAC;IACJ,CAAC;IAED,YAAY,CAAC,GAAG;QACd,OAAO,sCAAsC,GAAG,EAAE,CAAC;IACrD,CAAC;IAED,KAAK,EAAE;QACL,EAAE,GAAG,EAAE,4CAA4C,EAAE,WAAW,EAAE,IAAI,EAAE;QACxE,EAAE,GAAG,EAAE,6CAA6C,EAAE,WAAW,EAAE,IAAI,EAAE;QACzE,EAAE,GAAG,EAAE,0BAA0B,EAAE,WAAW,EAAE,KAAK,EAAE;QACvD,EAAE,GAAG,EAAE,sCAAsC,EAAE,WAAW,EAAE,KAAK,EAAE;KACpE;CACF,CAAC;AAEF,eAAe,MAAM,CAAC"}
-25
packages/doip/dist/serviceProviders/index.d.ts
··· 1 - import github from "./github.js"; 2 - import dns from "./dns.js"; 3 - import activitypub from "./activitypub.js"; 4 - import bsky from "./bsky.js"; 5 - import type { ServiceProvider, ServiceProviderMatch } from "./types.js"; 6 - export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI, } from "./types.js"; 7 - /** 8 - * Get a service provider by ID 9 - */ 10 - export declare function getProvider(id: string): ServiceProvider | undefined; 11 - /** 12 - * Get all registered service providers 13 - */ 14 - export declare function getAllProviders(): ServiceProvider[]; 15 - /** 16 - * Match a URI against all service providers 17 - * Returns all matching providers, with unambiguous matches stopping the search 18 - */ 19 - export declare function matchUri(uri: string): ServiceProviderMatch[]; 20 - /** 21 - * Get the proof text a user should add to verify a claim 22 - */ 23 - export declare function getProofTextForProvider(providerId: string, did: string, handle?: string): string | undefined; 24 - export { github, dns, activitypub, bsky }; 25 - //# sourceMappingURL=index.d.ts.map
-1
packages/doip/dist/serviceProviders/index.d.ts.map
··· 1 - {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/serviceProviders/index.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,WAAW,MAAM,kBAAkB,CAAC;AAC3C,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAExE,YAAY,EACV,eAAe,EACf,oBAAoB,EACpB,WAAW,EACX,YAAY,EACZ,YAAY,GACb,MAAM,YAAY,CAAC;AASpB;;GAEG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEnE;AAED;;GAEG;AACH,wBAAgB,eAAe,IAAI,eAAe,EAAE,CAEnD;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAmB5D;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,GAAG,SAAS,CAGpB;AAED,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC"}
-53
packages/doip/dist/serviceProviders/index.js
··· 1 - import github from "./github.js"; 2 - import dns from "./dns.js"; 3 - import activitypub from "./activitypub.js"; 4 - import bsky from "./bsky.js"; 5 - const providers = { 6 - github, 7 - dns, 8 - activitypub, 9 - bsky, 10 - }; 11 - /** 12 - * Get a service provider by ID 13 - */ 14 - export function getProvider(id) { 15 - return providers[id]; 16 - } 17 - /** 18 - * Get all registered service providers 19 - */ 20 - export function getAllProviders() { 21 - return Object.values(providers); 22 - } 23 - /** 24 - * Match a URI against all service providers 25 - * Returns all matching providers, with unambiguous matches stopping the search 26 - */ 27 - export function matchUri(uri) { 28 - const matches = []; 29 - for (const provider of Object.values(providers)) { 30 - const match = uri.match(provider.reUri); 31 - if (match) { 32 - matches.push({ 33 - provider, 34 - match, 35 - isAmbiguous: provider.isAmbiguous ?? false, 36 - }); 37 - // Stop on unambiguous match 38 - if (!provider.isAmbiguous) { 39 - break; 40 - } 41 - } 42 - } 43 - return matches; 44 - } 45 - /** 46 - * Get the proof text a user should add to verify a claim 47 - */ 48 - export function getProofTextForProvider(providerId, did, handle) { 49 - const provider = providers[providerId]; 50 - return provider?.getProofText(did, handle); 51 - } 52 - export { github, dns, activitypub, bsky }; 53 - //# sourceMappingURL=index.js.map
-1
packages/doip/dist/serviceProviders/index.js.map
··· 1 - {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/serviceProviders/index.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,WAAW,MAAM,kBAAkB,CAAC;AAC3C,OAAO,IAAI,MAAM,WAAW,CAAC;AAW7B,MAAM,SAAS,GAAoC;IACjD,MAAM;IACN,GAAG;IACH,WAAW;IACX,IAAI;CACL,CAAC;AAEF;;GAEG;AACH,MAAM,UAAU,WAAW,CAAC,EAAU;IACpC,OAAO,SAAS,CAAC,EAAE,CAAC,CAAC;AACvB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,eAAe;IAC7B,OAAO,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAW;IAClC,MAAM,OAAO,GAA2B,EAAE,CAAC;IAE3C,KAAK,MAAM,QAAQ,IAAI,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;QAChD,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;QACxC,IAAI,KAAK,EAAE,CAAC;YACV,OAAO,CAAC,IAAI,CAAC;gBACX,QAAQ;gBACR,KAAK;gBACL,WAAW,EAAE,QAAQ,CAAC,WAAW,IAAI,KAAK;aAC3C,CAAC,CAAC;YACH,4BAA4B;YAC5B,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAC;gBAC1B,MAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,uBAAuB,CACrC,UAAkB,EAClB,GAAW,EACX,MAAe;IAEf,MAAM,QAAQ,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;IACvC,OAAO,QAAQ,EAAE,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;AAC7C,CAAC;AAED,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC"}
-80
packages/doip/dist/serviceProviders/types.d.ts
··· 1 - /** 2 - * A match between a URI and a service provider 3 - */ 4 - export interface ServiceProviderMatch { 5 - provider: ServiceProvider; 6 - match: RegExpMatchArray; 7 - isAmbiguous: boolean; 8 - } 9 - /** 10 - * Where to look for proof in the fetched response 11 - */ 12 - export interface ProofTarget { 13 - /** JSON path to search for proof (e.g., ['description'], ['files', '*', 'content']) */ 14 - path: string[]; 15 - /** How to match: 'contains', 'equals', 'startsWith' */ 16 - relation: "contains" | "equals" | "startsWith"; 17 - /** Format of data at path */ 18 - format: "text" | "uri" | "json"; 19 - } 20 - /** 21 - * How to fetch the proof 22 - */ 23 - export interface ProofRequest { 24 - /** URL template with {placeholders} */ 25 - uri: string; 26 - /** Fetcher to use: 'http', 'dns', 'activitypub' */ 27 - fetcher: string; 28 - /** Expected response format */ 29 - format: "json" | "text"; 30 - /** Additional fetch options */ 31 - options?: { 32 - headers?: Record<string, string>; 33 - }; 34 - } 35 - /** 36 - * Result of processing a URI 37 - */ 38 - export interface ProcessedURI { 39 - /** Profile display info */ 40 - profile: { 41 - display: string; 42 - uri: string; 43 - qrcode?: boolean; 44 - }; 45 - /** How to fetch and verify the proof */ 46 - proof: { 47 - request: ProofRequest; 48 - target: ProofTarget[]; 49 - }; 50 - } 51 - /** 52 - * A service provider that can verify identity claims 53 - */ 54 - export interface ServiceProvider { 55 - /** Unique identifier */ 56 - id: string; 57 - /** Display name */ 58 - name: string; 59 - /** Homepage URL */ 60 - homepage: string; 61 - /** Regex to match claim URIs */ 62 - reUri: RegExp; 63 - /** Whether matches are potentially ambiguous (could match multiple providers) */ 64 - isAmbiguous?: boolean; 65 - /** Process matched URI into verification config */ 66 - processURI(uri: string, match: RegExpMatchArray): ProcessedURI; 67 - /** Optional post-processing after fetch */ 68 - postprocess?(data: unknown, match: RegExpMatchArray): { 69 - display?: string; 70 - uri?: string; 71 - }; 72 - /** Generate proof text for user to add to their profile */ 73 - getProofText(did: string, handle?: string): string; 74 - /** Test cases for validation */ 75 - tests: { 76 - uri: string; 77 - shouldMatch: boolean; 78 - }[]; 79 - } 80 - //# sourceMappingURL=types.d.ts.map
-1
packages/doip/dist/serviceProviders/types.d.ts.map
··· 1 - {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/serviceProviders/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,eAAe,CAAC;IAC1B,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,uFAAuF;IACvF,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,uDAAuD;IACvD,QAAQ,EAAE,UAAU,GAAG,QAAQ,GAAG,YAAY,CAAC;IAC/C,6BAA6B;IAC7B,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;CACjC;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,+BAA+B;IAC/B,OAAO,CAAC,EAAE;QACR,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KAClC,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,2BAA2B;IAC3B,OAAO,EAAE;QACP,OAAO,EAAE,MAAM,CAAC;QAChB,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,CAAC,EAAE,OAAO,CAAC;KAClB,CAAC;IACF,wCAAwC;IACxC,KAAK,EAAE;QACL,OAAO,EAAE,YAAY,CAAC;QACtB,MAAM,EAAE,WAAW,EAAE,CAAC;KACvB,CAAC;CACH;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,wBAAwB;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB;IACnB,QAAQ,EAAE,MAAM,CAAC;IAEjB,gCAAgC;IAChC,KAAK,EAAE,MAAM,CAAC;IAEd,iFAAiF;IACjF,WAAW,CAAC,EAAE,OAAO,CAAC;IAEtB,mDAAmD;IACnD,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,YAAY,CAAC;IAE/D,2CAA2C;IAC3C,WAAW,CAAC,CACV,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,gBAAgB,GACtB;QACD,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC;KACd,CAAC;IAEF,2DAA2D;IAC3D,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAEnD,gCAAgC;IAChC,KAAK,EAAE;QACL,GAAG,EAAE,MAAM,CAAC;QACZ,WAAW,EAAE,OAAO,CAAC;KACtB,EAAE,CAAC;CACL"}
-2
packages/doip/dist/serviceProviders/types.js
··· 1 - export {}; 2 - //# sourceMappingURL=types.js.map
-1
packages/doip/dist/serviceProviders/types.js.map
··· 1 - {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/serviceProviders/types.ts"],"names":[],"mappings":""}
-50
packages/doip/dist/types.d.ts
··· 1 - /** 2 - * Result of verifying a claim 3 - */ 4 - export interface ClaimVerificationResult { 5 - status: ClaimStatus; 6 - errors: string[]; 7 - timestamp: Date; 8 - } 9 - /** 10 - * Profile data from ATProto 11 - */ 12 - export interface ProfileData { 13 - did: string; 14 - handle: string; 15 - displayName?: string; 16 - avatar?: string; 17 - claims: ClaimData[]; 18 - } 19 - /** 20 - * Individual claim data from ATProto record 21 - */ 22 - export interface ClaimData { 23 - uri: string; 24 - did: string; 25 - comment?: string; 26 - createdAt: string; 27 - rkey: string; 28 - } 29 - /** 30 - * Options for verification operations 31 - */ 32 - export interface VerifyOptions { 33 - /** Timeout for fetcher operations in ms */ 34 - timeout?: number; 35 - /** Skip cache and force fresh verification */ 36 - skipCache?: boolean; 37 - /** Proxy URL for browser-based DNS/HTTP requests */ 38 - proxyUrl?: string; 39 - } 40 - /** 41 - * Claim status enum 42 - */ 43 - export declare enum ClaimStatus { 44 - INIT = "init", 45 - MATCHED = "matched", 46 - VERIFIED = "verified", 47 - FAILED = "failed", 48 - ERROR = "error" 49 - } 50 - //# sourceMappingURL=types.d.ts.map
-1
packages/doip/dist/types.d.ts.map
··· 1 - {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,WAAW,CAAC;IACpB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,oBAAY,WAAW;IACrB,IAAI,SAAS;IACb,OAAO,YAAY;IACnB,QAAQ,aAAa;IACrB,MAAM,WAAW;IACjB,KAAK,UAAU;CAChB"}
-12
packages/doip/dist/types.js
··· 1 - /** 2 - * Claim status enum 3 - */ 4 - export var ClaimStatus; 5 - (function (ClaimStatus) { 6 - ClaimStatus["INIT"] = "init"; 7 - ClaimStatus["MATCHED"] = "matched"; 8 - ClaimStatus["VERIFIED"] = "verified"; 9 - ClaimStatus["FAILED"] = "failed"; 10 - ClaimStatus["ERROR"] = "error"; 11 - })(ClaimStatus || (ClaimStatus = {})); 12 - //# sourceMappingURL=types.js.map
-1
packages/doip/dist/types.js.map
··· 1 - {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA2CA;;GAEG;AACH,MAAM,CAAN,IAAY,WAMX;AAND,WAAY,WAAW;IACrB,4BAAa,CAAA;IACb,kCAAmB,CAAA;IACnB,oCAAqB,CAAA;IACrB,gCAAiB,CAAA;IACjB,8BAAe,CAAA;AACjB,CAAC,EANW,WAAW,KAAX,WAAW,QAMtB"}
-34
packages/doip/package.json
··· 1 - { 2 - "name": "@keytrace/doip", 3 - "version": "0.0.1", 4 - "files": [ 5 - "dist" 6 - ], 7 - "type": "module", 8 - "main": "./dist/index.js", 9 - "types": "./dist/index.d.ts", 10 - "exports": { 11 - ".": { 12 - "types": "./dist/index.d.ts", 13 - "import": "./dist/index.js" 14 - } 15 - }, 16 - "scripts": { 17 - "build": "tsc", 18 - "test": "vitest run", 19 - "test:watch": "vitest", 20 - "typecheck": "tsc --noEmit", 21 - "lint": "echo 'lint not configured'" 22 - }, 23 - "dependencies": { 24 - "@atproto/api": "^0.14.0" 25 - }, 26 - "devDependencies": { 27 - "@types/node": "^22.0.0", 28 - "typescript": "^5.7.0", 29 - "vitest": "^2.1.0" 30 - }, 31 - "engines": { 32 - "node": ">=20" 33 - } 34 - }
-268
packages/doip/src/claim.ts
··· 1 - import { ClaimStatus } from "./types.js"; 2 - import { DEFAULT_TIMEOUT } from "./constants.js"; 3 - import { 4 - matchUri, 5 - type ServiceProviderMatch, 6 - type ProofRequest, 7 - type ProofTarget, 8 - } from "./serviceProviders/index.js"; 9 - import * as fetchers from "./fetchers/index.js"; 10 - import type { VerifyOptions, ClaimVerificationResult } from "./types.js"; 11 - 12 - // did:plc identifiers are base32-encoded, lowercase 13 - const DID_PLC_RE = /^did:plc:[a-z2-7]{24}$/; 14 - // did:web uses a domain name (with optional port and path segments encoded as colons) 15 - const DID_WEB_RE = /^did:web:[a-zA-Z0-9._:%-]+$/; 16 - 17 - /** 18 - * Validate a DID string. Accepts did:plc and did:web formats. 19 - */ 20 - export function isValidDid(did: string): boolean { 21 - return DID_PLC_RE.test(did) || DID_WEB_RE.test(did); 22 - } 23 - 24 - /** 25 - * Represents a single identity claim linking a DID to an external account 26 - */ 27 - export class Claim { 28 - private _uri: string; 29 - private _did: string; 30 - private _status: ClaimStatus = ClaimStatus.INIT; 31 - private _matches: ServiceProviderMatch[] = []; 32 - private _errors: string[] = []; 33 - 34 - constructor(uri: string, did: string) { 35 - this._uri = uri; 36 - 37 - // Validate DID format: must be did:plc:<id> or did:web:<host> 38 - if (!isValidDid(did)) { 39 - throw new Error(`Invalid DID format: ${did}`); 40 - } 41 - this._did = did; 42 - } 43 - 44 - get uri(): string { 45 - return this._uri; 46 - } 47 - 48 - get did(): string { 49 - return this._did; 50 - } 51 - 52 - get status(): ClaimStatus { 53 - return this._status; 54 - } 55 - 56 - get matches(): ServiceProviderMatch[] { 57 - return this._matches; 58 - } 59 - 60 - get errors(): string[] { 61 - return this._errors; 62 - } 63 - 64 - /** 65 - * Match the claim URI against known service providers 66 - */ 67 - match(): void { 68 - this._matches = matchUri(this._uri); 69 - this._status = this._matches.length > 0 ? ClaimStatus.MATCHED : ClaimStatus.ERROR; 70 - 71 - if (this._matches.length === 0) { 72 - this._errors.push(`No service provider matched URI: ${this._uri}`); 73 - } 74 - } 75 - 76 - /** 77 - * Check if the claim is ambiguous (matches multiple providers) 78 - */ 79 - isAmbiguous(): boolean { 80 - return this._matches.length > 1 || (this._matches.length === 1 && this._matches[0].isAmbiguous); 81 - } 82 - 83 - /** 84 - * Get the matched service provider (first unambiguous match, or first match) 85 - */ 86 - getMatchedProvider(): ServiceProviderMatch | undefined { 87 - return this._matches[0]; 88 - } 89 - 90 - /** 91 - * Verify the claim by fetching proof and checking for DID 92 - */ 93 - async verify(opts: VerifyOptions = {}): Promise<ClaimVerificationResult> { 94 - if (this._status === ClaimStatus.INIT) { 95 - this.match(); 96 - } 97 - 98 - if (this._matches.length === 0) { 99 - return { 100 - status: ClaimStatus.ERROR, 101 - errors: this._errors, 102 - timestamp: new Date(), 103 - }; 104 - } 105 - 106 - // Try each matched provider until one succeeds 107 - for (const match of this._matches) { 108 - try { 109 - const config = match.provider.processURI(this._uri, match.match); 110 - const proofData = await this.fetchProof(config.proof.request, opts); 111 - 112 - if (this.checkProof(proofData, config.proof.target)) { 113 - this._status = ClaimStatus.VERIFIED; 114 - return { 115 - status: ClaimStatus.VERIFIED, 116 - errors: [], 117 - timestamp: new Date(), 118 - }; 119 - } 120 - } catch (err) { 121 - this._errors.push( 122 - `${match.provider.id}: ${err instanceof Error ? err.message : "Unknown error"}`, 123 - ); 124 - } 125 - 126 - // Stop on unambiguous match 127 - if (!match.isAmbiguous) break; 128 - } 129 - 130 - this._status = ClaimStatus.FAILED; 131 - return { 132 - status: ClaimStatus.FAILED, 133 - errors: this._errors, 134 - timestamp: new Date(), 135 - }; 136 - } 137 - 138 - private async fetchProof(request: ProofRequest, opts: VerifyOptions): Promise<unknown> { 139 - const fetcher = fetchers.get(request.fetcher); 140 - if (!fetcher) { 141 - throw new Error(`Unknown fetcher: ${request.fetcher}`); 142 - } 143 - console.log(`[doip] Fetching proof: ${request.fetcher} ${request.uri} (format: ${request.format})`); 144 - const data = await fetcher.fetch(request.uri, { 145 - format: request.format, 146 - timeout: opts.timeout ?? DEFAULT_TIMEOUT, 147 - headers: request.options?.headers, 148 - }); 149 - const fileKeys = data && typeof data === "object" && "files" in data 150 - ? Object.keys((data as Record<string, unknown>).files as object) 151 - : []; 152 - console.log(`[doip] Fetched proof, files: ${JSON.stringify(fileKeys)}`); 153 - return data; 154 - } 155 - 156 - private checkProof(data: unknown, targets: ProofTarget[]): boolean { 157 - const proofPatterns = this.generateProofPatterns(); 158 - console.log(`[doip] Checking proof for DID ${this._did}, patterns: ${JSON.stringify(proofPatterns)}`); 159 - console.log(`[doip] Proof targets: ${JSON.stringify(targets.map((t) => t.path.join(".")))}`); 160 - 161 - for (const target of targets) { 162 - const values = this.extractValues(data, target.path); 163 - console.log(`[doip] Target ${target.path.join(".")}: found ${values.length} value(s)${values.length > 0 ? `: ${JSON.stringify(values.map((v) => v.slice(0, 100)))}` : ""}`); 164 - for (const value of values) { 165 - if (this.matchesPattern(value, proofPatterns, target.relation)) { 166 - console.log(`[doip] Match found at ${target.path.join(".")} (relation: ${target.relation})`); 167 - return true; 168 - } 169 - } 170 - } 171 - console.log(`[doip] No match found in any target`); 172 - return false; 173 - } 174 - 175 - private generateProofPatterns(): string[] { 176 - // Patterns to search for in proof locations 177 - const patterns = [ 178 - this._did, // did:plc:xxx 179 - ]; 180 - 181 - // Also add the short form for did:plc DIDs 182 - if (this._did.startsWith("did:plc:")) { 183 - patterns.push(this._did.replace("did:plc:", "")); 184 - } 185 - 186 - return patterns; 187 - } 188 - 189 - private extractValues(data: unknown, path: string[]): string[] { 190 - const results: string[] = []; 191 - this.extractValuesRecursive(data, path, 0, results); 192 - return results; 193 - } 194 - 195 - private extractValuesRecursive( 196 - data: unknown, 197 - path: string[], 198 - index: number, 199 - results: string[], 200 - ): void { 201 - if (data === null || data === undefined) return; 202 - 203 - if (index >= path.length) { 204 - // Reached the end of the path 205 - if (typeof data === "string") { 206 - results.push(data); 207 - } else if (Array.isArray(data)) { 208 - // If it's an array of strings, add them all 209 - for (const item of data) { 210 - if (typeof item === "string") { 211 - results.push(item); 212 - } 213 - } 214 - } 215 - return; 216 - } 217 - 218 - const key = path[index]; 219 - 220 - if (key === "*" && Array.isArray(data)) { 221 - // Wildcard: recurse into all array elements 222 - for (const item of data) { 223 - this.extractValuesRecursive(item, path, index + 1, results); 224 - } 225 - } else if (typeof data === "object" && data !== null) { 226 - const record = data as Record<string, unknown>; 227 - this.extractValuesRecursive(record[key], path, index + 1, results); 228 - } 229 - } 230 - 231 - private matchesPattern( 232 - value: string, 233 - patterns: string[], 234 - relation: "contains" | "equals" | "startsWith", 235 - ): boolean { 236 - for (const pattern of patterns) { 237 - switch (relation) { 238 - case "contains": 239 - if (value.includes(pattern)) return true; 240 - break; 241 - case "equals": 242 - if (value === pattern) return true; 243 - break; 244 - case "startsWith": 245 - if (value.startsWith(pattern)) return true; 246 - break; 247 - } 248 - } 249 - return false; 250 - } 251 - 252 - toJSON(): object { 253 - return { 254 - uri: this._uri, 255 - did: this._did, 256 - status: this._status, 257 - matches: this._matches.map((m) => ({ 258 - provider: m.provider.id, 259 - isAmbiguous: m.isAmbiguous, 260 - })), 261 - errors: this._errors, 262 - }; 263 - } 264 - 265 - static fromJSON(data: { uri: string; did: string }): Claim { 266 - return new Claim(data.uri, data.did); 267 - } 268 - }
packages/doip/src/constants.ts packages/runner/src/constants.ts
+3 -7
packages/doip/src/fetchers/activitypub.ts packages/runner/src/fetchers/activitypub.ts
··· 21 21 /** 22 22 * Fetch an ActivityPub actor document 23 23 */ 24 - export async function fetchActor( 25 - uri: string, 26 - options: ActivityPubFetchOptions = {}, 27 - ): Promise<ActivityPubActor> { 24 + export async function fetchActor(uri: string, options: ActivityPubFetchOptions = {}): Promise<ActivityPubActor> { 28 25 const timeout = options.timeout ?? DEFAULT_TIMEOUT; 29 26 const controller = new AbortController(); 30 27 const timeoutId = setTimeout(() => controller.abort(), timeout); ··· 32 29 try { 33 30 const response = await globalThis.fetch(uri, { 34 31 headers: { 35 - Accept: 36 - 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 37 - "User-Agent": "keytrace-doip/1.0", 32 + Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 33 + "User-Agent": "keytrace-runner/1.0", 38 34 }, 39 35 signal: controller.signal, 40 36 });
+1 -4
packages/doip/src/fetchers/dns.ts packages/runner/src/fetchers/dns.ts
··· 29 29 * Fetch DNS TXT records for a domain. 30 30 * Returns null in environments where DNS resolution is not available. 31 31 */ 32 - export async function fetch( 33 - domain: string, 34 - options: DnsFetchOptions = {}, 35 - ): Promise<DnsFetchResult | null> { 32 + export async function fetch(domain: string, options: DnsFetchOptions = {}): Promise<DnsFetchResult | null> { 36 33 if (!(await hasDnsModule())) { 37 34 console.debug("DNS fetching is not available in this environment"); 38 35 return null;
+1 -1
packages/doip/src/fetchers/http.ts packages/runner/src/fetchers/http.ts
··· 17 17 try { 18 18 const response = await globalThis.fetch(url, { 19 19 headers: { 20 - "User-Agent": "keytrace-doip/1.0", 20 + "User-Agent": "keytrace-runner/1.0", 21 21 Accept: options.format === "json" ? "application/json" : "text/plain", 22 22 ...options.headers, 23 23 },
packages/doip/src/fetchers/index.ts packages/runner/src/fetchers/index.ts
-23
packages/doip/src/index.ts
··· 1 - // Main exports 2 - export { Claim, isValidDid } from "./claim.js"; 3 - export { Profile, resolvePds } from "./profile.js"; 4 - 5 - // Types 6 - export { ClaimStatus } from "./types.js"; 7 - export type { ClaimVerificationResult, ProfileData, ClaimData, VerifyOptions } from "./types.js"; 8 - 9 - // Constants 10 - export { COLLECTION_NSID, DEFAULT_TIMEOUT, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 11 - 12 - // Service providers 13 - export * as serviceProviders from "./serviceProviders/index.js"; 14 - export type { 15 - ServiceProvider, 16 - ServiceProviderMatch, 17 - ProofTarget, 18 - ProofRequest, 19 - ProcessedURI, 20 - } from "./serviceProviders/types.js"; 21 - 22 - // Fetchers 23 - export * as fetchers from "./fetchers/index.js";
-279
packages/doip/src/profile.ts
··· 1 - import { AtpAgent } from "@atproto/api"; 2 - import { Claim } from "./claim.js"; 3 - import { ClaimStatus } from "./types.js"; 4 - import { COLLECTION_NSID, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 5 - import type { ProfileData, ClaimData, VerifyOptions } from "./types.js"; 6 - 7 - /** 8 - * DID document service entry 9 - */ 10 - interface DidService { 11 - id: string; 12 - type: string; 13 - serviceEndpoint: string; 14 - } 15 - 16 - /** 17 - * DID document shape (subset of fields we need) 18 - */ 19 - interface DidDocument { 20 - id: string; 21 - service?: DidService[]; 22 - } 23 - 24 - /** 25 - * Resolve the PDS endpoint from a DID document. 26 - * For did:plc, fetches from plc.directory. 27 - * For did:web, fetches from the well-known DID path. 28 - * Falls back to PUBLIC_API_URL on failure. 29 - */ 30 - export async function resolvePds(did: string): Promise<string> { 31 - try { 32 - let url: string; 33 - if (did.startsWith("did:plc:")) { 34 - url = `${PLC_DIRECTORY_URL}/${did}`; 35 - } else if (did.startsWith("did:web:")) { 36 - const host = did.replace("did:web:", "").replaceAll(":", "/"); 37 - url = `https://${host}/.well-known/did.json`; 38 - } else { 39 - return PUBLIC_API_URL; 40 - } 41 - 42 - const response = await globalThis.fetch(url, { 43 - headers: { Accept: "application/json" }, 44 - }); 45 - 46 - if (!response.ok) { 47 - return PUBLIC_API_URL; 48 - } 49 - 50 - const doc = (await response.json()) as DidDocument; 51 - const pdsService = doc.service?.find( 52 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 53 - ); 54 - 55 - return pdsService?.serviceEndpoint ?? PUBLIC_API_URL; 56 - } catch { 57 - return PUBLIC_API_URL; 58 - } 59 - } 60 - 61 - /** 62 - * Parse an AT URI and extract the rkey (record key). 63 - * AT URIs have the format: at://did/collection/rkey 64 - */ 65 - function parseAtUriRkey(atUri: string): string { 66 - const match = atUri.match(/^at:\/\/[^/]+\/[^/]+\/(.+)$/); 67 - return match?.[1] ?? ""; 68 - } 69 - 70 - /** 71 - * Represents a user profile with identity claims from ATProto 72 - */ 73 - export class Profile { 74 - private _did: string; 75 - private _handle: string; 76 - private _displayName?: string; 77 - private _avatar?: string; 78 - private _claims: Claim[] = []; 79 - private _claimRecords: ClaimData[] = []; 80 - 81 - private constructor(data: ProfileData) { 82 - this._did = data.did; 83 - this._handle = data.handle; 84 - this._displayName = data.displayName; 85 - this._avatar = data.avatar; 86 - this._claimRecords = data.claims; 87 - this._claims = data.claims.map((c) => new Claim(c.uri, data.did)); 88 - } 89 - 90 - get did(): string { 91 - return this._did; 92 - } 93 - 94 - get handle(): string { 95 - return this._handle; 96 - } 97 - 98 - get displayName(): string | undefined { 99 - return this._displayName; 100 - } 101 - 102 - get avatar(): string | undefined { 103 - return this._avatar; 104 - } 105 - 106 - get claims(): Claim[] { 107 - return this._claims; 108 - } 109 - 110 - get claimRecords(): ClaimData[] { 111 - return this._claimRecords; 112 - } 113 - 114 - /** 115 - * Fetch a profile from ATProto by DID or handle 116 - */ 117 - static async fetch(didOrHandle: string, serviceUrl?: string): Promise<Profile> { 118 - // Resolve PDS from DID document unless an explicit serviceUrl was provided 119 - let resolvedServiceUrl: string; 120 - let did = didOrHandle; 121 - 122 - if (serviceUrl) { 123 - resolvedServiceUrl = serviceUrl; 124 - } else if (didOrHandle.startsWith("did:")) { 125 - resolvedServiceUrl = await resolvePds(didOrHandle); 126 - } else { 127 - // Handle - we need to resolve via the public API first, then resolve PDS 128 - resolvedServiceUrl = PUBLIC_API_URL; 129 - } 130 - 131 - const agent = new AtpAgent({ service: resolvedServiceUrl }); 132 - 133 - // Resolve handle to DID if needed 134 - if (!didOrHandle.startsWith("did:")) { 135 - const resolved = await agent.resolveHandle({ handle: didOrHandle }); 136 - did = resolved.data.did; 137 - 138 - // Now that we have the DID, resolve the actual PDS if no explicit serviceUrl 139 - if (!serviceUrl) { 140 - const pdsUrl = await resolvePds(did); 141 - if (pdsUrl !== resolvedServiceUrl) { 142 - resolvedServiceUrl = pdsUrl; 143 - // Re-create agent pointed at the user's actual PDS 144 - const pdsAgent = new AtpAgent({ service: pdsUrl }); 145 - return Profile._fetchWithAgent(pdsAgent, did); 146 - } 147 - } 148 - } 149 - 150 - return Profile._fetchWithAgent(agent, did); 151 - } 152 - 153 - /** 154 - * Internal: fetch profile data using an already-configured agent 155 - */ 156 - private static async _fetchWithAgent(agent: AtpAgent, did: string): Promise<Profile> { 157 - // Fetch Bluesky profile for display info via public API (not PDS) 158 - // The PDS doesn't serve app.bsky.actor.getProfile - only the AppView does 159 - let bskyProfile: { handle: string; displayName?: string; avatar?: string } | null = null; 160 - try { 161 - const publicAgent = new AtpAgent({ service: PUBLIC_API_URL }); 162 - const profileRes = await publicAgent.getProfile({ actor: did }); 163 - bskyProfile = { 164 - handle: profileRes.data.handle, 165 - displayName: profileRes.data.displayName, 166 - avatar: profileRes.data.avatar, 167 - }; 168 - } catch (err: unknown) { 169 - // Profile fetch is optional - user may not have a Bluesky profile 170 - // 404 is expected; log other errors at debug level 171 - if (err instanceof Error && !err.message.includes("404")) { 172 - console.debug(`Failed to fetch profile for ${did}: ${err.message}`); 173 - } 174 - } 175 - 176 - // List all claim records with cursor-based pagination 177 - const claims: ClaimData[] = []; 178 - try { 179 - let cursor: string | undefined; 180 - do { 181 - const records = await agent.com.atproto.repo.listRecords({ 182 - repo: did, 183 - collection: COLLECTION_NSID, 184 - limit: 100, 185 - cursor, 186 - }); 187 - 188 - for (const record of records.data.records) { 189 - const value = record.value as { 190 - claimUri?: string; 191 - comment?: string; 192 - createdAt?: string; 193 - }; 194 - if (value.claimUri) { 195 - claims.push({ 196 - uri: value.claimUri, 197 - did, 198 - comment: value.comment, 199 - createdAt: value.createdAt ?? new Date().toISOString(), 200 - rkey: parseAtUriRkey(record.uri), 201 - }); 202 - } 203 - } 204 - 205 - cursor = records.data.cursor; 206 - } while (cursor); 207 - } catch (err: unknown) { 208 - // 404 means no records yet; log other errors 209 - if (err instanceof Error && !err.message.includes("404")) { 210 - console.debug(`Failed to list claim records for ${did}: ${err.message}`); 211 - } 212 - } 213 - 214 - return new Profile({ 215 - did, 216 - handle: bskyProfile?.handle ?? did, 217 - displayName: bskyProfile?.displayName, 218 - avatar: bskyProfile?.avatar, 219 - claims, 220 - }); 221 - } 222 - 223 - /** 224 - * Verify all claims in this profile 225 - */ 226 - async verifyAll(opts?: VerifyOptions): Promise<void> { 227 - await Promise.all(this._claims.map((claim) => claim.verify(opts))); 228 - } 229 - 230 - /** 231 - * Get verification summary 232 - */ 233 - getSummary(): { 234 - total: number; 235 - verified: number; 236 - failed: number; 237 - pending: number; 238 - } { 239 - return { 240 - total: this._claims.length, 241 - verified: this._claims.filter((c) => c.status === ClaimStatus.VERIFIED).length, 242 - failed: this._claims.filter( 243 - (c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR, 244 - ).length, 245 - pending: this._claims.filter( 246 - (c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED, 247 - ).length, 248 - }; 249 - } 250 - 251 - /** 252 - * Get claims grouped by status 253 - */ 254 - getClaimsByStatus(): { 255 - verified: Claim[]; 256 - failed: Claim[]; 257 - pending: Claim[]; 258 - } { 259 - return { 260 - verified: this._claims.filter((c) => c.status === ClaimStatus.VERIFIED), 261 - failed: this._claims.filter( 262 - (c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR, 263 - ), 264 - pending: this._claims.filter( 265 - (c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED, 266 - ), 267 - }; 268 - } 269 - 270 - toJSON(): ProfileData { 271 - return { 272 - did: this._did, 273 - handle: this._handle, 274 - displayName: this._displayName, 275 - avatar: this._avatar, 276 - claims: this._claimRecords, 277 - }; 278 - } 279 - }
packages/doip/src/serviceProviders/activitypub.ts packages/runner/src/serviceProviders/activitypub.ts
packages/doip/src/serviceProviders/bsky.ts packages/runner/src/serviceProviders/bsky.ts
+1 -2
packages/doip/src/serviceProviders/dns.ts packages/runner/src/serviceProviders/dns.ts
··· 12 12 homepage: "", 13 13 14 14 // Match dns:domain.tld URIs (must contain at least one dot) 15 - reUri: 16 - /^dns:([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+)$/, 15 + reUri: /^dns:([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+)$/, 17 16 18 17 isAmbiguous: false, 19 18
packages/doip/src/serviceProviders/github.ts packages/runner/src/serviceProviders/github.ts
+2 -12
packages/doip/src/serviceProviders/index.ts packages/runner/src/serviceProviders/index.ts
··· 4 4 import bsky from "./bsky.js"; 5 5 import type { ServiceProvider, ServiceProviderMatch } from "./types.js"; 6 6 7 - export type { 8 - ServiceProvider, 9 - ServiceProviderMatch, 10 - ProofTarget, 11 - ProofRequest, 12 - ProcessedURI, 13 - } from "./types.js"; 7 + export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI } from "./types.js"; 14 8 15 9 const providers: Record<string, ServiceProvider> = { 16 10 github, ··· 61 55 /** 62 56 * Get the proof text a user should add to verify a claim 63 57 */ 64 - export function getProofTextForProvider( 65 - providerId: string, 66 - did: string, 67 - handle?: string, 68 - ): string | undefined { 58 + export function getProofTextForProvider(providerId: string, did: string, handle?: string): string | undefined { 69 59 const provider = providers[providerId]; 70 60 return provider?.getProofText(did, handle); 71 61 }
packages/doip/src/serviceProviders/types.ts packages/runner/src/serviceProviders/types.ts
-53
packages/doip/src/types.ts
··· 1 - /** 2 - * Result of verifying a claim 3 - */ 4 - export interface ClaimVerificationResult { 5 - status: ClaimStatus; 6 - errors: string[]; 7 - timestamp: Date; 8 - } 9 - 10 - /** 11 - * Profile data from ATProto 12 - */ 13 - export interface ProfileData { 14 - did: string; 15 - handle: string; 16 - displayName?: string; 17 - avatar?: string; 18 - claims: ClaimData[]; 19 - } 20 - 21 - /** 22 - * Individual claim data from ATProto record 23 - */ 24 - export interface ClaimData { 25 - uri: string; 26 - did: string; 27 - comment?: string; 28 - createdAt: string; 29 - rkey: string; 30 - } 31 - 32 - /** 33 - * Options for verification operations 34 - */ 35 - export interface VerifyOptions { 36 - /** Timeout for fetcher operations in ms */ 37 - timeout?: number; 38 - /** Skip cache and force fresh verification */ 39 - skipCache?: boolean; 40 - /** Proxy URL for browser-based DNS/HTTP requests */ 41 - proxyUrl?: string; 42 - } 43 - 44 - /** 45 - * Claim status enum 46 - */ 47 - export enum ClaimStatus { 48 - INIT = "init", 49 - MATCHED = "matched", 50 - VERIFIED = "verified", 51 - FAILED = "failed", 52 - ERROR = "error", 53 - }
-131
packages/doip/tests/claim.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 - import { Claim, ClaimStatus } from "../src/index.js"; 3 - 4 - // Valid test DIDs (did:plc requires 24 base32 chars [a-z2-7]) 5 - const VALID_DID_PLC = "did:plc:z72i7hdynmk6r22z27h6tvur"; 6 - const VALID_DID_WEB = "did:web:example.com"; 7 - 8 - describe("Claim", () => { 9 - describe("constructor", () => { 10 - it("should accept valid did:plc DID", () => { 11 - const claim = new Claim("https://gist.github.com/alice/abc123def456", VALID_DID_PLC); 12 - expect(claim.did).toBe(VALID_DID_PLC); 13 - expect(claim.uri).toBe("https://gist.github.com/alice/abc123def456"); 14 - }); 15 - 16 - it("should accept did:web DIDs", () => { 17 - const claim = new Claim("dns:example.com", VALID_DID_WEB); 18 - expect(claim.did).toBe(VALID_DID_WEB); 19 - }); 20 - 21 - it("should reject invalid DID", () => { 22 - expect(() => new Claim("https://gist.github.com/alice/abc123", "invalid")).toThrow( 23 - "Invalid DID format", 24 - ); 25 - }); 26 - 27 - it("should reject empty DID", () => { 28 - expect(() => new Claim("https://gist.github.com/alice/abc123", "")).toThrow( 29 - "Invalid DID format", 30 - ); 31 - }); 32 - 33 - it("should reject did: prefix without valid method", () => { 34 - expect(() => new Claim("dns:example.com", "did:foo:bar")).toThrow("Invalid DID format"); 35 - }); 36 - 37 - it("should reject did:plc with wrong length", () => { 38 - expect(() => new Claim("dns:example.com", "did:plc:tooshort")).toThrow("Invalid DID format"); 39 - }); 40 - }); 41 - 42 - describe("match", () => { 43 - it("should match GitHub gist URI", () => { 44 - const claim = new Claim("https://gist.github.com/alice/abc123def456", VALID_DID_PLC); 45 - claim.match(); 46 - expect(claim.status).toBe(ClaimStatus.MATCHED); 47 - expect(claim.matches).toHaveLength(1); 48 - expect(claim.matches[0].provider.id).toBe("github"); 49 - }); 50 - 51 - it("should match DNS URI", () => { 52 - const claim = new Claim("dns:example.com", VALID_DID_PLC); 53 - claim.match(); 54 - expect(claim.status).toBe(ClaimStatus.MATCHED); 55 - expect(claim.matches).toHaveLength(1); 56 - expect(claim.matches[0].provider.id).toBe("dns"); 57 - }); 58 - 59 - it("should match ActivityPub/Mastodon URI", () => { 60 - const claim = new Claim("https://mastodon.social/@alice", VALID_DID_PLC); 61 - claim.match(); 62 - expect(claim.status).toBe(ClaimStatus.MATCHED); 63 - expect(claim.matches).toHaveLength(1); 64 - expect(claim.matches[0].provider.id).toBe("activitypub"); 65 - }); 66 - 67 - it("should match Bluesky URI", () => { 68 - const claim = new Claim("https://bsky.app/profile/alice.bsky.social", VALID_DID_PLC); 69 - claim.match(); 70 - expect(claim.status).toBe(ClaimStatus.MATCHED); 71 - expect(claim.matches).toHaveLength(1); 72 - expect(claim.matches[0].provider.id).toBe("bsky"); 73 - }); 74 - 75 - it("should fail on unknown URI", () => { 76 - const claim = new Claim("https://unknown.site/alice", VALID_DID_PLC); 77 - claim.match(); 78 - expect(claim.status).toBe(ClaimStatus.ERROR); 79 - expect(claim.errors).toHaveLength(1); 80 - expect(claim.errors[0]).toContain("No service provider matched"); 81 - }); 82 - 83 - it("should handle trailing slashes", () => { 84 - const claim = new Claim("https://gist.github.com/alice/abc123def456/", VALID_DID_PLC); 85 - claim.match(); 86 - expect(claim.status).toBe(ClaimStatus.MATCHED); 87 - }); 88 - }); 89 - 90 - describe("isAmbiguous", () => { 91 - it("should return false for GitHub (unambiguous)", () => { 92 - const claim = new Claim("https://gist.github.com/alice/abc123def456", VALID_DID_PLC); 93 - claim.match(); 94 - expect(claim.isAmbiguous()).toBe(false); 95 - }); 96 - 97 - it("should return true for ActivityPub (ambiguous)", () => { 98 - const claim = new Claim("https://mastodon.social/@alice", VALID_DID_PLC); 99 - claim.match(); 100 - expect(claim.isAmbiguous()).toBe(true); 101 - }); 102 - }); 103 - 104 - describe("toJSON", () => { 105 - it("should serialize claim to JSON", () => { 106 - const claim = new Claim("https://gist.github.com/alice/abc123def456", VALID_DID_PLC); 107 - claim.match(); 108 - 109 - const json = claim.toJSON(); 110 - expect(json).toEqual({ 111 - uri: "https://gist.github.com/alice/abc123def456", 112 - did: VALID_DID_PLC, 113 - status: ClaimStatus.MATCHED, 114 - matches: [{ provider: "github", isAmbiguous: false }], 115 - errors: [], 116 - }); 117 - }); 118 - }); 119 - 120 - describe("fromJSON", () => { 121 - it("should deserialize claim from JSON", () => { 122 - const claim = Claim.fromJSON({ 123 - uri: "https://gist.github.com/alice/abc123def456", 124 - did: VALID_DID_PLC, 125 - }); 126 - expect(claim.uri).toBe("https://gist.github.com/alice/abc123def456"); 127 - expect(claim.did).toBe(VALID_DID_PLC); 128 - expect(claim.status).toBe(ClaimStatus.INIT); 129 - }); 130 - }); 131 - });
-55
packages/doip/tests/serviceProviders/dns.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 - import dns from "../../src/serviceProviders/dns.js"; 3 - 4 - describe("DNS Service Provider", () => { 5 - describe("URI matching", () => { 6 - it.each(dns.tests)("should handle $uri correctly", ({ uri, shouldMatch }) => { 7 - const match = uri.match(dns.reUri); 8 - expect(!!match).toBe(shouldMatch); 9 - }); 10 - 11 - it("should extract domain", () => { 12 - const uri = "dns:example.com"; 13 - const match = uri.match(dns.reUri); 14 - expect(match).not.toBeNull(); 15 - expect(match![1]).toBe("example.com"); 16 - }); 17 - 18 - it("should extract subdomain", () => { 19 - const uri = "dns:sub.example.com"; 20 - const match = uri.match(dns.reUri); 21 - expect(match).not.toBeNull(); 22 - expect(match![1]).toBe("sub.example.com"); 23 - }); 24 - }); 25 - 26 - describe("processURI", () => { 27 - it("should return correct profile info", () => { 28 - const uri = "dns:example.com"; 29 - const match = uri.match(dns.reUri)!; 30 - const result = dns.processURI(uri, match); 31 - 32 - expect(result.profile.display).toBe("example.com"); 33 - expect(result.profile.uri).toBe("https://example.com"); 34 - expect(result.profile.qrcode).toBe(false); 35 - }); 36 - 37 - it("should return correct proof request", () => { 38 - const uri = "dns:example.com"; 39 - const match = uri.match(dns.reUri)!; 40 - const result = dns.processURI(uri, match); 41 - 42 - expect(result.proof.request.uri).toBe("example.com"); 43 - expect(result.proof.request.fetcher).toBe("dns"); 44 - expect(result.proof.request.format).toBe("json"); 45 - }); 46 - }); 47 - 48 - describe("getProofText", () => { 49 - it("should generate correct TXT record format", () => { 50 - const did = "did:plc:abc123"; 51 - const proofText = dns.getProofText(did); 52 - expect(proofText).toBe("keytrace-verification=did:plc:abc123"); 53 - }); 54 - }); 55 - });
-62
packages/doip/tests/serviceProviders/github.test.ts
··· 1 - import { describe, it, expect } from "vitest"; 2 - import github from "../../src/serviceProviders/github.js"; 3 - 4 - describe("GitHub Service Provider", () => { 5 - describe("URI matching", () => { 6 - it.each(github.tests)("should handle $uri correctly", ({ uri, shouldMatch }) => { 7 - const match = uri.match(github.reUri); 8 - expect(!!match).toBe(shouldMatch); 9 - }); 10 - 11 - it("should extract username and gist ID", () => { 12 - const uri = "https://gist.github.com/alice/abc123def456"; 13 - const match = uri.match(github.reUri); 14 - expect(match).not.toBeNull(); 15 - expect(match![1]).toBe("alice"); 16 - expect(match![2]).toBe("abc123def456"); 17 - }); 18 - }); 19 - 20 - describe("processURI", () => { 21 - it("should return correct profile info", () => { 22 - const uri = "https://gist.github.com/alice/abc123def456"; 23 - const match = uri.match(github.reUri)!; 24 - const result = github.processURI(uri, match); 25 - 26 - expect(result.profile.display).toBe("@alice"); 27 - expect(result.profile.uri).toBe("https://github.com/alice"); 28 - expect(result.profile.qrcode).toBe(true); 29 - }); 30 - 31 - it("should return correct proof request", () => { 32 - const uri = "https://gist.github.com/alice/abc123def456"; 33 - const match = uri.match(github.reUri)!; 34 - const result = github.processURI(uri, match); 35 - 36 - expect(result.proof.request.uri).toBe("https://api.github.com/gists/abc123def456"); 37 - expect(result.proof.request.fetcher).toBe("http"); 38 - expect(result.proof.request.format).toBe("json"); 39 - }); 40 - 41 - it("should have correct proof targets", () => { 42 - const uri = "https://gist.github.com/alice/abc123def456"; 43 - const match = uri.match(github.reUri)!; 44 - const result = github.processURI(uri, match); 45 - 46 - expect(result.proof.target.length).toBeGreaterThan(0); 47 - // Should check files and description 48 - const paths = result.proof.target.map((t) => t.path.join(".")); 49 - expect(paths).toContain("files.proof.md.content"); 50 - expect(paths).toContain("description"); 51 - }); 52 - }); 53 - 54 - describe("getProofText", () => { 55 - it("should generate correct proof text", () => { 56 - const did = "did:plc:abc123"; 57 - const proofText = github.getProofText(did); 58 - expect(proofText).toContain(did); 59 - expect(proofText).toContain("keytrace"); 60 - }); 61 - }); 62 - });
-9
packages/doip/tsconfig.json
··· 1 - { 2 - "extends": "../../tsconfig.base.json", 3 - "compilerOptions": { 4 - "outDir": "dist", 5 - "rootDir": "src" 6 - }, 7 - "include": ["src/**/*"], 8 - "exclude": ["node_modules", "dist"] 9 - }
-9
packages/doip/vitest.config.ts
··· 1 - import { defineConfig } from "vitest/config"; 2 - 3 - export default defineConfig({ 4 - test: { 5 - globals: true, 6 - environment: "node", 7 - include: ["tests/**/*.test.ts"], 8 - }, 9 - });
+7 -4
packages/runner/package.json
··· 2 2 "name": "@keytrace/runner", 3 3 "version": "0.0.1", 4 4 "files": [ 5 + "src", 5 6 "dist" 6 7 ], 7 8 "type": "module", 8 - "main": "./dist/index.js", 9 - "types": "./dist/index.d.ts", 9 + "main": "./src/index.ts", 10 + "types": "./src/index.ts", 10 11 "exports": { 11 12 ".": { 12 - "types": "./dist/index.d.ts", 13 - "import": "./dist/index.js" 13 + "types": "./src/index.ts", 14 + "default": "./src/index.ts" 14 15 } 15 16 }, 16 17 "scripts": { ··· 21 22 "lint": "echo 'lint not configured'" 22 23 }, 23 24 "dependencies": { 25 + "@atproto/api": "^0.14.0", 24 26 "cheerio": "^1.0.0" 25 27 }, 26 28 "devDependencies": { 27 29 "@types/node": "^22.0.0", 30 + "oxfmt": "^0.27.0", 28 31 "typescript": "^5.7.0", 29 32 "vitest": "^2.1.0" 30 33 },
+1 -4
packages/runner/src/actions/dns-txt.ts
··· 7 7 try { 8 8 dns = await import("node:dns/promises"); 9 9 } catch { 10 - throw new Error( 11 - "DNS TXT lookups are not available in the browser. " + 12 - "Use the server-side proxy endpoint (POST /api/proxy/dns) instead.", 13 - ); 10 + throw new Error("DNS TXT lookups are not available in the browser. " + "Use the server-side proxy endpoint (POST /api/proxy/dns) instead."); 14 11 } 15 12 16 13 const records = await dns.resolveTxt(domain);
+212
packages/runner/src/claim.ts
··· 1 + import { ClaimStatus } from "./types.js"; 2 + import { DEFAULT_TIMEOUT } from "./constants.js"; 3 + import { matchUri, type ServiceProviderMatch, type ProofRequest, type ProofTarget } from "./serviceProviders/index.js"; 4 + import * as fetchers from "./fetchers/index.js"; 5 + import type { VerifyOptions, ClaimVerificationResult } from "./types.js"; 6 + 7 + // did:plc identifiers are base32-encoded, lowercase 8 + const DID_PLC_RE = /^did:plc:[a-z2-7]{24}$/; 9 + // did:web uses a domain name (with optional port and path segments encoded as colons) 10 + const DID_WEB_RE = /^did:web:[a-zA-Z0-9._:%-]+$/; 11 + 12 + /** 13 + * Validate a DID string. Accepts did:plc and did:web formats. 14 + */ 15 + export function isValidDid(did: string): boolean { 16 + return DID_PLC_RE.test(did) || DID_WEB_RE.test(did); 17 + } 18 + 19 + /** 20 + * A single identity claim linking a DID to an external account 21 + */ 22 + export interface ClaimState { 23 + uri: string; 24 + did: string; 25 + status: ClaimStatus; 26 + matches: ServiceProviderMatch[]; 27 + errors: string[]; 28 + } 29 + 30 + /** 31 + * Create a new claim state 32 + */ 33 + export function createClaim(uri: string, did: string): ClaimState { 34 + if (!isValidDid(did)) { 35 + throw new Error(`Invalid DID format: ${did}`); 36 + } 37 + return { 38 + uri, 39 + did, 40 + status: ClaimStatus.INIT, 41 + matches: [], 42 + errors: [], 43 + }; 44 + } 45 + 46 + /** 47 + * Match the claim URI against known service providers 48 + */ 49 + export function matchClaim(claim: ClaimState): void { 50 + claim.matches = matchUri(claim.uri); 51 + claim.status = claim.matches.length > 0 ? ClaimStatus.MATCHED : ClaimStatus.ERROR; 52 + 53 + if (claim.matches.length === 0) { 54 + claim.errors.push(`No service provider matched URI: ${claim.uri}`); 55 + } 56 + } 57 + 58 + /** 59 + * Check if the claim is ambiguous (matches multiple providers) 60 + */ 61 + export function isClaimAmbiguous(claim: ClaimState): boolean { 62 + return claim.matches.length > 1 || (claim.matches.length === 1 && claim.matches[0].isAmbiguous); 63 + } 64 + 65 + /** 66 + * Get the matched service provider (first unambiguous match, or first match) 67 + */ 68 + export function getMatchedProvider(claim: ClaimState): ServiceProviderMatch | undefined { 69 + return claim.matches[0]; 70 + } 71 + 72 + /** 73 + * Verify the claim by fetching proof and checking for DID 74 + */ 75 + export async function verifyClaim(claim: ClaimState, opts: VerifyOptions = {}): Promise<ClaimVerificationResult> { 76 + if (claim.status === ClaimStatus.INIT) { 77 + matchClaim(claim); 78 + } 79 + 80 + if (claim.matches.length === 0) { 81 + return { 82 + status: ClaimStatus.ERROR, 83 + errors: claim.errors, 84 + timestamp: new Date(), 85 + }; 86 + } 87 + 88 + // Try each matched provider until one succeeds 89 + for (const match of claim.matches) { 90 + try { 91 + const config = match.provider.processURI(claim.uri, match.match); 92 + const proofData = await fetchProof(config.proof.request, opts); 93 + 94 + if (checkProof(proofData, config.proof.target, claim.did)) { 95 + claim.status = ClaimStatus.VERIFIED; 96 + return { 97 + status: ClaimStatus.VERIFIED, 98 + errors: [], 99 + timestamp: new Date(), 100 + }; 101 + } 102 + } catch (err) { 103 + claim.errors.push(`${match.provider.id}: ${err instanceof Error ? err.message : "Unknown error"}`); 104 + } 105 + 106 + // Stop on unambiguous match 107 + if (!match.isAmbiguous) break; 108 + } 109 + 110 + claim.status = ClaimStatus.FAILED; 111 + return { 112 + status: ClaimStatus.FAILED, 113 + errors: claim.errors, 114 + timestamp: new Date(), 115 + }; 116 + } 117 + 118 + async function fetchProof(request: ProofRequest, opts: VerifyOptions): Promise<unknown> { 119 + const fetcher = fetchers.get(request.fetcher); 120 + if (!fetcher) { 121 + throw new Error(`Unknown fetcher: ${request.fetcher}`); 122 + } 123 + console.log(`[runner] Fetching proof: ${request.fetcher} ${request.uri} (format: ${request.format})`); 124 + const data = await fetcher.fetch(request.uri, { 125 + format: request.format, 126 + timeout: opts.timeout ?? DEFAULT_TIMEOUT, 127 + headers: request.options?.headers, 128 + }); 129 + const fileKeys = data && typeof data === "object" && "files" in data ? Object.keys((data as Record<string, unknown>).files as object) : []; 130 + console.log(`[runner] Fetched proof, files: ${JSON.stringify(fileKeys)}`); 131 + return data; 132 + } 133 + 134 + function checkProof(data: unknown, targets: ProofTarget[], did: string): boolean { 135 + const proofPatterns = generateProofPatterns(did); 136 + console.log(`[runner] Checking proof for DID ${did}, patterns: ${JSON.stringify(proofPatterns)}`); 137 + console.log(`[runner] Proof targets: ${JSON.stringify(targets.map((t) => t.path.join(".")))}`); 138 + 139 + for (const target of targets) { 140 + const values = extractValues(data, target.path); 141 + console.log(`[runner] Target ${target.path.join(".")}: found ${values.length} value(s)${values.length > 0 ? `: ${JSON.stringify(values.map((v) => v.slice(0, 100)))}` : ""}`); 142 + for (const value of values) { 143 + if (matchesPattern(value, proofPatterns, target.relation)) { 144 + console.log(`[runner] Match found at ${target.path.join(".")} (relation: ${target.relation})`); 145 + return true; 146 + } 147 + } 148 + } 149 + console.log(`[runner] No match found in any target`); 150 + return false; 151 + } 152 + 153 + function generateProofPatterns(did: string): string[] { 154 + const patterns = [did]; 155 + 156 + if (did.startsWith("did:plc:")) { 157 + patterns.push(did.replace("did:plc:", "")); 158 + } 159 + 160 + return patterns; 161 + } 162 + 163 + function extractValues(data: unknown, path: string[]): string[] { 164 + const results: string[] = []; 165 + extractValuesRecursive(data, path, 0, results); 166 + return results; 167 + } 168 + 169 + function extractValuesRecursive(data: unknown, path: string[], index: number, results: string[]): void { 170 + if (data === null || data === undefined) return; 171 + 172 + if (index >= path.length) { 173 + if (typeof data === "string") { 174 + results.push(data); 175 + } else if (Array.isArray(data)) { 176 + for (const item of data) { 177 + if (typeof item === "string") { 178 + results.push(item); 179 + } 180 + } 181 + } 182 + return; 183 + } 184 + 185 + const key = path[index]; 186 + 187 + if (key === "*" && Array.isArray(data)) { 188 + for (const item of data) { 189 + extractValuesRecursive(item, path, index + 1, results); 190 + } 191 + } else if (typeof data === "object" && data !== null) { 192 + const record = data as Record<string, unknown>; 193 + extractValuesRecursive(record[key], path, index + 1, results); 194 + } 195 + } 196 + 197 + function matchesPattern(value: string, patterns: string[], relation: "contains" | "equals" | "startsWith"): boolean { 198 + for (const pattern of patterns) { 199 + switch (relation) { 200 + case "contains": 201 + if (value.includes(pattern)) return true; 202 + break; 203 + case "equals": 204 + if (value === pattern) return true; 205 + break; 206 + case "startsWith": 207 + if (value.startsWith(pattern)) return true; 208 + break; 209 + } 210 + } 211 + return false; 212 + }
+9 -5
packages/runner/src/expect.ts
··· 8 8 export function checkExpect(expectStr: string, actual: unknown): { pass: boolean; message: string } { 9 9 const colonIdx = expectStr.indexOf(":"); 10 10 if (colonIdx === -1) { 11 - return { pass: false, message: `Invalid expect format: "${expectStr}" (expected "type:value")` }; 11 + return { 12 + pass: false, 13 + message: `Invalid expect format: "${expectStr}" (expected "type:value")`, 14 + }; 12 15 } 13 16 14 17 const type = expectStr.slice(0, colonIdx); ··· 17 20 18 21 switch (type) { 19 22 case "equals": 20 - return actualStr === expected 21 - ? { pass: true, message: `Value equals "${expected}"` } 22 - : { pass: false, message: `Expected "${expected}" but got "${actualStr}"` }; 23 + return actualStr === expected ? { pass: true, message: `Value equals "${expected}"` } : { pass: false, message: `Expected "${expected}" but got "${actualStr}"` }; 23 24 24 25 case "contains": 25 26 return actualStr.includes(expected) 26 27 ? { pass: true, message: `Value contains "${expected}"` } 27 - : { pass: false, message: `Expected value to contain "${expected}" but got "${actualStr}"` }; 28 + : { 29 + pass: false, 30 + message: `Expected value to contain "${expected}" but got "${actualStr}"`, 31 + }; 28 32 29 33 default: 30 34 return { pass: false, message: `Unknown expect type: "${type}"` };
+21
packages/runner/src/index.ts
··· 13 13 StepResult, 14 14 RunnerConfig, 15 15 FetchFn, 16 + ClaimVerificationResult, 17 + ProfileData, 18 + ClaimData, 19 + VerifyOptions, 16 20 } from "./types.js"; 21 + export { ClaimStatus } from "./types.js"; 17 22 18 23 // Template interpolation 19 24 export { interpolate } from "./interpolate.js"; ··· 31 36 // Built-in recipes 32 37 export { githubGistRecipe } from "./recipes/github-gist.js"; 33 38 export { dnsTxtRecipe } from "./recipes/dns-txt.js"; 39 + 40 + // Claim & Profile (from runner) 41 + export { createClaim, matchClaim, verifyClaim, isClaimAmbiguous, getMatchedProvider, isValidDid } from "./claim.js"; 42 + export type { ClaimState } from "./claim.js"; 43 + export { fetchProfile, resolvePds, verifyAllClaims, getProfileSummary, getClaimsByStatus } from "./profile.js"; 44 + export type { FetchedProfile } from "./profile.js"; 45 + 46 + // Constants 47 + export { COLLECTION_NSID, DEFAULT_TIMEOUT, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 48 + 49 + // Service providers 50 + export * as serviceProviders from "./serviceProviders/index.js"; 51 + export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI } from "./serviceProviders/types.js"; 52 + 53 + // Fetchers 54 + export * as fetchers from "./fetchers/index.js";
+225
packages/runner/src/profile.ts
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + import { createClaim, verifyClaim, type ClaimState } from "./claim.js"; 3 + import { ClaimStatus } from "./types.js"; 4 + import { COLLECTION_NSID, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 5 + import type { ProfileData, ClaimData, VerifyOptions } from "./types.js"; 6 + 7 + /** 8 + * DID document service entry 9 + */ 10 + interface DidService { 11 + id: string; 12 + type: string; 13 + serviceEndpoint: string; 14 + } 15 + 16 + /** 17 + * DID document shape (subset of fields we need) 18 + */ 19 + interface DidDocument { 20 + id: string; 21 + service?: DidService[]; 22 + } 23 + 24 + /** 25 + * A fetched profile with resolved claims 26 + */ 27 + export interface FetchedProfile extends ProfileData { 28 + claims: ClaimData[]; 29 + claimInstances: ClaimState[]; 30 + } 31 + 32 + /** 33 + * Resolve the PDS endpoint from a DID document. 34 + * For did:plc, fetches from plc.directory. 35 + * For did:web, fetches from the well-known DID path. 36 + * Falls back to PUBLIC_API_URL on failure. 37 + */ 38 + export async function resolvePds(did: string): Promise<string> { 39 + try { 40 + let url: string; 41 + if (did.startsWith("did:plc:")) { 42 + url = `${PLC_DIRECTORY_URL}/${did}`; 43 + } else if (did.startsWith("did:web:")) { 44 + const host = did.replace("did:web:", "").replaceAll(":", "/"); 45 + url = `https://${host}/.well-known/did.json`; 46 + } else { 47 + return PUBLIC_API_URL; 48 + } 49 + 50 + const response = await globalThis.fetch(url, { 51 + headers: { Accept: "application/json" }, 52 + }); 53 + 54 + if (!response.ok) { 55 + return PUBLIC_API_URL; 56 + } 57 + 58 + const doc = (await response.json()) as DidDocument; 59 + const pdsService = doc.service?.find((s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"); 60 + 61 + return pdsService?.serviceEndpoint ?? PUBLIC_API_URL; 62 + } catch { 63 + return PUBLIC_API_URL; 64 + } 65 + } 66 + 67 + /** 68 + * Parse an AT URI and extract the rkey (record key). 69 + * AT URIs have the format: at://did/collection/rkey 70 + */ 71 + function parseAtUriRkey(atUri: string): string { 72 + const match = atUri.match(/^at:\/\/[^/]+\/[^/]+\/(.+)$/); 73 + return match?.[1] ?? ""; 74 + } 75 + 76 + /** 77 + * Internal: fetch profile data using an already-configured agent 78 + */ 79 + async function fetchWithAgent(agent: AtpAgent, did: string): Promise<FetchedProfile> { 80 + // Fetch Bluesky profile for display info via public API (not PDS) 81 + // The PDS doesn't serve app.bsky.actor.getProfile - only the AppView does 82 + let bskyProfile: { handle: string; displayName?: string; avatar?: string } | null = null; 83 + try { 84 + const publicAgent = new AtpAgent({ service: PUBLIC_API_URL }); 85 + const profileRes = await publicAgent.getProfile({ actor: did }); 86 + bskyProfile = { 87 + handle: profileRes.data.handle, 88 + displayName: profileRes.data.displayName, 89 + avatar: profileRes.data.avatar, 90 + }; 91 + } catch (err: unknown) { 92 + // Profile fetch is optional - user may not have a Bluesky profile 93 + // 404 is expected; log other errors at debug level 94 + if (err instanceof Error && !err.message.includes("404")) { 95 + console.debug(`Failed to fetch profile for ${did}: ${err.message}`); 96 + } 97 + } 98 + 99 + // List all claim records with cursor-based pagination 100 + const claims: ClaimData[] = []; 101 + try { 102 + let cursor: string | undefined; 103 + do { 104 + const records = await agent.com.atproto.repo.listRecords({ 105 + repo: did, 106 + collection: COLLECTION_NSID, 107 + limit: 100, 108 + cursor, 109 + }); 110 + 111 + for (const record of records.data.records) { 112 + const value = record.value as { 113 + claimUri?: string; 114 + comment?: string; 115 + createdAt?: string; 116 + }; 117 + if (value.claimUri) { 118 + claims.push({ 119 + uri: value.claimUri, 120 + did, 121 + comment: value.comment, 122 + createdAt: value.createdAt ?? new Date().toISOString(), 123 + rkey: parseAtUriRkey(record.uri), 124 + }); 125 + } 126 + } 127 + 128 + cursor = records.data.cursor; 129 + } while (cursor); 130 + } catch (err: unknown) { 131 + // 404 means no records yet; log other errors 132 + if (err instanceof Error && !err.message.includes("404")) { 133 + console.debug(`Failed to list claim records for ${did}: ${err.message}`); 134 + } 135 + } 136 + 137 + return { 138 + did, 139 + handle: bskyProfile?.handle ?? did, 140 + displayName: bskyProfile?.displayName, 141 + avatar: bskyProfile?.avatar, 142 + claims, 143 + claimInstances: claims.map((c) => createClaim(c.uri, did)), 144 + }; 145 + } 146 + 147 + /** 148 + * Fetch a profile from ATProto by DID or handle 149 + */ 150 + export async function fetchProfile(didOrHandle: string, serviceUrl?: string): Promise<FetchedProfile> { 151 + // Resolve PDS from DID document unless an explicit serviceUrl was provided 152 + let resolvedServiceUrl: string; 153 + let did = didOrHandle; 154 + 155 + if (serviceUrl) { 156 + resolvedServiceUrl = serviceUrl; 157 + } else if (didOrHandle.startsWith("did:")) { 158 + resolvedServiceUrl = await resolvePds(didOrHandle); 159 + } else { 160 + // Handle - we need to resolve via the public API first, then resolve PDS 161 + resolvedServiceUrl = PUBLIC_API_URL; 162 + } 163 + 164 + const agent = new AtpAgent({ service: resolvedServiceUrl }); 165 + 166 + // Resolve handle to DID if needed 167 + if (!didOrHandle.startsWith("did:")) { 168 + const resolved = await agent.resolveHandle({ handle: didOrHandle }); 169 + did = resolved.data.did; 170 + 171 + // Now that we have the DID, resolve the actual PDS if no explicit serviceUrl 172 + if (!serviceUrl) { 173 + const pdsUrl = await resolvePds(did); 174 + if (pdsUrl !== resolvedServiceUrl) { 175 + resolvedServiceUrl = pdsUrl; 176 + // Re-create agent pointed at the user's actual PDS 177 + const pdsAgent = new AtpAgent({ service: pdsUrl }); 178 + return fetchWithAgent(pdsAgent, did); 179 + } 180 + } 181 + } 182 + 183 + return fetchWithAgent(agent, did); 184 + } 185 + 186 + /** 187 + * Verify all claims in a profile 188 + */ 189 + export async function verifyAllClaims(profile: FetchedProfile, opts?: VerifyOptions): Promise<void> { 190 + await Promise.all(profile.claimInstances.map((claim) => verifyClaim(claim, opts))); 191 + } 192 + 193 + /** 194 + * Get verification summary for a profile 195 + */ 196 + export function getProfileSummary(profile: FetchedProfile): { 197 + total: number; 198 + verified: number; 199 + failed: number; 200 + pending: number; 201 + } { 202 + const claims = profile.claimInstances; 203 + return { 204 + total: claims.length, 205 + verified: claims.filter((c) => c.status === ClaimStatus.VERIFIED).length, 206 + failed: claims.filter((c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR).length, 207 + pending: claims.filter((c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED).length, 208 + }; 209 + } 210 + 211 + /** 212 + * Get claims grouped by status 213 + */ 214 + export function getClaimsByStatus(profile: FetchedProfile): { 215 + verified: ClaimState[]; 216 + failed: ClaimState[]; 217 + pending: ClaimState[]; 218 + } { 219 + const claims = profile.claimInstances; 220 + return { 221 + verified: claims.filter((c) => c.status === ClaimStatus.VERIFIED), 222 + failed: claims.filter((c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR), 223 + pending: claims.filter((c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED), 224 + }; 225 + }
+1 -2
packages/runner/src/recipes/dns-txt.ts
··· 17 17 label: "Domain name", 18 18 type: "domain", 19 19 placeholder: "example.com", 20 - pattern: 21 - "^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$", 20 + pattern: "^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$", 22 21 }, 23 22 ], 24 23 instructions: {
+3 -21
packages/runner/src/runner.ts
··· 1 - import type { 2 - Recipe, 3 - ClaimContext, 4 - RunnerConfig, 5 - VerificationResult, 6 - StepResult, 7 - VerificationStep, 8 - FetchFn, 9 - } from "./types.js"; 1 + import type { Recipe, ClaimContext, RunnerConfig, VerificationResult, StepResult, VerificationStep, FetchFn } from "./types.js"; 10 2 import { interpolate } from "./interpolate.js"; 11 3 import { checkExpect } from "./expect.js"; 12 4 import { httpGet } from "./actions/http-get.js"; ··· 21 13 * Execute a recipe's verification steps against a claim context. 22 14 * Returns a full result with per-step details. Stops on first failure. 23 15 */ 24 - export async function runRecipe( 25 - recipe: Recipe, 26 - context: ClaimContext, 27 - config?: RunnerConfig, 28 - ): Promise<VerificationResult> { 16 + export async function runRecipe(recipe: Recipe, context: ClaimContext, config?: RunnerConfig): Promise<VerificationResult> { 29 17 const fetchFn: FetchFn = config?.fetch ?? globalThis.fetch; 30 18 const timeout = config?.timeout ?? DEFAULT_TIMEOUT; 31 19 const steps: StepResult[] = []; ··· 66 54 return { success: true, steps, subject }; 67 55 } 68 56 69 - async function executeStep( 70 - step: VerificationStep, 71 - context: ClaimContext, 72 - previousOutput: unknown, 73 - fetchFn: FetchFn, 74 - timeout: number, 75 - ): Promise<StepResult> { 57 + async function executeStep(step: VerificationStep, context: ClaimContext, previousOutput: unknown, fetchFn: FetchFn, timeout: number): Promise<StepResult> { 76 58 try { 77 59 let data: unknown; 78 60
+54
packages/runner/src/types.ts
··· 1 + /** 2 + * Result of verifying a claim 3 + */ 4 + export interface ClaimVerificationResult { 5 + status: ClaimStatus; 6 + errors: string[]; 7 + timestamp: Date; 8 + } 9 + 10 + /** 11 + * Profile data from ATProto 12 + */ 13 + export interface ProfileData { 14 + did: string; 15 + handle: string; 16 + displayName?: string; 17 + avatar?: string; 18 + claims: ClaimData[]; 19 + } 20 + 21 + /** 22 + * Individual claim data from ATProto record 23 + */ 24 + export interface ClaimData { 25 + uri: string; 26 + did: string; 27 + comment?: string; 28 + createdAt: string; 29 + rkey: string; 30 + } 31 + 32 + /** 33 + * Options for verification operations 34 + */ 35 + export interface VerifyOptions { 36 + /** Timeout for fetcher operations in ms */ 37 + timeout?: number; 38 + /** Skip cache and force fresh verification */ 39 + skipCache?: boolean; 40 + /** Proxy URL for browser-based DNS/HTTP requests */ 41 + proxyUrl?: string; 42 + } 43 + 44 + /** 45 + * Claim status enum 46 + */ 47 + export enum ClaimStatus { 48 + INIT = "init", 49 + MATCHED = "matched", 50 + VERIFIED = "verified", 51 + FAILED = "failed", 52 + ERROR = "error", 53 + } 54 + 1 55 /** Injected fetch function - allows caller to provide proxy, auth, etc. */ 2 56 export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>; 3 57
+2 -6
packages/runner/tests/actions/regex-match.test.ts
··· 3 3 4 4 describe("regexMatch", () => { 5 5 it("should return first capture group", () => { 6 - expect(regexMatch("keytrace-verification=did:plc:abc", "keytrace-verification=(.+)")).toBe( 7 - "did:plc:abc", 8 - ); 6 + expect(regexMatch("keytrace-verification=did:plc:abc", "keytrace-verification=(.+)")).toBe("did:plc:abc"); 9 7 }); 10 8 11 9 it("should return full match when no capture group", () => { ··· 13 11 }); 14 12 15 13 it("should handle DID patterns", () => { 16 - expect(regexMatch("my did is did:plc:abc123 ok", "(did:plc:[a-z0-9]+)")).toBe( 17 - "did:plc:abc123", 18 - ); 14 + expect(regexMatch("my did is did:plc:abc123 ok", "(did:plc:[a-z0-9]+)")).toBe("did:plc:abc123"); 19 15 }); 20 16 21 17 it("should throw on no match", () => {
+1 -3
packages/runner/tests/interpolate.test.ts
··· 26 26 }); 27 27 28 28 it("should replace param keys", () => { 29 - expect(interpolate("{gistUrl}/raw", context)).toBe( 30 - "https://gist.github.com/alice/def456/raw", 31 - ); 29 + expect(interpolate("{gistUrl}/raw", context)).toBe("https://gist.github.com/alice/def456/raw"); 32 30 }); 33 31 34 32 it("should replace multiple placeholders in one string", () => {
+6 -19
packages/runner/tests/runner.test.ts
··· 47 47 48 48 describe("runRecipe", () => { 49 49 it("should succeed with valid gist content", async () => { 50 - const fetchFn = createMockFetch( 51 - JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:abc123" }), 52 - ); 50 + const fetchFn = createMockFetch(JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:abc123" })); 53 51 54 52 const result = await runRecipe(githubRecipe, context, { fetch: fetchFn }); 55 53 ··· 60 58 }); 61 59 62 60 it("should fail when claimId does not match", async () => { 63 - const fetchFn = createMockFetch( 64 - JSON.stringify({ keytrace: "kt-wrong", did: "did:plc:abc123" }), 65 - ); 61 + const fetchFn = createMockFetch(JSON.stringify({ keytrace: "kt-wrong", did: "did:plc:abc123" })); 66 62 67 63 const result = await runRecipe(githubRecipe, context, { fetch: fetchFn }); 68 64 ··· 73 69 }); 74 70 75 71 it("should fail when DID does not match", async () => { 76 - const fetchFn = createMockFetch( 77 - JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:wrong" }), 78 - ); 72 + const fetchFn = createMockFetch(JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:wrong" })); 79 73 80 74 const result = await runRecipe(githubRecipe, context, { fetch: fetchFn }); 81 75 ··· 102 96 }); 103 97 104 98 it("should interpolate URL correctly", async () => { 105 - const fetchFn = createMockFetch( 106 - JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:abc123" }), 107 - ); 99 + const fetchFn = createMockFetch(JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:abc123" })); 108 100 109 101 await runRecipe(githubRecipe, context, { fetch: fetchFn }); 110 102 111 - expect(fetchFn).toHaveBeenCalledWith( 112 - "https://gist.github.com/alice/def456/raw/keytrace.json", 113 - expect.any(Object), 114 - ); 103 + expect(fetchFn).toHaveBeenCalledWith("https://gist.github.com/alice/def456/raw/keytrace.json", expect.any(Object)); 115 104 }); 116 105 117 106 it("should extract subject from params using extractFrom", async () => { 118 - const fetchFn = createMockFetch( 119 - JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:abc123" }), 120 - ); 107 + const fetchFn = createMockFetch(JSON.stringify({ keytrace: "kt-a1b2c3d4", did: "did:plc:abc123" })); 121 108 122 109 const result = await runRecipe(githubRecipe, context, { fetch: fetchFn }); 123 110
-325
reviews/devils-advocate.md
··· 1 - # Devil's Advocate Review: Keytrace 2 - 3 - **Reviewer**: Devil's Advocate 4 - **Date**: 2026-02-08 5 - **Scope**: Existential risks, competitive landscape, trust model, sustainability, and architectural concerns 6 - 7 - --- 8 - 9 - ## Executive Summary 10 - 11 - Keytrace has a clear vision: be "Keybase for Bluesky." The core library (`@keytrace/doip`) is well-structured and the DOIP-inspired service provider architecture is sound. However, this project faces serious headwinds from Bluesky's own verification system, fundamental trust model contradictions, scope creep between plan and reality, and sustainability questions that need honest answers before investing further. 12 - 13 - **Verdict**: The project has a viable niche, but only if it narrows its ambitions and confronts the trust model problem head-on. As currently planned, it risks building something that is simultaneously too centralized to satisfy decentralization advocates and too obscure to satisfy mainstream users. 14 - 15 - --- 16 - 17 - ## 1. Does This Need to Exist? 18 - 19 - ### The "Bluesky Already Does This" Problem 20 - 21 - Bluesky launched its own multi-tier verification system in April 2025: 22 - - **Domain verification**: 309,000+ accounts already use domain handles 23 - - **Blue checkmarks**: 4,327 accounts verified by end of 2025 24 - - **Trusted Verifiers**: 21 organizations (NYT, CNN, European Commission) can issue verification badges directly in the app 25 - - **Built into the client**: Badges appear natively in all Bluesky clients 26 - 27 - Keytrace is building a third-party verification layer on top of a platform that already has two forms of built-in verification. The plan never mentions Bluesky's Trusted Verifiers program, which is a significant oversight. 28 - 29 - **Counter-argument**: Bluesky's verification is about "notable" accounts (celebrities, journalists, organizations). Keytrace is about proving cross-platform identity for anyone. These are genuinely different use cases. But the plan should explicitly articulate this distinction. 30 - 31 - ### The "Keyoxide Already Exists" Problem 32 - 33 - Keyoxide already supports Bluesky verification. Users can add their Bluesky profile as a claim on their Keyoxide profile via OpenPGP identity proofs. The DOIP (Decentralized OpenPGP Identity Proofs) specification is mature, with implementations in both JavaScript (doipjs) and Rust (doip-rs). 34 - 35 - The naming of `@keytrace/doip` package is telling -- this project is aware of and building on Keyoxide's concepts. But if you're rebuilding doipjs with ATProto as the identity backbone instead of PGP, you should be explicit about what's gained and lost. 36 - 37 - **What's gained**: No PGP key management UX nightmare, data lives in user's ATProto repo, portable with PDS. 38 - 39 - **What's lost**: PGP-based proofs are cryptographically self-sovereign. The user holds the private key. In Keytrace, the user delegates trust to keytrace.dev as a signing authority. This is a fundamental philosophical regression. 40 - 41 - --- 42 - 43 - ## 2. The Trust Model Contradiction 44 - 45 - This is the single biggest problem with the project. 46 - 47 - ### Keytrace is a Trusted Third Party 48 - 49 - The plan describes keytrace as a "trusted third-party verifier that signs claims on behalf of users." It generates daily rotating ES256 keys, signs attestations, and writes those attestations to user repos via OAuth. 50 - 51 - This means: 52 - - **Users must trust keytrace** to verify claims honestly 53 - - **Users must trust keytrace** with full repo write access 54 - - **Verifiers must trust keytrace's signing keys** to validate attestations 55 - - **Keytrace's DID must be hardcoded/pinned** in client code for security 56 - 57 - You've re-invented a Certificate Authority for identity proofs. In a project inspired by Keybase (which was criticized for centralization) and Keyoxide (which was built specifically to avoid centralization), keytrace introduces a new central point of trust and failure. 58 - 59 - ### The "Keytrace Signs Its Own Attestations" Problem 60 - 61 - When keytrace verifies a GitHub gist and signs an attestation, what does that signature actually prove? It proves that at some point in time, keytrace's server successfully fetched a URL and found a matching string. That's it. 62 - 63 - An attacker who compromises keytrace's S3 bucket (where private keys are stored) can forge attestations for anyone. An attacker who compromises the server can issue fraudulent attestations in real-time. There's no way for a third party to independently verify the original proof without re-running the verification themselves. 64 - 65 - If third parties need to re-run verification anyway (which the plan says should happen: "Verification is done on-demand"), then what value does the signed attestation add? It's a cache of a previous verification result signed by a party you have to trust anyway. 66 - 67 - ### What Would Fix This 68 - 69 - Consider a model where: 70 - 1. Users create the claim record themselves (they already have write access to their own repo) 71 - 2. Keytrace provides the verification engine (the runner) but doesn't sign anything 72 - 3. Anyone can verify by running the recipe against the claim 73 - 4. Keytrace.dev is just a convenient UI, not a signing authority 74 - 75 - This would be more aligned with the project's stated philosophy and closer to how Keyoxide actually works. 76 - 77 - --- 78 - 79 - ## 3. OAuth Scope: The Elephant in the Room 80 - 81 - The plan acknowledges this directly: 82 - 83 - > ATProto OAuth doesn't yet support fine-grained collection-level scopes. The `atproto` scope grants full repo access, but keytrace only writes to its own collection. 84 - 85 - The August 2025 ATProto discussion on auth scopes shows progress toward granular permissions, with draft protocol features published and server-side implementation underway. But as of this writing, keytrace.dev has `scope: "atproto transition:generic"` in its OAuth config (`/apps/keytrace.dev/server/utils/oauth.ts:47`), which grants full read/write access to the user's entire ATProto repository. 86 - 87 - This means keytrace could: 88 - - Read all of a user's private messages 89 - - Delete their posts 90 - - Modify their profile 91 - - Create records in any collection 92 - 93 - For a verification service, this is an unacceptable level of access. Users who are security-conscious enough to want identity verification proofs are exactly the users who will balk at granting full repo access to a third-party service. 94 - 95 - **Mitigation**: The plan should have a concrete strategy for when granular scopes ship. It should also have a prominent disclosure on the auth page explaining what access is being granted and why. 96 - 97 - --- 98 - 99 - ## 4. Plan vs. Reality (Scope Creep Analysis) 100 - 101 - ### What the Plan Describes 102 - 103 - The plan describes a system with: 104 - - `keytrace-runner` package with recipe execution engine 105 - - Multiple action types (http-get, json-path, css-select, regex-match, dns-txt, http-paginate) 106 - - Template interpolation with `{variable}` syntax 107 - - Daily rotating signing keys stored in S3 108 - - Signed attestations with JWS 109 - - Recipes stored in keytrace's ATProto repo 110 - - Strong references (URI + CID) for recipe integrity 111 - - Claim IDs with CUID entropy 112 - - Recent claims feed stored in S3 113 - - HTTP proxy with domain allowlist 114 - - 5 implementation phases 115 - 116 - ### What Actually Exists 117 - 118 - - A `@keytrace/doip` package (not `keytrace-runner`) with: 119 - - 4 service providers (GitHub gists, DNS, ActivityPub, Bluesky) 120 - - 3 fetchers (HTTP, DNS, ActivityPub) 121 - - A Claim class that can match and verify 122 - - A Profile class that fetches from ATProto 123 - - A Nuxt web app with: 124 - - OAuth login/logout flow 125 - - Session storage (S3 or file-based) 126 - - A bare-bones index page showing authenticated user info 127 - 128 - ### What's Missing 129 - 130 - Everything from the attestation system: no signing keys, no recipes stored in ATProto, no JWS signatures, no `dev.keytrace.key` or `dev.keytrace.recipe` or `dev.keytrace.claim` lexicons, no claim creation flow, no verification UI, no proxy endpoints, no recent claims feed. 131 - 132 - The lexicon that exists (`dev.keytrace.identity.claim`) is the simple version from the top of the plan, not the attested version from the attestation section. 133 - 134 - **The gap between the plan and the code is enormous.** The plan describes a Phase 5 system. The code is at early Phase 1. This isn't inherently bad -- every project starts somewhere -- but the plan should be honest about the critical path and make clear which parts are aspirational vs. committed. 135 - 136 - --- 137 - 138 - ## 5. Recipe System: Over-Engineering? 139 - 140 - The recipe system described in the plan is a full DSL for verification: 141 - - Parameterized templates 142 - - Multi-step verification pipelines 143 - - CSS selectors, JSONPath, regex matching 144 - - Pagination support 145 - - Recipes stored as ATProto records with CID-based integrity 146 - 147 - Meanwhile, the actual code uses a much simpler approach: hardcoded service providers with regex matching and path-based value extraction. This is pragmatic and works. 148 - 149 - The question is: does the recipe system justify its complexity? 150 - 151 - **Against recipes**: Every recipe is custom logic that needs to be written, tested, and maintained. When GitHub changes their gist API, someone needs to update the recipe. When a service provider changes their HTML structure, the CSS selector breaks. This is the same maintenance burden as hardcoded providers, just expressed in a different format. Recipes in ATProto don't make them more maintainable; they make them less mutable (CID-pinned). 152 - 153 - **For recipes**: Recipes are user-auditable. Anyone can read the recipe and understand exactly what keytrace checks. This is genuine transparency value. 154 - 155 - **Recommendation**: Keep the current hardcoded provider approach for v1. The recipe system is a v2 feature at best. Don't let the perfect be the enemy of the good. 156 - 157 - --- 158 - 159 - ## 6. ATProto Dependency Risks 160 - 161 - ### Protocol Instability 162 - 163 - ATProto is still a moving target. The DID PLC verification method constraints were being relaxed as recently as June 2025. OAuth scopes are still being implemented. The lexicon system may evolve. 164 - 165 - Keytrace stores its core data (claims, keys, recipes) as ATProto records. If the protocol changes how custom lexicons work, or if PDS operators decide to reject unknown collections, keytrace's data becomes inaccessible. 166 - 167 - ### Bluesky Pivot Risk 168 - 169 - ATProto and Bluesky are technically separate, but practically coupled. The code hardcodes `https://public.api.bsky.app` as `PUBLIC_API_URL` and `https://bsky.social` as the PDS endpoint. If Bluesky changes its API, the project breaks. 170 - 171 - More concerning: Bluesky could decide that third-party verification services compete with their Trusted Verifiers program and make it harder for apps like keytrace to operate (e.g., by restricting what collections third-party OAuth apps can write to). 172 - 173 - ### Ecosystem Lock-in 174 - 175 - By building exclusively on ATProto, keytrace ties its fate to a single protocol. Keyoxide works with PGP keys, which are protocol-agnostic. A keytrace identity proof is meaningless outside the ATProto ecosystem. 176 - 177 - --- 178 - 179 - ## 7. Adoption: The Chicken-and-Egg Problem 180 - 181 - Identity verification is a network effect business: 182 - - Users won't add proofs if nobody checks them 183 - - Nobody will check them if few users have proofs 184 - - Third-party apps won't integrate if few users have proofs 185 - - Users won't bother if third-party apps don't show verification status 186 - 187 - Keybase solved this by bundling verification with encrypted chat, file sharing, and git. It made the verification system a side benefit of an already-useful product. 188 - 189 - Keytrace is a pure verification service. The value proposition is: "Prove you own the same GitHub account linked to your Bluesky profile." Who needs this today? Security researchers, journalists, developers. A small niche. The plan's homepage "recent claims feed" showing the last 50 verifications could look very sparse for a very long time. 190 - 191 - **Recommendation**: Focus ruthlessly on the developer audience first. Make `@keytrace/doip` an excellent library that other ATProto app developers integrate. The verification UI is secondary to the library being useful in other people's apps. 192 - 193 - --- 194 - 195 - ## 8. Failure Modes 196 - 197 - ### Service Dependencies 198 - 199 - | Dependency | Failure Impact | 200 - |-----------|----------------| 201 - | GitHub API | Rate-limited at 60 req/hr unauthenticated. A popular keytrace instance verifying many GitHub claims will hit this fast. | 202 - | S3 (Scaleway) | All sessions lost. No new OAuth logins. No signing keys accessible. Complete outage. | 203 - | public.api.bsky.app | Cannot resolve handles, fetch profiles, or list claims. Complete outage. | 204 - | bsky.social PDS | Cannot write attestations to user repos. Cannot publish keys/recipes. | 205 - | DNS resolution | DNS claim verification fails. | 206 - | Third-party services | Each service provider is a separate failure point. | 207 - 208 - ### Missing Resilience 209 - 210 - The code has no: 211 - - Retry logic on any fetcher 212 - - Circuit breakers for failing services 213 - - Caching of verification results 214 - - Graceful degradation (e.g., showing stale results when a service is down) 215 - - Health check endpoints 216 - - Error tracking/alerting 217 - 218 - ### S3 as Single Point of Failure 219 - 220 - The plan puts everything critical in S3: signing keys, sessions, recent claims feed. S3 is highly available, but the keytrace implementation has no fallback. If S3 credentials expire or the bucket is misconfigured, the entire service is dead. 221 - 222 - --- 223 - 224 - ## 9. Security Theater Concerns 225 - 226 - ### "Verified" Doesn't Mean "Trustworthy" 227 - 228 - A keytrace verification proves: "At time T, URL X contained string Y matching DID Z." It does not prove: 229 - - The GitHub account is not compromised 230 - - The person behind the DID is who they claim to be 231 - - The verification is still valid (the gist could be deleted 1 second after verification) 232 - 233 - The verification UI should make these limitations crystal clear. A green checkmark next to "GitHub: @alice" could give false confidence. 234 - 235 - ### Temporal Validity Problem 236 - 237 - The plan says "Verification is done on-demand (not stored) - keeps data fresh." But the attestation system signs claims at verification time. If a user removes their proof after getting the attestation, the attestation remains in their repo, signed and "valid." There's no revocation mechanism. 238 - 239 - ### Proof Squatting 240 - 241 - If Alice creates a gist with Bob's DID, and then submits that gist URL as her own claim, keytrace would verify it. The verification checks that the DID appears in the gist, not that the gist belongs to the person making the claim. The `extractFrom` parameter in recipes partially addresses this (extracting username from gist URL), but the current code doesn't verify that the extracted username matches any expected value. 242 - 243 - --- 244 - 245 - ## 10. Business Sustainability 246 - 247 - ### Who Pays? 248 - 249 - The plan mentions Railway for hosting, Scaleway S3 for storage. These cost money. The project has: 250 - - No pricing model 251 - - No sponsorship strategy 252 - - No mention of sustainability 253 - - S3 storage that grows linearly with users (sessions, keys) 254 - 255 - ### Maintenance Burden 256 - 257 - Each service provider needs ongoing maintenance: 258 - - GitHub changes their API? Update the provider. 259 - - Mastodon instances have different API versions? Handle edge cases. 260 - - New services to support? Write and test new providers. 261 - - Security vulnerabilities? Patch and redeploy. 262 - 263 - This is a significant ongoing commitment for what appears to be a side project. 264 - 265 - --- 266 - 267 - ## 11. Specific Code Concerns 268 - 269 - ### Naming Confusion 270 - 271 - The package is called `@keytrace/doip` but the plan describes `keytrace-runner`. The lexicon uses `dev.keytrace.identity.claim` but the plan's attestation section uses `dev.keytrace.claim`. The plan references `org.[domain].identity.claim` as a placeholder. These inconsistencies suggest the design is still in flux. 272 - 273 - ### Hardcoded Bluesky Dependency 274 - 275 - `/packages/doip/src/constants.ts` hardcodes `PUBLIC_API_URL = "https://public.api.bsky.app"`. The plan talks about ATProto portability but the code is Bluesky-specific. A user on a third-party PDS would need their AppView to resolve, which may or may not be public.api.bsky.app. 276 - 277 - ### Missing Input Validation 278 - 279 - The `Claim` constructor validates DID format with just `did.startsWith("did:")`. It doesn't validate: 280 - - DID method (plc, web, key, etc.) 281 - - DID-specific format requirements 282 - - URI format for claim URIs 283 - - Maximum lengths 284 - 285 - ### Service Provider Ordering Matters 286 - 287 - In `/packages/doip/src/serviceProviders/index.ts`, the `matchUri` function iterates providers in insertion order and stops at the first unambiguous match. The order `github, dns, activitypub, bsky` means if a URL somehow matched both github and another provider, only github would be tried. This is fragile -- provider ordering should be explicit and documented. 288 - 289 - --- 290 - 291 - ## 12. Recommendations (Constructive) 292 - 293 - ### Must-Do Before Launch 294 - 295 - 1. **Resolve the trust model**: Either commit to being a signing authority (and own that responsibility) or remove the attestation system and be a verification engine only 296 - 2. **Address OAuth scope disclosure**: Add a prominent explanation of what access is being granted 297 - 3. **Plan for granular scopes**: Have migration code ready for when ATProto ships collection-level permissions 298 - 4. **Add retry logic and error handling**: The fetchers are too fragile for production 299 - 300 - ### Should-Do 301 - 302 - 5. **Differentiate from Bluesky verification explicitly**: The landing page should explain why keytrace exists when Bluesky has verification 303 - 6. **Focus on the library first**: Make `@keytrace/doip` the best verification library in the ATProto ecosystem 304 - 7. **Skip the recipe system for v1**: Hardcoded providers work fine and are easier to maintain 305 - 8. **Add a cache/staleness model**: Decide how long a verification result is valid 306 - 307 - ### Could-Do 308 - 309 - 9. **Consider becoming a Bluesky labeler**: Instead of custom attestations, keytrace could label accounts using the existing labeler system, getting native Bluesky UI integration for free 310 - 10. **Support verification without OAuth**: Let anyone verify someone else's profile without logging in (read-only verification) 311 - 11. **Add webhook/federation**: Let other instances run verification so keytrace.dev isn't a single point of failure 312 - 313 - --- 314 - 315 - ## Summary 316 - 317 - Keytrace is building something useful at its core: a library and service for cross-platform identity verification on ATProto. The `@keytrace/doip` package is well-structured and the service provider architecture is extensible. 318 - 319 - But the project needs to confront three fundamental tensions: 320 - 321 - 1. **Centralized verifier in a decentralized ecosystem**: The attestation/signing model contradicts the project's philosophical roots 322 - 2. **Competing with the platform**: Bluesky's own verification system covers the most visible use case 323 - 3. **Plan vs. reality**: The plan describes a much larger system than what exists or may be needed 324 - 325 - The path forward is to focus on what keytrace uniquely offers: cross-platform identity verification for everyday users (not just "notable" accounts), packaged as both a library and a simple web UI. Drop the complex attestation system, embrace verification-on-demand, and make the library so good that other ATProto apps want to integrate it.
+2 -11
yarn.lock
··· 1934 1934 languageName: node 1935 1935 linkType: hard 1936 1936 1937 - "@keytrace/doip@workspace:packages/doip": 1938 - version: 0.0.0-use.local 1939 - resolution: "@keytrace/doip@workspace:packages/doip" 1940 - dependencies: 1941 - "@atproto/api": "npm:^0.14.0" 1942 - "@types/node": "npm:^22.0.0" 1943 - typescript: "npm:^5.7.0" 1944 - vitest: "npm:^2.1.0" 1945 - languageName: unknown 1946 - linkType: soft 1947 - 1948 1937 "@keytrace/lexicon@workspace:packages/lexicon": 1949 1938 version: 0.0.0-use.local 1950 1939 resolution: "@keytrace/lexicon@workspace:packages/lexicon" ··· 1955 1944 version: 0.0.0-use.local 1956 1945 resolution: "@keytrace/runner@workspace:packages/runner" 1957 1946 dependencies: 1947 + "@atproto/api": "npm:^0.14.0" 1958 1948 "@types/node": "npm:^22.0.0" 1959 1949 cheerio: "npm:^1.0.0" 1950 + oxfmt: "npm:^0.27.0" 1960 1951 typescript: "npm:^5.7.0" 1961 1952 vitest: "npm:^2.1.0" 1962 1953 languageName: unknown