@keytrace/runner 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -0
- package/dist/actions/css-select.d.ts +6 -0
- package/dist/actions/css-select.d.ts.map +1 -0
- package/dist/actions/css-select.js +14 -0
- package/dist/actions/css-select.js.map +1 -0
- package/dist/actions/dns-txt.d.ts +6 -0
- package/dist/actions/dns-txt.d.ts.map +1 -0
- package/dist/actions/dns-txt.js +17 -0
- package/dist/actions/dns-txt.js.map +1 -0
- package/dist/actions/http-get.d.ts +6 -0
- package/dist/actions/http-get.d.ts.map +1 -0
- package/dist/actions/http-get.js +19 -0
- package/dist/actions/http-get.js.map +1 -0
- package/dist/actions/index.d.ts +6 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +6 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/actions/json-path.d.ts +12 -0
- package/dist/actions/json-path.d.ts.map +1 -0
- package/dist/actions/json-path.js +26 -0
- package/dist/actions/json-path.js.map +1 -0
- package/dist/actions/regex-match.d.ts +6 -0
- package/dist/actions/regex-match.d.ts.map +1 -0
- package/dist/actions/regex-match.js +14 -0
- package/dist/actions/regex-match.js.map +1 -0
- package/dist/claim.d.ts +38 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +253 -0
- package/dist/claim.js.map +1 -0
- package/dist/constants.d.ts +17 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +17 -0
- package/dist/constants.js.map +1 -0
- package/dist/expect.d.ts +12 -0
- package/dist/expect.d.ts.map +1 -0
- package/dist/expect.js +33 -0
- package/dist/expect.js.map +1 -0
- package/dist/fetchers/activitypub.d.ts +25 -0
- package/dist/fetchers/activitypub.d.ts.map +1 -0
- package/dist/fetchers/activitypub.js +32 -0
- package/dist/fetchers/activitypub.js.map +1 -0
- package/dist/fetchers/dns.d.ts +21 -0
- package/dist/fetchers/dns.d.ts.map +1 -0
- package/dist/fetchers/dns.js +61 -0
- package/dist/fetchers/dns.js.map +1 -0
- package/dist/fetchers/http.d.ts +10 -0
- package/dist/fetchers/http.d.ts.map +1 -0
- package/dist/fetchers/http.js +30 -0
- package/dist/fetchers/http.js.map +1 -0
- package/dist/fetchers/index.d.ts +16 -0
- package/dist/fetchers/index.d.ts.map +1 -0
- package/dist/fetchers/index.js +22 -0
- package/dist/fetchers/index.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/interpolate.d.ts +12 -0
- package/dist/interpolate.d.ts.map +1 -0
- package/dist/interpolate.js +23 -0
- package/dist/interpolate.js.map +1 -0
- package/dist/profile.d.ts +42 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +176 -0
- package/dist/profile.js.map +1 -0
- package/dist/recipes/dns-txt.d.ts +9 -0
- package/dist/recipes/dns-txt.d.ts.map +1 -0
- package/dist/recipes/dns-txt.js +45 -0
- package/dist/recipes/dns-txt.js.map +1 -0
- package/dist/recipes/github-gist.d.ts +9 -0
- package/dist/recipes/github-gist.d.ts.map +1 -0
- package/dist/recipes/github-gist.js +52 -0
- package/dist/recipes/github-gist.js.map +1 -0
- package/dist/recipes/index.d.ts +3 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +3 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/runner.d.ts +7 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +100 -0
- package/dist/runner.js.map +1 -0
- package/dist/serviceProviders/activitypub.d.ts +10 -0
- package/dist/serviceProviders/activitypub.d.ts.map +1 -0
- package/dist/serviceProviders/activitypub.js +73 -0
- package/dist/serviceProviders/activitypub.js.map +1 -0
- package/dist/serviceProviders/bsky.d.ts +10 -0
- package/dist/serviceProviders/bsky.d.ts.map +1 -0
- package/dist/serviceProviders/bsky.js +63 -0
- package/dist/serviceProviders/bsky.js.map +1 -0
- package/dist/serviceProviders/dns.d.ts +10 -0
- package/dist/serviceProviders/dns.d.ts.map +1 -0
- package/dist/serviceProviders/dns.js +65 -0
- package/dist/serviceProviders/dns.js.map +1 -0
- package/dist/serviceProviders/github.d.ts +10 -0
- package/dist/serviceProviders/github.d.ts.map +1 -0
- package/dist/serviceProviders/github.js +100 -0
- package/dist/serviceProviders/github.js.map +1 -0
- package/dist/serviceProviders/index.d.ts +26 -0
- package/dist/serviceProviders/index.d.ts.map +1 -0
- package/dist/serviceProviders/index.js +55 -0
- package/dist/serviceProviders/index.js.map +1 -0
- package/dist/serviceProviders/npm.d.ts +10 -0
- package/dist/serviceProviders/npm.d.ts.map +1 -0
- package/dist/serviceProviders/npm.js +99 -0
- package/dist/serviceProviders/npm.js.map +1 -0
- package/dist/serviceProviders/types.d.ts +106 -0
- package/dist/serviceProviders/types.d.ts.map +1 -0
- package/dist/serviceProviders/types.js +2 -0
- package/dist/serviceProviders/types.js.map +1 -0
- package/dist/types.d.ts +165 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/src/actions/css-select.ts +14 -0
- package/src/actions/dns-txt.ts +16 -0
- package/src/actions/http-get.ts +19 -0
- package/src/actions/index.ts +5 -0
- package/src/actions/json-path.ts +29 -0
- package/src/actions/regex-match.ts +13 -0
- package/src/claim.ts +293 -0
- package/src/constants.ts +19 -0
- package/src/expect.ts +36 -0
- package/src/fetchers/activitypub.ts +53 -0
- package/src/fetchers/dns.ts +82 -0
- package/src/fetchers/http.ts +38 -0
- package/src/fetchers/index.ts +30 -0
- package/src/index.ts +57 -0
- package/src/interpolate.ts +20 -0
- package/src/profile.ts +229 -0
- package/src/recipes/dns-txt.ts +46 -0
- package/src/recipes/github-gist.ts +53 -0
- package/src/recipes/index.ts +2 -0
- package/src/runner.ts +116 -0
- package/src/serviceProviders/activitypub.ts +84 -0
- package/src/serviceProviders/bsky.ts +73 -0
- package/src/serviceProviders/dns.ts +75 -0
- package/src/serviceProviders/github.ts +112 -0
- package/src/serviceProviders/index.ts +65 -0
- package/src/serviceProviders/npm.ts +116 -0
- package/src/serviceProviders/types.ts +121 -0
- package/src/types.ts +181 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DEFAULT_TIMEOUT } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
export interface ActivityPubActor {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
preferredUsername?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
summary?: string;
|
|
9
|
+
attachment?: Array<{
|
|
10
|
+
type: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
value?: string;
|
|
13
|
+
}>;
|
|
14
|
+
attributedTo?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ActivityPubFetchOptions {
|
|
18
|
+
timeout?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fetch an ActivityPub actor document
|
|
23
|
+
*/
|
|
24
|
+
export async function fetchActor(uri: string, options: ActivityPubFetchOptions = {}): Promise<ActivityPubActor> {
|
|
25
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const response = await globalThis.fetch(uri, {
|
|
31
|
+
headers: {
|
|
32
|
+
Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
|
33
|
+
"User-Agent": "keytrace-runner/1.0",
|
|
34
|
+
},
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (await response.json()) as ActivityPubActor;
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timeoutId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fetch data from an ActivityPub URL (alias for http fetch with AP headers)
|
|
50
|
+
*/
|
|
51
|
+
export async function fetch(uri: string, options: ActivityPubFetchOptions = {}): Promise<unknown> {
|
|
52
|
+
return fetchActor(uri, options);
|
|
53
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DEFAULT_TIMEOUT } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
export interface DnsFetchResult {
|
|
4
|
+
domain: string;
|
|
5
|
+
records: {
|
|
6
|
+
txt: string[];
|
|
7
|
+
};
|
|
8
|
+
/** Debug info showing which locations were checked */
|
|
9
|
+
_locations?: {
|
|
10
|
+
root: string[];
|
|
11
|
+
_keytrace: string[];
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DnsFetchOptions {
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if the Node.js `dns` module is available.
|
|
21
|
+
* This is more reliable than checking `typeof window` since SSR frameworks
|
|
22
|
+
* and edge runtimes can have `window` defined or `dns` unavailable.
|
|
23
|
+
*/
|
|
24
|
+
async function hasDnsModule(): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
await import("dns");
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fetch DNS TXT records for a domain.
|
|
35
|
+
* Checks both the root domain and _keytrace subdomain.
|
|
36
|
+
* Returns null in environments where DNS resolution is not available.
|
|
37
|
+
*/
|
|
38
|
+
export async function fetch(domain: string, options: DnsFetchOptions = {}): Promise<DnsFetchResult | null> {
|
|
39
|
+
if (!(await hasDnsModule())) {
|
|
40
|
+
console.debug("DNS fetching is not available in this environment");
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const dns = await import("dns");
|
|
48
|
+
const dnsPromises = dns.promises;
|
|
49
|
+
|
|
50
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
51
|
+
setTimeout(() => reject(new Error("DNS timeout")), timeout);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Check both root domain and _keytrace subdomain
|
|
55
|
+
const rootDomain = domain;
|
|
56
|
+
const keytraceDomain = `_keytrace.${domain}`;
|
|
57
|
+
|
|
58
|
+
const fetchPromise = Promise.all([
|
|
59
|
+
dnsPromises.resolveTxt(rootDomain).catch(() => []),
|
|
60
|
+
dnsPromises.resolveTxt(keytraceDomain).catch(() => []),
|
|
61
|
+
]).then(([rootRecords, keytraceRecords]) => ({
|
|
62
|
+
domain,
|
|
63
|
+
records: {
|
|
64
|
+
txt: [...rootRecords.flat(), ...keytraceRecords.flat()],
|
|
65
|
+
},
|
|
66
|
+
// Include which locations were checked for debugging
|
|
67
|
+
_locations: {
|
|
68
|
+
root: rootRecords.flat(),
|
|
69
|
+
_keytrace: keytraceRecords.flat(),
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
return await Promise.race([fetchPromise, timeoutPromise]);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof Error && error.message === "DNS timeout") {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
// DNS lookup failed (NXDOMAIN, etc.)
|
|
79
|
+
console.debug(`DNS lookup failed for ${domain}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { DEFAULT_TIMEOUT } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
export interface HttpFetchOptions {
|
|
4
|
+
format: "json" | "text";
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch data from an HTTP/HTTPS URL
|
|
11
|
+
*/
|
|
12
|
+
export async function fetch(url: string, options: HttpFetchOptions): Promise<unknown> {
|
|
13
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const response = await globalThis.fetch(url, {
|
|
19
|
+
headers: {
|
|
20
|
+
"User-Agent": "keytrace-runner/1.0",
|
|
21
|
+
Accept: options.format === "json" ? "application/json" : "text/plain",
|
|
22
|
+
...options.headers,
|
|
23
|
+
},
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.format === "json") {
|
|
32
|
+
return await response.json();
|
|
33
|
+
}
|
|
34
|
+
return await response.text();
|
|
35
|
+
} finally {
|
|
36
|
+
clearTimeout(timeoutId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as http from "./http.js";
|
|
2
|
+
import * as dns from "./dns.js";
|
|
3
|
+
import * as activitypub from "./activitypub.js";
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
+
export interface Fetcher {
|
|
7
|
+
fetch: (uri: string, options?: any) => Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const fetchers: Record<string, Fetcher> = {
|
|
11
|
+
http,
|
|
12
|
+
dns,
|
|
13
|
+
activitypub,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get a fetcher by name
|
|
18
|
+
*/
|
|
19
|
+
export function get(name: string): Fetcher | undefined {
|
|
20
|
+
return fetchers[name];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get all available fetchers
|
|
25
|
+
*/
|
|
26
|
+
export function getAll(): Record<string, Fetcher> {
|
|
27
|
+
return { ...fetchers };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { http, dns, activitypub };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Core runner
|
|
2
|
+
export { runRecipe } from "./runner.js";
|
|
3
|
+
|
|
4
|
+
// Types
|
|
5
|
+
export type {
|
|
6
|
+
Recipe,
|
|
7
|
+
RecipeParam,
|
|
8
|
+
RecipeInstructions,
|
|
9
|
+
RecipeVerification,
|
|
10
|
+
VerificationStep,
|
|
11
|
+
ClaimContext,
|
|
12
|
+
VerificationResult,
|
|
13
|
+
StepResult,
|
|
14
|
+
RunnerConfig,
|
|
15
|
+
FetchFn,
|
|
16
|
+
ClaimVerificationResult,
|
|
17
|
+
ProfileData,
|
|
18
|
+
ClaimData,
|
|
19
|
+
VerifyOptions,
|
|
20
|
+
IdentityMetadata,
|
|
21
|
+
ProofDetails,
|
|
22
|
+
ProofTargetResult,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
export { ClaimStatus } from "./types.js";
|
|
25
|
+
|
|
26
|
+
// Template interpolation
|
|
27
|
+
export { interpolate } from "./interpolate.js";
|
|
28
|
+
|
|
29
|
+
// Expect matchers
|
|
30
|
+
export { checkExpect } from "./expect.js";
|
|
31
|
+
|
|
32
|
+
// Individual actions
|
|
33
|
+
export { httpGet } from "./actions/http-get.js";
|
|
34
|
+
export { jsonPath } from "./actions/json-path.js";
|
|
35
|
+
export { cssSelect } from "./actions/css-select.js";
|
|
36
|
+
export { regexMatch } from "./actions/regex-match.js";
|
|
37
|
+
export { dnsTxt } from "./actions/dns-txt.js";
|
|
38
|
+
|
|
39
|
+
// Built-in recipes
|
|
40
|
+
export { githubGistRecipe } from "./recipes/github-gist.js";
|
|
41
|
+
export { dnsTxtRecipe } from "./recipes/dns-txt.js";
|
|
42
|
+
|
|
43
|
+
// Claim & Profile (from runner)
|
|
44
|
+
export { createClaim, matchClaim, verifyClaim, isClaimAmbiguous, getMatchedProvider, isValidDid } from "./claim.js";
|
|
45
|
+
export type { ClaimState } from "./claim.js";
|
|
46
|
+
export { fetchProfile, resolvePds, verifyAllClaims, getProfileSummary, getClaimsByStatus } from "./profile.js";
|
|
47
|
+
export type { FetchedProfile } from "./profile.js";
|
|
48
|
+
|
|
49
|
+
// Constants
|
|
50
|
+
export { COLLECTION_NSID, DEFAULT_TIMEOUT, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js";
|
|
51
|
+
|
|
52
|
+
// Service providers
|
|
53
|
+
export * as serviceProviders from "./serviceProviders/index.js";
|
|
54
|
+
export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./serviceProviders/types.js";
|
|
55
|
+
|
|
56
|
+
// Fetchers
|
|
57
|
+
export * as fetchers from "./fetchers/index.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ClaimContext } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interpolate {variable} placeholders in a template string using claim context.
|
|
5
|
+
*
|
|
6
|
+
* Replaces:
|
|
7
|
+
* {claimId} - from context.claimId
|
|
8
|
+
* {did} - from context.did
|
|
9
|
+
* {handle} - from context.handle
|
|
10
|
+
* {anyKey} - from context.params[anyKey]
|
|
11
|
+
*/
|
|
12
|
+
export function interpolate(template: string, context: ClaimContext): string {
|
|
13
|
+
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => {
|
|
14
|
+
if (key === "claimId") return context.claimId;
|
|
15
|
+
if (key === "did") return context.did;
|
|
16
|
+
if (key === "handle") return context.handle;
|
|
17
|
+
if (key in context.params) return context.params[key];
|
|
18
|
+
return `{${key}}`;
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { AtpAgent } from "@atproto/api";
|
|
2
|
+
import { createClaim, verifyClaim, type ClaimState } from "./claim.js";
|
|
3
|
+
import { ClaimStatus } from "./types.js";
|
|
4
|
+
import { COLLECTION_NSID, PUBLIC_API_URL, PLC_DIRECTORY_URL } from "./constants.js";
|
|
5
|
+
import type { ProfileData, ClaimData, VerifyOptions, IdentityMetadata } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* DID document service entry
|
|
9
|
+
*/
|
|
10
|
+
interface DidService {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
serviceEndpoint: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DID document shape (subset of fields we need)
|
|
18
|
+
*/
|
|
19
|
+
interface DidDocument {
|
|
20
|
+
id: string;
|
|
21
|
+
service?: DidService[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A fetched profile with resolved claims
|
|
26
|
+
*/
|
|
27
|
+
export interface FetchedProfile extends ProfileData {
|
|
28
|
+
claims: ClaimData[];
|
|
29
|
+
claimInstances: ClaimState[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the PDS endpoint from a DID document.
|
|
34
|
+
* For did:plc, fetches from plc.directory.
|
|
35
|
+
* For did:web, fetches from the well-known DID path.
|
|
36
|
+
* Falls back to PUBLIC_API_URL on failure.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolvePds(did: string): Promise<string> {
|
|
39
|
+
try {
|
|
40
|
+
let url: string;
|
|
41
|
+
if (did.startsWith("did:plc:")) {
|
|
42
|
+
url = `${PLC_DIRECTORY_URL}/${did}`;
|
|
43
|
+
} else if (did.startsWith("did:web:")) {
|
|
44
|
+
const host = did.replace("did:web:", "").replaceAll(":", "/");
|
|
45
|
+
url = `https://${host}/.well-known/did.json`;
|
|
46
|
+
} else {
|
|
47
|
+
return PUBLIC_API_URL;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const response = await globalThis.fetch(url, {
|
|
51
|
+
headers: { Accept: "application/json" },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
return PUBLIC_API_URL;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const doc = (await response.json()) as DidDocument;
|
|
59
|
+
const pdsService = doc.service?.find((s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer");
|
|
60
|
+
|
|
61
|
+
return pdsService?.serviceEndpoint ?? PUBLIC_API_URL;
|
|
62
|
+
} catch {
|
|
63
|
+
return PUBLIC_API_URL;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse an AT URI and extract the rkey (record key).
|
|
69
|
+
* AT URIs have the format: at://did/collection/rkey
|
|
70
|
+
*/
|
|
71
|
+
function parseAtUriRkey(atUri: string): string {
|
|
72
|
+
const match = atUri.match(/^at:\/\/[^/]+\/[^/]+\/(.+)$/);
|
|
73
|
+
return match?.[1] ?? "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Internal: fetch profile data using an already-configured agent
|
|
78
|
+
*/
|
|
79
|
+
async function fetchWithAgent(agent: AtpAgent, did: string): Promise<FetchedProfile> {
|
|
80
|
+
// Fetch Bluesky profile for display info via public API (not PDS)
|
|
81
|
+
// The PDS doesn't serve app.bsky.actor.getProfile - only the AppView does
|
|
82
|
+
let bskyProfile: { handle: string; displayName?: string; avatar?: string } | null = null;
|
|
83
|
+
try {
|
|
84
|
+
const publicAgent = new AtpAgent({ service: PUBLIC_API_URL });
|
|
85
|
+
const profileRes = await publicAgent.getProfile({ actor: did });
|
|
86
|
+
bskyProfile = {
|
|
87
|
+
handle: profileRes.data.handle,
|
|
88
|
+
displayName: profileRes.data.displayName,
|
|
89
|
+
avatar: profileRes.data.avatar,
|
|
90
|
+
};
|
|
91
|
+
} catch (err: unknown) {
|
|
92
|
+
// Profile fetch is optional - user may not have a Bluesky profile
|
|
93
|
+
// 404 is expected; log other errors at debug level
|
|
94
|
+
if (err instanceof Error && !err.message.includes("404")) {
|
|
95
|
+
console.debug(`Failed to fetch profile for ${did}: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// List all claim records with cursor-based pagination
|
|
100
|
+
const claims: ClaimData[] = [];
|
|
101
|
+
try {
|
|
102
|
+
let cursor: string | undefined;
|
|
103
|
+
do {
|
|
104
|
+
const records = await agent.com.atproto.repo.listRecords({
|
|
105
|
+
repo: did,
|
|
106
|
+
collection: COLLECTION_NSID,
|
|
107
|
+
limit: 100,
|
|
108
|
+
cursor,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const record of records.data.records) {
|
|
112
|
+
const value = record.value as {
|
|
113
|
+
claimUri?: string;
|
|
114
|
+
type?: string;
|
|
115
|
+
comment?: string;
|
|
116
|
+
createdAt?: string;
|
|
117
|
+
identity?: IdentityMetadata;
|
|
118
|
+
};
|
|
119
|
+
if (value.claimUri) {
|
|
120
|
+
claims.push({
|
|
121
|
+
uri: value.claimUri,
|
|
122
|
+
did,
|
|
123
|
+
type: value.type,
|
|
124
|
+
comment: value.comment,
|
|
125
|
+
createdAt: value.createdAt ?? new Date().toISOString(),
|
|
126
|
+
rkey: parseAtUriRkey(record.uri),
|
|
127
|
+
identity: value.identity,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
cursor = records.data.cursor;
|
|
133
|
+
} while (cursor);
|
|
134
|
+
} catch (err: unknown) {
|
|
135
|
+
// 404 means no records yet; log other errors
|
|
136
|
+
if (err instanceof Error && !err.message.includes("404")) {
|
|
137
|
+
console.debug(`Failed to list claim records for ${did}: ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
did,
|
|
143
|
+
handle: bskyProfile?.handle ?? did,
|
|
144
|
+
displayName: bskyProfile?.displayName,
|
|
145
|
+
avatar: bskyProfile?.avatar,
|
|
146
|
+
claims,
|
|
147
|
+
claimInstances: claims.map((c) => createClaim(c.uri, did)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Fetch a profile from ATProto by DID or handle
|
|
153
|
+
*/
|
|
154
|
+
export async function fetchProfile(didOrHandle: string, serviceUrl?: string): Promise<FetchedProfile> {
|
|
155
|
+
// Resolve PDS from DID document unless an explicit serviceUrl was provided
|
|
156
|
+
let resolvedServiceUrl: string;
|
|
157
|
+
let did = didOrHandle;
|
|
158
|
+
|
|
159
|
+
if (serviceUrl) {
|
|
160
|
+
resolvedServiceUrl = serviceUrl;
|
|
161
|
+
} else if (didOrHandle.startsWith("did:")) {
|
|
162
|
+
resolvedServiceUrl = await resolvePds(didOrHandle);
|
|
163
|
+
} else {
|
|
164
|
+
// Handle - we need to resolve via the public API first, then resolve PDS
|
|
165
|
+
resolvedServiceUrl = PUBLIC_API_URL;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const agent = new AtpAgent({ service: resolvedServiceUrl });
|
|
169
|
+
|
|
170
|
+
// Resolve handle to DID if needed
|
|
171
|
+
if (!didOrHandle.startsWith("did:")) {
|
|
172
|
+
const resolved = await agent.resolveHandle({ handle: didOrHandle });
|
|
173
|
+
did = resolved.data.did;
|
|
174
|
+
|
|
175
|
+
// Now that we have the DID, resolve the actual PDS if no explicit serviceUrl
|
|
176
|
+
if (!serviceUrl) {
|
|
177
|
+
const pdsUrl = await resolvePds(did);
|
|
178
|
+
if (pdsUrl !== resolvedServiceUrl) {
|
|
179
|
+
resolvedServiceUrl = pdsUrl;
|
|
180
|
+
// Re-create agent pointed at the user's actual PDS
|
|
181
|
+
const pdsAgent = new AtpAgent({ service: pdsUrl });
|
|
182
|
+
return fetchWithAgent(pdsAgent, did);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return fetchWithAgent(agent, did);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Verify all claims in a profile
|
|
192
|
+
*/
|
|
193
|
+
export async function verifyAllClaims(profile: FetchedProfile, opts?: VerifyOptions): Promise<void> {
|
|
194
|
+
await Promise.all(profile.claimInstances.map((claim) => verifyClaim(claim, opts)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get verification summary for a profile
|
|
199
|
+
*/
|
|
200
|
+
export function getProfileSummary(profile: FetchedProfile): {
|
|
201
|
+
total: number;
|
|
202
|
+
verified: number;
|
|
203
|
+
failed: number;
|
|
204
|
+
pending: number;
|
|
205
|
+
} {
|
|
206
|
+
const claims = profile.claimInstances;
|
|
207
|
+
return {
|
|
208
|
+
total: claims.length,
|
|
209
|
+
verified: claims.filter((c) => c.status === ClaimStatus.VERIFIED).length,
|
|
210
|
+
failed: claims.filter((c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR).length,
|
|
211
|
+
pending: claims.filter((c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED).length,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get claims grouped by status
|
|
217
|
+
*/
|
|
218
|
+
export function getClaimsByStatus(profile: FetchedProfile): {
|
|
219
|
+
verified: ClaimState[];
|
|
220
|
+
failed: ClaimState[];
|
|
221
|
+
pending: ClaimState[];
|
|
222
|
+
} {
|
|
223
|
+
const claims = profile.claimInstances;
|
|
224
|
+
return {
|
|
225
|
+
verified: claims.filter((c) => c.status === ClaimStatus.VERIFIED),
|
|
226
|
+
failed: claims.filter((c) => c.status === ClaimStatus.FAILED || c.status === ClaimStatus.ERROR),
|
|
227
|
+
pending: claims.filter((c) => c.status === ClaimStatus.INIT || c.status === ClaimStatus.MATCHED),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Recipe } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in recipe: Domain verification via DNS TXT record.
|
|
5
|
+
*
|
|
6
|
+
* The user adds a TXT record to their domain containing their DID,
|
|
7
|
+
* then provides the domain name for verification.
|
|
8
|
+
*/
|
|
9
|
+
export const dnsTxtRecipe: Recipe = {
|
|
10
|
+
$type: "dev.keytrace.recipe",
|
|
11
|
+
type: "dns",
|
|
12
|
+
version: 1,
|
|
13
|
+
displayName: "Domain (via DNS TXT)",
|
|
14
|
+
params: [
|
|
15
|
+
{
|
|
16
|
+
key: "domain",
|
|
17
|
+
label: "Domain name",
|
|
18
|
+
type: "domain",
|
|
19
|
+
placeholder: "example.com",
|
|
20
|
+
pattern: "^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
instructions: {
|
|
24
|
+
steps: [
|
|
25
|
+
"Log into your domain's DNS management panel",
|
|
26
|
+
"Add a new TXT record to the root domain (or _keytrace subdomain)",
|
|
27
|
+
"Set the value to the verification text below",
|
|
28
|
+
"Wait for DNS propagation (may take a few minutes)",
|
|
29
|
+
],
|
|
30
|
+
proofTemplate: "keytrace-verification={did}",
|
|
31
|
+
proofLocation: "DNS TXT record on your domain",
|
|
32
|
+
},
|
|
33
|
+
verification: {
|
|
34
|
+
steps: [
|
|
35
|
+
{
|
|
36
|
+
action: "dns-txt",
|
|
37
|
+
url: "{domain}",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
action: "regex-match",
|
|
41
|
+
pattern: "keytrace-verification=({did})",
|
|
42
|
+
expect: "equals:{did}",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Recipe } from "../types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in recipe: GitHub Account verification via public Gist.
|
|
5
|
+
*
|
|
6
|
+
* The user creates a public gist named keytrace.json containing
|
|
7
|
+
* their claim ID and DID, then provides the gist URL for verification.
|
|
8
|
+
*/
|
|
9
|
+
export const githubGistRecipe: Recipe = {
|
|
10
|
+
$type: "dev.keytrace.recipe",
|
|
11
|
+
type: "github-gist",
|
|
12
|
+
version: 1,
|
|
13
|
+
displayName: "GitHub Account (via Gist)",
|
|
14
|
+
params: [
|
|
15
|
+
{
|
|
16
|
+
key: "gistUrl",
|
|
17
|
+
label: "Gist URL",
|
|
18
|
+
type: "url",
|
|
19
|
+
placeholder: "https://gist.github.com/octocat/abc123...",
|
|
20
|
+
pattern: "^https://gist\\.github\\.com/([^/]+)/([a-f0-9]+)$",
|
|
21
|
+
extractFrom: "^https://gist\\.github\\.com/([^/]+)/",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
instructions: {
|
|
25
|
+
steps: [
|
|
26
|
+
"Go to https://gist.github.com",
|
|
27
|
+
"Create a new public gist",
|
|
28
|
+
"Name the file `keytrace.json`",
|
|
29
|
+
"Paste the verification content below into the file",
|
|
30
|
+
"Save the gist and paste the URL below",
|
|
31
|
+
],
|
|
32
|
+
proofTemplate: '{\n "keytrace": "{claimId}",\n "did": "{did}"\n}',
|
|
33
|
+
proofLocation: "Public gist with keytrace.json",
|
|
34
|
+
},
|
|
35
|
+
verification: {
|
|
36
|
+
steps: [
|
|
37
|
+
{
|
|
38
|
+
action: "http-get",
|
|
39
|
+
url: "{gistUrl}/raw/keytrace.json",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
action: "json-path",
|
|
43
|
+
selector: "$.keytrace",
|
|
44
|
+
expect: "equals:{claimId}",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
action: "json-path",
|
|
48
|
+
selector: "$.did",
|
|
49
|
+
expect: "equals:{did}",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
};
|