@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.
Files changed (142) hide show
  1. package/README.md +139 -0
  2. package/dist/actions/css-select.d.ts +6 -0
  3. package/dist/actions/css-select.d.ts.map +1 -0
  4. package/dist/actions/css-select.js +14 -0
  5. package/dist/actions/css-select.js.map +1 -0
  6. package/dist/actions/dns-txt.d.ts +6 -0
  7. package/dist/actions/dns-txt.d.ts.map +1 -0
  8. package/dist/actions/dns-txt.js +17 -0
  9. package/dist/actions/dns-txt.js.map +1 -0
  10. package/dist/actions/http-get.d.ts +6 -0
  11. package/dist/actions/http-get.d.ts.map +1 -0
  12. package/dist/actions/http-get.js +19 -0
  13. package/dist/actions/http-get.js.map +1 -0
  14. package/dist/actions/index.d.ts +6 -0
  15. package/dist/actions/index.d.ts.map +1 -0
  16. package/dist/actions/index.js +6 -0
  17. package/dist/actions/index.js.map +1 -0
  18. package/dist/actions/json-path.d.ts +12 -0
  19. package/dist/actions/json-path.d.ts.map +1 -0
  20. package/dist/actions/json-path.js +26 -0
  21. package/dist/actions/json-path.js.map +1 -0
  22. package/dist/actions/regex-match.d.ts +6 -0
  23. package/dist/actions/regex-match.d.ts.map +1 -0
  24. package/dist/actions/regex-match.js +14 -0
  25. package/dist/actions/regex-match.js.map +1 -0
  26. package/dist/claim.d.ts +38 -0
  27. package/dist/claim.d.ts.map +1 -0
  28. package/dist/claim.js +253 -0
  29. package/dist/claim.js.map +1 -0
  30. package/dist/constants.d.ts +17 -0
  31. package/dist/constants.d.ts.map +1 -0
  32. package/dist/constants.js +17 -0
  33. package/dist/constants.js.map +1 -0
  34. package/dist/expect.d.ts +12 -0
  35. package/dist/expect.d.ts.map +1 -0
  36. package/dist/expect.js +33 -0
  37. package/dist/expect.js.map +1 -0
  38. package/dist/fetchers/activitypub.d.ts +25 -0
  39. package/dist/fetchers/activitypub.d.ts.map +1 -0
  40. package/dist/fetchers/activitypub.js +32 -0
  41. package/dist/fetchers/activitypub.js.map +1 -0
  42. package/dist/fetchers/dns.d.ts +21 -0
  43. package/dist/fetchers/dns.d.ts.map +1 -0
  44. package/dist/fetchers/dns.js +61 -0
  45. package/dist/fetchers/dns.js.map +1 -0
  46. package/dist/fetchers/http.d.ts +10 -0
  47. package/dist/fetchers/http.d.ts.map +1 -0
  48. package/dist/fetchers/http.js +30 -0
  49. package/dist/fetchers/http.js.map +1 -0
  50. package/dist/fetchers/index.d.ts +16 -0
  51. package/dist/fetchers/index.d.ts.map +1 -0
  52. package/dist/fetchers/index.js +22 -0
  53. package/dist/fetchers/index.js.map +1 -0
  54. package/dist/index.d.ts +21 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +26 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/interpolate.d.ts +12 -0
  59. package/dist/interpolate.d.ts.map +1 -0
  60. package/dist/interpolate.js +23 -0
  61. package/dist/interpolate.js.map +1 -0
  62. package/dist/profile.d.ts +42 -0
  63. package/dist/profile.d.ts.map +1 -0
  64. package/dist/profile.js +176 -0
  65. package/dist/profile.js.map +1 -0
  66. package/dist/recipes/dns-txt.d.ts +9 -0
  67. package/dist/recipes/dns-txt.d.ts.map +1 -0
  68. package/dist/recipes/dns-txt.js +45 -0
  69. package/dist/recipes/dns-txt.js.map +1 -0
  70. package/dist/recipes/github-gist.d.ts +9 -0
  71. package/dist/recipes/github-gist.d.ts.map +1 -0
  72. package/dist/recipes/github-gist.js +52 -0
  73. package/dist/recipes/github-gist.js.map +1 -0
  74. package/dist/recipes/index.d.ts +3 -0
  75. package/dist/recipes/index.d.ts.map +1 -0
  76. package/dist/recipes/index.js +3 -0
  77. package/dist/recipes/index.js.map +1 -0
  78. package/dist/runner.d.ts +7 -0
  79. package/dist/runner.d.ts.map +1 -0
  80. package/dist/runner.js +100 -0
  81. package/dist/runner.js.map +1 -0
  82. package/dist/serviceProviders/activitypub.d.ts +10 -0
  83. package/dist/serviceProviders/activitypub.d.ts.map +1 -0
  84. package/dist/serviceProviders/activitypub.js +73 -0
  85. package/dist/serviceProviders/activitypub.js.map +1 -0
  86. package/dist/serviceProviders/bsky.d.ts +10 -0
  87. package/dist/serviceProviders/bsky.d.ts.map +1 -0
  88. package/dist/serviceProviders/bsky.js +63 -0
  89. package/dist/serviceProviders/bsky.js.map +1 -0
  90. package/dist/serviceProviders/dns.d.ts +10 -0
  91. package/dist/serviceProviders/dns.d.ts.map +1 -0
  92. package/dist/serviceProviders/dns.js +65 -0
  93. package/dist/serviceProviders/dns.js.map +1 -0
  94. package/dist/serviceProviders/github.d.ts +10 -0
  95. package/dist/serviceProviders/github.d.ts.map +1 -0
  96. package/dist/serviceProviders/github.js +100 -0
  97. package/dist/serviceProviders/github.js.map +1 -0
  98. package/dist/serviceProviders/index.d.ts +26 -0
  99. package/dist/serviceProviders/index.d.ts.map +1 -0
  100. package/dist/serviceProviders/index.js +55 -0
  101. package/dist/serviceProviders/index.js.map +1 -0
  102. package/dist/serviceProviders/npm.d.ts +10 -0
  103. package/dist/serviceProviders/npm.d.ts.map +1 -0
  104. package/dist/serviceProviders/npm.js +99 -0
  105. package/dist/serviceProviders/npm.js.map +1 -0
  106. package/dist/serviceProviders/types.d.ts +106 -0
  107. package/dist/serviceProviders/types.d.ts.map +1 -0
  108. package/dist/serviceProviders/types.js +2 -0
  109. package/dist/serviceProviders/types.js.map +1 -0
  110. package/dist/types.d.ts +165 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +12 -0
  113. package/dist/types.js.map +1 -0
  114. package/package.json +37 -0
  115. package/src/actions/css-select.ts +14 -0
  116. package/src/actions/dns-txt.ts +16 -0
  117. package/src/actions/http-get.ts +19 -0
  118. package/src/actions/index.ts +5 -0
  119. package/src/actions/json-path.ts +29 -0
  120. package/src/actions/regex-match.ts +13 -0
  121. package/src/claim.ts +293 -0
  122. package/src/constants.ts +19 -0
  123. package/src/expect.ts +36 -0
  124. package/src/fetchers/activitypub.ts +53 -0
  125. package/src/fetchers/dns.ts +82 -0
  126. package/src/fetchers/http.ts +38 -0
  127. package/src/fetchers/index.ts +30 -0
  128. package/src/index.ts +57 -0
  129. package/src/interpolate.ts +20 -0
  130. package/src/profile.ts +229 -0
  131. package/src/recipes/dns-txt.ts +46 -0
  132. package/src/recipes/github-gist.ts +53 -0
  133. package/src/recipes/index.ts +2 -0
  134. package/src/runner.ts +116 -0
  135. package/src/serviceProviders/activitypub.ts +84 -0
  136. package/src/serviceProviders/bsky.ts +73 -0
  137. package/src/serviceProviders/dns.ts +75 -0
  138. package/src/serviceProviders/github.ts +112 -0
  139. package/src/serviceProviders/index.ts +65 -0
  140. package/src/serviceProviders/npm.ts +116 -0
  141. package/src/serviceProviders/types.ts +121 -0
  142. package/src/types.ts +181 -0
