this repo has no description
0
fork

Configure Feed

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

Adds support for hooking up recipes and providers, and re-running verificatoin

orta b0636b30 1bc0b0aa

+1418 -145
+92
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + Keytrace is an identity verification system for ATProto. Users link their decentralized identities (DIDs) to external accounts (GitHub, DNS, ActivityPub, Bluesky) by creating claims that are cryptographically verified and stored as ATProto records. 8 + 9 + ## Commands 10 + 11 + ```bash 12 + # Development 13 + yarn dev # Start Nuxt dev server on :3000 14 + 15 + # Build 16 + yarn build # Build all packages (turbo) 17 + 18 + # Testing 19 + yarn test # Run all tests (vitest in runner package) 20 + cd packages/runner && yarn test:watch # Watch mode for runner tests 21 + 22 + # Type checking & linting 23 + yarn typecheck # TypeScript checking across all packages 24 + yarn lint # Run linting 25 + 26 + # Formatting 27 + yarn format # Format with oxfmt 28 + yarn format:check # Check formatting 29 + ``` 30 + 31 + ## Architecture 32 + 33 + ### Monorepo Structure 34 + 35 + - **`packages/runner`** - Core verification library (`@keytrace/runner`). Reusable SDK for claim verification with recipe-based verification system. 36 + - **`packages/lexicon`** - ATProto lexicon JSON schemas defining record types (`dev.keytrace.claim`, `dev.keytrace.recipe`, etc.) 37 + - **`apps/keytrace.dev`** - Nuxt 3 full-stack web application with OAuth and API 38 + 39 + ### Verification Pipeline (Runner Package) 40 + 41 + The runner implements a recipe-based verification system: 42 + 43 + 1. **Service Providers** (`packages/runner/src/serviceProviders/`) - Map claim URIs to verification strategies (GitHub, DNS, ActivityPub, Bluesky) 44 + 2. **Recipes** - JSON specifications defining verification steps 45 + 3. **Verification Steps** - Composable actions: `http-get`, `dns-txt`, `css-select`, `json-path`, `regex-match` 46 + 47 + **Claim Status Flow:** `INIT` → `MATCHED` → `VERIFIED` / `FAILED` / `ERROR` 48 + 49 + Key functions: 50 + - `createClaim(uri, did)` - Create claim state 51 + - `verifyClaim(claim, opts)` - Run verification 52 + - `matchUri(uri)` - Match URI to service provider 53 + - `runRecipe(recipe, context, config)` - Execute recipe steps 54 + 55 + ### Web App (Nuxt 3) 56 + 57 + **Server API** (`apps/keytrace.dev/server/api/`): 58 + - `POST /api/claims` - Create verified claim record 59 + - `POST /api/verify` - Verify single claim 60 + - `GET /api/profile/[handleOrDid]` - Fetch profile with claims 61 + - `DELETE /api/claims/[rkey]` - Delete claim 62 + 63 + **OAuth Flow** (`apps/keytrace.dev/server/routes/oauth/`): 64 + - Login initiates ATProto OAuth 65 + - Sessions stored with HMAC-SHA256 signed DID cookies 66 + - Keytrace service account writes records to user repos 67 + 68 + **Frontend**: 69 + - Components auto-import from `components/` without `Ui` prefix (due to `pathPrefix: false` in nuxt.config) 70 + - Composables in `composables/` for shared state 71 + - TailwindCSS with custom `kt-*` color tokens defined in `assets/css/main.css` 72 + 73 + ## Data Flow Example 74 + 75 + ``` 76 + User submits gist URL 77 + → POST /api/claims 78 + → Runner matches URI to GitHub provider 79 + → Recipe executes: HTTP GET → CSS select → regex match for DID 80 + → Extract identity metadata (username, avatar) 81 + → Create attestation signature 82 + → Write dev.keytrace.claim to user's ATProto repo 83 + ``` 84 + 85 + ## Key Files 86 + 87 + - `packages/runner/src/types.ts` - Core type definitions 88 + - `packages/runner/src/runner.ts` - Verification engine 89 + - `packages/runner/src/claim.ts` - Claim state machine 90 + - `packages/lexicon/lexicons/dev/keytrace/recipe.json` - Recipe schema 91 + - `apps/keytrace.dev/server/api/claims/index.post.ts` - Claim creation 92 + - `apps/keytrace.dev/assets/css/main.css` - CSS variables for theming
+51 -11
apps/keytrace.dev/components/ui/ClaimCard.vue
··· 3 3 <!-- Header row --> 4 4 <div class="flex items-center justify-between px-4 py-3 border-b border-zinc-800/50"> 5 5 <div class="flex items-center gap-2.5"> 6 - <!-- Service icon --> 7 - <div class="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center"> 6 + <!-- Identity avatar or service icon --> 7 + <img 8 + v-if="claim.identity?.avatarUrl" 9 + :src="claim.identity.avatarUrl" 10 + :alt="claim.identity.displayName || claim.identity.subject || 'Avatar'" 11 + class="w-8 h-8 rounded-full object-cover" 12 + /> 13 + <div v-else class="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center"> 8 14 <component :is="serviceIcon" class="w-4 h-4 text-zinc-300" /> 9 15 </div> 10 - <span class="text-sm font-semibold text-zinc-200"> 11 - {{ claim.displayName }} 12 - </span> 16 + <div class="min-w-0"> 17 + <span class="text-sm font-semibold text-zinc-200 block truncate"> 18 + {{ claim.identity?.displayName || claim.identity?.subject || claim.displayName }} 19 + </span> 20 + <span v-if="claim.comment" class="text-xs text-zinc-500 block truncate"> 21 + {{ claim.comment }} 22 + </span> 23 + </div> 13 24 </div> 14 25 15 - <!-- Status badge --> 16 - <UiStatusBadge :status="claim.status" /> 26 + <div class="flex items-center gap-2"> 27 + <!-- Actions slot --> 28 + <slot name="actions" /> 29 + </div> 17 30 </div> 18 31 19 32 <!-- Body --> 20 33 <div class="px-4 py-3 space-y-1.5"> 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"> 22 - {{ claim.subject }} 34 + <a 35 + v-if="claim.identity?.profileUrl || claim.subject" 36 + :href="claim.identity?.profileUrl || claim.subject" 37 + target="_blank" 38 + rel="noopener noreferrer" 39 + class="text-sm text-violet-400 hover:text-violet-300 font-mono transition-colors block truncate" 40 + > 41 + {{ claim.identity?.profileUrl || claim.subject }} 23 42 </a> 24 43 25 - <div class="flex items-center gap-3 text-xs text-zinc-500"> 26 - <span v-if="claim.recipeName">via {{ claim.recipeName }}</span> 44 + <div class="flex items-center flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500"> 45 + <NuxtLink 46 + v-if="claim.serviceType" 47 + :to="`/recipes/${claim.serviceType}`" 48 + class="hover:text-zinc-300 transition-colors" 49 + > 50 + via {{ claim.recipeName || claim.serviceType }} 51 + </NuxtLink> 52 + <span v-else-if="claim.recipeName">via {{ claim.recipeName }}</span> 53 + <template v-if="claim.createdAt"> 54 + <span v-if="claim.recipeName || claim.serviceType" class="text-zinc-700">&middot;</span> 55 + <span>Added {{ formatDate(claim.createdAt) }}</span> 56 + </template> 27 57 <template v-if="claim.attestation?.signedAt"> 28 58 <span class="text-zinc-700">&middot;</span> 29 59 <span>Attested {{ formatDate(claim.attestation.signedAt) }}</span> ··· 47 77 import { computed } from "vue"; 48 78 import { Github, Globe, AtSign, Key } from "lucide-vue-next"; 49 79 80 + export interface ClaimIdentity { 81 + subject?: string; 82 + avatarUrl?: string; 83 + profileUrl?: string; 84 + displayName?: string; 85 + } 86 + 50 87 export interface ClaimData { 51 88 displayName: string; 52 89 status: "verified" | "pending" | "failed" | "unverified"; 53 90 serviceType?: string; 54 91 subject?: string; 55 92 recipeName?: string; 93 + comment?: string; 94 + createdAt?: string; 95 + identity?: ClaimIdentity; 56 96 attestation?: { 57 97 signedAt?: string; 58 98 signingKey?: { uri: string };
+99
apps/keytrace.dev/components/ui/LoginModal.vue
··· 1 + <template> 2 + <Teleport to="body"> 3 + <Transition name="modal"> 4 + <div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center"> 5 + <!-- Backdrop --> 6 + <div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="close" /> 7 + 8 + <!-- Modal --> 9 + <div class="relative bg-kt-card border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6"> 10 + <button 11 + class="absolute top-4 right-4 text-zinc-500 hover:text-zinc-300 transition-colors" 12 + @click="close" 13 + > 14 + <XIcon class="w-5 h-5" /> 15 + </button> 16 + 17 + <div class="text-center mb-6"> 18 + <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 mb-4"> 19 + <span class="font-mono text-sm font-bold text-violet-400">kt</span> 20 + </div> 21 + <h2 class="text-lg font-semibold text-zinc-100">Sign in to keytrace</h2> 22 + <p class="text-sm text-zinc-500 mt-1">Enter your Bluesky handle to continue</p> 23 + </div> 24 + 25 + <form @submit.prevent="handleLogin"> 26 + <input 27 + ref="handleInput" 28 + v-model="handle" 29 + type="text" 30 + placeholder="you.bsky.social" 31 + class="w-full 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" 32 + /> 33 + <button 34 + type="submit" 35 + class="w-full mt-3 px-4 py-2.5 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium rounded-lg transition-all" 36 + > 37 + Sign in with Bluesky 38 + </button> 39 + </form> 40 + 41 + <p class="text-xs text-zinc-600 text-center mt-4"> 42 + You'll be redirected to authorize with your PDS 43 + </p> 44 + </div> 45 + </div> 46 + </Transition> 47 + </Teleport> 48 + </template> 49 + 50 + <script setup lang="ts"> 51 + import { X as XIcon } from "lucide-vue-next"; 52 + 53 + const { isOpen, close } = useLoginModal(); 54 + const { login } = useSession(); 55 + 56 + const handle = ref(""); 57 + const handleInput = ref<HTMLInputElement | null>(null); 58 + 59 + // Focus input when modal opens 60 + watch(isOpen, (open) => { 61 + if (open) { 62 + nextTick(() => { 63 + handleInput.value?.focus(); 64 + }); 65 + } else { 66 + handle.value = ""; 67 + } 68 + }); 69 + 70 + function handleLogin() { 71 + if (handle.value) { 72 + login(handle.value); 73 + close(); 74 + } 75 + } 76 + </script> 77 + 78 + <style scoped> 79 + .modal-enter-active, 80 + .modal-leave-active { 81 + transition: opacity 0.2s ease; 82 + } 83 + 84 + .modal-enter-from, 85 + .modal-leave-to { 86 + opacity: 0; 87 + } 88 + 89 + .modal-enter-active .relative, 90 + .modal-leave-active .relative { 91 + transition: transform 0.2s ease, opacity 0.2s ease; 92 + } 93 + 94 + .modal-enter-from .relative, 95 + .modal-leave-to .relative { 96 + transform: scale(0.95); 97 + opacity: 0; 98 + } 99 + </style>
+42
apps/keytrace.dev/components/ui/Markdown.vue
··· 1 + <template> 2 + <span v-html="rendered" /> 3 + </template> 4 + 5 + <script setup lang="ts"> 6 + import { computed } from "vue"; 7 + 8 + const props = defineProps<{ 9 + content: string; 10 + }>(); 11 + 12 + /** 13 + * Simple markdown renderer for instruction text. 14 + * Supports: **bold**, `code`, [links](url) 15 + */ 16 + const rendered = computed(() => { 17 + let html = escapeHtml(props.content); 18 + 19 + // Convert **bold** to <strong> 20 + html = html.replace(/\*\*([^*]+)\*\*/g, '<strong class="text-zinc-100 font-medium">$1</strong>'); 21 + 22 + // Convert `code` to <code> 23 + html = html.replace(/`([^`]+)`/g, '<code class="px-1.5 py-0.5 rounded bg-zinc-800 text-violet-400 text-xs font-mono">$1</code>'); 24 + 25 + // Convert [text](url) to <a> 26 + html = html.replace( 27 + /\[([^\]]+)\]\(([^)]+)\)/g, 28 + '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-violet-400 hover:text-violet-300 underline underline-offset-2">$1</a>', 29 + ); 30 + 31 + return html; 32 + }); 33 + 34 + function escapeHtml(text: string): string { 35 + return text 36 + .replace(/&/g, "&amp;") 37 + .replace(/</g, "&lt;") 38 + .replace(/>/g, "&gt;") 39 + .replace(/"/g, "&quot;") 40 + .replace(/'/g, "&#039;"); 41 + } 42 + </script>
+8 -2
apps/keytrace.dev/components/ui/ProfileHeader.vue
··· 15 15 <p class="text-zinc-500 mt-0.5"><span class="text-zinc-600">@</span>{{ profile.handle }}</p> 16 16 17 17 <div class="flex items-center gap-2 mt-1"> 18 - <code class="text-xs font-mono text-zinc-500 truncate max-w-[280px]"> 18 + <a 19 + :href="`https://pdsls.dev/at://${profile.did}/dev.keytrace.claim`" 20 + target="_blank" 21 + rel="noopener noreferrer" 22 + class="text-xs font-mono text-zinc-500 hover:text-zinc-300 truncate max-w-[280px] transition-colors" 23 + title="View claims on pdsls.dev" 24 + > 19 25 {{ profile.did }} 20 - </code> 26 + </a> 21 27 <CopyButton :value="profile.did" /> 22 28 </div> 23 29
+26 -5
apps/keytrace.dev/components/ui/StatusBadge.vue
··· 7 7 8 8 <script setup lang="ts"> 9 9 import { computed } from "vue"; 10 - import { CheckCircle, Clock, XCircle, MinusCircle } from "lucide-vue-next"; 10 + import { CheckCircle, Clock, XCircle, MinusCircle, HelpCircle } from "lucide-vue-next"; 11 11 12 12 const props = defineProps<{ 13 - status: "verified" | "pending" | "failed" | "unverified"; 13 + status: string; 14 14 }>(); 15 15 16 - const configs = { 16 + const configs: Record<string, { classes: string; icon: typeof CheckCircle; label: string }> = { 17 17 verified: { 18 18 classes: "bg-verified/15 text-verified border border-verified/20", 19 19 icon: CheckCircle, 20 20 label: "Verified", 21 21 }, 22 22 pending: { 23 + classes: "bg-pending/15 text-pending border border-pending/20", 24 + icon: Clock, 25 + label: "Pending", 26 + }, 27 + init: { 28 + classes: "bg-zinc-500/15 text-zinc-400 border border-zinc-500/20", 29 + icon: Clock, 30 + label: "Not checked", 31 + }, 32 + matched: { 23 33 classes: "bg-pending/15 text-pending border border-pending/20", 24 34 icon: Clock, 25 35 label: "Pending", ··· 29 39 icon: XCircle, 30 40 label: "Failed", 31 41 }, 42 + error: { 43 + classes: "bg-failed/15 text-failed border border-failed/20", 44 + icon: XCircle, 45 + label: "Error", 46 + }, 32 47 unverified: { 33 48 classes: "bg-zinc-500/15 text-zinc-400 border border-zinc-500/20", 34 49 icon: MinusCircle, 35 50 label: "Unverified", 36 51 }, 37 - } as const; 52 + }; 53 + 54 + const defaultConfig = { 55 + classes: "bg-zinc-500/15 text-zinc-400 border border-zinc-500/20", 56 + icon: HelpCircle, 57 + label: "Unknown", 58 + }; 38 59 39 - const config = computed(() => configs[props.status]); 60 + const config = computed(() => configs[props.status] || defaultConfig); 40 61 </script>
+5 -3
apps/keytrace.dev/components/ui/VerificationLog.vue
··· 33 33 </div> 34 34 </template> 35 35 36 - <script setup lang="ts"> 37 - import { CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 38 - 36 + <script lang="ts"> 39 37 export interface VerificationStep { 40 38 action: string; 41 39 detail?: string; 42 40 status: "pending" | "running" | "success" | "error"; 43 41 duration?: number; 44 42 } 43 + </script> 44 + 45 + <script setup lang="ts"> 46 + import { CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 45 47 46 48 defineProps<{ 47 49 steps: VerificationStep[];
+208
apps/keytrace.dev/components/ui/VerifyPopover.vue
··· 1 + <template> 2 + <div class="relative" @mouseenter="showPopover" @mouseleave="hidePopoverDelayed"> 3 + <!-- Trigger button --> 4 + <button 5 + class="p-1.5 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors disabled:opacity-50" 6 + title="Re-verify claim" 7 + :disabled="isRunning" 8 + @click.stop="runVerification" 9 + > 10 + <RefreshCwIcon class="w-4 h-4" :class="{ 'animate-spin': isRunning }" /> 11 + </button> 12 + 13 + <!-- Popover --> 14 + <Transition name="popover"> 15 + <div 16 + v-if="isVisible" 17 + class="absolute right-0 top-full mt-2 w-72 z-50" 18 + @mouseenter="cancelHide" 19 + @mouseleave="hidePopoverDelayed" 20 + > 21 + <div class="bg-kt-elevated border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"> 22 + <!-- Header --> 23 + <div class="px-4 py-3 border-b border-zinc-800"> 24 + <h3 class="text-sm font-semibold text-zinc-200"> 25 + {{ isRunning ? 'Verifying...' : result ? 'Verification Result' : 'Re-verify Claim' }} 26 + </h3> 27 + <p class="text-xs text-zinc-500 mt-0.5 truncate">{{ displayName }}</p> 28 + </div> 29 + 30 + <!-- Content --> 31 + <div class="p-4"> 32 + <VerificationLog :steps="steps" /> 33 + 34 + <!-- Result message --> 35 + <div v-if="result" class="mt-3"> 36 + <div 37 + v-if="result.status === 'verified'" 38 + class="flex items-center gap-2 px-3 py-2 rounded-lg bg-verified/10 border border-verified/20" 39 + > 40 + <CheckCircleIcon class="w-4 h-4 text-verified flex-shrink-0" /> 41 + <span class="text-xs text-verified font-medium">Verified successfully</span> 42 + </div> 43 + <div 44 + v-else 45 + class="px-3 py-2 rounded-lg bg-failed/10 border border-failed/20" 46 + > 47 + <div class="flex items-center gap-2"> 48 + <XCircleIcon class="w-4 h-4 text-failed flex-shrink-0" /> 49 + <span class="text-xs text-failed font-medium">Verification failed</span> 50 + </div> 51 + <p v-if="result.errors.length" class="text-xs text-zinc-500 mt-1 pl-6"> 52 + {{ result.errors[0] }} 53 + </p> 54 + </div> 55 + </div> 56 + 57 + <!-- Action hint when not running --> 58 + <p v-if="!isRunning && !result" class="text-xs text-zinc-600 mt-3 text-center"> 59 + Click to run verification 60 + </p> 61 + </div> 62 + </div> 63 + </div> 64 + </Transition> 65 + </div> 66 + </template> 67 + 68 + <script setup lang="ts"> 69 + import { RefreshCw as RefreshCwIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 70 + 71 + interface VerificationStep { 72 + action: string; 73 + detail?: string; 74 + status: "pending" | "running" | "success" | "error"; 75 + duration?: number; 76 + } 77 + 78 + const props = defineProps<{ 79 + claimUri: string; 80 + did: string; 81 + displayName: string; 82 + providerName?: string; 83 + }>(); 84 + 85 + const emit = defineEmits<{ 86 + verified: []; 87 + }>(); 88 + 89 + const isVisible = ref(false); 90 + const isRunning = ref(false); 91 + const hideTimeout = ref<ReturnType<typeof setTimeout> | null>(null); 92 + 93 + const steps = ref<VerificationStep[]>([ 94 + { action: "Matching service provider", status: "pending" }, 95 + { action: "Fetching proof", status: "pending" }, 96 + { action: "Checking for DID", status: "pending" }, 97 + ]); 98 + 99 + const result = ref<{ status: "verified" | "failed"; errors: string[] } | null>(null); 100 + 101 + function showPopover() { 102 + cancelHide(); 103 + isVisible.value = true; 104 + } 105 + 106 + function hidePopoverDelayed() { 107 + hideTimeout.value = setTimeout(() => { 108 + // Don't hide while running 109 + if (!isRunning.value) { 110 + isVisible.value = false; 111 + // Reset state when hiding 112 + resetState(); 113 + } 114 + }, 200); 115 + } 116 + 117 + function cancelHide() { 118 + if (hideTimeout.value) { 119 + clearTimeout(hideTimeout.value); 120 + hideTimeout.value = null; 121 + } 122 + } 123 + 124 + function resetState() { 125 + steps.value = [ 126 + { action: "Matching service provider", status: "pending" }, 127 + { action: "Fetching proof", status: "pending" }, 128 + { action: "Checking for DID", status: "pending" }, 129 + ]; 130 + result.value = null; 131 + } 132 + 133 + async function runVerification() { 134 + if (isRunning.value) return; 135 + 136 + isRunning.value = true; 137 + isVisible.value = true; 138 + cancelHide(); 139 + resetState(); 140 + 141 + try { 142 + // Step 1: Matching 143 + steps.value[0] = { ...steps.value[0], status: "running" }; 144 + await new Promise((r) => setTimeout(r, 300)); 145 + steps.value[0] = { 146 + ...steps.value[0], 147 + status: "success", 148 + detail: props.providerName || "Matched", 149 + }; 150 + 151 + // Step 2: Fetching proof 152 + steps.value[1] = { ...steps.value[1], status: "running" }; 153 + 154 + const apiResult = await $fetch("/api/verify", { 155 + method: "POST", 156 + body: { 157 + claimUri: props.claimUri, 158 + did: props.did, 159 + }, 160 + }); 161 + 162 + steps.value[1] = { ...steps.value[1], status: "success" }; 163 + 164 + // Step 3: Checking for DID 165 + steps.value[2] = { ...steps.value[2], status: "running" }; 166 + await new Promise((r) => setTimeout(r, 200)); 167 + 168 + if (apiResult.status === "verified") { 169 + steps.value[2] = { 170 + ...steps.value[2], 171 + status: "success", 172 + detail: "DID found in proof", 173 + }; 174 + result.value = { status: "verified", errors: [] }; 175 + emit("verified"); 176 + } else { 177 + steps.value[2] = { 178 + ...steps.value[2], 179 + status: "error", 180 + detail: "DID not found", 181 + }; 182 + result.value = { status: "failed", errors: apiResult.errors || [] }; 183 + } 184 + } catch (err) { 185 + steps.value[1] = { ...steps.value[1], status: "error" }; 186 + steps.value[2] = { ...steps.value[2], status: "error" }; 187 + result.value = { 188 + status: "failed", 189 + errors: [err instanceof Error ? err.message : "Verification failed"], 190 + }; 191 + } finally { 192 + isRunning.value = false; 193 + } 194 + } 195 + </script> 196 + 197 + <style scoped> 198 + .popover-enter-active, 199 + .popover-leave-active { 200 + transition: opacity 0.15s ease, transform 0.15s ease; 201 + } 202 + 203 + .popover-enter-from, 204 + .popover-leave-to { 205 + opacity: 0; 206 + transform: translateY(-4px); 207 + } 208 + </style>
+17
apps/keytrace.dev/composables/useLoginModal.ts
··· 1 + const isOpen = ref(false); 2 + 3 + export function useLoginModal() { 4 + function open() { 5 + isOpen.value = true; 6 + } 7 + 8 + function close() { 9 + isOpen.value = false; 10 + } 11 + 12 + return { 13 + isOpen, 14 + open, 15 + close, 16 + }; 17 + }
+12 -5
apps/keytrace.dev/layouts/default.vue
··· 35 35 </div> 36 36 </Transition> 37 37 </div> 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> 38 + <button v-else class="px-3 py-1.5 text-sm text-zinc-400 hover:text-zinc-200 transition-colors" @click="openLoginModal"> Sign in </button> 39 39 </template> 40 40 </NavBar> 41 41 ··· 45 45 46 46 <footer class="border-t border-zinc-800/50 mt-16"> 47 47 <div class="max-w-5xl mx-auto px-4 py-8 flex items-center justify-between"> 48 - <div class="flex items-center gap-2"> 49 - <div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-violet-600/15 border border-violet-500/20"> 50 - <span class="font-mono text-xs font-bold text-violet-400">kt</span> 48 + <div class="flex items-center gap-4"> 49 + <div class="flex items-center gap-2"> 50 + <div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-violet-600/15 border border-violet-500/20"> 51 + <span class="font-mono text-xs font-bold text-violet-400">kt</span> 52 + </div> 53 + <span class="text-xs text-zinc-500">keytrace.dev</span> 51 54 </div> 52 - <span class="text-xs text-zinc-500">keytrace.dev</span> 55 + <NuxtLink to="/recipes" class="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"> 56 + How it works 57 + </NuxtLink> 53 58 </div> 54 59 <span class="text-xs text-zinc-600"> Identity verification for ATProto </span> 55 60 </div> 56 61 </footer> 62 + <LoginModal /> 57 63 </div> 58 64 </template> 59 65 ··· 61 67 import { User as UserIcon } from "lucide-vue-next"; 62 68 63 69 const { session, logout } = useSession(); 70 + const { open: openLoginModal } = useLoginModal(); 64 71 const menuOpen = ref(false); 65 72 const menuRef = ref<HTMLElement | null>(null); 66 73
+39 -4
apps/keytrace.dev/pages/[handle].vue
··· 52 52 53 53 <!-- Claims list --> 54 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)" /> 55 + <ClaimCard v-for="claim in profile.claims" :key="claim.uri" :claim="mapClaim(claim)"> 56 + <template #actions> 57 + <VerifyPopover :claim-uri="claim.uri" :did="profile.did" :display-name="mapClaim(claim).displayName" :provider-name="claim.matches?.[0]?.providerName" /> 58 + <button 59 + v-if="isOwnProfile" 60 + class="p-1.5 rounded-lg text-zinc-500 hover:text-failed hover:bg-failed/10 transition-colors" 61 + title="Delete claim" 62 + @click.stop="deleteClaim(claim)" 63 + > 64 + <Trash2Icon class="w-4 h-4" /> 65 + </button> 66 + </template> 67 + </ClaimCard> 56 68 </div> 57 69 58 70 <!-- Empty state --> ··· 78 90 </template> 79 91 80 92 <script setup lang="ts"> 81 - import { AlertCircle as AlertCircleIcon, Share2 as ShareIcon, Link as LinkIcon } from "lucide-vue-next"; 93 + import { AlertCircle as AlertCircleIcon, Share2 as ShareIcon, Link as LinkIcon, Trash2 as Trash2Icon } from "lucide-vue-next"; 82 94 83 95 const route = useRoute(); 96 + const { session } = useSession(); 84 97 85 98 const rawHandle = computed(() => { 86 99 const param = route.params.handle as string; ··· 92 105 return rawHandle.value.replace(/^@/, ""); 93 106 }); 94 107 95 - const { data: profile, pending, error } = await useFetch(() => `/api/profile/${encodeURIComponent(cleanHandle.value)}`); 108 + const { data: profile, pending, error, refresh } = await useFetch(() => `/api/profile/${encodeURIComponent(cleanHandle.value)}`); 109 + 110 + // Check if viewing own profile 111 + const isOwnProfile = computed(() => { 112 + if (!session.value?.authenticated || !profile.value) return false; 113 + return session.value.did === profile.value.did; 114 + }); 96 115 97 116 // Map API claims to the shape ProfileHeader expects 98 117 const profileClaims = computed(() => (profile.value?.claims ?? []).map((c: any) => ({ status: c.status }))); ··· 103 122 return { 104 123 displayName: match?.providerName ?? guessDisplayName(claim.uri), 105 124 status: claim.status, 106 - serviceType: match?.provider ?? guessServiceType(claim.uri), 125 + serviceType: claim.type ?? match?.provider ?? guessServiceType(claim.uri), 107 126 subject: claim.uri, 108 127 recipeName: match?.provider, 128 + comment: claim.comment, 129 + createdAt: claim.createdAt, 130 + identity: claim.identity, 109 131 attestation: undefined, 110 132 recipe: undefined, 111 133 }; ··· 131 153 navigator.share({ title: `${profile.value?.displayName} on Keytrace`, url }); 132 154 } else if (navigator.clipboard) { 133 155 navigator.clipboard.writeText(url); 156 + } 157 + } 158 + 159 + async function deleteClaim(claim: any) { 160 + if (!claim.rkey) return; 161 + 162 + if (!confirm("Are you sure you want to delete this claim?")) return; 163 + 164 + try { 165 + await $fetch(`/api/claims/${claim.rkey}`, { method: "DELETE" }); 166 + await refresh(); 167 + } catch { 168 + // Could add toast notification here 134 169 } 135 170 } 136 171
+56 -50
apps/keytrace.dev/pages/add.vue
··· 23 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"> 24 24 {{ i + 1 }} 25 25 </span> 26 - <span class="text-sm text-zinc-300 pt-0.5">{{ instruction }}</span> 26 + <span class="text-sm text-zinc-300 pt-0.5"> 27 + <Markdown :content="instruction" /> 28 + </span> 27 29 </li> 28 30 </ol> 29 31 ··· 94 96 </template> 95 97 96 98 <script setup lang="ts"> 97 - import { Github, Globe, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 99 + import { Github, Globe, AtSign, Cloud, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 98 100 import type { ServiceOption } from "~/components/ui/ServicePicker.vue"; 99 101 import type { VerificationStep } from "~/components/ui/VerificationLog.vue"; 100 102 ··· 110 112 }, 111 113 { immediate: true }, 112 114 ); 115 + 116 + // Fetch services from API 117 + const { data: servicesData } = await useFetch("/api/services"); 118 + 119 + // Map icon names to components 120 + const iconMap: Record<string, unknown> = { 121 + github: Github, 122 + globe: Globe, 123 + "at-sign": AtSign, 124 + cloud: Cloud, 125 + }; 126 + 127 + // Transform API response into ServiceOption format 128 + interface ServiceFromAPI { 129 + id: string; 130 + name: string; 131 + homepage: string; 132 + ui: { 133 + description: string; 134 + icon: string; 135 + inputLabel: string; 136 + inputPlaceholder: string; 137 + instructions: string[]; 138 + proofTemplate: string; 139 + }; 140 + } 141 + 142 + interface ServiceWithUI extends ServiceOption { 143 + inputLabel: string; 144 + inputPlaceholder: string; 145 + instructions: string[]; 146 + proofTemplate: string; 147 + } 148 + 149 + const services = computed<ServiceWithUI[]>(() => { 150 + if (!servicesData.value) return []; 151 + return (servicesData.value as ServiceFromAPI[]).map((s) => ({ 152 + id: s.id, 153 + name: s.name, 154 + description: s.ui.description, 155 + icon: iconMap[s.ui.icon] ?? Globe, 156 + inputLabel: s.ui.inputLabel, 157 + inputPlaceholder: s.ui.inputPlaceholder, 158 + instructions: s.ui.instructions, 159 + proofTemplate: s.ui.proofTemplate, 160 + })); 161 + }); 113 162 114 163 const currentStep = ref(0); 115 - const selectedService = ref<(ServiceOption & { inputLabel?: string; inputPlaceholder?: string; instructions?: string[]; proofTemplate?: string }) | null>(null); 164 + const selectedService = ref<ServiceWithUI | null>(null); 116 165 const claimUri = ref(""); 117 166 const claimUriError = ref(""); 118 - const claimId = ref(""); 119 167 120 168 const stepLabels = ["Choose service", "Create proof", "Verify"]; 121 169 122 - const services: (ServiceOption & { inputLabel: string; inputPlaceholder: string; instructions: string[]; proofTemplate: string })[] = [ 123 - { 124 - id: "github-gist", 125 - name: "GitHub", 126 - description: "Link via a public gist", 127 - icon: Github, 128 - inputLabel: "Gist URL", 129 - inputPlaceholder: "https://gist.github.com/username/abc123...", 130 - instructions: [ 131 - "Go to https://gist.github.com", 132 - "Create a new public gist", 133 - "Name the file keytrace.json", 134 - "Paste the verification content below into the file", 135 - "Save the gist and paste the URL below", 136 - ], 137 - proofTemplate: '{\n "keytrace": "{claimId}",\n "did": "{did}"\n}', 138 - }, 139 - { 140 - id: "dns-txt", 141 - name: "Domain", 142 - description: "Link via DNS TXT record", 143 - icon: Globe, 144 - inputLabel: "Domain", 145 - inputPlaceholder: "example.com", 146 - instructions: [ 147 - "Open your domain's DNS settings", 148 - "Add a new TXT record to the root domain", 149 - "Set the value to the verification content below", 150 - "Wait for DNS propagation (may take a few minutes)", 151 - "Enter your domain below and verify", 152 - ], 153 - proofTemplate: "keytrace-did={did}", 154 - }, 155 - ]; 156 - 157 170 const proofContent = computed(() => { 158 171 const template = selectedService.value?.proofTemplate ?? ""; 159 - return template.replace(/\{claimId\}/g, claimId.value).replace(/\{did\}/g, session.value?.did ?? "did:plc:..."); 172 + return template.replace(/\{did\}/g, session.value?.did ?? "did:plc:...").replace(/\{handle\}/g, session.value?.handle ?? "handle"); 160 173 }); 161 174 162 175 const selectedInstructions = computed(() => selectedService.value?.instructions ?? []); 163 176 164 - function generateClaimId() { 165 - const bytes = new Uint8Array(8); 166 - crypto.getRandomValues(bytes); 167 - return "kt-" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); 168 - } 169 - 170 177 function selectService(service: ServiceOption) { 171 - selectedService.value = services.find((s) => s.id === service.id) ?? null; 178 + selectedService.value = services.value.find((s) => s.id === service.id) ?? null; 172 179 claimUri.value = ""; 173 180 claimUriError.value = ""; 174 - claimId.value = generateClaimId(); 175 181 currentStep.value = 1; 176 182 } 177 183 ··· 194 200 195 201 // Build the claim URI for the API 196 202 let apiClaimUri = claimUri.value; 197 - if (selectedService.value?.id === "dns-txt") { 203 + if (selectedService.value?.id === "dns") { 204 + // For DNS, convert domain to dns: URI format 198 205 apiClaimUri = `dns:${claimUri.value.replace(/^(https?:\/\/)?/, "").replace(/\/.*$/, "")}`; 199 206 } 200 207 ··· 300 307 selectedService.value = null; 301 308 claimUri.value = ""; 302 309 claimUriError.value = ""; 303 - claimId.value = ""; 304 310 verificationSteps.value = []; 305 311 verificationComplete.value = false; 306 312 verificationSuccess.value = false;
+8 -31
apps/keytrace.dev/pages/index.vue
··· 39 39 > 40 40 Go to Dashboard &rarr; 41 41 </NuxtLink> 42 - <template v-else> 43 - <button 44 - 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" 45 - @click="showLogin = true" 46 - > 47 - Get Started 48 - </button> 49 - </template> 42 + <button 43 + v-else 44 + 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" 45 + @click="openLoginModal" 46 + > 47 + Get Started 48 + </button> 50 49 <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> 51 50 </div> 52 - 53 - <!-- Inline login form --> 54 - <Transition name="slide"> 55 - <div v-if="showLogin && !session?.authenticated" class="mt-8 max-w-sm mx-auto"> 56 - <form class="flex gap-2" @submit.prevent="handleLogin"> 57 - <input 58 - v-model="handle" 59 - type="text" 60 - placeholder="you.bsky.social" 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" 62 - /> 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> 64 - </form> 65 - </div> 66 - </Transition> 67 51 </div> 68 52 </section> 69 53 ··· 102 86 103 87 <script setup lang="ts"> 104 88 const { session } = useSession(); 105 - 106 - const showLogin = ref(false); 107 - const handle = ref(""); 108 - 109 - function handleLogin() { 110 - const { login } = useSession(); 111 - login(handle.value); 112 - } 89 + const { open: openLoginModal } = useLoginModal(); 113 90 114 91 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>`; 115 92
+235
apps/keytrace.dev/pages/recipes/[provider].vue
··· 1 + <template> 2 + <div class="max-w-3xl mx-auto px-4 py-12"> 3 + <!-- Back link --> 4 + <NuxtLink to="/recipes" class="text-sm text-zinc-500 hover:text-zinc-300 mb-6 inline-flex items-center gap-1"> 5 + <ArrowLeftIcon class="w-4 h-4" /> 6 + All recipes 7 + </NuxtLink> 8 + 9 + <div v-if="pending" class="space-y-6 mt-6"> 10 + <div class="animate-pulse"> 11 + <div class="h-8 w-48 bg-zinc-800 rounded mb-2" /> 12 + <div class="h-4 w-64 bg-zinc-800/60 rounded" /> 13 + </div> 14 + <div class="animate-pulse bg-kt-card border border-zinc-800 rounded-lg p-6"> 15 + <div class="h-4 w-32 bg-zinc-800 rounded mb-3" /> 16 + <div class="h-12 w-full bg-zinc-800/60 rounded" /> 17 + </div> 18 + <div class="animate-pulse bg-kt-card border border-zinc-800 rounded-lg p-6"> 19 + <div class="h-4 w-32 bg-zinc-800 rounded mb-3" /> 20 + <div class="h-24 w-full bg-zinc-800/60 rounded" /> 21 + </div> 22 + </div> 23 + 24 + <div v-else-if="error" class="text-center py-12"> 25 + <p class="text-zinc-400">Recipe not found</p> 26 + <NuxtLink to="/recipes" class="text-violet-400 hover:text-violet-300 mt-2 inline-block"> 27 + View all recipes 28 + </NuxtLink> 29 + </div> 30 + 31 + <div v-else-if="recipe" class="space-y-8"> 32 + <!-- Header --> 33 + <div> 34 + <h1 class="text-2xl font-bold text-zinc-100 mb-2">{{ recipe.name }} Verification</h1> 35 + <p class="text-zinc-400">How keytrace verifies {{ recipe.name }} identity claims</p> 36 + </div> 37 + 38 + <!-- URI Pattern --> 39 + <section class="bg-kt-card border border-zinc-800 rounded-lg p-6"> 40 + <h2 class="text-sm font-semibold text-zinc-300 mb-3">Claim URI Format</h2> 41 + <code class="block bg-kt-inset px-4 py-3 rounded-lg text-sm font-mono text-violet-400 overflow-x-auto"> 42 + {{ recipe.sampleUri }} 43 + </code> 44 + <p class="text-xs text-zinc-500 mt-2"> 45 + Pattern: <code class="text-zinc-400">{{ recipe.uriPattern }}</code> 46 + </p> 47 + </section> 48 + 49 + <!-- Create Your Proof --> 50 + <section v-if="recipe.ui?.instructions" class="bg-kt-card border border-zinc-800 rounded-lg p-6"> 51 + <h2 class="text-sm font-semibold text-zinc-300 mb-4">Create Your Proof</h2> 52 + <ol class="space-y-3"> 53 + <li v-for="(instruction, idx) in recipe.ui.instructions" :key="idx" class="flex gap-3"> 54 + <span class="flex-shrink-0 w-6 h-6 rounded-full bg-violet-600/20 text-violet-400 flex items-center justify-center text-xs font-bold"> 55 + {{ idx + 1 }} 56 + </span> 57 + <span class="text-sm text-zinc-300 pt-0.5"> 58 + <Markdown :content="instruction" /> 59 + </span> 60 + </li> 61 + </ol> 62 + </section> 63 + 64 + <!-- Proof Text --> 65 + <section class="bg-kt-card border border-zinc-800 rounded-lg p-6"> 66 + <h2 class="text-sm font-semibold text-zinc-300 mb-3">Proof Text</h2> 67 + <p class="text-zinc-400 text-sm mb-3"> 68 + You need to include this text in your {{ recipe.name }} proof location: 69 + </p> 70 + <div class="bg-kt-inset px-4 py-3 rounded-lg flex items-center justify-between gap-4"> 71 + <code class="text-sm font-mono text-emerald-400 break-all">{{ recipe.proofText }}</code> 72 + <CopyButton :value="recipe.proofText" /> 73 + </div> 74 + <p v-if="recipe.proofLocation" class="text-xs text-zinc-500 mt-3"> 75 + <span class="text-zinc-400">Where to put it:</span> <Markdown :content="recipe.proofLocation" /> 76 + </p> 77 + </section> 78 + 79 + <!-- Verification Steps --> 80 + <section v-if="recipe.verification" class="bg-kt-card border border-zinc-800 rounded-lg p-6"> 81 + <h2 class="text-sm font-semibold text-zinc-300 mb-4">Verification Steps</h2> 82 + 83 + <ol class="space-y-6"> 84 + <!-- Step 1: Fetch --> 85 + <li class="flex gap-4"> 86 + <div class="flex-shrink-0 w-8 h-8 rounded-full bg-violet-600/20 text-violet-400 flex items-center justify-center text-sm font-bold"> 87 + 1 88 + </div> 89 + <div class="flex-1 min-w-0"> 90 + <h3 class="text-sm font-medium text-zinc-200 mb-1">Fetch proof data</h3> 91 + <p class="text-xs text-zinc-500 mb-2"> 92 + Using the <code class="text-zinc-400">{{ recipe.verification.fetcher }}</code> fetcher 93 + </p> 94 + <code class="block bg-kt-inset px-3 py-2 rounded text-xs font-mono text-zinc-300 overflow-x-auto"> 95 + {{ recipe.verification.fetchUrl }} 96 + </code> 97 + </div> 98 + </li> 99 + 100 + <!-- Step 2: Check targets --> 101 + <li class="flex gap-4"> 102 + <div class="flex-shrink-0 w-8 h-8 rounded-full bg-violet-600/20 text-violet-400 flex items-center justify-center text-sm font-bold"> 103 + 2 104 + </div> 105 + <div class="flex-1 min-w-0"> 106 + <h3 class="text-sm font-medium text-zinc-200 mb-1">Search for DID in response</h3> 107 + <p class="text-xs text-zinc-500 mb-3"> 108 + The runner checks the following locations for your DID: 109 + </p> 110 + <ul class="space-y-2"> 111 + <li 112 + v-for="(target, idx) in recipe.verification.targets" 113 + :key="idx" 114 + class="bg-kt-inset px-3 py-2 rounded text-xs" 115 + > 116 + <code class="text-violet-400 font-mono">{{ target.path }}</code> 117 + <span class="text-zinc-500 ml-2">{{ target.relation }}</span> 118 + <p class="text-zinc-400 mt-1">{{ target.description }}</p> 119 + </li> 120 + </ul> 121 + </div> 122 + </li> 123 + 124 + <!-- Step 3: Verify --> 125 + <li class="flex gap-4"> 126 + <div class="flex-shrink-0 w-8 h-8 rounded-full bg-verified/20 text-verified flex items-center justify-center text-sm font-bold"> 127 + 3 128 + </div> 129 + <div class="flex-1"> 130 + <h3 class="text-sm font-medium text-zinc-200 mb-1">Attestation</h3> 131 + <p class="text-xs text-zinc-500"> 132 + If the DID is found, keytrace signs an attestation linking your identity to your ATProto DID and stores it in your repo. 133 + </p> 134 + </div> 135 + </li> 136 + </ol> 137 + </section> 138 + 139 + <!-- Try it --> 140 + <section class="bg-kt-card border border-zinc-800 rounded-lg p-6"> 141 + <h2 class="text-sm font-semibold text-zinc-300 mb-3">Try Verification</h2> 142 + <p class="text-zinc-400 text-sm mb-4"> 143 + Test verification with your own claim URI and DID: 144 + </p> 145 + 146 + <form class="space-y-3" @submit.prevent="runVerification"> 147 + <div> 148 + <label class="block text-xs text-zinc-500 mb-1">Claim URI</label> 149 + <input 150 + v-model="testUri" 151 + type="text" 152 + :placeholder="recipe.sampleUri" 153 + class="w-full px-3 py-2 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" 154 + /> 155 + </div> 156 + <div> 157 + <label class="block text-xs text-zinc-500 mb-1">DID</label> 158 + <input 159 + v-model="testDid" 160 + type="text" 161 + placeholder="did:plc:..." 162 + class="w-full px-3 py-2 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" 163 + /> 164 + </div> 165 + <button 166 + type="submit" 167 + :disabled="verifying || !testUri || !testDid" 168 + class="px-4 py-2 bg-violet-600 hover:bg-violet-500 disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-medium rounded-lg transition-all" 169 + > 170 + {{ verifying ? "Verifying..." : "Run Verification" }} 171 + </button> 172 + </form> 173 + 174 + <!-- Result --> 175 + <div v-if="verifyResult" class="mt-4 p-4 rounded-lg" :class="verifyResult.status === 'verified' ? 'bg-verified/10 border border-verified/30' : 'bg-red-500/10 border border-red-500/30'"> 176 + <div class="flex items-center gap-2 mb-2"> 177 + <CheckCircleIcon v-if="verifyResult.status === 'verified'" class="w-5 h-5 text-verified" /> 178 + <XCircleIcon v-else class="w-5 h-5 text-red-400" /> 179 + <span class="text-sm font-medium" :class="verifyResult.status === 'verified' ? 'text-verified' : 'text-red-400'"> 180 + {{ verifyResult.status === "verified" ? "Verified!" : "Verification Failed" }} 181 + </span> 182 + </div> 183 + <div v-if="verifyResult.errors?.length" class="text-xs text-zinc-400 space-y-1"> 184 + <p v-for="(err, i) in verifyResult.errors" :key="i">{{ err }}</p> 185 + </div> 186 + <div v-if="verifyResult.identity" class="mt-3 text-xs text-zinc-400"> 187 + <p v-if="verifyResult.identity.subject">Subject: <span class="text-zinc-200">{{ verifyResult.identity.subject }}</span></p> 188 + <p v-if="verifyResult.identity.avatarUrl">Avatar: <a :href="verifyResult.identity.avatarUrl" target="_blank" class="text-violet-400 hover:underline">{{ verifyResult.identity.avatarUrl }}</a></p> 189 + </div> 190 + </div> 191 + </section> 192 + </div> 193 + </div> 194 + </template> 195 + 196 + <script setup lang="ts"> 197 + import { ArrowLeft as ArrowLeftIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon } from "lucide-vue-next"; 198 + 199 + const route = useRoute(); 200 + const providerId = computed(() => route.params.provider as string); 201 + 202 + const { data: recipe, pending, error } = await useFetch(`/api/recipes/${providerId.value}`); 203 + 204 + const testUri = ref(""); 205 + const testDid = ref(""); 206 + const verifying = ref(false); 207 + const verifyResult = ref<{ 208 + status: string; 209 + errors?: string[]; 210 + identity?: { subject?: string; avatarUrl?: string }; 211 + } | null>(null); 212 + 213 + async function runVerification() { 214 + verifying.value = true; 215 + verifyResult.value = null; 216 + 217 + try { 218 + const result = await $fetch("/api/verify", { 219 + method: "POST", 220 + body: { 221 + claimUri: testUri.value, 222 + did: testDid.value, 223 + }, 224 + }); 225 + verifyResult.value = result as typeof verifyResult.value; 226 + } catch (err: unknown) { 227 + verifyResult.value = { 228 + status: "error", 229 + errors: [(err as Error).message || "Verification failed"], 230 + }; 231 + } finally { 232 + verifying.value = false; 233 + } 234 + } 235 + </script>
+77
apps/keytrace.dev/pages/recipes/index.vue
··· 1 + <template> 2 + <div class="max-w-3xl mx-auto px-4 py-12"> 3 + <div class="mb-8"> 4 + <h1 class="text-2xl font-bold text-zinc-100 mb-2">Keytrace</h1> 5 + <p class="text-zinc-200">Keytrace takes ideas from Keybase and Keyoxide and brings them to the decentralized web.</p> 6 + <p class="text-zinc-400 text-md-start mt-4"> 7 + The site lets you create proofs that you own a certain identity (like a GitHub account, website address, or social media profile) and have that stored in your user registry 8 + in atproto. 9 + </p> 10 + <p class="text-zinc-400 text-md-start mt-4"> 11 + All of the identity claims are public and can be independently verified by anyone using the same steps using an npm module or by re-running them in this website. Below are 12 + our recipes for how we verify whether you have access to an identity: 13 + </p> 14 + </div> 15 + 16 + <div v-if="pending" class="space-y-4"> 17 + <div v-for="i in 4" :key="i" class="animate-pulse bg-kt-card border border-zinc-800 rounded-lg p-5"> 18 + <div class="h-5 w-32 bg-zinc-800 rounded mb-2" /> 19 + <div class="h-4 w-64 bg-zinc-800/60 rounded" /> 20 + </div> 21 + </div> 22 + 23 + <div v-else-if="recipes" class="space-y-4"> 24 + <NuxtLink 25 + v-for="recipe in recipes" 26 + :key="recipe.id" 27 + :to="`/recipes/${recipe.id}`" 28 + class="block bg-kt-card border border-zinc-800 rounded-lg p-5 hover:border-zinc-700 transition-colors group" 29 + > 30 + <div class="flex items-start justify-between"> 31 + <div> 32 + <h2 class="text-lg font-semibold text-zinc-200 group-hover:text-violet-400 transition-colors"> 33 + {{ recipe.name }} 34 + </h2> 35 + <p class="text-sm text-zinc-500 mt-1"> 36 + {{ recipe.description }} 37 + </p> 38 + </div> 39 + <div class="flex items-center gap-2 text-zinc-500"> 40 + <span v-if="recipe.homepage" class="text-xs">{{ getDomain(recipe.homepage) }}</span> 41 + <ArrowRightIcon class="w-4 h-4 group-hover:text-violet-400 transition-colors" /> 42 + </div> 43 + </div> 44 + </NuxtLink> 45 + </div> 46 + 47 + <!-- Info section --> 48 + <div class="mt-12 bg-kt-card border border-zinc-800 rounded-lg p-6"> 49 + <h2 class="text-sm font-semibold text-zinc-300 mb-3">How Verification Works</h2> 50 + <div class="text-sm text-zinc-400 space-y-3"> 51 + <p>Each recipe defines a specific way to verify ownership of an external identity. The process is fully transparent and reproducible:</p> 52 + <ol class="list-decimal list-inside space-y-2 text-zinc-500"> 53 + <li>You create a proof at the external service (e.g., a GitHub gist, DNS TXT record)</li> 54 + <li>The proof contains your ATProto DID to link the identities</li> 55 + <li>Keytrace fetches the proof from the public URL</li> 56 + <li>The runner checks if your DID is present in the expected location</li> 57 + <li>If verified, keytrace signs an attestation and stores it in your ATProto repo</li> 58 + </ol> 59 + <p class="text-xs text-zinc-600 pt-2">Anyone can re-run verification using the same steps to independently confirm your claims.</p> 60 + </div> 61 + </div> 62 + </div> 63 + </template> 64 + 65 + <script setup lang="ts"> 66 + import { ArrowRight as ArrowRightIcon } from "lucide-vue-next"; 67 + 68 + const { data: recipes, pending } = await useFetch("/api/recipes"); 69 + 70 + function getDomain(url: string): string { 71 + try { 72 + return new URL(url).hostname; 73 + } catch { 74 + return ""; 75 + } 76 + } 77 + </script>
+50 -1
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/runner"; 8 + import { COLLECTION_NSID, serviceProviders, createClaim, verifyClaim, ClaimStatus } from "@keytrace/runner"; 9 9 import { getSessionAgent } from "~/server/utils/session"; 10 + import { createAttestation } from "~/server/utils/attestation"; 10 11 11 12 export default defineEventHandler(async (event) => { 12 13 const { did, agent } = await getSessionAgent(event); ··· 29 30 }); 30 31 } 31 32 33 + // Verify the claim first to ensure it's valid and extract identity metadata 34 + console.log(`[claims] Verifying claim: uri=${body.claimUri} did=${did}`); 35 + const claim = createClaim(body.claimUri, did); 36 + const verifyResult = await verifyClaim(claim, { timeout: 10_000 }); 37 + 38 + if (verifyResult.status !== ClaimStatus.VERIFIED) { 39 + console.log(`[claims] Verification failed: ${verifyResult.errors.join(", ")}`); 40 + throw createError({ 41 + statusCode: 400, 42 + statusMessage: `Claim verification failed: ${verifyResult.errors.join(", ") || "Could not verify ownership"}`, 43 + }); 44 + } 45 + 46 + // Get the matched provider 47 + const { provider, match } = matches[0]; 48 + const processed = provider.processURI(body.claimUri, match); 49 + 50 + // Use identity from verification result, falling back to processed profile 51 + const verifiedIdentity = verifyResult.identity; 52 + const subject = verifiedIdentity?.subject ?? processed.profile.display.replace(/^@/, ""); 53 + 54 + // Create cryptographic attestation 55 + const attestation = await createAttestation(did, provider.id, subject); 56 + 57 + // Build the signature object per dev.keytrace.signature lexicon 58 + const sig = { 59 + kid: new Date().toISOString().split("T")[0], // YYYY-MM-DD 60 + src: attestation.signingKey.uri, 61 + signedAt: attestation.signedAt, 62 + attestation: attestation.sig, 63 + }; 64 + 65 + // Build the identity object per dev.keytrace.claim#identity lexicon 66 + const identity: { 67 + subject: string; 68 + avatarUrl?: string; 69 + profileUrl?: string; 70 + displayName?: string; 71 + } = { 72 + subject, 73 + avatarUrl: verifiedIdentity?.avatarUrl, 74 + profileUrl: verifiedIdentity?.profileUrl ?? processed.profile.uri, 75 + displayName: verifiedIdentity?.displayName, 76 + }; 77 + 32 78 try { 33 79 const record = { 34 80 $type: COLLECTION_NSID, 81 + type: provider.id, 35 82 claimUri: body.claimUri, 83 + identity, 84 + sig, 36 85 comment: body.comment, 37 86 createdAt: new Date().toISOString(), 38 87 };
+20 -11
apps/keytrace.dev/server/api/profile/[handleOrDid].get.ts
··· 30 30 handle: profile.handle, 31 31 displayName: profile.displayName, 32 32 avatar: profile.avatar, 33 - claims: profile.claimInstances.map((claim) => ({ 34 - uri: claim.uri, 35 - did: claim.did, 36 - status: claim.status, 37 - matches: claim.matches.map((m) => ({ 38 - provider: m.provider.id, 39 - providerName: m.provider.name, 40 - isAmbiguous: m.isAmbiguous, 41 - })), 42 - errors: claim.errors, 43 - })), 33 + claims: profile.claimInstances.map((claim) => { 34 + // Find corresponding claim data for additional fields 35 + const claimData = profile.claims.find((c) => c.uri === claim.uri); 36 + return { 37 + uri: claim.uri, 38 + did: claim.did, 39 + status: claim.status, 40 + type: claimData?.type, 41 + rkey: claimData?.rkey, 42 + comment: claimData?.comment, 43 + createdAt: claimData?.createdAt, 44 + identity: claimData?.identity, 45 + matches: claim.matches.map((m) => ({ 46 + provider: m.provider.id, 47 + providerName: m.provider.name, 48 + isAmbiguous: m.isAmbiguous, 49 + })), 50 + errors: claim.errors, 51 + }; 52 + }), 44 53 summary: getProfileSummary(profile), 45 54 }; 46 55 } catch (err: unknown) {
+73
apps/keytrace.dev/server/api/recipes/[provider].get.ts
··· 1 + /** 2 + * GET /api/recipes/:provider 3 + * 4 + * Get verification recipe details for a service provider. 5 + * Returns the provider info and verification steps. 6 + */ 7 + 8 + import { serviceProviders } from "@keytrace/runner"; 9 + 10 + export default defineEventHandler(async (event) => { 11 + const providerId = getRouterParam(event, "provider"); 12 + 13 + if (!providerId) { 14 + throw createError({ statusCode: 400, statusMessage: "Missing provider ID" }); 15 + } 16 + 17 + const provider = serviceProviders.getProvider(providerId); 18 + 19 + if (!provider) { 20 + throw createError({ statusCode: 404, statusMessage: "Provider not found" }); 21 + } 22 + 23 + // Generate a sample processed URI for documentation 24 + const sampleUri = provider.tests.find((t) => t.shouldMatch)?.uri ?? ""; 25 + const sampleMatch = sampleUri ? sampleUri.match(provider.reUri) : null; 26 + const sampleProcessed = sampleMatch ? provider.processURI(sampleUri, sampleMatch) : null; 27 + 28 + return { 29 + id: provider.id, 30 + name: provider.name, 31 + description: provider.description, 32 + homepage: provider.homepage, 33 + uriPattern: provider.reUri.source, 34 + isAmbiguous: provider.isAmbiguous ?? false, 35 + sampleUri, 36 + proofText: provider.getProofText("did:plc:example123456789012345678"), 37 + proofLocation: sampleMatch && provider.getProofLocation ? provider.getProofLocation(sampleMatch) : null, 38 + ui: provider.ui 39 + ? { 40 + instructions: provider.ui.instructions, 41 + proofTemplate: provider.ui.proofTemplate, 42 + inputLabel: provider.ui.inputLabel, 43 + inputPlaceholder: provider.ui.inputPlaceholder, 44 + } 45 + : null, 46 + verification: sampleProcessed 47 + ? { 48 + fetchUrl: sampleProcessed.proof.request.uri, 49 + fetcher: sampleProcessed.proof.request.fetcher, 50 + format: sampleProcessed.proof.request.format, 51 + targets: sampleProcessed.proof.target.map((t) => ({ 52 + path: t.path.join("."), 53 + relation: t.relation, 54 + description: describeTarget(t.path, t.relation), 55 + })), 56 + } 57 + : null, 58 + }; 59 + }); 60 + 61 + function describeTarget(path: string[], relation: string): string { 62 + const pathStr = path.join(" → "); 63 + switch (relation) { 64 + case "contains": 65 + return `Check if ${pathStr} contains the DID`; 66 + case "equals": 67 + return `Check if ${pathStr} equals the DID`; 68 + case "startsWith": 69 + return `Check if ${pathStr} starts with the DID`; 70 + default: 71 + return `Check ${pathStr}`; 72 + } 73 + }
+19
apps/keytrace.dev/server/api/recipes/index.get.ts
··· 1 + /** 2 + * GET /api/recipes 3 + * 4 + * List all available service providers/recipes. 5 + */ 6 + 7 + import { serviceProviders } from "@keytrace/runner"; 8 + 9 + export default defineEventHandler(async () => { 10 + const providers = serviceProviders.getAllProviders(); 11 + 12 + return providers.map((provider) => ({ 13 + id: provider.id, 14 + name: provider.name, 15 + description: provider.description, 16 + homepage: provider.homepage, 17 + isAmbiguous: provider.isAmbiguous ?? false, 18 + })); 19 + });
+19
apps/keytrace.dev/server/api/services/index.get.ts
··· 1 + /** 2 + * GET /api/services 3 + * 4 + * Get all available service providers with their UI configuration. 5 + * Used by the add claim wizard to display service options and instructions. 6 + */ 7 + 8 + import { serviceProviders } from "@keytrace/runner"; 9 + 10 + export default defineEventHandler(() => { 11 + const providers = serviceProviders.getAllProviders(); 12 + 13 + return providers.map((provider) => ({ 14 + id: provider.id, 15 + name: provider.name, 16 + homepage: provider.homepage, 17 + ui: provider.ui, 18 + })); 19 + });
+1
apps/keytrace.dev/server/api/verify.post.ts
··· 39 39 providerName: m.provider.name, 40 40 isAmbiguous: m.isAmbiguous, 41 41 })), 42 + identity: result.identity, 42 43 }; 43 44 } catch (err: unknown) { 44 45 console.error(`[verify] Error:`, err);
+2 -2
apps/keytrace.dev/server/routes/oauth/login.get.ts
··· 1 - import { getOAuthClient } from "~/server/utils/oauth"; 1 + import { getOAuthClient, OAUTH_SCOPE } from "~/server/utils/oauth"; 2 2 3 3 export default defineEventHandler(async (event) => { 4 4 const query = getQuery(event); ··· 11 11 try { 12 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", 14 + scope: OAUTH_SCOPE, 15 15 }); 16 16 17 17 return sendRedirect(event, url.toString());
+16
apps/keytrace.dev/server/utils/keys.ts
··· 76 76 /** 77 77 * Publish a public key to keytrace's ATProto repo as a dev.keytrace.key record. 78 78 * Record key = date (YYYY-MM-DD). 79 + * Skipped in local dev mode (when not using S3). 79 80 */ 80 81 async function publishKeyToATProto(date: string, publicJwk: JsonWebKey): Promise<void> { 82 + if (!useS3()) { 83 + console.log(`[keys] Skipping ATProto publish in local dev mode (date=${date})`); 84 + return; 85 + } 86 + 81 87 try { 82 88 const agent = await getKeytraceAgent(); 83 89 const config = useRuntimeConfig(); ··· 103 109 104 110 /** 105 111 * Get the strong ref (URI + CID) for today's key record. 112 + * Returns a local placeholder in dev mode (when not using S3). 106 113 */ 107 114 export async function getTodaysKeyRef(): Promise<{ 108 115 uri: string; ··· 110 117 }> { 111 118 const today = new Date().toISOString().split("T")[0]; 112 119 const config = useRuntimeConfig(); 120 + 121 + if (!useS3()) { 122 + // Return a local placeholder in dev mode 123 + return { 124 + uri: `at://${config.keytraceDid}/dev.keytrace.key/${today}`, 125 + cid: "local-dev-key", 126 + }; 127 + } 128 + 113 129 const agent = await getKeytraceAgent(); 114 130 115 131 const response = await agent.com.atproto.repo.getRecord({
+2 -4
apps/keytrace.dev/server/utils/keytrace-agent.ts
··· 12 12 if (!config.keytraceDid || !config.keytraceAppPassword) { 13 13 throw new Error("Missing NUXT_KEYTRACE_DID or NUXT_KEYTRACE_APP_PASSWORD environment variables"); 14 14 } 15 + 15 16 _agent = new AtpAgent({ service: "https://bsky.social" }); 16 - await _agent.login({ 17 - identifier: config.keytraceDid, 18 - password: config.keytraceAppPassword, 19 - }); 17 + await _agent.login({ identifier: config.keytraceDid, password: config.keytraceAppPassword }); 20 18 } 21 19 return _agent; 22 20 }
+3 -1
apps/keytrace.dev/server/utils/oauth.ts
··· 54 54 } 55 55 } 56 56 57 + export const OAUTH_SCOPE = "atproto repo:dev.keytrace.claim?action=create repo:dev.keytrace.claim?action=delete"; 58 + 57 59 export function getPublicUrl(): string { 58 60 const config = useRuntimeConfig(); 59 61 return (config.public.publicUrl || "http://127.0.0.1:3000").replace(/\/$/, ""); ··· 82 84 redirect_uris: [`${redirectBase}/oauth/callback`] as [string], 83 85 grant_types: ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 84 86 response_types: ["code"] as ["code"], 85 - scope: "atproto repo:dev.keytrace.claim?action=create repo:dev.keytrace.claim?action=delete", 87 + scope: OAUTH_SCOPE, 86 88 token_endpoint_auth_method: "none" as const, 87 89 application_type: "web" as const, 88 90 dpop_bound_access_tokens: true,
+42 -2
packages/lexicon/lexicons/dev/keytrace/claim.json
··· 8 8 "description": "An identity claim linking this DID to an external account", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["claimUri", "createdAt"], 11 + "required": ["type", "claimUri", "identity", "sig", "createdAt"], 12 12 "properties": { 13 + "type": { 14 + "type": "string", 15 + "knownValues": ["github", "dns", "mastodon", "twitter", "website"], 16 + "description": "The claim type identifier" 17 + }, 13 18 "claimUri": { 14 19 "type": "string", 15 - "description": "The identity claim URI (e.g., https://gist.github.com/username/id, dns:example.com)" 20 + "description": "The identity claim URI (e.g., for github: https://gist.github.com/username/id, dns:example.com)" 21 + }, 22 + "identity": { 23 + "type": "ref", 24 + "ref": "#identity", 25 + "description": "Structured data about the claimed identity" 26 + }, 27 + "sig": { 28 + "type": "ref", 29 + "ref": "dev.keytrace.signature#main", 30 + "description": "Cryptographic attestation signature from the keytrace service" 16 31 }, 17 32 "comment": { 18 33 "type": "string", ··· 23 38 "type": "string", 24 39 "format": "datetime" 25 40 } 41 + } 42 + } 43 + }, 44 + "identity": { 45 + "type": "object", 46 + "description": "Generic identity data for the claimed account", 47 + "required": ["subject"], 48 + "properties": { 49 + "subject": { 50 + "type": "string", 51 + "description": "Primary identifier (username, domain, handle, etc.)" 52 + }, 53 + "avatarUrl": { 54 + "type": "string", 55 + "format": "uri", 56 + "description": "Avatar/profile image URL" 57 + }, 58 + "profileUrl": { 59 + "type": "string", 60 + "format": "uri", 61 + "description": "Profile page URL" 62 + }, 63 + "displayName": { 64 + "type": "string", 65 + "description": "Display name if different from subject" 26 66 } 27 67 } 28 68 }
+31
packages/lexicon/lexicons/dev/keytrace/signature.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "dev.keytrace.signature", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "A cryptographic signature attesting to a claim", 8 + "required": ["kid", "src", "signedAt", "attestation"], 9 + "properties": { 10 + "kid": { 11 + "type": "string", 12 + "description": "Key identifier (e.g., date in YYYY-MM-DD format)" 13 + }, 14 + "src": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "AT URI reference to the signing key record (e.g., at://did:plc:xxx/dev.keytrace.key/2024-01-15)" 18 + }, 19 + "signedAt": { 20 + "type": "string", 21 + "format": "datetime", 22 + "description": "Timestamp when the signature was created" 23 + }, 24 + "attestation": { 25 + "type": "string", 26 + "description": "The cryptographic signature (base64-encoded)" 27 + } 28 + } 29 + } 30 + } 31 + }
+15 -1
packages/runner/src/claim.ts
··· 2 2 import { DEFAULT_TIMEOUT } from "./constants.js"; 3 3 import { matchUri, type ServiceProviderMatch, type ProofRequest, type ProofTarget } from "./serviceProviders/index.js"; 4 4 import * as fetchers from "./fetchers/index.js"; 5 - import type { VerifyOptions, ClaimVerificationResult } from "./types.js"; 5 + import type { VerifyOptions, ClaimVerificationResult, IdentityMetadata } from "./types.js"; 6 6 7 7 // did:plc identifiers are base32-encoded, lowercase 8 8 const DID_PLC_RE = /^did:plc:[a-z2-7]{24}$/; ··· 93 93 94 94 if (checkProof(proofData, config.proof.target, claim.did)) { 95 95 claim.status = ClaimStatus.VERIFIED; 96 + 97 + // Extract identity metadata via postprocess if available 98 + let identity: IdentityMetadata | undefined; 99 + if (match.provider.postprocess) { 100 + const metadata = match.provider.postprocess(proofData, match.match); 101 + identity = { 102 + subject: metadata.subject, 103 + avatarUrl: metadata.avatarUrl, 104 + profileUrl: metadata.profileUrl, 105 + displayName: metadata.displayName, 106 + }; 107 + } 108 + 96 109 return { 97 110 status: ClaimStatus.VERIFIED, 98 111 errors: [], 99 112 timestamp: new Date(), 113 + identity, 100 114 }; 101 115 } 102 116 } catch (err) {
+2 -1
packages/runner/src/index.ts
··· 17 17 ProfileData, 18 18 ClaimData, 19 19 VerifyOptions, 20 + IdentityMetadata, 20 21 } from "./types.js"; 21 22 export { ClaimStatus } from "./types.js"; 22 23 ··· 48 49 49 50 // Service providers 50 51 export * as serviceProviders from "./serviceProviders/index.js"; 51 - export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI } from "./serviceProviders/types.js"; 52 + export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./serviceProviders/types.js"; 52 53 53 54 // Fetchers 54 55 export * as fetchers from "./fetchers/index.js";
+5 -1
packages/runner/src/profile.ts
··· 2 2 import { createClaim, verifyClaim, type ClaimState } from "./claim.js"; 3 3 import { ClaimStatus } from "./types.js"; 4 4 import { COLLECTION_NSID, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js"; 5 - import type { ProfileData, ClaimData, VerifyOptions } from "./types.js"; 5 + import type { ProfileData, ClaimData, VerifyOptions, IdentityMetadata } from "./types.js"; 6 6 7 7 /** 8 8 * DID document service entry ··· 111 111 for (const record of records.data.records) { 112 112 const value = record.value as { 113 113 claimUri?: string; 114 + type?: string; 114 115 comment?: string; 115 116 createdAt?: string; 117 + identity?: IdentityMetadata; 116 118 }; 117 119 if (value.claimUri) { 118 120 claims.push({ 119 121 uri: value.claimUri, 120 122 did, 123 + type: value.type, 121 124 comment: value.comment, 122 125 createdAt: value.createdAt ?? new Date().toISOString(), 123 126 rkey: parseAtUriRkey(record.uri), 127 + identity: value.identity, 124 128 }); 125 129 } 126 130 }
+1 -1
packages/runner/src/recipes/github-gist.ts
··· 25 25 steps: [ 26 26 "Go to https://gist.github.com", 27 27 "Create a new public gist", 28 - "Name the file keytrace.json", 28 + "Name the file `keytrace.json`", 29 29 "Paste the verification content below into the file", 30 30 "Save the gist and paste the URL below", 31 31 ],
+24 -4
packages/runner/src/serviceProviders/activitypub.ts
··· 8 8 */ 9 9 const activitypub: ServiceProvider = { 10 10 id: "activitypub", 11 - name: "ActivityPub", 12 - homepage: "", 11 + name: "Mastodon", 12 + homepage: "https://joinmastodon.org", 13 13 14 14 // Match Mastodon-style profile URLs: https://instance/@username 15 15 reUri: /^https:\/\/([^/]+)\/@([^/]+)\/?$/, ··· 17 17 // Could match other ActivityPub software with same URL pattern 18 18 isAmbiguous: true, 19 19 20 + ui: { 21 + description: "Link your Mastodon or Fediverse account", 22 + icon: "at-sign", 23 + inputLabel: "Profile URL", 24 + inputPlaceholder: "https://mastodon.social/@username", 25 + instructions: [ 26 + "Go to your Mastodon instance and open **Edit profile**", 27 + "Add your DID to your **bio** or create a new **profile metadata field**", 28 + "For metadata fields, set the label to `keytrace` and paste your DID as the value", 29 + "Save your profile changes", 30 + "Paste your full profile URL below (e.g., `https://mastodon.social/@username`)", 31 + ], 32 + proofTemplate: "{did}", 33 + }, 34 + 20 35 processURI(uri, match) { 21 36 const [, domain, username] = match; 22 37 ··· 43 58 }, 44 59 45 60 postprocess(data) { 46 - const actor = data as { preferredUsername?: string; name?: string }; 61 + const actor = data as { preferredUsername?: string; name?: string; icon?: { url?: string } }; 47 62 return { 48 - display: actor.name || actor.preferredUsername, 63 + displayName: actor.name || actor.preferredUsername, 64 + avatarUrl: actor.icon?.url, 49 65 }; 50 66 }, 51 67 52 68 getProofText(did) { 53 69 return did; 70 + }, 71 + 72 + getProofLocation() { 73 + return `Add to your profile bio or a profile metadata field`; 54 74 }, 55 75 56 76 tests: [
+19
packages/runner/src/serviceProviders/bsky.ts
··· 16 16 17 17 isAmbiguous: false, 18 18 19 + ui: { 20 + description: "Link another Bluesky account", 21 + icon: "cloud", 22 + inputLabel: "Profile URL", 23 + inputPlaceholder: "https://bsky.app/profile/username.bsky.social", 24 + instructions: [ 25 + "Log into the Bluesky account you want to link", 26 + "Go to **Settings** → **Edit Profile**", 27 + "Add your DID to your **bio** (the verification DID, not this account's DID)", 28 + "Save your profile changes", 29 + "Paste the profile URL below", 30 + ], 31 + proofTemplate: "{did}", 32 + }, 33 + 19 34 processURI(uri, match) { 20 35 const [, handle] = match; 21 36 ··· 41 56 42 57 getProofText(did) { 43 58 return did; 59 + }, 60 + 61 + getProofLocation() { 62 + return `Add to your profile bio`; 44 63 }, 45 64 46 65 tests: [
+21 -1
packages/runner/src/serviceProviders/dns.ts
··· 8 8 */ 9 9 const dns: ServiceProvider = { 10 10 id: "dns", 11 - name: "DNS", 11 + name: "Domain", 12 12 homepage: "", 13 13 14 14 // Match dns:domain.tld URIs (must contain at least one dot) ··· 16 16 17 17 isAmbiguous: false, 18 18 19 + ui: { 20 + description: "Link via DNS TXT record", 21 + icon: "globe", 22 + inputLabel: "Domain", 23 + inputPlaceholder: "example.com", 24 + instructions: [ 25 + "Open your domain's DNS settings (usually in your registrar or hosting provider)", 26 + "Add a new **TXT record** at the root domain (or at `_keytrace.yourdomain.com`)", 27 + "Set the record value to the verification content below", 28 + "Save and wait for DNS propagation (may take a few minutes to an hour)", 29 + "Enter your domain below and verify", 30 + ], 31 + proofTemplate: "keytrace-verification={did}", 32 + }, 33 + 19 34 processURI(uri, match) { 20 35 const [, domain] = match; 21 36 ··· 41 56 42 57 getProofText(did) { 43 58 return `keytrace-verification=${did}`; 59 + }, 60 + 61 + getProofLocation(match) { 62 + const [, domain] = match; 63 + return `Add a TXT record at the root of ${domain} (or at _keytrace.${domain})`; 44 64 }, 45 65 46 66 tests: [
+30
packages/runner/src/serviceProviders/github.ts
··· 16 16 17 17 isAmbiguous: false, 18 18 19 + ui: { 20 + description: "Link via a public gist", 21 + icon: "github", 22 + inputLabel: "Gist URL", 23 + inputPlaceholder: "https://gist.github.com/username/abc123...", 24 + instructions: [ 25 + "Go to [gist.github.com](https://gist.github.com) and create a new gist", 26 + "Name the file `keytrace.json` (or `keytrace.md` or `proof.md`)", 27 + "Paste the verification content below into the file", 28 + "Make sure the gist is **public**, then save it", 29 + "Copy the gist URL and paste it below", 30 + ], 31 + proofTemplate: '{\n "did": "{did}"\n}', 32 + }, 33 + 19 34 processURI(uri, match) { 20 35 const [, username, gistId] = match; 21 36 ··· 68 83 }; 69 84 }, 70 85 86 + postprocess(data, match) { 87 + const [, username] = match; 88 + const gist = data as { owner?: { avatar_url?: string; login?: string } }; 89 + 90 + return { 91 + subject: gist.owner?.login ?? username, 92 + avatarUrl: gist.owner?.avatar_url, 93 + profileUrl: `https://github.com/${gist.owner?.login ?? username}`, 94 + }; 95 + }, 96 + 71 97 getProofText(did) { 72 98 return `Verifying my identity on keytrace: ${did}`; 99 + }, 100 + 101 + getProofLocation() { 102 + return `Create a public gist with a file named keytrace.json, keytrace.md, or proof.md containing the proof text`; 73 103 }, 74 104 75 105 tests: [
+1 -1
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 { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI } from "./types.js"; 7 + export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./types.js"; 8 8 9 9 const providers: Record<string, ServiceProvider> = { 10 10 github,
+29 -3
packages/runner/src/serviceProviders/types.ts
··· 53 53 } 54 54 55 55 /** 56 + * UI configuration for the add claim wizard 57 + */ 58 + export interface ServiceProviderUI { 59 + /** Short description for service picker (e.g., "Link via a public gist") */ 60 + description: string; 61 + /** Lucide icon name (e.g., "github", "globe") */ 62 + icon: string; 63 + /** Label for the claim URI input field */ 64 + inputLabel: string; 65 + /** Placeholder text for the claim URI input */ 66 + inputPlaceholder: string; 67 + /** Step-by-step instructions (markdown supported) */ 68 + instructions: string[]; 69 + /** Template for proof content. Supports {did} and {handle} placeholders */ 70 + proofTemplate: string; 71 + } 72 + 73 + /** 56 74 * A service provider that can verify identity claims 57 75 */ 58 76 export interface ServiceProvider { ··· 69 87 /** Whether matches are potentially ambiguous (could match multiple providers) */ 70 88 isAmbiguous?: boolean; 71 89 90 + /** UI configuration for the add claim wizard */ 91 + ui: ServiceProviderUI; 92 + 72 93 /** Process matched URI into verification config */ 73 94 processURI(uri: string, match: RegExpMatchArray): ProcessedURI; 74 95 75 - /** Optional post-processing after fetch */ 96 + /** Optional post-processing after fetch to extract identity metadata */ 76 97 postprocess?( 77 98 data: unknown, 78 99 match: RegExpMatchArray, 79 100 ): { 80 - display?: string; 81 - uri?: string; 101 + subject?: string; 102 + avatarUrl?: string; 103 + profileUrl?: string; 104 + displayName?: string; 82 105 }; 83 106 84 107 /** Generate proof text for user to add to their profile */ 85 108 getProofText(did: string, handle?: string): string; 109 + 110 + /** Human-readable instructions for where to place the proof */ 111 + getProofLocation?(match: RegExpMatchArray): string; 86 112 87 113 /** Test cases for validation */ 88 114 tests: {
+18
packages/runner/src/types.ts
··· 1 + /** 2 + * Identity metadata extracted during verification 3 + */ 4 + export interface IdentityMetadata { 5 + /** Display name / username */ 6 + subject?: string; 7 + /** Avatar/profile image URL */ 8 + avatarUrl?: string; 9 + /** Profile page URL */ 10 + profileUrl?: string; 11 + /** Display name if different from subject */ 12 + displayName?: string; 13 + } 14 + 1 15 /** 2 16 * Result of verifying a claim 3 17 */ ··· 5 19 status: ClaimStatus; 6 20 errors: string[]; 7 21 timestamp: Date; 22 + /** Identity metadata extracted from the proof source */ 23 + identity?: IdentityMetadata; 8 24 } 9 25 10 26 /** ··· 24 40 export interface ClaimData { 25 41 uri: string; 26 42 did: string; 43 + type?: string; 27 44 comment?: string; 28 45 createdAt: string; 29 46 rkey: string; 47 + identity?: IdentityMetadata; 30 48 } 31 49 32 50 /**