···11-import * as http from "./http.js";
22-import * as dns from "./dns.js";
33-import * as activitypub from "./activitypub.js";
44-export interface Fetcher {
55- fetch: (uri: string, options?: any) => Promise<unknown>;
66-}
77-/**
88- * Get a fetcher by name
99- */
1010-export declare function get(name: string): Fetcher | undefined;
1111-/**
1212- * Get all available fetchers
1313- */
1414-export declare function getAll(): Record<string, Fetcher>;
1515-export { http, dns, activitypub };
1616-//# sourceMappingURL=index.d.ts.map
···11-import * as http from "./http.js";
22-import * as dns from "./dns.js";
33-import * as activitypub from "./activitypub.js";
44-const fetchers = {
55- http,
66- dns,
77- activitypub,
88-};
99-/**
1010- * Get a fetcher by name
1111- */
1212-export function get(name) {
1313- return fetchers[name];
1414-}
1515-/**
1616- * Get all available fetchers
1717- */
1818-export function getAll() {
1919- return { ...fetchers };
2020-}
2121-export { http, dns, activitypub };
2222-//# sourceMappingURL=index.js.map
···11-import type { ServiceProvider } from "./types.js";
22-/**
33- * ActivityPub (Mastodon/Fediverse) service provider
44- *
55- * Users prove ownership by adding their DID to their profile bio or fields.
66- * The claim URI is the profile URL (e.g., https://mastodon.social/@username)
77- */
88-declare const activitypub: ServiceProvider;
99-export default activitypub;
1010-//# sourceMappingURL=activitypub.d.ts.map
···11-import type { ServiceProvider } from "./types.js";
22-/**
33- * Bluesky service provider
44- *
55- * Users prove ownership of another Bluesky account by adding their DID to the profile bio.
66- * The claim URI is the bsky.app profile URL.
77- */
88-declare const bsky: ServiceProvider;
99-export default bsky;
1010-//# sourceMappingURL=bsky.d.ts.map
···11-import type { ServiceProvider } from "./types.js";
22-/**
33- * DNS TXT record service provider
44- *
55- * Users prove domain ownership by adding a TXT record containing their DID.
66- * The claim URI format is: dns:example.com
77- */
88-declare const dns: ServiceProvider;
99-export default dns;
1010-//# sourceMappingURL=dns.d.ts.map
···11-import type { ServiceProvider } from "./types.js";
22-/**
33- * GitHub Gist service provider
44- *
55- * Users prove ownership by creating a public gist containing their DID.
66- * The gist URL is used as the claim URI.
77- */
88-declare const github: ServiceProvider;
99-export default github;
1010-//# sourceMappingURL=github.d.ts.map
···11-import github from "./github.js";
22-import dns from "./dns.js";
33-import activitypub from "./activitypub.js";
44-import bsky from "./bsky.js";
55-import type { ServiceProvider, ServiceProviderMatch } from "./types.js";
66-export type { ServiceProvider, ServiceProviderMatch, ProofTarget, ProofRequest, ProcessedURI, } from "./types.js";
77-/**
88- * Get a service provider by ID
99- */
1010-export declare function getProvider(id: string): ServiceProvider | undefined;
1111-/**
1212- * Get all registered service providers
1313- */
1414-export declare function getAllProviders(): ServiceProvider[];
1515-/**
1616- * Match a URI against all service providers
1717- * Returns all matching providers, with unambiguous matches stopping the search
1818- */
1919-export declare function matchUri(uri: string): ServiceProviderMatch[];
2020-/**
2121- * Get the proof text a user should add to verify a claim
2222- */
2323-export declare function getProofTextForProvider(providerId: string, did: string, handle?: string): string | undefined;
2424-export { github, dns, activitypub, bsky };
2525-//# sourceMappingURL=index.d.ts.map
···11-import github from "./github.js";
22-import dns from "./dns.js";
33-import activitypub from "./activitypub.js";
44-import bsky from "./bsky.js";
55-const providers = {
66- github,
77- dns,
88- activitypub,
99- bsky,
1010-};
1111-/**
1212- * Get a service provider by ID
1313- */
1414-export function getProvider(id) {
1515- return providers[id];
1616-}
1717-/**
1818- * Get all registered service providers
1919- */
2020-export function getAllProviders() {
2121- return Object.values(providers);
2222-}
2323-/**
2424- * Match a URI against all service providers
2525- * Returns all matching providers, with unambiguous matches stopping the search
2626- */
2727-export function matchUri(uri) {
2828- const matches = [];
2929- for (const provider of Object.values(providers)) {
3030- const match = uri.match(provider.reUri);
3131- if (match) {
3232- matches.push({
3333- provider,
3434- match,
3535- isAmbiguous: provider.isAmbiguous ?? false,
3636- });
3737- // Stop on unambiguous match
3838- if (!provider.isAmbiguous) {
3939- break;
4040- }
4141- }
4242- }
4343- return matches;
4444-}
4545-/**
4646- * Get the proof text a user should add to verify a claim
4747- */
4848-export function getProofTextForProvider(providerId, did, handle) {
4949- const provider = providers[providerId];
5050- return provider?.getProofText(did, handle);
5151-}
5252-export { github, dns, activitypub, bsky };
5353-//# sourceMappingURL=index.js.map
···2929 * Fetch DNS TXT records for a domain.
3030 * Returns null in environments where DNS resolution is not available.
3131 */
3232-export async function fetch(
3333- domain: string,
3434- options: DnsFetchOptions = {},
3535-): Promise<DnsFetchResult | null> {
3232+export async function fetch(domain: string, options: DnsFetchOptions = {}): Promise<DnsFetchResult | null> {
3633 if (!(await hasDnsModule())) {
3734 console.debug("DNS fetching is not available in this environment");
3835 return null;
···77 try {
88 dns = await import("node:dns/promises");
99 } catch {
1010- throw new Error(
1111- "DNS TXT lookups are not available in the browser. " +
1212- "Use the server-side proxy endpoint (POST /api/proxy/dns) instead.",
1313- );
1010+ throw new Error("DNS TXT lookups are not available in the browser. " + "Use the server-side proxy endpoint (POST /api/proxy/dns) instead.");
1411 }
15121613 const records = await dns.resolveTxt(domain);
+212
packages/runner/src/claim.ts
···11+import { ClaimStatus } from "./types.js";
22+import { DEFAULT_TIMEOUT } from "./constants.js";
33+import { matchUri, type ServiceProviderMatch, type ProofRequest, type ProofTarget } from "./serviceProviders/index.js";
44+import * as fetchers from "./fetchers/index.js";
55+import type { VerifyOptions, ClaimVerificationResult } from "./types.js";
66+77+// did:plc identifiers are base32-encoded, lowercase
88+const DID_PLC_RE = /^did:plc:[a-z2-7]{24}$/;
99+// did:web uses a domain name (with optional port and path segments encoded as colons)
1010+const DID_WEB_RE = /^did:web:[a-zA-Z0-9._:%-]+$/;
1111+1212+/**
1313+ * Validate a DID string. Accepts did:plc and did:web formats.
1414+ */
1515+export function isValidDid(did: string): boolean {
1616+ return DID_PLC_RE.test(did) || DID_WEB_RE.test(did);
1717+}
1818+1919+/**
2020+ * A single identity claim linking a DID to an external account
2121+ */
2222+export interface ClaimState {
2323+ uri: string;
2424+ did: string;
2525+ status: ClaimStatus;
2626+ matches: ServiceProviderMatch[];
2727+ errors: string[];
2828+}
2929+3030+/**
3131+ * Create a new claim state
3232+ */
3333+export function createClaim(uri: string, did: string): ClaimState {
3434+ if (!isValidDid(did)) {
3535+ throw new Error(`Invalid DID format: ${did}`);
3636+ }
3737+ return {
3838+ uri,
3939+ did,
4040+ status: ClaimStatus.INIT,
4141+ matches: [],
4242+ errors: [],
4343+ };
4444+}
4545+4646+/**
4747+ * Match the claim URI against known service providers
4848+ */
4949+export function matchClaim(claim: ClaimState): void {
5050+ claim.matches = matchUri(claim.uri);
5151+ claim.status = claim.matches.length > 0 ? ClaimStatus.MATCHED : ClaimStatus.ERROR;
5252+5353+ if (claim.matches.length === 0) {
5454+ claim.errors.push(`No service provider matched URI: ${claim.uri}`);
5555+ }
5656+}
5757+5858+/**
5959+ * Check if the claim is ambiguous (matches multiple providers)
6060+ */
6161+export function isClaimAmbiguous(claim: ClaimState): boolean {
6262+ return claim.matches.length > 1 || (claim.matches.length === 1 && claim.matches[0].isAmbiguous);
6363+}
6464+6565+/**
6666+ * Get the matched service provider (first unambiguous match, or first match)
6767+ */
6868+export function getMatchedProvider(claim: ClaimState): ServiceProviderMatch | undefined {
6969+ return claim.matches[0];
7070+}
7171+7272+/**
7373+ * Verify the claim by fetching proof and checking for DID
7474+ */
7575+export async function verifyClaim(claim: ClaimState, opts: VerifyOptions = {}): Promise<ClaimVerificationResult> {
7676+ if (claim.status === ClaimStatus.INIT) {
7777+ matchClaim(claim);
7878+ }
7979+8080+ if (claim.matches.length === 0) {
8181+ return {
8282+ status: ClaimStatus.ERROR,
8383+ errors: claim.errors,
8484+ timestamp: new Date(),
8585+ };
8686+ }
8787+8888+ // Try each matched provider until one succeeds
8989+ for (const match of claim.matches) {
9090+ try {
9191+ const config = match.provider.processURI(claim.uri, match.match);
9292+ const proofData = await fetchProof(config.proof.request, opts);
9393+9494+ if (checkProof(proofData, config.proof.target, claim.did)) {
9595+ claim.status = ClaimStatus.VERIFIED;
9696+ return {
9797+ status: ClaimStatus.VERIFIED,
9898+ errors: [],
9999+ timestamp: new Date(),
100100+ };
101101+ }
102102+ } catch (err) {
103103+ claim.errors.push(`${match.provider.id}: ${err instanceof Error ? err.message : "Unknown error"}`);
104104+ }
105105+106106+ // Stop on unambiguous match
107107+ if (!match.isAmbiguous) break;
108108+ }
109109+110110+ claim.status = ClaimStatus.FAILED;
111111+ return {
112112+ status: ClaimStatus.FAILED,
113113+ errors: claim.errors,
114114+ timestamp: new Date(),
115115+ };
116116+}
117117+118118+async function fetchProof(request: ProofRequest, opts: VerifyOptions): Promise<unknown> {
119119+ const fetcher = fetchers.get(request.fetcher);
120120+ if (!fetcher) {
121121+ throw new Error(`Unknown fetcher: ${request.fetcher}`);
122122+ }
123123+ console.log(`[runner] Fetching proof: ${request.fetcher} ${request.uri} (format: ${request.format})`);
124124+ const data = await fetcher.fetch(request.uri, {
125125+ format: request.format,
126126+ timeout: opts.timeout ?? DEFAULT_TIMEOUT,
127127+ headers: request.options?.headers,
128128+ });
129129+ const fileKeys = data && typeof data === "object" && "files" in data ? Object.keys((data as Record<string, unknown>).files as object) : [];
130130+ console.log(`[runner] Fetched proof, files: ${JSON.stringify(fileKeys)}`);
131131+ return data;
132132+}
133133+134134+function checkProof(data: unknown, targets: ProofTarget[], did: string): boolean {
135135+ const proofPatterns = generateProofPatterns(did);
136136+ console.log(`[runner] Checking proof for DID ${did}, patterns: ${JSON.stringify(proofPatterns)}`);
137137+ console.log(`[runner] Proof targets: ${JSON.stringify(targets.map((t) => t.path.join(".")))}`);
138138+139139+ for (const target of targets) {
140140+ const values = extractValues(data, target.path);
141141+ console.log(`[runner] Target ${target.path.join(".")}: found ${values.length} value(s)${values.length > 0 ? `: ${JSON.stringify(values.map((v) => v.slice(0, 100)))}` : ""}`);
142142+ for (const value of values) {
143143+ if (matchesPattern(value, proofPatterns, target.relation)) {
144144+ console.log(`[runner] Match found at ${target.path.join(".")} (relation: ${target.relation})`);
145145+ return true;
146146+ }
147147+ }
148148+ }
149149+ console.log(`[runner] No match found in any target`);
150150+ return false;
151151+}
152152+153153+function generateProofPatterns(did: string): string[] {
154154+ const patterns = [did];
155155+156156+ if (did.startsWith("did:plc:")) {
157157+ patterns.push(did.replace("did:plc:", ""));
158158+ }
159159+160160+ return patterns;
161161+}
162162+163163+function extractValues(data: unknown, path: string[]): string[] {
164164+ const results: string[] = [];
165165+ extractValuesRecursive(data, path, 0, results);
166166+ return results;
167167+}
168168+169169+function extractValuesRecursive(data: unknown, path: string[], index: number, results: string[]): void {
170170+ if (data === null || data === undefined) return;
171171+172172+ if (index >= path.length) {
173173+ if (typeof data === "string") {
174174+ results.push(data);
175175+ } else if (Array.isArray(data)) {
176176+ for (const item of data) {
177177+ if (typeof item === "string") {
178178+ results.push(item);
179179+ }
180180+ }
181181+ }
182182+ return;
183183+ }
184184+185185+ const key = path[index];
186186+187187+ if (key === "*" && Array.isArray(data)) {
188188+ for (const item of data) {
189189+ extractValuesRecursive(item, path, index + 1, results);
190190+ }
191191+ } else if (typeof data === "object" && data !== null) {
192192+ const record = data as Record<string, unknown>;
193193+ extractValuesRecursive(record[key], path, index + 1, results);
194194+ }
195195+}
196196+197197+function matchesPattern(value: string, patterns: string[], relation: "contains" | "equals" | "startsWith"): boolean {
198198+ for (const pattern of patterns) {
199199+ switch (relation) {
200200+ case "contains":
201201+ if (value.includes(pattern)) return true;
202202+ break;
203203+ case "equals":
204204+ if (value === pattern) return true;
205205+ break;
206206+ case "startsWith":
207207+ if (value.startsWith(pattern)) return true;
208208+ break;
209209+ }
210210+ }
211211+ return false;
212212+}
···11-# Devil's Advocate Review: Keytrace
22-33-**Reviewer**: Devil's Advocate
44-**Date**: 2026-02-08
55-**Scope**: Existential risks, competitive landscape, trust model, sustainability, and architectural concerns
66-77----
88-99-## Executive Summary
1010-1111-Keytrace has a clear vision: be "Keybase for Bluesky." The core library (`@keytrace/doip`) is well-structured and the DOIP-inspired service provider architecture is sound. However, this project faces serious headwinds from Bluesky's own verification system, fundamental trust model contradictions, scope creep between plan and reality, and sustainability questions that need honest answers before investing further.
1212-1313-**Verdict**: The project has a viable niche, but only if it narrows its ambitions and confronts the trust model problem head-on. As currently planned, it risks building something that is simultaneously too centralized to satisfy decentralization advocates and too obscure to satisfy mainstream users.
1414-1515----
1616-1717-## 1. Does This Need to Exist?
1818-1919-### The "Bluesky Already Does This" Problem
2020-2121-Bluesky launched its own multi-tier verification system in April 2025:
2222-- **Domain verification**: 309,000+ accounts already use domain handles
2323-- **Blue checkmarks**: 4,327 accounts verified by end of 2025
2424-- **Trusted Verifiers**: 21 organizations (NYT, CNN, European Commission) can issue verification badges directly in the app
2525-- **Built into the client**: Badges appear natively in all Bluesky clients
2626-2727-Keytrace is building a third-party verification layer on top of a platform that already has two forms of built-in verification. The plan never mentions Bluesky's Trusted Verifiers program, which is a significant oversight.
2828-2929-**Counter-argument**: Bluesky's verification is about "notable" accounts (celebrities, journalists, organizations). Keytrace is about proving cross-platform identity for anyone. These are genuinely different use cases. But the plan should explicitly articulate this distinction.
3030-3131-### The "Keyoxide Already Exists" Problem
3232-3333-Keyoxide already supports Bluesky verification. Users can add their Bluesky profile as a claim on their Keyoxide profile via OpenPGP identity proofs. The DOIP (Decentralized OpenPGP Identity Proofs) specification is mature, with implementations in both JavaScript (doipjs) and Rust (doip-rs).
3434-3535-The naming of `@keytrace/doip` package is telling -- this project is aware of and building on Keyoxide's concepts. But if you're rebuilding doipjs with ATProto as the identity backbone instead of PGP, you should be explicit about what's gained and lost.
3636-3737-**What's gained**: No PGP key management UX nightmare, data lives in user's ATProto repo, portable with PDS.
3838-3939-**What's lost**: PGP-based proofs are cryptographically self-sovereign. The user holds the private key. In Keytrace, the user delegates trust to keytrace.dev as a signing authority. This is a fundamental philosophical regression.
4040-4141----
4242-4343-## 2. The Trust Model Contradiction
4444-4545-This is the single biggest problem with the project.
4646-4747-### Keytrace is a Trusted Third Party
4848-4949-The plan describes keytrace as a "trusted third-party verifier that signs claims on behalf of users." It generates daily rotating ES256 keys, signs attestations, and writes those attestations to user repos via OAuth.
5050-5151-This means:
5252-- **Users must trust keytrace** to verify claims honestly
5353-- **Users must trust keytrace** with full repo write access
5454-- **Verifiers must trust keytrace's signing keys** to validate attestations
5555-- **Keytrace's DID must be hardcoded/pinned** in client code for security
5656-5757-You've re-invented a Certificate Authority for identity proofs. In a project inspired by Keybase (which was criticized for centralization) and Keyoxide (which was built specifically to avoid centralization), keytrace introduces a new central point of trust and failure.
5858-5959-### The "Keytrace Signs Its Own Attestations" Problem
6060-6161-When keytrace verifies a GitHub gist and signs an attestation, what does that signature actually prove? It proves that at some point in time, keytrace's server successfully fetched a URL and found a matching string. That's it.
6262-6363-An attacker who compromises keytrace's S3 bucket (where private keys are stored) can forge attestations for anyone. An attacker who compromises the server can issue fraudulent attestations in real-time. There's no way for a third party to independently verify the original proof without re-running the verification themselves.
6464-6565-If third parties need to re-run verification anyway (which the plan says should happen: "Verification is done on-demand"), then what value does the signed attestation add? It's a cache of a previous verification result signed by a party you have to trust anyway.
6666-6767-### What Would Fix This
6868-6969-Consider a model where:
7070-1. Users create the claim record themselves (they already have write access to their own repo)
7171-2. Keytrace provides the verification engine (the runner) but doesn't sign anything
7272-3. Anyone can verify by running the recipe against the claim
7373-4. Keytrace.dev is just a convenient UI, not a signing authority
7474-7575-This would be more aligned with the project's stated philosophy and closer to how Keyoxide actually works.
7676-7777----
7878-7979-## 3. OAuth Scope: The Elephant in the Room
8080-8181-The plan acknowledges this directly:
8282-8383-> ATProto OAuth doesn't yet support fine-grained collection-level scopes. The `atproto` scope grants full repo access, but keytrace only writes to its own collection.
8484-8585-The August 2025 ATProto discussion on auth scopes shows progress toward granular permissions, with draft protocol features published and server-side implementation underway. But as of this writing, keytrace.dev has `scope: "atproto transition:generic"` in its OAuth config (`/apps/keytrace.dev/server/utils/oauth.ts:47`), which grants full read/write access to the user's entire ATProto repository.
8686-8787-This means keytrace could:
8888-- Read all of a user's private messages
8989-- Delete their posts
9090-- Modify their profile
9191-- Create records in any collection
9292-9393-For a verification service, this is an unacceptable level of access. Users who are security-conscious enough to want identity verification proofs are exactly the users who will balk at granting full repo access to a third-party service.
9494-9595-**Mitigation**: The plan should have a concrete strategy for when granular scopes ship. It should also have a prominent disclosure on the auth page explaining what access is being granted and why.
9696-9797----
9898-9999-## 4. Plan vs. Reality (Scope Creep Analysis)
100100-101101-### What the Plan Describes
102102-103103-The plan describes a system with:
104104-- `keytrace-runner` package with recipe execution engine
105105-- Multiple action types (http-get, json-path, css-select, regex-match, dns-txt, http-paginate)
106106-- Template interpolation with `{variable}` syntax
107107-- Daily rotating signing keys stored in S3
108108-- Signed attestations with JWS
109109-- Recipes stored in keytrace's ATProto repo
110110-- Strong references (URI + CID) for recipe integrity
111111-- Claim IDs with CUID entropy
112112-- Recent claims feed stored in S3
113113-- HTTP proxy with domain allowlist
114114-- 5 implementation phases
115115-116116-### What Actually Exists
117117-118118-- A `@keytrace/doip` package (not `keytrace-runner`) with:
119119- - 4 service providers (GitHub gists, DNS, ActivityPub, Bluesky)
120120- - 3 fetchers (HTTP, DNS, ActivityPub)
121121- - A Claim class that can match and verify
122122- - A Profile class that fetches from ATProto
123123-- A Nuxt web app with:
124124- - OAuth login/logout flow
125125- - Session storage (S3 or file-based)
126126- - A bare-bones index page showing authenticated user info
127127-128128-### What's Missing
129129-130130-Everything from the attestation system: no signing keys, no recipes stored in ATProto, no JWS signatures, no `dev.keytrace.key` or `dev.keytrace.recipe` or `dev.keytrace.claim` lexicons, no claim creation flow, no verification UI, no proxy endpoints, no recent claims feed.
131131-132132-The lexicon that exists (`dev.keytrace.identity.claim`) is the simple version from the top of the plan, not the attested version from the attestation section.
133133-134134-**The gap between the plan and the code is enormous.** The plan describes a Phase 5 system. The code is at early Phase 1. This isn't inherently bad -- every project starts somewhere -- but the plan should be honest about the critical path and make clear which parts are aspirational vs. committed.
135135-136136----
137137-138138-## 5. Recipe System: Over-Engineering?
139139-140140-The recipe system described in the plan is a full DSL for verification:
141141-- Parameterized templates
142142-- Multi-step verification pipelines
143143-- CSS selectors, JSONPath, regex matching
144144-- Pagination support
145145-- Recipes stored as ATProto records with CID-based integrity
146146-147147-Meanwhile, the actual code uses a much simpler approach: hardcoded service providers with regex matching and path-based value extraction. This is pragmatic and works.
148148-149149-The question is: does the recipe system justify its complexity?
150150-151151-**Against recipes**: Every recipe is custom logic that needs to be written, tested, and maintained. When GitHub changes their gist API, someone needs to update the recipe. When a service provider changes their HTML structure, the CSS selector breaks. This is the same maintenance burden as hardcoded providers, just expressed in a different format. Recipes in ATProto don't make them more maintainable; they make them less mutable (CID-pinned).
152152-153153-**For recipes**: Recipes are user-auditable. Anyone can read the recipe and understand exactly what keytrace checks. This is genuine transparency value.
154154-155155-**Recommendation**: Keep the current hardcoded provider approach for v1. The recipe system is a v2 feature at best. Don't let the perfect be the enemy of the good.
156156-157157----
158158-159159-## 6. ATProto Dependency Risks
160160-161161-### Protocol Instability
162162-163163-ATProto is still a moving target. The DID PLC verification method constraints were being relaxed as recently as June 2025. OAuth scopes are still being implemented. The lexicon system may evolve.
164164-165165-Keytrace stores its core data (claims, keys, recipes) as ATProto records. If the protocol changes how custom lexicons work, or if PDS operators decide to reject unknown collections, keytrace's data becomes inaccessible.
166166-167167-### Bluesky Pivot Risk
168168-169169-ATProto and Bluesky are technically separate, but practically coupled. The code hardcodes `https://public.api.bsky.app` as `PUBLIC_API_URL` and `https://bsky.social` as the PDS endpoint. If Bluesky changes its API, the project breaks.
170170-171171-More concerning: Bluesky could decide that third-party verification services compete with their Trusted Verifiers program and make it harder for apps like keytrace to operate (e.g., by restricting what collections third-party OAuth apps can write to).
172172-173173-### Ecosystem Lock-in
174174-175175-By building exclusively on ATProto, keytrace ties its fate to a single protocol. Keyoxide works with PGP keys, which are protocol-agnostic. A keytrace identity proof is meaningless outside the ATProto ecosystem.
176176-177177----
178178-179179-## 7. Adoption: The Chicken-and-Egg Problem
180180-181181-Identity verification is a network effect business:
182182-- Users won't add proofs if nobody checks them
183183-- Nobody will check them if few users have proofs
184184-- Third-party apps won't integrate if few users have proofs
185185-- Users won't bother if third-party apps don't show verification status
186186-187187-Keybase solved this by bundling verification with encrypted chat, file sharing, and git. It made the verification system a side benefit of an already-useful product.
188188-189189-Keytrace is a pure verification service. The value proposition is: "Prove you own the same GitHub account linked to your Bluesky profile." Who needs this today? Security researchers, journalists, developers. A small niche. The plan's homepage "recent claims feed" showing the last 50 verifications could look very sparse for a very long time.
190190-191191-**Recommendation**: Focus ruthlessly on the developer audience first. Make `@keytrace/doip` an excellent library that other ATProto app developers integrate. The verification UI is secondary to the library being useful in other people's apps.
192192-193193----
194194-195195-## 8. Failure Modes
196196-197197-### Service Dependencies
198198-199199-| Dependency | Failure Impact |
200200-|-----------|----------------|
201201-| GitHub API | Rate-limited at 60 req/hr unauthenticated. A popular keytrace instance verifying many GitHub claims will hit this fast. |
202202-| S3 (Scaleway) | All sessions lost. No new OAuth logins. No signing keys accessible. Complete outage. |
203203-| public.api.bsky.app | Cannot resolve handles, fetch profiles, or list claims. Complete outage. |
204204-| bsky.social PDS | Cannot write attestations to user repos. Cannot publish keys/recipes. |
205205-| DNS resolution | DNS claim verification fails. |
206206-| Third-party services | Each service provider is a separate failure point. |
207207-208208-### Missing Resilience
209209-210210-The code has no:
211211-- Retry logic on any fetcher
212212-- Circuit breakers for failing services
213213-- Caching of verification results
214214-- Graceful degradation (e.g., showing stale results when a service is down)
215215-- Health check endpoints
216216-- Error tracking/alerting
217217-218218-### S3 as Single Point of Failure
219219-220220-The plan puts everything critical in S3: signing keys, sessions, recent claims feed. S3 is highly available, but the keytrace implementation has no fallback. If S3 credentials expire or the bucket is misconfigured, the entire service is dead.
221221-222222----
223223-224224-## 9. Security Theater Concerns
225225-226226-### "Verified" Doesn't Mean "Trustworthy"
227227-228228-A keytrace verification proves: "At time T, URL X contained string Y matching DID Z." It does not prove:
229229-- The GitHub account is not compromised
230230-- The person behind the DID is who they claim to be
231231-- The verification is still valid (the gist could be deleted 1 second after verification)
232232-233233-The verification UI should make these limitations crystal clear. A green checkmark next to "GitHub: @alice" could give false confidence.
234234-235235-### Temporal Validity Problem
236236-237237-The plan says "Verification is done on-demand (not stored) - keeps data fresh." But the attestation system signs claims at verification time. If a user removes their proof after getting the attestation, the attestation remains in their repo, signed and "valid." There's no revocation mechanism.
238238-239239-### Proof Squatting
240240-241241-If Alice creates a gist with Bob's DID, and then submits that gist URL as her own claim, keytrace would verify it. The verification checks that the DID appears in the gist, not that the gist belongs to the person making the claim. The `extractFrom` parameter in recipes partially addresses this (extracting username from gist URL), but the current code doesn't verify that the extracted username matches any expected value.
242242-243243----
244244-245245-## 10. Business Sustainability
246246-247247-### Who Pays?
248248-249249-The plan mentions Railway for hosting, Scaleway S3 for storage. These cost money. The project has:
250250-- No pricing model
251251-- No sponsorship strategy
252252-- No mention of sustainability
253253-- S3 storage that grows linearly with users (sessions, keys)
254254-255255-### Maintenance Burden
256256-257257-Each service provider needs ongoing maintenance:
258258-- GitHub changes their API? Update the provider.
259259-- Mastodon instances have different API versions? Handle edge cases.
260260-- New services to support? Write and test new providers.
261261-- Security vulnerabilities? Patch and redeploy.
262262-263263-This is a significant ongoing commitment for what appears to be a side project.
264264-265265----
266266-267267-## 11. Specific Code Concerns
268268-269269-### Naming Confusion
270270-271271-The package is called `@keytrace/doip` but the plan describes `keytrace-runner`. The lexicon uses `dev.keytrace.identity.claim` but the plan's attestation section uses `dev.keytrace.claim`. The plan references `org.[domain].identity.claim` as a placeholder. These inconsistencies suggest the design is still in flux.
272272-273273-### Hardcoded Bluesky Dependency
274274-275275-`/packages/doip/src/constants.ts` hardcodes `PUBLIC_API_URL = "https://public.api.bsky.app"`. The plan talks about ATProto portability but the code is Bluesky-specific. A user on a third-party PDS would need their AppView to resolve, which may or may not be public.api.bsky.app.
276276-277277-### Missing Input Validation
278278-279279-The `Claim` constructor validates DID format with just `did.startsWith("did:")`. It doesn't validate:
280280-- DID method (plc, web, key, etc.)
281281-- DID-specific format requirements
282282-- URI format for claim URIs
283283-- Maximum lengths
284284-285285-### Service Provider Ordering Matters
286286-287287-In `/packages/doip/src/serviceProviders/index.ts`, the `matchUri` function iterates providers in insertion order and stops at the first unambiguous match. The order `github, dns, activitypub, bsky` means if a URL somehow matched both github and another provider, only github would be tried. This is fragile -- provider ordering should be explicit and documented.
288288-289289----
290290-291291-## 12. Recommendations (Constructive)
292292-293293-### Must-Do Before Launch
294294-295295-1. **Resolve the trust model**: Either commit to being a signing authority (and own that responsibility) or remove the attestation system and be a verification engine only
296296-2. **Address OAuth scope disclosure**: Add a prominent explanation of what access is being granted
297297-3. **Plan for granular scopes**: Have migration code ready for when ATProto ships collection-level permissions
298298-4. **Add retry logic and error handling**: The fetchers are too fragile for production
299299-300300-### Should-Do
301301-302302-5. **Differentiate from Bluesky verification explicitly**: The landing page should explain why keytrace exists when Bluesky has verification
303303-6. **Focus on the library first**: Make `@keytrace/doip` the best verification library in the ATProto ecosystem
304304-7. **Skip the recipe system for v1**: Hardcoded providers work fine and are easier to maintain
305305-8. **Add a cache/staleness model**: Decide how long a verification result is valid
306306-307307-### Could-Do
308308-309309-9. **Consider becoming a Bluesky labeler**: Instead of custom attestations, keytrace could label accounts using the existing labeler system, getting native Bluesky UI integration for free
310310-10. **Support verification without OAuth**: Let anyone verify someone else's profile without logging in (read-only verification)
311311-11. **Add webhook/federation**: Let other instances run verification so keytrace.dev isn't a single point of failure
312312-313313----
314314-315315-## Summary
316316-317317-Keytrace is building something useful at its core: a library and service for cross-platform identity verification on ATProto. The `@keytrace/doip` package is well-structured and the service provider architecture is extensible.
318318-319319-But the project needs to confront three fundamental tensions:
320320-321321-1. **Centralized verifier in a decentralized ecosystem**: The attestation/signing model contradicts the project's philosophical roots
322322-2. **Competing with the platform**: Bluesky's own verification system covers the most visible use case
323323-3. **Plan vs. reality**: The plan describes a much larger system than what exists or may be needed
324324-325325-The path forward is to focus on what keytrace uniquely offers: cross-platform identity verification for everyday users (not just "notable" accounts), packaged as both a library and a simple web UI. Drop the complex attestation system, embrace verification-on-demand, and make the library so good that other ATProto apps want to integrate it.