package/src/runner.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { Recipe, ClaimContext, RunnerConfig, VerificationResult, StepResult, VerificationStep, FetchFn } from "./types.js";
2
+ import { interpolate } from "./interpolate.js";
3
+ import { checkExpect } from "./expect.js";
4
+ import { httpGet } from "./actions/http-get.js";
5
+ import { jsonPath } from "./actions/json-path.js";
6
+ import { cssSelect } from "./actions/css-select.js";
7
+ import { regexMatch } from "./actions/regex-match.js";
8
+ import { dnsTxt } from "./actions/dns-txt.js";
9
+
10
+ const DEFAULT_TIMEOUT = 10_000;
11
+
12
+ /**
13
+ * Execute a recipe's verification steps against a claim context.
14
+ * Returns a full result with per-step details. Stops on first failure.
15
+ */
16
+ export async function runRecipe(recipe: Recipe, context: ClaimContext, config?: RunnerConfig): Promise<VerificationResult> {
17
+ const fetchFn: FetchFn = config?.fetch ?? globalThis.fetch;
18
+ const timeout = config?.timeout ?? DEFAULT_TIMEOUT;
19
+ const steps: StepResult[] = [];
20
+
21
+ // Extract subject from params if a param defines extractFrom
22
+ let subject: string | undefined;
23
+ if (recipe.params) {
24
+ for (const param of recipe.params) {
25
+ if (param.extractFrom && context.params[param.key]) {
26
+ const regex = new RegExp(param.extractFrom);
27
+ const match = context.params[param.key].match(regex);
28
+ if (match?.[1]) {
29
+ subject = `${recipe.type.split("-")[0]}:${match[1]}`;
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ // Tracks the last "fetch" output (http-get, dns-txt) for extraction steps to use.
36
+ // Extraction steps (json-path, css-select, regex-match) always operate on the
37
+ // last fetch output, not on each other's output.
38
+ let lastFetchOutput: unknown = undefined;
39
+
40
+ for (const step of recipe.verification.steps) {
41
+ const isFetchAction = step.action === "http-get" || step.action === "dns-txt";
42
+ const result = await executeStep(step, context, lastFetchOutput, fetchFn, timeout);
43
+ steps.push(result);
44
+
45
+ if (!result.success) {
46
+ return { success: false, steps, subject, error: result.error };
47
+ }
48
+
49
+ if (isFetchAction) {
50
+ lastFetchOutput = result.data;
51
+ }
52
+ }
53
+
54
+ return { success: true, steps, subject };
55
+ }
56
+
57
+ async function executeStep(step: VerificationStep, context: ClaimContext, previousOutput: unknown, fetchFn: FetchFn, timeout: number): Promise<StepResult> {
58
+ try {
59
+ let data: unknown;
60
+
61
+ switch (step.action) {
62
+ case "http-get": {
63
+ const url = interpolate(step.url!, context);
64
+ data = await httpGet(url, fetchFn, timeout);
65
+ break;
66
+ }
67
+
68
+ case "json-path": {
69
+ const selector = interpolate(step.selector!, context);
70
+ const input = previousOutput ?? "";
71
+ data = jsonPath(input as string | object, selector);
72
+ break;
73
+ }
74
+
75
+ case "css-select": {
76
+ const selector = interpolate(step.selector!, context);
77
+ const input = previousOutput as string;
78
+ if (typeof input !== "string") {
79
+ throw new Error("css-select requires string input from a previous step");
80
+ }
81
+ data = cssSelect(input, selector);
82
+ break;
83
+ }
84
+
85
+ case "regex-match": {
86
+ const pattern = interpolate(step.pattern!, context);
87
+ const input = typeof previousOutput === "string" ? previousOutput : String(previousOutput ?? "");
88
+ data = regexMatch(input, pattern);
89
+ break;
90
+ }
91
+
92
+ case "dns-txt": {
93
+ const domain = step.url ? interpolate(step.url, context) : interpolate(step.pattern!, context);
94
+ data = await dnsTxt(domain);
95
+ break;
96
+ }
97
+
98
+ default:
99
+ throw new Error(`Unknown action: "${step.action}"`);
100
+ }
101
+
102
+ // Check expect if defined
103
+ if (step.expect) {
104
+ const expectStr = interpolate(step.expect, context);
105
+ const result = checkExpect(expectStr, data);
106
+ if (!result.pass) {
107
+ return { action: step.action, success: false, data, error: result.message };
108
+ }
109
+ }
110
+
111
+ return { action: step.action, success: true, data };
112
+ } catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ return { action: step.action, success: false, error: message };
115
+ }
116
+ }
@@ -0,0 +1,84 @@
1
+ import type { ServiceProvider } from "./types.js";
2
+
3
+ /**
4
+ * ActivityPub (Mastodon/Fediverse) service provider
5
+ *
6
+ * Users prove ownership by adding their DID to their profile bio or fields.
7
+ * The claim URI is the profile URL (e.g., https://mastodon.social/@username)
8
+ */
9
+ const activitypub: ServiceProvider = {
10
+ id: "activitypub",
11
+ name: "Mastodon",
12
+ homepage: "https://joinmastodon.org",
13
+
14
+ // Match Mastodon-style profile URLs: https://instance/@username
15
+ reUri: /^https:\/\/([^/]+)\/@([^/]+)\/?$/,
16
+
17
+ // Could match other ActivityPub software with same URL pattern
18
+ isAmbiguous: true,
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
+
35
+ processURI(uri, match) {
36
+ const [, domain, username] = match;
37
+
38
+ return {
39
+ profile: {
40
+ display: `@${username}@${domain}`,
41
+ uri,
42
+ },
43
+ proof: {
44
+ request: {
45
+ uri,
46
+ fetcher: "activitypub",
47
+ format: "json",
48
+ },
49
+ target: [
50
+ // Check profile bio/summary (HTML content)
51
+ { path: ["summary"], relation: "contains", format: "text" },
52
+ // Check profile fields (Mastodon-style verification fields)
53
+ { path: ["attachment", "*", "value"], relation: "contains", format: "text" },
54
+ ],
55
+ },
56
+ };
57
+ },
58
+
59
+ postprocess(data) {
60
+ const actor = data as { preferredUsername?: string; name?: string; icon?: { url?: string } };
61
+ return {
62
+ displayName: actor.name || actor.preferredUsername,
63
+ avatarUrl: actor.icon?.url,
64
+ };
65
+ },
66
+
67
+ getProofText(did) {
68
+ return did;
69
+ },
70
+
71
+ getProofLocation() {
72
+ return `Add to your profile bio or a profile metadata field`;
73
+ },
74
+
75
+ tests: [
76
+ { uri: "https://mastodon.social/@alice", shouldMatch: true },
77
+ { uri: "https://fosstodon.org/@bob/", shouldMatch: true },
78
+ { uri: "https://hachyderm.io/@user", shouldMatch: true },
79
+ { uri: "https://twitter.com/alice", shouldMatch: false },
80
+ { uri: "https://mastodon.social/alice", shouldMatch: false },
81
+ ],
82
+ };
83
+
84
+ export default activitypub;
@@ -0,0 +1,73 @@
1
+ import type { ServiceProvider } from "./types.js";
2
+
3
+ /**
4
+ * Bluesky service provider
5
+ *
6
+ * Users prove ownership of another Bluesky account by adding their DID to the profile bio.
7
+ * The claim URI is the bsky.app profile URL.
8
+ */
9
+ const bsky: ServiceProvider = {
10
+ id: "bsky",
11
+ name: "Bluesky",
12
+ homepage: "https://bsky.app",
13
+
14
+ // Match Bluesky profile URLs: https://bsky.app/profile/handle or did
15
+ reUri: /^https:\/\/bsky\.app\/profile\/([^/]+)\/?$/,
16
+
17
+ isAmbiguous: false,
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
+
34
+ processURI(uri, match) {
35
+ const [, handle] = match;
36
+
37
+ return {
38
+ profile: {
39
+ display: handle.startsWith("did:") ? handle : `@${handle}`,
40
+ uri,
41
+ },
42
+ proof: {
43
+ request: {
44
+ uri: `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`,
45
+ fetcher: "http",
46
+ format: "json",
47
+ },
48
+ target: [
49
+ // Check profile description/bio
50
+ { path: ["description"], relation: "contains", format: "text" },
51
+ ],
52
+ },
53
+ };
54
+ },
55
+
56
+ getProofText(did) {
57
+ return did;
58
+ },
59
+
60
+ getProofLocation() {
61
+ return `Add to your profile bio`;
62
+ },
63
+
64
+ tests: [
65
+ { uri: "https://bsky.app/profile/alice.bsky.social", shouldMatch: true },
66
+ { uri: "https://bsky.app/profile/did:plc:abc123", shouldMatch: true },
67
+ { uri: "https://bsky.app/profile/alice.bsky.social/", shouldMatch: true },
68
+ { uri: "https://bsky.app/profile/alice/post/123", shouldMatch: false },
69
+ { uri: "https://bsky.social/profile/alice", shouldMatch: false },
70
+ ],
71
+ };
72
+
73
+ export default bsky;
@@ -0,0 +1,75 @@
1
+ import type { ServiceProvider } from "./types.js";
2
+
3
+ /**
4
+ * DNS TXT record service provider
5
+ *
6
+ * Users prove domain ownership by adding a TXT record containing their DID.
7
+ * The claim URI format is: dns:example.com
8
+ */
9
+ const dns: ServiceProvider = {
10
+ id: "dns",
11
+ name: "Domain",
12
+ homepage: "",
13
+
14
+ // Match dns:domain.tld URIs (must contain at least one dot)
15
+ reUri: /^dns:([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+)$/,
16
+
17
+ isAmbiguous: false,
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
+
34
+ processURI(uri, match) {
35
+ const [, domain] = match;
36
+
37
+ return {
38
+ profile: {
39
+ display: domain,
40
+ uri: `https://${domain}`,
41
+ },
42
+ proof: {
43
+ request: {
44
+ uri: domain,
45
+ fetcher: "dns",
46
+ format: "json",
47
+ },
48
+ target: [
49
+ // Look for DID in any TXT record
50
+ { path: ["records", "txt"], relation: "contains", format: "text" },
51
+ ],
52
+ },
53
+ };
54
+ },
55
+
56
+ getProofText(did) {
57
+ return `keytrace-verification=${did}`;
58
+ },
59
+
60
+ getProofLocation(match) {
61
+ const [, domain] = match;
62
+ return `Add a TXT record at the root of ${domain} (or at _keytrace.${domain})`;
63
+ },
64
+
65
+ tests: [
66
+ { uri: "dns:example.com", shouldMatch: true },
67
+ { uri: "dns:sub.example.com", shouldMatch: true },
68
+ { uri: "dns:a.b.c.example.com", shouldMatch: true },
69
+ { uri: "dns:example", shouldMatch: false },
70
+ { uri: "dns:-invalid.com", shouldMatch: false },
71
+ { uri: "https://example.com", shouldMatch: false },
72
+ ],
73
+ };
74
+
75
+ export default dns;
@@ -0,0 +1,112 @@
1
+ import type { ServiceProvider } from "./types.js";
2
+
3
+ /**
4
+ * GitHub Gist service provider
5
+ *
6
+ * Users prove ownership by creating a public gist containing their DID.
7
+ * The gist URL is used as the claim URI.
8
+ */
9
+ const github: ServiceProvider = {
10
+ id: "github",
11
+ name: "GitHub",
12
+ homepage: "https://github.com",
13
+
14
+ // Match GitHub Gist URLs: https://gist.github.com/username/gistid
15
+ reUri: /^https:\/\/gist\.github\.com\/([^/]+)\/([a-f0-9]+)\/?$/,
16
+
17
+ isAmbiguous: false,
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
+
34
+ processURI(uri, match) {
35
+ const [, username, gistId] = match;
36
+
37
+ return {
38
+ profile: {
39
+ display: `@${username}`,
40
+ uri: `https://github.com/${username}`,
41
+ },
42
+ proof: {
43
+ request: {
44
+ uri: `https://api.github.com/gists/${gistId}`,
45
+ fetcher: "http",
46
+ format: "json",
47
+ options: {
48
+ headers: {
49
+ Accept: "application/vnd.github.v3+json",
50
+ },
51
+ },
52
+ },
53
+ target: [
54
+ // Check keytrace.json file content
55
+ {
56
+ path: ["files", "keytrace.json", "content"],
57
+ relation: "contains",
58
+ format: "text",
59
+ },
60
+ // Check proof.md file content
61
+ {
62
+ path: ["files", "proof.md", "content"],
63
+ relation: "contains",
64
+ format: "text",
65
+ },
66
+ // Check keytrace.md file content
67
+ {
68
+ path: ["files", "keytrace.md", "content"],
69
+ relation: "contains",
70
+ format: "text",
71
+ },
72
+ // Check openpgp.md for backwards compatibility with Keyoxide
73
+ {
74
+ path: ["files", "openpgp.md", "content"],
75
+ relation: "contains",
76
+ format: "text",
77
+ },
78
+ // Check gist description
79
+ { path: ["description"], relation: "contains", format: "text" },
80
+ ],
81
+ },
82
+ };
83
+ },
84
+
85
+ postprocess(data, match) {
86
+ const [, username] = match;
87
+ const gist = data as { owner?: { avatar_url?: string; login?: string } };
88
+
89
+ return {
90
+ subject: gist.owner?.login ?? username,
91
+ avatarUrl: gist.owner?.avatar_url,
92
+ profileUrl: `https://github.com/${gist.owner?.login ?? username}`,
93
+ };
94
+ },
95
+
96
+ getProofText(did) {
97
+ return `Verifying my identity on keytrace: ${did}`;
98
+ },
99
+
100
+ getProofLocation() {
101
+ return `Create a public gist with a file named keytrace.json, keytrace.md, or proof.md containing the proof text`;
102
+ },
103
+
104
+ tests: [
105
+ { uri: "https://gist.github.com/alice/abc123def456", shouldMatch: true },
106
+ { uri: "https://gist.github.com/alice/abc123def456/", shouldMatch: true },
107
+ { uri: "https://github.com/alice", shouldMatch: false },
108
+ { uri: "https://gist.gitlab.com/alice/abc123", shouldMatch: false },
109
+ ],
110
+ };
111
+
112
+ export default github;
@@ -0,0 +1,65 @@
1
+ import github from "./github.js";
2
+ import dns from "./dns.js";
3
+ import activitypub from "./activitypub.js";
4
+ import bsky from "./bsky.js";
5
+ import npm from "./npm.js";
6
+ import type { ServiceProvider, ServiceProviderMatch } from "./types.js";
7
+
8
+ export type { ServiceProvider, ServiceProviderMatch, ServiceProviderUI, ProofTarget, ProofRequest, ProcessedURI } from "./types.js";
9
+
10
+ const providers: Record<string, ServiceProvider> = {
11
+ github,
12
+ dns,
13
+ activitypub,
14
+ bsky,
15
+ npm,
16
+ };
17
+
18
+ /**
19
+ * Get a service provider by ID
20
+ */
21
+ export function getProvider(id: string): ServiceProvider | undefined {
22
+ return providers[id];
23
+ }
24
+
25
+ /**
26
+ * Get all registered service providers
27
+ */
28
+ export function getAllProviders(): ServiceProvider[] {
29
+ return Object.values(providers);
30
+ }
31
+
32
+ /**
33
+ * Match a URI against all service providers
34
+ * Returns all matching providers, with unambiguous matches stopping the search
35
+ */
36
+ export function matchUri(uri: string): ServiceProviderMatch[] {
37
+ const matches: ServiceProviderMatch[] = [];
38
+
39
+ for (const provider of Object.values(providers)) {
40
+ const match = uri.match(provider.reUri);
41
+ if (match) {
42
+ matches.push({
43
+ provider,
44
+ match,
45
+ isAmbiguous: provider.isAmbiguous ?? false,
46
+ });
47
+ // Stop on unambiguous match
48
+ if (!provider.isAmbiguous) {
49
+ break;
50
+ }
51
+ }
52
+ }
53
+
54
+ return matches;
55
+ }
56
+
57
+ /**
58
+ * Get the proof text a user should add to verify a claim
59
+ */
60
+ export function getProofTextForProvider(providerId: string, did: string, handle?: string): string | undefined {
61
+ const provider = providers[providerId];
62
+ return provider?.getProofText(did, handle);
63
+ }
64
+
65
+ export { github, dns, activitypub, bsky, npm };
@@ -0,0 +1,116 @@
1
+ import type { ServiceProvider } from "./types.js";
2
+
3
+ /**
4
+ * Slugify a handle for use in npm package names.
5
+ * Replaces dots with dashes since npm doesn't allow dots.
6
+ * e.g. "orta.io" -> "orta-io"
7
+ */
8
+ function slugifyHandle(handle: string): string {
9
+ return handle.replace(/\./g, "-").toLowerCase();
10
+ }
11
+
12
+ /**
13
+ * npm service provider
14
+ *
15
+ * Users prove ownership by publishing an npm package named keytrace-[handle]
16
+ * containing their DID in the package.json keytrace field.
17
+ */
18
+ const npm: ServiceProvider = {
19
+ id: "npm",
20
+ name: "npm",
21
+ homepage: "https://www.npmjs.com",
22
+
23
+ // Match npm package URLs: https://www.npmjs.com/package/keytrace-username
24
+ reUri: /^https:\/\/(?:www\.)?npmjs\.com\/package\/(keytrace-[a-z0-9_-]+)\/?$/i,
25
+
26
+ isAmbiguous: false,
27
+
28
+ ui: {
29
+ description: "Link via an npm package",
30
+ icon: "npm",
31
+ inputLabel: "npm Package URL",
32
+ inputPlaceholder: "https://www.npmjs.com/package/keytrace-yourhandle",
33
+ inputDefaultTemplate: "https://www.npmjs.com/package/keytrace-{slugHandle}",
34
+ instructions: [
35
+ "Create a new folder with a `package.json` containing the verification content below",
36
+ "Run `npm publish --access public` to publish",
37
+ "Paste the npm package URL below",
38
+ ],
39
+ proofTemplate: `{
40
+ "name": "keytrace-{slugHandle}",
41
+ "version": "0.0.1",
42
+ "keytrace": "{claimId}",
43
+ "did": "{did}"
44
+ }`,
45
+ },
46
+
47
+ processURI(uri, match) {
48
+ const [, packageName] = match;
49
+ // Extract the handle from keytrace-handle
50
+ const handle = packageName.replace(/^keytrace-/i, "");
51
+
52
+ return {
53
+ profile: {
54
+ display: `~${handle}`,
55
+ uri: `https://www.npmjs.com/~${handle}`,
56
+ },
57
+ proof: {
58
+ request: {
59
+ // Use npm registry to get the packument with full metadata
60
+ uri: `https://registry.npmjs.org/${packageName}`,
61
+ fetcher: "http",
62
+ format: "json",
63
+ },
64
+ target: [
65
+ // Check for DID in the did field of any version
66
+ // The packument has versions as an object: { versions: { "0.0.1": { did: "did:...", keytrace: "..." } } }
67
+ {
68
+ path: ["versions", "*", "did"],
69
+ relation: "contains",
70
+ format: "text",
71
+ },
72
+ ],
73
+ },
74
+ };
75
+ },
76
+
77
+ postprocess(data, _match) {
78
+ const packument = data as {
79
+ maintainers?: Array<{ name: string; email?: string }>;
80
+ author?: { name?: string; email?: string } | string;
81
+ };
82
+
83
+ // Get the first maintainer's npm username
84
+ const maintainer = packument.maintainers?.[0];
85
+ const npmUsername = maintainer?.name;
86
+
87
+ return {
88
+ subject: npmUsername,
89
+ profileUrl: npmUsername ? `https://www.npmjs.com/~${npmUsername}` : undefined,
90
+ };
91
+ },
92
+
93
+ getProofText(did, handle) {
94
+ const slug = handle ? slugifyHandle(handle) : "[your-handle]";
95
+ return `{
96
+ "name": "keytrace-${slug}",
97
+ "version": "0.0.1",
98
+ "keytrace": "[claim-id]",
99
+ "did": "${did}"
100
+ }`;
101
+ },
102
+
103
+ getProofLocation() {
104
+ return `Publish an npm package named keytrace-[your-handle] with your DID in the keytrace field of package.json`;
105
+ },
106
+
107
+ tests: [
108
+ { uri: "https://www.npmjs.com/package/keytrace-alice", shouldMatch: true },
109
+ { uri: "https://npmjs.com/package/keytrace-alice", shouldMatch: true },
110
+ { uri: "https://www.npmjs.com/package/keytrace-alice/", shouldMatch: true },
111
+ { uri: "https://www.npmjs.com/package/some-other-pkg", shouldMatch: false },
112
+ { uri: "https://www.npmjs.com/~alice", shouldMatch: false },
113
+ ],
114
+ };
115
+
116
+ export default npm;