crawlforge-mcp-server 5.2.8 → 5.3.0

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 (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +407 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +517 -13
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. package/src/utils/webBotAuth.js +193 -0
@@ -0,0 +1,206 @@
1
+ /**
2
+ * robotsGate — the one pre-fetch gate every fetching tool goes through.
3
+ *
4
+ * Before this module, `RobotsChecker` was instantiated in exactly one place
5
+ * (BFSCrawler), so only `crawl_deep` honoured robots.txt; `scrape`,
6
+ * `batch_scrape`, `scrape_template`, `track_changes`, `map_site` and every
7
+ * `extract_*` tool did no robots check at all. Ground rule G5 says every
8
+ * fetching tool respects robots.txt by default, so the check has to live at the
9
+ * fetch boundary rather than in one crawler.
10
+ *
11
+ * Order matters. The platform blocklist (G7) is consulted first and is not
12
+ * overridable by anything a caller can send; robots (G5) is next and *is*
13
+ * overridable, but only explicitly, with a warning and an audit row; the
14
+ * host's Crawl-delay (G6) then feeds the per-host throttle.
15
+ *
16
+ * Callers replace `await throttleHost(url)` with `await preflightFetch(url, …)`
17
+ * and spread the returned `headers` into the request.
18
+ */
19
+
20
+ import { RobotsChecker } from './robotsChecker.js';
21
+ import { assertHostAllowed } from './hostBlocklist.js';
22
+ import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
23
+ import { throttleHost } from './hostRateLimiter.js';
24
+ import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
25
+ import { signRequestHeaders } from './webBotAuth.js';
26
+ import { markPreflightRefusal } from '../server/requestContext.js';
27
+ import { config } from '../constants/config.js';
28
+
29
+ export class RobotsDisallowedError extends Error {
30
+ constructor(url) {
31
+ super(
32
+ `robots.txt on ${new URL(url).host} disallows this path for CrawlForge. ` +
33
+ `Pass respect_robots: false to fetch it anyway — that override is recorded ` +
34
+ `against your API key and is your decision to make.`
35
+ );
36
+ this.name = 'RobotsDisallowedError';
37
+ this.code = 'ROBOTS_DISALLOWED';
38
+ this.url = url;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * One checker per identity, so the robots cache is process-wide rather than
44
+ * per-tool — otherwise every tool would re-fetch the same robots.txt.
45
+ * @type {Map<string, RobotsChecker>}
46
+ */
47
+ const checkers = new Map();
48
+
49
+ function checkerFor(userAgent) {
50
+ let checker = checkers.get(userAgent);
51
+ if (!checker) {
52
+ checker = new RobotsChecker(userAgent);
53
+ checkers.set(userAgent, checker);
54
+ }
55
+ return checker;
56
+ }
57
+
58
+ /**
59
+ * Decide whether a URL may be fetched. Pure decision — does no throttling and
60
+ * sends no request other than the (cached) robots.txt lookup.
61
+ *
62
+ * @param {string} url
63
+ * @param {object} [options]
64
+ * @param {boolean} [options.respectRobots] per-request override; defaults to
65
+ * `config.crawling.respectRobots`. `false` is honoured, warned about, audited.
66
+ * @param {string} [options.userAgent] per-request identity override
67
+ * @param {string} [options.tool] tool name, for the audit row
68
+ * @param {string} [options.apiKey] hashed into the audit row, never stored raw
69
+ * @returns {Promise<{ allowed: boolean, userAgent: string, crawlDelayMs: number,
70
+ * warnings: string[], overridden: boolean }>}
71
+ * @throws {BlockedHostError} for a permanently blocked host
72
+ */
73
+ export async function robotsPreflight(url, options = {}) {
74
+ // G7 — first, and not overridable. Stamp before rethrowing so a blocked host
75
+ // costs the caller nothing: we refused, we fetched nothing.
76
+ try {
77
+ assertHostAllowed(url);
78
+ } catch (error) {
79
+ if (error?.code === 'HOST_BLOCKED') markPreflightRefusal('HOST_BLOCKED');
80
+ throw error;
81
+ }
82
+
83
+ const userAgent = resolveUserAgent(options.userAgent);
84
+ const warnings = [];
85
+
86
+ const explicitOverride = options.respectRobots === false;
87
+ const respect = options.respectRobots === undefined
88
+ ? config.crawling.respectRobots
89
+ : options.respectRobots !== false;
90
+
91
+ const checker = checkerFor(userAgent);
92
+ let allowed = true;
93
+ let crawlDelayMs = 0;
94
+
95
+ try {
96
+ allowed = await checker.canFetch(url);
97
+ crawlDelayMs = (await checker.fetchCrawlDelay(url)) * 1000;
98
+ } catch {
99
+ // Unreadable robots.txt is not a disallow (see RobotsChecker.canFetch).
100
+ allowed = true;
101
+ }
102
+
103
+ if (explicitOverride) {
104
+ warnings.push(
105
+ allowed
106
+ ? 'respect_robots was disabled for this request. robots.txt did not disallow this URL, so the override changed nothing. The request is recorded against your API key.'
107
+ : `respect_robots was disabled for this request and robots.txt on ${new URL(url).host} disallows this path. Fetching anyway is your decision and is recorded against your API key.`
108
+ );
109
+ recordComplianceEvent({
110
+ event: 'robots_override',
111
+ url,
112
+ tool: options.tool || null,
113
+ apiKeyId: apiKeyId(options.apiKey),
114
+ userAgent,
115
+ robotsAllowed: allowed
116
+ });
117
+ }
118
+
119
+ return {
120
+ allowed: allowed || !respect,
121
+ userAgent,
122
+ crawlDelayMs,
123
+ warnings,
124
+ overridden: explicitOverride && !allowed
125
+ };
126
+ }
127
+
128
+ /**
129
+ * The call-site helper: run the gate, honour Crawl-delay and any recorded
130
+ * `Retry-After`, and hand back the identity headers to send.
131
+ *
132
+ * @param {string} url
133
+ * @param {object} [options] see {@link robotsPreflight}
134
+ * @returns {Promise<{ headers: Record<string,string>, userAgent: string,
135
+ * warnings: string[], overridden: boolean }>}
136
+ * @throws {BlockedHostError|RobotsDisallowedError}
137
+ */
138
+ export async function preflightFetch(url, options = {}) {
139
+ const decision = await robotsPreflight(url, options);
140
+ if (!decision.allowed) {
141
+ markPreflightRefusal('ROBOTS_DISALLOWED');
142
+ throw new RobotsDisallowedError(url);
143
+ }
144
+
145
+ await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
146
+
147
+ // Web Bot Auth: when a signing key is configured, every request also carries
148
+ // a signature a site owner can verify against our published key. No key
149
+ // configured means no headers and no behaviour change. Requests with a
150
+ // caller-supplied userAgent override are still signed — the signature covers
151
+ // @authority, not the UA, and it identifies the operator (us), not the
152
+ // identity the caller asked us to present.
153
+ const signature = signRequestHeaders(url) || {};
154
+
155
+ return {
156
+ headers: { ...identityHeaders({ userAgent: decision.userAgent }), ...signature },
157
+ userAgent: decision.userAgent,
158
+ warnings: decision.warnings,
159
+ overridden: decision.overridden
160
+ };
161
+ }
162
+
163
+ /**
164
+ * The gate for browser paths. Same decision as {@link preflightFetch}, minus
165
+ * the identity and signature headers — those belong on an HTTP fetch, not on a
166
+ * browser context that presents its own identity.
167
+ *
168
+ * Deliberately takes no `userAgent`: robots.txt is matched against our
169
+ * canonical product token even when the browser presents another UA. Matching
170
+ * on the presented UA would let browser traffic walk past the rules our own
171
+ * token is bound by, which is the G5 hole this gate exists to close.
172
+ *
173
+ * @param {string} url
174
+ * @param {object} [options]
175
+ * @param {boolean} [options.respectRobots] per-request override
176
+ * @param {string} [options.tool] tool name, for the audit row
177
+ * @param {string} [options.apiKey] hashed into the audit row, never stored raw
178
+ * @returns {Promise<string[]>} warnings to surface on the response
179
+ * @throws {BlockedHostError|RobotsDisallowedError}
180
+ */
181
+ export async function browserPreflight(url, options = {}) {
182
+ const decision = await robotsPreflight(url, {
183
+ respectRobots: options.respectRobots,
184
+ tool: options.tool,
185
+ apiKey: options.apiKey
186
+ });
187
+ if (!decision.allowed) {
188
+ markPreflightRefusal('ROBOTS_DISALLOWED');
189
+ throw new RobotsDisallowedError(url);
190
+ }
191
+
192
+ await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
193
+ return decision.warnings;
194
+ }
195
+
196
+ /** Test/diagnostic hook: drop every cached robots.txt. */
197
+ export function _resetRobotsGate() {
198
+ checkers.clear();
199
+ }
200
+
201
+ /** Test/diagnostic hook: total robots.txt requests made across all identities. */
202
+ export function _robotsFetchCount() {
203
+ let total = 0;
204
+ for (const checker of checkers.values()) total += checker.fetchCount;
205
+ return total;
206
+ }
@@ -4,13 +4,16 @@ import { promisify } from 'util';
4
4
  import { CacheManager } from '../core/cache/CacheManager.js';
5
5
  import { normalizeUrl } from './urlNormalizer.js';
6
6
  import { safeFetch } from './ssrfGuard.js';
7
+ import { CRAWLFORGE_USER_AGENT } from './fetchIdentity.js';
8
+ import { preflightFetch } from './robotsGate.js';
9
+ import { noteRetryAfter } from './hostRateLimiter.js';
7
10
 
8
11
  const gunzip = promisify(zlib.gunzip);
9
12
 
10
13
  export class SitemapParser {
11
14
  constructor(options = {}) {
12
15
  const {
13
- userAgent = 'CrawlForge/1.0',
16
+ userAgent = CRAWLFORGE_USER_AGENT,
14
17
  timeout = 10000,
15
18
  maxRecursionDepth = 3,
16
19
  maxUrlsPerSitemap = 50000,
@@ -50,13 +53,17 @@ export class SitemapParser {
50
53
  * Parse a sitemap from a URL with full feature support
51
54
  * @param {string} url - Sitemap URL
52
55
  * @param {Object} options - Parsing options
56
+ * @param {boolean} [options.respectRobots] - Per-request robots override.
57
+ * Per call, not per instance: MapSiteTool builds one SitemapParser and
58
+ * reuses it, so a flag stored on the instance would leak between requests.
53
59
  * @returns {Promise<Object>} Parsed sitemap data
54
60
  */
55
61
  async parseSitemap(url, options = {}) {
56
62
  const {
57
63
  includeMetadata = true,
58
64
  followIndexes = true,
59
- maxDepth = this.maxRecursionDepth
65
+ maxDepth = this.maxRecursionDepth,
66
+ respectRobots
60
67
  } = options;
61
68
 
62
69
  // Reset stats for new parsing session
@@ -72,7 +79,8 @@ export class SitemapParser {
72
79
  try {
73
80
  const result = await this._parseSitemapRecursive(url, 0, maxDepth, {
74
81
  includeMetadata,
75
- followIndexes
82
+ followIndexes,
83
+ respectRobots
76
84
  });
77
85
 
78
86
  return {
@@ -99,11 +107,12 @@ export class SitemapParser {
99
107
  /**
100
108
  * Parse sitemap index files and return all contained sitemaps
101
109
  * @param {string} indexUrl - Sitemap index URL
110
+ * @param {boolean} [respectRobots] - Per-request robots override
102
111
  * @returns {Promise<Array>} Array of sitemap URLs with metadata
103
112
  */
104
- async parseSitemapIndex(indexUrl) {
113
+ async parseSitemapIndex(indexUrl, respectRobots) {
105
114
  try {
106
- const content = await this._fetchSitemapContent(indexUrl);
115
+ const content = await this._fetchSitemapContent(indexUrl, respectRobots);
107
116
  if (!content) return [];
108
117
 
109
118
  const $ = load(content, { xmlMode: true });
@@ -260,9 +269,10 @@ export class SitemapParser {
260
269
  * Discover sitemap URLs from various sources
261
270
  * @param {string} baseUrl - Base URL of the website
262
271
  * @param {Object} sources - Sources to check
272
+ * @param {boolean} [respectRobots] - Per-request robots override
263
273
  * @returns {Promise<Array>} Array of discovered sitemap URLs
264
274
  */
265
- async discoverSitemaps(baseUrl, sources = {}) {
275
+ async discoverSitemaps(baseUrl, sources = {}, respectRobots) {
266
276
  const {
267
277
  checkRobotsTxt = true,
268
278
  checkCommonPaths = true,
@@ -277,7 +287,7 @@ export class SitemapParser {
277
287
  if (checkRobotsTxt) {
278
288
  try {
279
289
  const robotsUrl = `${baseOrigin}/robots.txt`;
280
- const robotsContent = await this._fetchWithTimeout(robotsUrl);
290
+ const robotsContent = await this._fetchWithTimeout(robotsUrl, respectRobots);
281
291
  if (robotsContent) {
282
292
  const sitemapMatches = robotsContent.match(/^Sitemap:\s*(.+)$/gmi);
283
293
  if (sitemapMatches) {
@@ -308,7 +318,7 @@ export class SitemapParser {
308
318
  for (const path of commonPaths) {
309
319
  const sitemapUrl = `${baseOrigin}${path}`;
310
320
  try {
311
- const response = await this._fetchWithTimeoutResponse(sitemapUrl);
321
+ const response = await this._fetchWithTimeoutResponse(sitemapUrl, respectRobots);
312
322
  if (response && response.ok) {
313
323
  discovered.add(sitemapUrl);
314
324
  }
@@ -344,7 +354,7 @@ export class SitemapParser {
344
354
  }
345
355
 
346
356
  try {
347
- const content = await this._fetchSitemapContent(url);
357
+ const content = await this._fetchSitemapContent(url, options.respectRobots);
348
358
  if (!content) {
349
359
  throw new Error(`Failed to fetch sitemap content from ${url}`);
350
360
  }
@@ -385,9 +395,9 @@ export class SitemapParser {
385
395
  * Fetch and decompress sitemap content
386
396
  * @private
387
397
  */
388
- async _fetchSitemapContent(url) {
398
+ async _fetchSitemapContent(url, respectRobots) {
389
399
  try {
390
- const response = await this._fetchWithTimeoutResponse(url);
400
+ const response = await this._fetchWithTimeoutResponse(url, respectRobots);
391
401
  if (!response || !response.ok) {
392
402
  return null;
393
403
  }
@@ -621,8 +631,8 @@ export class SitemapParser {
621
631
  * Fetch with timeout
622
632
  * @private
623
633
  */
624
- async _fetchWithTimeout(url) {
625
- const response = await this._fetchWithTimeoutResponse(url);
634
+ async _fetchWithTimeout(url, respectRobots) {
635
+ const response = await this._fetchWithTimeoutResponse(url, respectRobots);
626
636
  return response ? await response.text() : null;
627
637
  }
628
638
 
@@ -630,7 +640,12 @@ export class SitemapParser {
630
640
  * Fetch with timeout returning response object
631
641
  * @private
632
642
  */
633
- async _fetchWithTimeoutResponse(url) {
643
+ async _fetchWithTimeoutResponse(url, respectRobots) {
644
+ const gate = await preflightFetch(url, {
645
+ respectRobots,
646
+ userAgent: this.userAgent,
647
+ tool: 'sitemap'
648
+ });
634
649
  const controller = new AbortController();
635
650
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
636
651
 
@@ -638,12 +653,15 @@ export class SitemapParser {
638
653
  const response = await safeFetch(url, {
639
654
  signal: controller.signal,
640
655
  headers: {
641
- 'User-Agent': this.userAgent,
656
+ ...gate.headers,
642
657
  'Accept': 'application/xml,text/xml,text/plain,*/*',
643
658
  'Accept-Encoding': 'gzip, deflate'
644
659
  }
645
660
  });
646
661
  clearTimeout(timeoutId);
662
+ if (response.status === 429 || response.status === 503) {
663
+ noteRetryAfter(url, response.headers.get('retry-after'));
664
+ }
647
665
  return response;
648
666
  } catch (error) {
649
667
  clearTimeout(timeoutId);
@@ -6,6 +6,7 @@
6
6
  import { promisify } from 'util';
7
7
  import dns from 'dns';
8
8
  import net from 'net';
9
+ import { identityHeaders } from './fetchIdentity.js';
9
10
 
10
11
  const dnsLookup = promisify(dns.lookup);
11
12
 
@@ -560,7 +561,7 @@ export class SSRFProtection {
560
561
  timeout: Math.min(fetchOptions.timeout || 30000, this.config.maxTimeout),
561
562
  redirect: 'manual', // Handle redirects manually
562
563
  headers: {
563
- 'User-Agent': 'CrawlForge/3.0 (Security Enhanced)',
564
+ ...identityHeaders({ role: 'health-check' }),
564
565
  ...fetchOptions.headers
565
566
  }
566
567
  };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * webBotAuth — signs outbound requests so a site owner can verify who we are.
3
+ *
4
+ * A User-Agent is a claim anyone can make. Web Bot Auth turns our identity into
5
+ * something a site can check: an Ed25519 signature over the request, per
6
+ * RFC 9421 (HTTP Message Signatures) with the `web-bot-auth` profile from
7
+ * draft-meunier-web-bot-auth-architecture. The public key is published at
8
+ * `/.well-known/http-message-signatures-directory` on crawlforge.dev.
9
+ *
10
+ * This is the mechanism behind ground rule G4. Honest identification only helps
11
+ * a site owner if it cannot be spoofed by someone else claiming to be us.
12
+ *
13
+ * Signing is OPT-IN and absent by default: with no key configured every export
14
+ * here is a no-op and requests go out exactly as before. Key material lives
15
+ * only in the environment, never in the repo.
16
+ *
17
+ * Verified against the official test vectors (architecture draft Appendix A.2.1
18
+ * with the RFC 9421 Appendix B.1.4 key) — see tests/unit/webBotAuth.test.js.
19
+ */
20
+
21
+ import { createHash, createPrivateKey, createPublicKey, sign, randomBytes } from 'crypto';
22
+
23
+ /** The profile tag every web-bot-auth signature carries. */
24
+ const WEB_BOT_AUTH_TAG = 'web-bot-auth';
25
+
26
+ /** The draft RECOMMENDS an expiry no more than 24 hours; ours is far shorter. */
27
+ const DEFAULT_LIFETIME_SECONDS = 300;
28
+
29
+ let cachedKey; // undefined = not yet resolved, null = none configured
30
+
31
+ /**
32
+ * The raw 32-byte Ed25519 public key as base64url, which is the JWK `x`.
33
+ * @param {import('crypto').KeyObject} publicKey
34
+ * @returns {string}
35
+ */
36
+ function publicKeyX(publicKey) {
37
+ // An Ed25519 SPKI DER is a 12-byte header followed by the 32-byte key.
38
+ const der = publicKey.export({ type: 'spki', format: 'der' });
39
+ return Buffer.from(der.subarray(der.length - 32)).toString('base64url');
40
+ }
41
+
42
+ /**
43
+ * RFC 8037 Appendix A.3 thumbprint: SHA-256 over the canonical JWK with its
44
+ * member names in lexicographic order and no whitespace, base64url unpadded.
45
+ * The member order is load-bearing — reordering it changes the key id.
46
+ * @param {string} x base64url raw public key
47
+ * @returns {string}
48
+ */
49
+ export function jwkThumbprint(x) {
50
+ const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x });
51
+ return createHash('sha256').update(canonical).digest('base64url');
52
+ }
53
+
54
+ /**
55
+ * The public JWK for a key pair, in the shape the directory publishes.
56
+ * @param {import('crypto').KeyObject} publicKey
57
+ */
58
+ export function publicJwk(publicKey) {
59
+ const x = publicKeyX(publicKey);
60
+ return { kty: 'OKP', crv: 'Ed25519', kid: jwkThumbprint(x), x, use: 'sig', alg: 'ed25519' };
61
+ }
62
+
63
+ /**
64
+ * Resolve the signing key from the environment, or null when none is set.
65
+ *
66
+ * `CRAWLFORGE_SIGNING_KEY` holds an Ed25519 private key as a PKCS#8 PEM —
67
+ * either literally (with real newlines) or base64-encoded, since most secret
68
+ * stores mangle multi-line values. A malformed key is a configuration error we
69
+ * surface once and then ignore: it must not take every fetch down with it.
70
+ *
71
+ * @returns {{ privateKey: import('crypto').KeyObject, jwk: object } | null}
72
+ */
73
+ export function getSigningKey() {
74
+ if (cachedKey !== undefined) return cachedKey;
75
+
76
+ const raw = process.env.CRAWLFORGE_SIGNING_KEY;
77
+ if (!raw || !raw.trim()) {
78
+ cachedKey = null;
79
+ return cachedKey;
80
+ }
81
+
82
+ try {
83
+ const pem = raw.includes('-----BEGIN')
84
+ ? raw.replace(/\\n/g, '\n')
85
+ : Buffer.from(raw.trim(), 'base64').toString('utf8');
86
+
87
+ const privateKey = createPrivateKey(pem);
88
+ if (privateKey.asymmetricKeyType !== 'ed25519') {
89
+ throw new Error(`expected an ed25519 key, got ${privateKey.asymmetricKeyType}`);
90
+ }
91
+ cachedKey = { privateKey, jwk: publicJwk(createPublicKey(privateKey)) };
92
+ } catch (error) {
93
+ console.error(
94
+ `[web-bot-auth] CRAWLFORGE_SIGNING_KEY could not be loaded, so requests will go out unsigned: ${error.message}`
95
+ );
96
+ cachedKey = null;
97
+ }
98
+
99
+ return cachedKey;
100
+ }
101
+
102
+ /**
103
+ * Serialise the @signature-params of a signature base.
104
+ * Parameter order is part of the signed bytes, so it must match what the
105
+ * verifier reconstructs — it is the draft's order, not an arbitrary one.
106
+ */
107
+ function signatureParams(components, { created, expires, keyid, nonce }) {
108
+ const covered = components.map((c) => `"${c}"`).join(' ');
109
+ return (
110
+ `(${covered})` +
111
+ `;created=${created}` +
112
+ `;keyid="${keyid}"` +
113
+ `;alg="ed25519"` +
114
+ `;expires=${expires}` +
115
+ `;nonce="${nonce}"` +
116
+ `;tag="${WEB_BOT_AUTH_TAG}"`
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Build the RFC 9421 signature base for a request.
122
+ *
123
+ * Exported for the test vectors: the base is the exact byte string that gets
124
+ * signed, so reproducing the published one is what proves the implementation
125
+ * interoperates rather than merely agreeing with itself.
126
+ *
127
+ * @param {{ authority: string, signatureAgent?: string|null }} request
128
+ * @param {{ created: number, expires: number, keyid: string, nonce: string }} params
129
+ * @returns {{ base: string, components: string[], params: string }}
130
+ */
131
+ export function buildSignatureBase(request, params) {
132
+ const components = ['@authority'];
133
+ const lines = [`"@authority": ${request.authority}`];
134
+
135
+ // The draft requires Signature-Agent to be covered whenever it is sent.
136
+ if (request.signatureAgent) {
137
+ components.push('signature-agent');
138
+ lines.push(`"signature-agent": "${request.signatureAgent}"`);
139
+ }
140
+
141
+ const serialised = signatureParams(components, params);
142
+ lines.push(`"@signature-params": ${serialised}`);
143
+
144
+ return { base: lines.join('\n'), components, params: serialised };
145
+ }
146
+
147
+ /**
148
+ * Signature headers for an outbound request, or null when signing is off.
149
+ *
150
+ * @param {string} url the request target
151
+ * @param {object} [options]
152
+ * @param {string|null} [options.signatureAgent] directory URL to advertise
153
+ * @param {number} [options.now] epoch seconds, for deterministic tests
154
+ * @param {number} [options.expires] epoch seconds, for deterministic tests
155
+ * @param {string} [options.nonce] base64 nonce, for deterministic tests
156
+ * @returns {Record<string,string>|null}
157
+ */
158
+ export function signRequestHeaders(url, options = {}) {
159
+ const key = getSigningKey();
160
+ if (!key) return null;
161
+
162
+ let authority;
163
+ try {
164
+ authority = new URL(url).host;
165
+ } catch {
166
+ return null; // not our job to validate URLs; the fetch path already does
167
+ }
168
+
169
+ const created = options.now ?? Math.floor(Date.now() / 1000);
170
+ const expires = options.expires ?? created + DEFAULT_LIFETIME_SECONDS;
171
+ // The draft RECOMMENDS 64 random bytes, unique within the validity window.
172
+ const nonce = options.nonce ?? randomBytes(64).toString('base64');
173
+ const signatureAgent = options.signatureAgent ?? process.env.WEB_BOT_AUTH_DIRECTORY ?? null;
174
+
175
+ const { base, params } = buildSignatureBase(
176
+ { authority, signatureAgent },
177
+ { created, expires, keyid: key.jwk.kid, nonce }
178
+ );
179
+
180
+ const signature = sign(null, Buffer.from(base, 'utf8'), key.privateKey).toString('base64');
181
+
182
+ const headers = {
183
+ 'Signature-Input': `sig1=${params}`,
184
+ 'Signature': `sig1=:${signature}:`
185
+ };
186
+ if (signatureAgent) headers['Signature-Agent'] = `"${signatureAgent}"`;
187
+ return headers;
188
+ }
189
+
190
+ /** Test hook: forget the resolved key so a changed env var is picked up. */
191
+ export function _resetSigningKey() {
192
+ cachedKey = undefined;
193
+ }