A replica of Keytrace

Rename recipes to services

orta fd1dc832 ecf5a0ef

+397 -56
+2 -2
CLAUDE.md
··· 4 4 5 5 ## Project Overview 6 6 7 - Keytrace is an identity verification system for ATProto. Users link their decentralized identities (DIDs) to external accounts (GitHub, DNS, ActivityPub, Bluesky, npm, PGP, Tangled, Twitter, LinkedIn) by creating claims that are cryptographically verified and stored as ATProto records. 7 + Keytrace is an identity verification system for ATProto. Users link their decentralized identities (DIDs) to external accounts (GitHub, DNS, ActivityPub, Bluesky, npm, PGP, Tangled, Twitter, LinkedIn, Instagram) by creating claims that are cryptographically verified and stored as ATProto records. 8 8 9 9 ## Workflow 10 10 ··· 36 36 37 37 ### Monorepo Structure 38 38 39 - - **`packages/runner`** - Core verification library (`@keytrace/runner`). Recipe-based claim verification with service providers for GitHub, DNS, ActivityPub, Bluesky, npm, PGP, Tangled, Twitter, LinkedIn. 39 + - **`packages/runner`** - Core verification library (`@keytrace/runner`). Recipe-based claim verification with service providers for GitHub, DNS, ActivityPub, Bluesky, npm, PGP, Tangled, Twitter, LinkedIn, Instagram. 40 40 - **`packages/claims`** - Client-side claim verification library (`@keytrace/claims`). ES256 signature verification, ATProto record fetching. 41 41 - **`packages/lexicon`** - ATProto lexicon JSON schemas and generated TypeScript types. Lexicons: `dev.keytrace.claim`, `dev.keytrace.signature`, `dev.keytrace.serverPublicKey`, `dev.keytrace.statement`, `dev.keytrace.userPublicKey`, `dev.keytrace.recipe`, `dev.keytrace.profile`. Run `yarn codegen` in this package after editing lexicon JSON to regenerate `types/`. 42 42 - **`apps/keytrace.dev`** - Nuxt 3 full-stack web application with OAuth, API, and SSR.
+1 -1
README.md
··· 1 1 # Keytrace 2 2 3 - Identity verification for ATProto. Link your decentralized identity (DID) to external accounts like GitHub, LinkedIn, DNS, and Mastodon with cryptographically signed attestations. 3 + Identity verification for ATProto. Link your decentralized identity (DID) to external accounts like GitHub, LinkedIn, Instagram, DNS, and Mastodon with cryptographically signed attestations. 4 4 5 5 ## What is Keytrace? 6 6
+1 -1
apps/keytrace.dev/components/ui/ClaimCard.vue
··· 58 58 </a> 59 59 60 60 <div class="flex items-center flex-wrap gap-x-3 gap-y-1 text-xs text-zinc-500"> 61 - <NuxtLink v-if="claim.serviceType" :to="`/recipes/${claim.serviceType}`" class="hover:text-zinc-300 transition-colors"> 61 + <NuxtLink v-if="claim.serviceType" :to="`/services/${claim.serviceType}`" class="hover:text-zinc-300 transition-colors"> 62 62 via {{ claim.recipeName || claim.serviceType }} 63 63 </NuxtLink> 64 64 <span v-else-if="claim.recipeName">via {{ claim.recipeName }}</span>
+2 -2
apps/keytrace.dev/layouts/default.vue
··· 61 61 </div> 62 62 <span class="text-xs text-zinc-500">keytrace.dev</span> 63 63 </div> 64 - <NuxtLink to="/recipes" class="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"> 65 - How it works 64 + <NuxtLink to="/services" class="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"> 65 + Services 66 66 </NuxtLink> 67 67 <NuxtLink to="/developers" class="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"> 68 68 For Developers
+6 -6
apps/keytrace.dev/pages/recipes/[provider].vue apps/keytrace.dev/pages/services/[provider].vue
··· 1 1 <template> 2 2 <div class="max-w-3xl mx-auto px-4 py-12"> 3 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"> 4 + <NuxtLink to="/services" class="text-sm text-zinc-500 hover:text-zinc-300 mb-6 inline-flex items-center gap-1"> 5 5 <ArrowLeftIcon class="w-4 h-4" /> 6 - All recipes 6 + All services 7 7 </NuxtLink> 8 8 9 9 <div v-if="pending" class="space-y-6 mt-6"> ··· 22 22 </div> 23 23 24 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 25 + <p class="text-zinc-400">Service not found</p> 26 + <NuxtLink to="/services" class="text-violet-400 hover:text-violet-300 mt-2 inline-block"> 27 + View all services 28 28 </NuxtLink> 29 29 </div> 30 30 ··· 199 199 const route = useRoute(); 200 200 const providerId = computed(() => route.params.provider as string); 201 201 202 - const { data: recipe, pending, error } = await useFetch(`/api/recipes/${providerId.value}`); 202 + const { data: recipe, pending, error } = await useFetch(`/api/services/${providerId.value}`); 203 203 204 204 const testUri = ref(""); 205 205 const testDid = ref("");
+32 -16
apps/keytrace.dev/pages/recipes/index.vue apps/keytrace.dev/pages/services/index.vue
··· 9 9 </p> 10 10 <p class="text-zinc-400 text-md-start mt-4"> 11 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: 12 + the services we support for verifying your identity: 13 13 </p> 14 14 </div> 15 15 ··· 20 20 </div> 21 21 </div> 22 22 23 - <div v-else-if="recipes" class="space-y-4"> 23 + <div v-else-if="services" class="space-y-4"> 24 24 <NuxtLink 25 - v-for="recipe in recipes" 26 - :key="recipe.id" 27 - :to="`/recipes/${recipe.id}`" 25 + v-for="service in services" 26 + :key="service.id" 27 + :to="`/services/${service.id}`" 28 28 class="block bg-kt-card border border-zinc-800 rounded-lg p-5 hover:border-zinc-700 transition-colors group" 29 29 > 30 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> 31 + <div class="flex items-start gap-3"> 32 + <GithubIcon 33 + v-if="service.id === 'github'" 34 + class="w-6 h-6 mt-0.5 text-zinc-400" 35 + /> 36 + <FileCodeIcon 37 + v-else-if="service.id === 'tangled'" 38 + class="w-6 h-6 mt-0.5 text-zinc-400" 39 + /> 40 + <img 41 + v-else-if="service.homepage" 42 + :src="`https://www.google.com/s2/favicons?domain=${getDomain(service.homepage)}&sz=32`" 43 + :alt="`${service.name} favicon`" 44 + class="w-6 h-6 mt-0.5 rounded" 45 + /> 46 + <div> 47 + <h2 class="text-lg font-semibold text-zinc-200 group-hover:text-violet-400 transition-colors"> 48 + {{ service.name }} 49 + </h2> 50 + <p class="text-sm text-zinc-500 mt-1"> 51 + {{ service.description }} 52 + </p> 53 + </div> 38 54 </div> 39 55 <div class="flex items-center gap-2 text-zinc-500"> 40 - <span v-if="recipe.homepage" class="text-xs">{{ getDomain(recipe.homepage) }}</span> 56 + <span v-if="service.homepage" class="text-xs">{{ getDomain(service.homepage) }}</span> 41 57 <ArrowRightIcon class="w-4 h-4 group-hover:text-violet-400 transition-colors" /> 42 58 </div> 43 59 </div> ··· 48 64 <div class="mt-12 bg-kt-card border border-zinc-800 rounded-lg p-6"> 49 65 <h2 class="text-sm font-semibold text-zinc-300 mb-3">How Verification Works</h2> 50 66 <div class="text-sm text-zinc-400 space-y-3"> 51 - <p>Each Keytrace recipe defines a specific way to verify ownership of an external identity. The process is fully transparent and reproducible:</p> 67 + <p>Each Keytrace service defines a specific way to verify ownership of an external identity. The process is fully transparent and reproducible:</p> 52 68 <ol class="list-decimal list-inside space-y-2 text-zinc-500"> 53 69 <li>You create a proof at the external service (e.g., a GitHub gist, DNS TXT record)</li> 54 70 <li>The proof contains your ATProto DID to link the identities</li> ··· 65 81 </template> 66 82 67 83 <script setup lang="ts"> 68 - import { ArrowRight as ArrowRightIcon } from "lucide-vue-next"; 84 + import { ArrowRight as ArrowRightIcon, Github as GithubIcon, FileCode as FileCodeIcon } from "lucide-vue-next"; 69 85 70 - const { data: recipes, pending } = await useFetch("/api/recipes"); 86 + const { data: services, pending } = await useFetch("/api/services"); 71 87 72 88 function getDomain(url: string): string { 73 89 try {
+2 -2
apps/keytrace.dev/server/api/recipes/[provider].get.ts apps/keytrace.dev/server/api/services/[provider].get.ts
··· 1 1 /** 2 - * GET /api/recipes/:provider 2 + * GET /api/services/:provider 3 3 * 4 - * Get verification recipe details for a service provider. 4 + * Get verification details for a service provider. 5 5 * Returns the provider info and verification steps. 6 6 */ 7 7
-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.ui.description, 16 - homepage: provider.homepage, 17 - isAmbiguous: provider.isAmbiguous ?? false, 18 - })); 19 - });
+4 -4
apps/keytrace.dev/server/api/services/index.get.ts
··· 1 1 /** 2 2 * GET /api/services 3 3 * 4 - * Get all available service providers with their UI configuration. 5 - * Used by the add claim wizard to display service options and instructions. 4 + * List all available service providers. 6 5 */ 7 6 8 7 import { serviceProviders } from "@keytrace/runner"; 9 8 10 - export default defineEventHandler(() => { 9 + export default defineEventHandler(async () => { 11 10 const providers = serviceProviders.getAllProviders(); 12 11 13 12 return providers.map((provider) => ({ 14 13 id: provider.id, 15 14 name: provider.name, 15 + description: provider.ui.description, 16 16 homepage: provider.homepage, 17 - ui: provider.ui, 17 + isAmbiguous: provider.isAmbiguous ?? false, 18 18 })); 19 19 });
+24 -1
packages/runner/src/fetchers/http.ts
··· 2 2 import * as cheerio from "cheerio"; 3 3 4 4 export interface HttpFetchOptions { 5 - format: "json" | "text" | "json-ld"; 5 + format: "json" | "text" | "json-ld" | "og-meta"; 6 6 headers?: Record<string, string>; 7 7 timeout?: number; 8 8 } ··· 48 48 } catch (err) { 49 49 throw new Error(`Failed to parse JSON-LD: ${err instanceof Error ? err.message : 'Unknown error'}`); 50 50 } 51 + } 52 + 53 + if (options.format === "og-meta") { 54 + const html = await response.text(); 55 + const $ = cheerio.load(html); 56 + const ogData: Record<string, string> = {}; 57 + 58 + // Extract all Open Graph meta tags 59 + $('meta[property^="og:"]').each((_, elem) => { 60 + const property = $(elem).attr('property'); 61 + const content = $(elem).attr('content'); 62 + if (property && content) { 63 + // Convert og:title to title, og:description to description, etc. 64 + const key = property.replace('og:', ''); 65 + ogData[key] = content; 66 + } 67 + }); 68 + 69 + if (Object.keys(ogData).length === 0) { 70 + throw new Error('No Open Graph meta tags found in HTML'); 71 + } 72 + 73 + return ogData; 51 74 } 52 75 53 76 return await response.text();
+3 -1
packages/runner/src/serviceProviders/index.ts
··· 7 7 import pgp from "./pgp.js"; 8 8 import twitter from "./twitter.js"; 9 9 import linkedin from "./linkedin.js"; 10 + import instagram from "./instagram.js"; 10 11 import type { ServiceProvider, ServiceProviderMatch } from "./types.js"; 11 12 12 13 export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ExtraInput, ProofTarget, ProofRequest, ProcessedURI } from "./types.js"; ··· 21 22 pgp, 22 23 twitter, 23 24 linkedin, 25 + instagram, 24 26 }; 25 27 26 28 /** ··· 70 72 return provider?.getProofText(did, handle); 71 73 } 72 74 73 - export { github, dns, activitypub, bsky, npm, tangled, pgp, twitter, linkedin }; 75 + export { github, dns, activitypub, bsky, npm, tangled, pgp, twitter, linkedin, instagram };
+158
packages/runner/src/serviceProviders/instagram.ts
··· 1 + import type { ServiceProvider } from "./types.js"; 2 + 3 + /** 4 + * Instagram service provider 5 + * 6 + * Users prove ownership of their Instagram account by posting a public post 7 + * containing their DID. The post URL is used as the claim URI. 8 + * 9 + * Fetching uses the og-meta format to extract Open Graph meta tags from the 10 + * Instagram post page HTML. 11 + */ 12 + const instagram: ServiceProvider = { 13 + id: "instagram", 14 + name: "Instagram", 15 + homepage: "https://www.instagram.com", 16 + 17 + // Match Instagram post URLs 18 + // Format: https://www.instagram.com/p/{post-id}/ 19 + // Or: https://www.instagram.com/{username}/p/{post-id}/ 20 + reUri: /^https:\/\/www\.instagram\.com\/(?:([^/]+)\/)?p\/([A-Za-z0-9_-]+)\/?$/, 21 + 22 + isAmbiguous: false, 23 + 24 + ui: { 25 + description: "Link via an Instagram post", 26 + icon: "instagram", 27 + inputLabel: "Instagram Post URL", 28 + inputPlaceholder: "https://www.instagram.com/p/...", 29 + instructions: [ 30 + "Post a new **public post** on Instagram", 31 + "Paste the verification content below as the post caption", 32 + "Make sure the post is **public** (not private or for close friends)", 33 + "Copy the URL of the post (tap ... → Copy link)", 34 + "Paste the post URL below", 35 + ], 36 + proofTemplate: "Linking my keytrace.dev - {did}", 37 + }, 38 + 39 + processURI(uri, match) { 40 + const [, username, postId] = match; 41 + 42 + // Clean URL - remove query parameters and trailing slashes 43 + const cleanUri = `https://www.instagram.com/p/${postId}/`; 44 + 45 + return { 46 + profile: { 47 + display: username ? `@${username}` : postId, 48 + uri: username ? `https://www.instagram.com/${username}/` : cleanUri, 49 + }, 50 + proof: { 51 + request: { 52 + uri: cleanUri, 53 + fetcher: "http", 54 + format: "og-meta", 55 + options: { 56 + headers: { 57 + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", 58 + }, 59 + }, 60 + }, 61 + target: [ 62 + // Check og:title (format: "Author Name on Instagram: "post text"") 63 + { 64 + path: ["title"], 65 + relation: "contains", 66 + format: "text", 67 + }, 68 + // Check og:description (format: "X likes, Y comments - username on date: "post text"") 69 + { 70 + path: ["description"], 71 + relation: "contains", 72 + format: "text", 73 + }, 74 + ], 75 + }, 76 + }; 77 + }, 78 + 79 + postprocess(data, match) { 80 + const [, usernameFromUrl] = match; 81 + 82 + type InstagramOgMeta = { 83 + title?: string; 84 + description?: string; 85 + url?: string; 86 + username?: string; 87 + image?: string; 88 + }; 89 + 90 + const ogData = data as InstagramOgMeta; 91 + 92 + // Extract username from various sources 93 + let username = usernameFromUrl || ogData.username; 94 + 95 + // Try to extract from og:title (format: "Author Name on Instagram: ...") 96 + if (!username && ogData.title) { 97 + const titleMatch = ogData.title.match(/^([^:]+) on Instagram:/); 98 + if (titleMatch) { 99 + username = titleMatch[1]; 100 + } 101 + } 102 + 103 + // Try to extract from og:description (format: "... - username on ...") 104 + if (!username && ogData.description) { 105 + const descMatch = ogData.description.match(/- ([a-zA-Z0-9._]+) on /); 106 + if (descMatch) { 107 + username = descMatch[1]; 108 + } 109 + } 110 + 111 + // Try to extract from og:url 112 + if (!username && ogData.url) { 113 + const urlMatch = ogData.url.match(/instagram\.com\/([^/]+)\//); 114 + if (urlMatch) { 115 + username = urlMatch[1]; 116 + } 117 + } 118 + 119 + const displayName = ogData.title?.split(' on Instagram:')[0]; 120 + const avatarUrl = ogData.image; 121 + 122 + return { 123 + subject: username || "unknown", 124 + avatarUrl, 125 + profileUrl: username ? `https://www.instagram.com/${username}/` : undefined, 126 + displayName, 127 + }; 128 + }, 129 + 130 + getProofText(did) { 131 + return `Linking my keytrace.dev - ${did}`; 132 + }, 133 + 134 + getProofLocation() { 135 + return `Post a public Instagram post containing your DID in the caption`; 136 + }, 137 + 138 + tests: [ 139 + { uri: "https://www.instagram.com/p/DVS8Tm6DWzP/", shouldMatch: true }, 140 + { uri: "https://www.instagram.com/p/ABC123xyz/", shouldMatch: true }, 141 + { uri: "https://www.instagram.com/orta/p/DVS8Tm6DWzP/", shouldMatch: true }, 142 + { uri: "https://www.instagram.com/alice/p/ABC123/", shouldMatch: true }, 143 + // With trailing slash 144 + { uri: "https://www.instagram.com/p/ABC123/", shouldMatch: true }, 145 + // Profile URLs should NOT match 146 + { uri: "https://www.instagram.com/orta/", shouldMatch: false }, 147 + { uri: "https://www.instagram.com/alice", shouldMatch: false }, 148 + // Reel URLs should NOT match 149 + { uri: "https://www.instagram.com/reel/ABC123/", shouldMatch: false }, 150 + // Stories should NOT match 151 + { uri: "https://www.instagram.com/stories/alice/123/", shouldMatch: false }, 152 + // Wrong domain 153 + { uri: "https://twitter.com/alice/status/123", shouldMatch: false }, 154 + { uri: "https://facebook.com/alice/posts/123", shouldMatch: false }, 155 + ], 156 + }; 157 + 158 + export default instagram;
+1 -1
packages/runner/src/serviceProviders/types.ts
··· 28 28 /** Fetcher to use: 'http', 'dns', 'activitypub' */ 29 29 fetcher: string; 30 30 /** Expected response format */ 31 - format: "json" | "text" | "json-ld"; 31 + format: "json" | "text" | "json-ld" | "og-meta"; 32 32 /** Additional fetch options */ 33 33 options?: { 34 34 headers?: Record<string, string>;
+161
packages/runner/tests/serviceProviders/instagram.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import instagram from "../../src/serviceProviders/instagram.js"; 3 + import { createClaim, verifyClaim } from "../../src/claim.js"; 4 + import { ClaimStatus } from "../../src/types.js"; 5 + 6 + describe("instagram service provider", () => { 7 + describe("URI matching", () => { 8 + for (const { uri, shouldMatch } of instagram.tests) { 9 + it(`${shouldMatch ? "accepts" : "rejects"} ${uri}`, () => { 10 + const match = uri.match(instagram.reUri); 11 + expect(Boolean(match)).toBe(shouldMatch); 12 + }); 13 + } 14 + }); 15 + 16 + describe("processURI", () => { 17 + it("extracts post ID from standard post URL", () => { 18 + const uri = "https://www.instagram.com/p/DVS8Tm6DWzP/"; 19 + const match = uri.match(instagram.reUri)!; 20 + const result = instagram.processURI(uri, match); 21 + 22 + expect(result.profile.display).toBe("DVS8Tm6DWzP"); 23 + expect(result.proof.request.fetcher).toBe("http"); 24 + expect(result.proof.request.uri).toBe("https://www.instagram.com/p/DVS8Tm6DWzP/"); 25 + expect(result.proof.request.format).toBe("og-meta"); 26 + expect(result.proof.request.options?.headers?.["User-Agent"]).toContain("Mozilla"); 27 + }); 28 + 29 + it("extracts username and post ID from URL with username", () => { 30 + const uri = "https://www.instagram.com/orta/p/DVS8Tm6DWzP/"; 31 + const match = uri.match(instagram.reUri)!; 32 + const result = instagram.processURI(uri, match); 33 + 34 + expect(result.profile.display).toBe("@orta"); 35 + expect(result.profile.uri).toBe("https://www.instagram.com/orta/"); 36 + expect(result.proof.request.uri).toBe("https://www.instagram.com/p/DVS8Tm6DWzP/"); 37 + }); 38 + 39 + it("sets proof targets to title and description", () => { 40 + const uri = "https://www.instagram.com/p/ABC123/"; 41 + const match = uri.match(instagram.reUri)!; 42 + const result = instagram.processURI(uri, match); 43 + 44 + expect(result.proof.target).toHaveLength(2); 45 + expect(result.proof.target[0].path).toEqual(["title"]); 46 + expect(result.proof.target[0].relation).toBe("contains"); 47 + expect(result.proof.target[1].path).toEqual(["description"]); 48 + }); 49 + }); 50 + 51 + describe("postprocess", () => { 52 + it("extracts username and display name from Open Graph data", () => { 53 + const uri = "https://www.instagram.com/orta/p/DVS8Tm6DWzP/"; 54 + const match = uri.match(instagram.reUri)!; 55 + 56 + const ogData = { 57 + type: "article", 58 + site_name: "Instagram", 59 + title: "Orta Therox on Instagram: \"Linking my keytrace.dev - did:plc:test123\"", 60 + description: "0 likes, 0 comments - orta on February 28, 2026: \"Linking my keytrace.dev - did:plc:test123\"", 61 + url: "https://www.instagram.com/orta/p/DVS8Tm6DWzP/", 62 + image: "https://scontent.cdninstagram.com/example.jpg", 63 + username: "orta", 64 + }; 65 + 66 + const result = instagram.postprocess!(ogData, match); 67 + 68 + expect(result.subject).toBe("orta"); 69 + expect(result.displayName).toBe("Orta Therox"); 70 + expect(result.profileUrl).toBe("https://www.instagram.com/orta/"); 71 + expect(result.avatarUrl).toBe("https://scontent.cdninstagram.com/example.jpg"); 72 + }); 73 + 74 + it("extracts username from URL when not in og data", () => { 75 + const uri = "https://www.instagram.com/alice/p/ABC123/"; 76 + const match = uri.match(instagram.reUri)!; 77 + 78 + const ogData = { 79 + title: "Alice Smith on Instagram: \"Test post\"", 80 + description: "5 likes - alice on January 1, 2026: \"Test post\"", 81 + }; 82 + 83 + const result = instagram.postprocess!(ogData, match); 84 + expect(result.subject).toBe("alice"); 85 + }); 86 + 87 + it("falls back to unknown when no username available", () => { 88 + const uri = "https://www.instagram.com/p/ABC123/"; 89 + const match = uri.match(instagram.reUri)!; 90 + 91 + const result = instagram.postprocess!({}, match); 92 + expect(result.subject).toBe("unknown"); 93 + }); 94 + }); 95 + 96 + describe("verifyClaim integration (mocked fetch)", () => { 97 + // Valid did:plc uses base32 alphabet (a-z, 2-7), exactly 24 chars 98 + const did = "did:plc:abcdefghijklmnopqrst2345"; 99 + 100 + beforeEach(() => { 101 + vi.restoreAllMocks(); 102 + }); 103 + 104 + function mockFetchHtml(caption: string, username: string = "alice") { 105 + const html = ` 106 + <!DOCTYPE html> 107 + <html> 108 + <head> 109 + <meta property="og:type" content="article" /> 110 + <meta property="og:site_name" content="Instagram" /> 111 + <meta property="og:title" content="${username} on Instagram: &quot;${caption}&quot;" /> 112 + <meta property="og:description" content="0 likes, 0 comments - ${username} on February 28, 2026: &quot;${caption}&quot;" /> 113 + <meta property="og:url" content="https://www.instagram.com/${username}/p/ABC123/" /> 114 + <meta property="og:image" content="https://scontent.cdninstagram.com/test.jpg" /> 115 + </head> 116 + <body> 117 + <p>${caption}</p> 118 + </body> 119 + </html> 120 + `; 121 + 122 + vi.stubGlobal( 123 + "fetch", 124 + vi.fn().mockResolvedValueOnce({ 125 + ok: true, 126 + text: () => Promise.resolve(html), 127 + }), 128 + ); 129 + } 130 + 131 + it("verifies an Instagram post containing the DID", async () => { 132 + mockFetchHtml(`Linking my keytrace.dev - ${did}`, "alice"); 133 + 134 + const claim = createClaim("https://www.instagram.com/p/ABC123/", did); 135 + const result = await verifyClaim(claim); 136 + 137 + expect(result.status).toBe(ClaimStatus.VERIFIED); 138 + expect(result.identity?.subject).toBe("alice"); 139 + }); 140 + 141 + it("fails when post does not contain the DID", async () => { 142 + mockFetchHtml("Just a regular Instagram post", "alice"); 143 + 144 + const claim = createClaim("https://www.instagram.com/p/ABC123/", did); 145 + const result = await verifyClaim(claim); 146 + 147 + expect(result.status).toBe(ClaimStatus.FAILED); 148 + }); 149 + 150 + it("verifies with username in URL", async () => { 151 + mockFetchHtml(`Verifying: ${did}`, "bob"); 152 + 153 + const claim = createClaim("https://www.instagram.com/bob/p/XYZ789/", did); 154 + const result = await verifyClaim(claim); 155 + 156 + expect(result.status).toBe(ClaimStatus.VERIFIED); 157 + expect(result.identity?.subject).toBe("bob"); 158 + expect(result.identity?.profileUrl).toBe("https://www.instagram.com/bob/"); 159 + }); 160 + }); 161 + });