@x12i/content-fetcher 0.1.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 (90) hide show
  1. package/README.md +133 -0
  2. package/dist/adapters/default-http-client.d.ts +17 -0
  3. package/dist/adapters/default-http-client.d.ts.map +1 -0
  4. package/dist/adapters/default-http-client.js +74 -0
  5. package/dist/adapters/default-http-client.js.map +1 -0
  6. package/dist/channels.d.ts +34 -0
  7. package/dist/channels.d.ts.map +1 -0
  8. package/dist/channels.js +164 -0
  9. package/dist/channels.js.map +1 -0
  10. package/dist/classify.d.ts +13 -0
  11. package/dist/classify.d.ts.map +1 -0
  12. package/dist/classify.js +122 -0
  13. package/dist/classify.js.map +1 -0
  14. package/dist/config.d.ts +7 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +197 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/create-content-fetcher.d.ts +9 -0
  19. package/dist/create-content-fetcher.d.ts.map +1 -0
  20. package/dist/create-content-fetcher.js +836 -0
  21. package/dist/create-content-fetcher.js.map +1 -0
  22. package/dist/errors.d.ts +42 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +73 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/handoff.d.ts +8 -0
  27. package/dist/handoff.d.ts.map +1 -0
  28. package/dist/handoff.js +20 -0
  29. package/dist/handoff.js.map +1 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +14 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/ratelimit.d.ts +27 -0
  35. package/dist/ratelimit.d.ts.map +1 -0
  36. package/dist/ratelimit.js +43 -0
  37. package/dist/ratelimit.js.map +1 -0
  38. package/dist/robots/robots-service.d.ts +40 -0
  39. package/dist/robots/robots-service.d.ts.map +1 -0
  40. package/dist/robots/robots-service.js +131 -0
  41. package/dist/robots/robots-service.js.map +1 -0
  42. package/dist/robots/robots-txt.d.ts +24 -0
  43. package/dist/robots/robots-txt.d.ts.map +1 -0
  44. package/dist/robots/robots-txt.js +91 -0
  45. package/dist/robots/robots-txt.js.map +1 -0
  46. package/dist/runtime-utils.d.ts +11 -0
  47. package/dist/runtime-utils.d.ts.map +1 -0
  48. package/dist/runtime-utils.js +32 -0
  49. package/dist/runtime-utils.js.map +1 -0
  50. package/dist/security/dns.d.ts +33 -0
  51. package/dist/security/dns.d.ts.map +1 -0
  52. package/dist/security/dns.js +60 -0
  53. package/dist/security/dns.js.map +1 -0
  54. package/dist/security/headers.d.ts +16 -0
  55. package/dist/security/headers.d.ts.map +1 -0
  56. package/dist/security/headers.js +54 -0
  57. package/dist/security/headers.js.map +1 -0
  58. package/dist/security/net-guards.d.ts +9 -0
  59. package/dist/security/net-guards.d.ts.map +1 -0
  60. package/dist/security/net-guards.js +102 -0
  61. package/dist/security/net-guards.js.map +1 -0
  62. package/dist/security/url.d.ts +32 -0
  63. package/dist/security/url.d.ts.map +1 -0
  64. package/dist/security/url.js +82 -0
  65. package/dist/security/url.js.map +1 -0
  66. package/dist/storage.d.ts +38 -0
  67. package/dist/storage.d.ts.map +1 -0
  68. package/dist/storage.js +109 -0
  69. package/dist/storage.js.map +1 -0
  70. package/dist/types.d.ts +232 -0
  71. package/dist/types.d.ts.map +1 -0
  72. package/dist/types.js +9 -0
  73. package/dist/types.js.map +1 -0
  74. package/dist/util/bytes.d.ts +21 -0
  75. package/dist/util/bytes.d.ts.map +1 -0
  76. package/dist/util/bytes.js +44 -0
  77. package/dist/util/bytes.js.map +1 -0
  78. package/dist/util/hash.d.ts +5 -0
  79. package/dist/util/hash.d.ts.map +1 -0
  80. package/dist/util/hash.js +11 -0
  81. package/dist/util/hash.js.map +1 -0
  82. package/dist/util/ids.d.ts +3 -0
  83. package/dist/util/ids.d.ts.map +1 -0
  84. package/dist/util/ids.js +8 -0
  85. package/dist/util/ids.js.map +1 -0
  86. package/dist/validate-request.d.ts +9 -0
  87. package/dist/validate-request.d.ts.map +1 -0
  88. package/dist/validate-request.js +137 -0
  89. package/dist/validate-request.js.map +1 -0
  90. package/package.json +34 -0
@@ -0,0 +1,836 @@
1
+ import { ArtifactStoreError, ConfigurationError, InvalidFetchRequestError, SourcePolicyViolationError, UnknownSourcePolicyError, UnsafeUrlError, UnsupportedChannelError, } from "./errors.js";
2
+ import { validateConfiguration } from "./config.js";
3
+ import { contentTypeMatches, extractFeedEntryId, FALLTHROUGH_STATUSES, isTokenHeaderName, parseArchiveReference, sanitizedMessageFor, selectChannelPlan, } from "./channels.js";
4
+ import { classifyHttpResponse, shouldSniffSuccessBody } from "./classify.js";
5
+ import { AddressValidator, SystemDnsResolver } from "./security/dns.js";
6
+ import { fileNameFromUrl, hostMatchesAllowedDomains, parseOutboundUrl, sanitizeFileName, sanitizeUrl, } from "./security/url.js";
7
+ import { getHeader, parseRetryAfter, retainedResponseHeaders } from "./security/headers.js";
8
+ import { readBody } from "./util/bytes.js";
9
+ import { sha256ContentHash } from "./util/hash.js";
10
+ import { newAcquisitionId } from "./util/ids.js";
11
+ import { RateLimiterRegistry } from "./ratelimit.js";
12
+ import { RobotsService } from "./robots/robots-service.js";
13
+ import { resourceKindOf, validateFetchRequest } from "./validate-request.js";
14
+ import { ArtifactPersistence } from "./storage.js";
15
+ const TYPED_ERRORS = [
16
+ InvalidFetchRequestError,
17
+ UnknownSourcePolicyError,
18
+ SourcePolicyViolationError,
19
+ UnsafeUrlError,
20
+ UnsupportedChannelError,
21
+ ArtifactStoreError,
22
+ ConfigurationError,
23
+ ];
24
+ function isTypedError(error) {
25
+ return TYPED_ERRORS.some((type) => error instanceof type);
26
+ }
27
+ function isAbortLike(error) {
28
+ if (!(error instanceof Error))
29
+ return false;
30
+ return error.name === "AbortError" || /abort|timed?\s*out/i.test(error.message);
31
+ }
32
+ class FetcherImpl {
33
+ policies;
34
+ httpClient;
35
+ browserAdapter;
36
+ authProvider;
37
+ robots;
38
+ limiter;
39
+ addresses;
40
+ persistence;
41
+ userAgent;
42
+ requestTimeoutMs;
43
+ nowFn;
44
+ constructor(options) {
45
+ validateConfiguration(options);
46
+ this.policies = new Map(options.sourcePolicies.map((p) => [p.sourceId, p]));
47
+ this.httpClient = options.httpClient;
48
+ this.browserAdapter = options.browserAdapter;
49
+ this.authProvider = options.authProvider;
50
+ const clockMs = () => this.now().getTime();
51
+ this.addresses = new AddressValidator(options.dnsResolver ?? new SystemDnsResolver(), clockMs);
52
+ this.userAgent = options.userAgent ?? "x12i-content-fetcher/1.0";
53
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
54
+ this.robots = new RobotsService(this.httpClient, this.addresses, this.userAgent, options.robotsCacheTtlMs ?? 10 * 60_000, this.requestTimeoutMs, clockMs);
55
+ this.limiter = new RateLimiterRegistry(clockMs);
56
+ this.persistence = new ArtifactPersistence(options.artifactStore);
57
+ this.nowFn = options.now ?? (() => new Date());
58
+ }
59
+ now() {
60
+ return this.nowFn();
61
+ }
62
+ async fetch(rawRequest) {
63
+ const request = validateFetchRequest(rawRequest);
64
+ const policy = this.policies.get(request.sourceId);
65
+ if (policy === undefined) {
66
+ throw new UnknownSourcePolicyError(`No source policy is registered for sourceId '${request.sourceId}'.`, request.sourceId);
67
+ }
68
+ const acquisitionId = newAcquisitionId();
69
+ const requestedAt = this.now();
70
+ const kind = resourceKindOf(request.resource);
71
+ const plan = selectChannelPlan(kind, policy);
72
+ const targetUrl = readResourceUrl(request, kind);
73
+ const attempts = [];
74
+ if (plan.length === 0) {
75
+ return this.finalize(request, policy, undefined, acquisitionId, requestedAt, {
76
+ status: "source_not_authorised",
77
+ reasonCode: "channel_not_permitted",
78
+ ...(targetUrl !== undefined ? { requestedUrl: sanitizeUrl(targetUrl) } : {}),
79
+ }, attempts);
80
+ }
81
+ let lastOutcome;
82
+ for (const channel of plan) {
83
+ const outcome = await this.attempt(request, policy, kind, channel, targetUrl);
84
+ attempts.push({ channel, status: outcome.status, reasonCode: outcome.reasonCode });
85
+ const hasFurtherChannels = plan.indexOf(channel) < plan.length - 1;
86
+ if (FALLTHROUGH_STATUSES.includes(outcome.status) && hasFurtherChannels) {
87
+ lastOutcome = outcome;
88
+ continue;
89
+ }
90
+ return this.finalize(request, policy, channel, acquisitionId, requestedAt, outcome, attempts);
91
+ }
92
+ // Every attempt was a fallthrough status; report the last one.
93
+ return this.finalize(request, policy, plan[plan.length - 1], acquisitionId, requestedAt, lastOutcome, attempts);
94
+ }
95
+ attempt(request, policy, kind, channel, targetUrl) {
96
+ if (channel === "manual_file") {
97
+ return this.attemptManualFile(request, policy);
98
+ }
99
+ if (targetUrl === undefined) {
100
+ return Promise.resolve({
101
+ status: "source_not_authorised",
102
+ reasonCode: "channel_not_permitted",
103
+ });
104
+ }
105
+ if (channel === "permitted_browser") {
106
+ return this.attemptBrowser(request, policy, targetUrl);
107
+ }
108
+ return this.attemptHttp(request, policy, channel, targetUrl);
109
+ }
110
+ /* ----------------------------------------------------------------------
111
+ * manual_file
112
+ * -------------------------------------------------------------------- */
113
+ attemptManualFile(request, policy) {
114
+ const file = request.resource.manualFile;
115
+ const contentHash = sha256ContentHash(file.bytes);
116
+ if (request.previousVersion?.contentHash !== undefined &&
117
+ request.previousVersion.contentHash !== contentHash) {
118
+ return Promise.resolve({
119
+ status: "integrity_error",
120
+ reasonCode: "previous_hash_mismatch",
121
+ metadataExtras: { providedFileName: sanitizeFileName(file.fileName) },
122
+ });
123
+ }
124
+ if (request.expectedContentTypes !== undefined &&
125
+ !contentTypeMatches(file.contentType, request.expectedContentTypes)) {
126
+ return Promise.resolve({
127
+ status: "unsupported_content_type",
128
+ reasonCode: "content_type_mismatch",
129
+ metadataExtras: { providedFileName: sanitizeFileName(file.fileName) },
130
+ });
131
+ }
132
+ if (file.bytes.length > policy.limits.maxResponseBytes) {
133
+ return Promise.resolve({
134
+ status: "content_too_large",
135
+ reasonCode: "max_bytes_exceeded",
136
+ metadataExtras: { providedFileName: sanitizeFileName(file.fileName) },
137
+ });
138
+ }
139
+ return Promise.resolve({
140
+ status: "collected",
141
+ reasonCode: "manual_file_supplied",
142
+ bytes: file.bytes,
143
+ contentType: file.contentType,
144
+ fileName: sanitizeFileName(file.fileName),
145
+ metadataExtras: {
146
+ providedFileName: file.fileName,
147
+ providedContentType: file.contentType,
148
+ },
149
+ });
150
+ }
151
+ /* ----------------------------------------------------------------------
152
+ * permitted_browser
153
+ * -------------------------------------------------------------------- */
154
+ async attemptBrowser(request, policy, targetUrl) {
155
+ const requestedSanitized = sanitizeUrl(targetUrl);
156
+ const gate = await this.gateTarget(request, policy, "permitted_browser", targetUrl, requestedSanitized);
157
+ if (gate !== undefined)
158
+ return gate;
159
+ if (this.browserAdapter === undefined) {
160
+ throw new UnsupportedChannelError("Channel 'permitted_browser' was selected but no browser adapter is configured.", "permitted_browser");
161
+ }
162
+ const slot = this.limiter.tryAcquire(policy.sourceId, policy);
163
+ if (!slot.ok) {
164
+ return {
165
+ status: "rate_limited",
166
+ reasonCode: "local_rate_limit",
167
+ retryAfterSeconds: slot.retryAfterSeconds,
168
+ requestedUrl: requestedSanitized,
169
+ };
170
+ }
171
+ try {
172
+ const output = await this.browserAdapter.fetchPage({
173
+ url: targetUrl,
174
+ userAgent: this.userAgent,
175
+ });
176
+ return this.mapBrowserOutput(request, policy, output, targetUrl, requestedSanitized);
177
+ }
178
+ finally {
179
+ slot.release();
180
+ }
181
+ }
182
+ mapBrowserOutput(request, policy, output, targetUrl, requestedSanitized) {
183
+ if (output.blockedReason !== undefined) {
184
+ const statusByReason = {
185
+ captcha: "waf_or_bot_challenge",
186
+ waf: "waf_or_bot_challenge",
187
+ login: "login_required",
188
+ paywall: "paywalled",
189
+ geo: "geo_restricted",
190
+ };
191
+ return {
192
+ status: statusByReason[output.blockedReason],
193
+ reasonCode: "browser_adapter_reported_block",
194
+ requestedUrl: requestedSanitized,
195
+ finalUrl: output.finalUrl !== undefined ? sanitizeUrl(output.finalUrl) : undefined,
196
+ metadataExtras: { browserFinalUrl: sanitizeUrl(output.finalUrl ?? targetUrl) },
197
+ };
198
+ }
199
+ if (output.status < 200 || output.status >= 300) {
200
+ const classified = classifyHttpResponse(output.status, null);
201
+ return {
202
+ status: classified.status,
203
+ reasonCode: classified.reasonCode,
204
+ httpStatus: output.status,
205
+ requestedUrl: requestedSanitized,
206
+ finalUrl: sanitizeUrl(output.finalUrl ?? targetUrl),
207
+ };
208
+ }
209
+ if (output.bytes === undefined || output.bytes.length === 0) {
210
+ return {
211
+ status: "temporarily_unavailable",
212
+ reasonCode: "empty_browser_response",
213
+ httpStatus: output.status,
214
+ requestedUrl: requestedSanitized,
215
+ };
216
+ }
217
+ const contentType = output.contentType ?? "text/html";
218
+ if (request.expectedContentTypes !== undefined &&
219
+ !contentTypeMatches(contentType, request.expectedContentTypes)) {
220
+ return {
221
+ status: "unsupported_content_type",
222
+ reasonCode: "content_type_mismatch",
223
+ httpStatus: output.status,
224
+ requestedUrl: requestedSanitized,
225
+ };
226
+ }
227
+ if (output.bytes.length > policy.limits.maxResponseBytes) {
228
+ return {
229
+ status: "content_too_large",
230
+ reasonCode: "max_bytes_exceeded",
231
+ httpStatus: output.status,
232
+ requestedUrl: requestedSanitized,
233
+ };
234
+ }
235
+ return {
236
+ status: "collected",
237
+ reasonCode: "http_success",
238
+ bytes: output.bytes,
239
+ contentType,
240
+ fileName: fileNameFromUrl(output.finalUrl ?? targetUrl),
241
+ etag: output.headers !== undefined ? getHeader(output.headers, "etag") : undefined,
242
+ lastModified: output.headers !== undefined ? getHeader(output.headers, "last-modified") : undefined,
243
+ requestedUrl: requestedSanitized,
244
+ finalUrl: sanitizeUrl(output.finalUrl ?? targetUrl),
245
+ metadataExtras: { browserFinalUrl: sanitizeUrl(output.finalUrl ?? targetUrl) },
246
+ };
247
+ }
248
+ /* ----------------------------------------------------------------------
249
+ * HTTP channels: direct_http, official_api, rss_or_atom, approved_archive,
250
+ * licensed_provider
251
+ * -------------------------------------------------------------------- */
252
+ async attemptHttp(request, policy, channel, targetUrl) {
253
+ const requestedSanitized = sanitizeUrl(targetUrl);
254
+ const provenanceExtras = {};
255
+ const metadataExtras = {};
256
+ if (channel === "rss_or_atom") {
257
+ const entryId = extractFeedEntryId(targetUrl);
258
+ if (entryId !== undefined)
259
+ provenanceExtras.feedEntryId = entryId;
260
+ metadataExtras.feedUrl = requestedSanitized;
261
+ }
262
+ else if (channel === "official_api") {
263
+ provenanceExtras.apiResource = requestedSanitized;
264
+ }
265
+ else if (channel === "approved_archive") {
266
+ provenanceExtras.archiveReference = requestedSanitized;
267
+ }
268
+ else if (channel === "licensed_provider") {
269
+ metadataExtras.licensedResourceId = requestedSanitized;
270
+ }
271
+ const gate = await this.gateTarget(request, policy, channel, targetUrl, requestedSanitized, provenanceExtras, metadataExtras);
272
+ if (gate !== undefined)
273
+ return gate;
274
+ const auth = await this.buildAuthHeaders(policy, channel, targetUrl);
275
+ if (auth.outcome !== undefined) {
276
+ return {
277
+ ...auth.outcome,
278
+ requestedUrl: requestedSanitized,
279
+ provenanceExtras,
280
+ metadataExtras,
281
+ };
282
+ }
283
+ const conditionalHeaders = {};
284
+ if (request.previousVersion?.etag !== undefined) {
285
+ conditionalHeaders["if-none-match"] = request.previousVersion.etag;
286
+ }
287
+ if (request.previousVersion?.lastModified !== undefined) {
288
+ conditionalHeaders["if-modified-since"] = request.previousVersion.lastModified;
289
+ }
290
+ const slot = this.limiter.tryAcquire(policy.sourceId, policy);
291
+ if (!slot.ok) {
292
+ return {
293
+ status: "rate_limited",
294
+ reasonCode: "local_rate_limit",
295
+ retryAfterSeconds: slot.retryAfterSeconds,
296
+ requestedUrl: requestedSanitized,
297
+ provenanceExtras,
298
+ metadataExtras,
299
+ };
300
+ }
301
+ try {
302
+ let current = parseOutboundUrl(targetUrl);
303
+ let redirects = 0;
304
+ while (true) {
305
+ let response;
306
+ try {
307
+ response = await this.timedRequest(current.url.toString(), {
308
+ method: "GET",
309
+ headers: { ...auth.headers, ...conditionalHeaders },
310
+ });
311
+ }
312
+ catch (error) {
313
+ if (isTypedError(error))
314
+ throw error;
315
+ return {
316
+ status: "temporarily_unavailable",
317
+ reasonCode: isAbortLike(error) ? "request_timed_out" : "network_error",
318
+ requestedUrl: requestedSanitized,
319
+ provenanceExtras,
320
+ metadataExtras,
321
+ };
322
+ }
323
+ if (REDIRECT_STATUSES.has(response.status)) {
324
+ const verdict = await this.evaluateRedirect(response, current, policy, channel, redirects);
325
+ if (verdict.next !== undefined) {
326
+ current = verdict.next;
327
+ redirects += 1;
328
+ continue;
329
+ }
330
+ return {
331
+ ...verdict.outcome,
332
+ requestedUrl: requestedSanitized,
333
+ provenanceExtras,
334
+ metadataExtras,
335
+ };
336
+ }
337
+ return await this.handleTerminalResponse(request, policy, channel, response, current, requestedSanitized, provenanceExtras, metadataExtras);
338
+ }
339
+ }
340
+ finally {
341
+ slot.release();
342
+ }
343
+ }
344
+ /**
345
+ * Shared pre-network gate for browser and HTTP channels. Address safety
346
+ * (hostname blocklist, DNS resolution, resolved-IP ranges) runs FIRST: it is
347
+ * a security invariant that holds regardless of the source policy. Policy
348
+ * protocol/domain checks and robots evaluation follow.
349
+ */
350
+ async gateTarget(request, policy, channel, targetUrl, requestedSanitized, provenanceExtras, metadataExtras) {
351
+ void request;
352
+ const parsed = parseOutboundUrl(targetUrl);
353
+ // Address safety. UnsafeUrlError propagates: security violations on the
354
+ // originally requested URL are usage errors, not retrieval outcomes.
355
+ const validation = await this.addresses.validate(parsed.hostname);
356
+ if (!validation.ok) {
357
+ return {
358
+ status: "temporarily_unavailable",
359
+ reasonCode: "dns_resolution_failed",
360
+ requestedUrl: requestedSanitized,
361
+ ...(provenanceExtras !== undefined ? { provenanceExtras } : {}),
362
+ ...(metadataExtras !== undefined ? { metadataExtras } : {}),
363
+ };
364
+ }
365
+ if (!policy.allowedProtocols.includes(parsed.protocol)) {
366
+ return this.policyRefusal("protocol_not_allowed", requestedSanitized, provenanceExtras, metadataExtras);
367
+ }
368
+ if (!hostMatchesAllowedDomains(parsed.hostname, policy.allowedDomains)) {
369
+ return this.policyRefusal("domain_not_allowed", requestedSanitized, provenanceExtras, metadataExtras);
370
+ }
371
+ if (policy.robotsPolicy === "required" && isRobotsGoverned(channel)) {
372
+ const decision = await this.robots.decide(parsed.url);
373
+ if (decision.outcome === "disallowed") {
374
+ return {
375
+ status: "robots_disallowed",
376
+ reasonCode: "robots_disallowed_path",
377
+ requestedUrl: requestedSanitized,
378
+ ...(provenanceExtras !== undefined ? { provenanceExtras } : {}),
379
+ ...(metadataExtras !== undefined ? { metadataExtras } : {}),
380
+ };
381
+ }
382
+ if (decision.outcome === "unavailable") {
383
+ return {
384
+ status: "manual_review_required",
385
+ reasonCode: "robots_txt_unavailable",
386
+ requestedUrl: requestedSanitized,
387
+ ...(provenanceExtras !== undefined ? { provenanceExtras } : {}),
388
+ ...(metadataExtras !== undefined ? { metadataExtras } : {}),
389
+ };
390
+ }
391
+ }
392
+ return undefined;
393
+ }
394
+ async evaluateRedirect(response, current, policy, channel, redirectsSoFar) {
395
+ const location = getHeader(response.headers, "location");
396
+ if (location === undefined) {
397
+ return {
398
+ outcome: {
399
+ status: "temporarily_unavailable",
400
+ reasonCode: "redirect_missing_location",
401
+ httpStatus: response.status,
402
+ },
403
+ };
404
+ }
405
+ if (redirectsSoFar >= policy.limits.maxRedirects) {
406
+ return {
407
+ outcome: {
408
+ status: "redirect_disallowed",
409
+ reasonCode: "max_redirects_exceeded",
410
+ httpStatus: response.status,
411
+ },
412
+ };
413
+ }
414
+ let nextUrl;
415
+ try {
416
+ nextUrl = new URL(location, current.url);
417
+ }
418
+ catch {
419
+ return {
420
+ outcome: {
421
+ status: "redirect_disallowed",
422
+ reasonCode: "redirect_location_invalid",
423
+ httpStatus: response.status,
424
+ },
425
+ };
426
+ }
427
+ const hostname = nextUrl.hostname.replace(/^\[|\]$/g, "").toLowerCase();
428
+ const protocol = nextUrl.protocol.replace(":", "");
429
+ if (protocol !== "https" && protocol !== "http") {
430
+ return {
431
+ outcome: {
432
+ status: "redirect_disallowed",
433
+ reasonCode: "unsafe_redirect_scheme",
434
+ httpStatus: response.status,
435
+ },
436
+ };
437
+ }
438
+ if (nextUrl.username !== "" || nextUrl.password !== "") {
439
+ return {
440
+ outcome: {
441
+ status: "redirect_disallowed",
442
+ reasonCode: "redirect_credentials_forbidden",
443
+ httpStatus: response.status,
444
+ },
445
+ };
446
+ }
447
+ if (!policy.allowedProtocols.includes(protocol)) {
448
+ return {
449
+ outcome: {
450
+ status: "redirect_disallowed",
451
+ reasonCode: "redirect_protocol_not_allowed",
452
+ httpStatus: response.status,
453
+ },
454
+ };
455
+ }
456
+ if (!hostMatchesAllowedDomains(hostname, policy.allowedDomains)) {
457
+ return {
458
+ outcome: {
459
+ status: "redirect_disallowed",
460
+ reasonCode: "redirect_domain_not_allowed",
461
+ httpStatus: response.status,
462
+ },
463
+ };
464
+ }
465
+ try {
466
+ const validation = await this.addresses.validate(hostname);
467
+ if (!validation.ok) {
468
+ return {
469
+ outcome: {
470
+ status: "temporarily_unavailable",
471
+ reasonCode: "dns_resolution_failed",
472
+ httpStatus: response.status,
473
+ },
474
+ };
475
+ }
476
+ }
477
+ catch (error) {
478
+ if (error instanceof UnsafeUrlError) {
479
+ return {
480
+ outcome: {
481
+ status: "redirect_disallowed",
482
+ reasonCode: "unsafe_redirect_address",
483
+ httpStatus: response.status,
484
+ },
485
+ };
486
+ }
487
+ throw error;
488
+ }
489
+ if (policy.robotsPolicy === "required" && isRobotsGoverned(channel)) {
490
+ const decision = await this.robots.decide(nextUrl);
491
+ if (decision.outcome === "disallowed") {
492
+ return {
493
+ outcome: {
494
+ status: "robots_disallowed",
495
+ reasonCode: "redirect_target_robots_disallowed",
496
+ httpStatus: response.status,
497
+ },
498
+ };
499
+ }
500
+ if (decision.outcome === "unavailable") {
501
+ return {
502
+ outcome: {
503
+ status: "manual_review_required",
504
+ reasonCode: "robots_txt_unavailable",
505
+ httpStatus: response.status,
506
+ },
507
+ };
508
+ }
509
+ }
510
+ return { next: { url: nextUrl, hostname, protocol } };
511
+ }
512
+ async handleTerminalResponse(request, policy, channel, response, current, requestedSanitized, provenanceExtras, metadataExtras) {
513
+ const finalSanitized = sanitizeUrl(current.url.toString());
514
+ metadataExtras.finalRetainedHeaders = retainedResponseHeaders(response.headers);
515
+ if (channel === "approved_archive") {
516
+ const details = parseArchiveReference(current.url);
517
+ const headerTimestamp = getHeader(response.headers, "x-archive-capture-timestamp");
518
+ const headerOriginal = getHeader(response.headers, "x-archive-original-url");
519
+ metadataExtras.archiveProvider = current.url.host;
520
+ metadataExtras.captureTimestamp = headerTimestamp ?? details.captureTimestamp;
521
+ if (headerOriginal !== undefined && /^https?:\/\//i.test(headerOriginal)) {
522
+ metadataExtras.originalResourceUrl = sanitizeUrl(headerOriginal);
523
+ }
524
+ else if (details.originalResourceUrl !== undefined) {
525
+ metadataExtras.originalResourceUrl = details.originalResourceUrl;
526
+ }
527
+ }
528
+ if (channel === "official_api" || channel === "licensed_provider") {
529
+ metadataExtras.provider = current.url.host;
530
+ }
531
+ const etag = getHeader(response.headers, "etag");
532
+ const lastModified = getHeader(response.headers, "last-modified");
533
+ if (response.status === 304) {
534
+ return {
535
+ status: "not_modified",
536
+ reasonCode: "http_304",
537
+ httpStatus: 304,
538
+ etag: etag ?? request.previousVersion?.etag,
539
+ lastModified: lastModified ?? request.previousVersion?.lastModified,
540
+ requestedUrl: requestedSanitized,
541
+ finalUrl: finalSanitized,
542
+ provenanceExtras,
543
+ metadataExtras,
544
+ };
545
+ }
546
+ if (response.status < 200 || response.status >= 300) {
547
+ const prefix = await readBody(response.body, {
548
+ maxBytes: 4096,
549
+ prefixOnly: true,
550
+ prefixBytes: 4096,
551
+ });
552
+ const prefixText = Buffer.from(prefix.bytes.subarray(0, 4096)).toString("utf8");
553
+ const classified = classifyHttpResponse(response.status, prefixText);
554
+ return {
555
+ status: classified.status,
556
+ reasonCode: classified.reasonCode,
557
+ httpStatus: response.status,
558
+ retryAfterSeconds: parseRetryAfter(response.headers, this.now()),
559
+ requestedUrl: requestedSanitized,
560
+ finalUrl: finalSanitized,
561
+ provenanceExtras,
562
+ metadataExtras,
563
+ };
564
+ }
565
+ // 2xx path: only identity encoding is accepted so stored bytes are exact.
566
+ const encoding = getHeader(response.headers, "content-encoding");
567
+ if (encoding !== undefined && !/^identity$/i.test(encoding.trim())) {
568
+ return {
569
+ status: "unsupported_content_type",
570
+ reasonCode: "unsupported_content_encoding",
571
+ httpStatus: response.status,
572
+ requestedUrl: requestedSanitized,
573
+ finalUrl: finalSanitized,
574
+ provenanceExtras,
575
+ metadataExtras,
576
+ };
577
+ }
578
+ const contentType = normalizeContentType(getHeader(response.headers, "content-type"));
579
+ if (request.expectedContentTypes !== undefined &&
580
+ !contentTypeMatches(contentType, request.expectedContentTypes)) {
581
+ return {
582
+ status: "unsupported_content_type",
583
+ reasonCode: "content_type_mismatch",
584
+ httpStatus: response.status,
585
+ requestedUrl: requestedSanitized,
586
+ finalUrl: finalSanitized,
587
+ provenanceExtras,
588
+ metadataExtras,
589
+ };
590
+ }
591
+ const declaredLength = parseContentLength(getHeader(response.headers, "content-length"));
592
+ if (declaredLength !== undefined && declaredLength > policy.limits.maxResponseBytes) {
593
+ return {
594
+ status: "content_too_large",
595
+ reasonCode: "declared_length_exceeded",
596
+ httpStatus: response.status,
597
+ requestedUrl: requestedSanitized,
598
+ finalUrl: finalSanitized,
599
+ provenanceExtras,
600
+ metadataExtras,
601
+ };
602
+ }
603
+ const read = await readBody(response.body, { maxBytes: policy.limits.maxResponseBytes });
604
+ if (read.exceeded) {
605
+ return {
606
+ status: "content_too_large",
607
+ reasonCode: "max_bytes_exceeded",
608
+ httpStatus: response.status,
609
+ requestedUrl: requestedSanitized,
610
+ finalUrl: finalSanitized,
611
+ provenanceExtras,
612
+ metadataExtras,
613
+ };
614
+ }
615
+ if (declaredLength !== undefined && read.bytes.length !== declaredLength) {
616
+ return {
617
+ status: "integrity_error",
618
+ reasonCode: "content_length_mismatch",
619
+ httpStatus: response.status,
620
+ requestedUrl: requestedSanitized,
621
+ finalUrl: finalSanitized,
622
+ provenanceExtras,
623
+ metadataExtras,
624
+ };
625
+ }
626
+ if (shouldSniffSuccessBody(contentType, read.bytes.length)) {
627
+ const prefixText = Buffer.from(read.bytes).toString("utf8");
628
+ const classified = classifyHttpResponse(response.status, prefixText);
629
+ if (classified.status !== "collected") {
630
+ return {
631
+ status: classified.status,
632
+ reasonCode: classified.reasonCode,
633
+ httpStatus: response.status,
634
+ requestedUrl: requestedSanitized,
635
+ finalUrl: finalSanitized,
636
+ provenanceExtras,
637
+ metadataExtras,
638
+ };
639
+ }
640
+ }
641
+ return {
642
+ status: "collected",
643
+ reasonCode: "http_success",
644
+ httpStatus: response.status,
645
+ bytes: read.bytes,
646
+ contentType,
647
+ fileName: fileNameFromUrl(current.url.toString()),
648
+ etag,
649
+ lastModified,
650
+ requestedUrl: requestedSanitized,
651
+ finalUrl: finalSanitized,
652
+ provenanceExtras,
653
+ metadataExtras,
654
+ };
655
+ }
656
+ policyRefusal(reasonCode, requestedSanitized, provenanceExtras, metadataExtras) {
657
+ return {
658
+ status: "source_not_authorised",
659
+ reasonCode,
660
+ requestedUrl: requestedSanitized,
661
+ ...(provenanceExtras !== undefined ? { provenanceExtras } : {}),
662
+ ...(metadataExtras !== undefined ? { metadataExtras } : {}),
663
+ };
664
+ }
665
+ async buildAuthHeaders(policy, channel, url) {
666
+ if (policy.authProfileId === undefined)
667
+ return { headers: {} };
668
+ if (this.authProvider === undefined) {
669
+ throw new UnsupportedChannelError(`Channel '${channel}' requires authentication profile '${policy.authProfileId}' but no auth provider is configured.`, channel);
670
+ }
671
+ const credentials = await this.authProvider.getCredentials({
672
+ authProfileId: policy.authProfileId,
673
+ url,
674
+ channel,
675
+ });
676
+ if (credentials === null) {
677
+ return {
678
+ outcome: { status: "source_not_authorised", reasonCode: "auth_unavailable" },
679
+ };
680
+ }
681
+ if (credentials.type === "bearer") {
682
+ return { headers: { authorization: `Bearer ${credentials.value}` } };
683
+ }
684
+ const name = (credentials.headerName ?? "x-api-key").toLowerCase();
685
+ if (!isTokenHeaderName(name)) {
686
+ return {
687
+ outcome: { status: "source_not_authorised", reasonCode: "auth_invalid" },
688
+ };
689
+ }
690
+ return { headers: { [name]: credentials.value } };
691
+ }
692
+ async timedRequest(url, init) {
693
+ const controller = new AbortController();
694
+ const timer = setTimeout(() => controller.abort(new Error("request timed out")), this.requestTimeoutMs);
695
+ try {
696
+ return await this.httpClient.request(url, {
697
+ method: init.method,
698
+ headers: init.headers,
699
+ signal: controller.signal,
700
+ });
701
+ }
702
+ finally {
703
+ clearTimeout(timer);
704
+ }
705
+ }
706
+ /* ----------------------------------------------------------------------
707
+ * Result assembly, persistence, audit
708
+ * -------------------------------------------------------------------- */
709
+ async finalize(request, policy, channel, acquisitionId, requestedAt, outcome, attempts) {
710
+ const completedAt = this.now();
711
+ const provenance = {
712
+ ...(outcome.requestedUrl !== undefined ? { requestedUrl: outcome.requestedUrl } : {}),
713
+ ...(outcome.finalUrl !== undefined ? { finalUrl: outcome.finalUrl } : {}),
714
+ retrievedAt: completedAt.toISOString(),
715
+ publisher: policy.publisher,
716
+ ...(outcome.provenanceExtras ?? {}),
717
+ };
718
+ let status = outcome.status;
719
+ let reasonCode = outcome.reasonCode;
720
+ let artifact;
721
+ let version;
722
+ if (outcome.status === "collected" && outcome.bytes !== undefined) {
723
+ const persisted = await this.persistence.persistArtifact({
724
+ acquisitionId,
725
+ sourceId: policy.sourceId,
726
+ fileName: outcome.fileName ?? "artifact",
727
+ contentType: outcome.contentType ?? "application/octet-stream",
728
+ bytes: outcome.bytes,
729
+ rights: policy.rights,
730
+ });
731
+ artifact = persisted.artifact;
732
+ if (persisted.artifact === undefined) {
733
+ status = "metadata_only";
734
+ reasonCode = "raw_retention_not_allowed";
735
+ }
736
+ version = compactVersion({
737
+ etag: outcome.etag,
738
+ lastModified: outcome.lastModified,
739
+ previousVersionId: await this.persistence.resolvePreviousVersionId(request, policy),
740
+ duplicateOfArtifactId: persisted.duplicateOfArtifactId,
741
+ });
742
+ }
743
+ else if (outcome.status === "not_modified") {
744
+ version = compactVersion({
745
+ etag: outcome.etag,
746
+ lastModified: outcome.lastModified,
747
+ previousVersionId: await this.persistence.resolvePreviousVersionId(request, policy),
748
+ });
749
+ }
750
+ const result = {
751
+ requestId: request.requestId,
752
+ acquisitionId,
753
+ status,
754
+ sourceId: policy.sourceId,
755
+ ...(channel !== undefined ? { channel } : {}),
756
+ ...(artifact !== undefined ? { artifact } : {}),
757
+ ...(version !== undefined ? { version } : {}),
758
+ provenance,
759
+ rights: policy.rights,
760
+ diagnostics: {
761
+ ...(outcome.httpStatus !== undefined ? { httpStatus: outcome.httpStatus } : {}),
762
+ ...(outcome.retryAfterSeconds !== undefined
763
+ ? { retryAfterSeconds: outcome.retryAfterSeconds }
764
+ : {}),
765
+ reasonCode,
766
+ sanitizedMessage: sanitizedMessageFor(reasonCode),
767
+ },
768
+ };
769
+ await this.persistence.writeAcquisitionMetadata(result, {
770
+ requestedAt: requestedAt.toISOString(),
771
+ completedAt: completedAt.toISOString(),
772
+ attempts,
773
+ ...(outcome.metadataExtras ?? {}),
774
+ });
775
+ await this.persistence.writeAuditRecord(result, requestedAt, completedAt);
776
+ return result;
777
+ }
778
+ }
779
+ /* --------------------------------------------------------------------------
780
+ * Local helpers
781
+ * ------------------------------------------------------------------------ */
782
+ function readResourceUrl(request, kind) {
783
+ switch (kind) {
784
+ case "url":
785
+ return request.resource.url;
786
+ case "api_resource":
787
+ return request.resource.apiResource;
788
+ case "feed_entry":
789
+ return request.resource.feedEntryUrl;
790
+ case "archive_reference":
791
+ return request.resource.archiveReference;
792
+ case "licensed_resource":
793
+ return request.resource.licensedResourceId;
794
+ default:
795
+ return undefined;
796
+ }
797
+ }
798
+ function isRobotsGoverned(channel) {
799
+ return (channel === "direct_http" || channel === "rss_or_atom" || channel === "permitted_browser");
800
+ }
801
+ /** Statuses whose Location header must be followed (validated per hop). 304 is conditional retrieval, not a redirect. */
802
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
803
+ function compactVersion(parts) {
804
+ const version = {};
805
+ if (parts.etag !== undefined)
806
+ version.etag = parts.etag;
807
+ if (parts.lastModified !== undefined)
808
+ version.lastModified = parts.lastModified;
809
+ if (parts.previousVersionId !== undefined)
810
+ version.previousVersionId = parts.previousVersionId;
811
+ if (parts.duplicateOfArtifactId !== undefined) {
812
+ version.duplicateOfArtifactId = parts.duplicateOfArtifactId;
813
+ }
814
+ return Object.keys(version).length > 0 ? version : undefined;
815
+ }
816
+ function normalizeContentType(raw) {
817
+ if (raw === undefined)
818
+ return "application/octet-stream";
819
+ const trimmed = raw.split(";")[0].trim().toLowerCase();
820
+ return trimmed === "" ? "application/octet-stream" : trimmed;
821
+ }
822
+ function parseContentLength(raw) {
823
+ if (raw === undefined || !/^\d+$/.test(raw.trim()))
824
+ return undefined;
825
+ return Number(raw.trim());
826
+ }
827
+ /**
828
+ * Create a policy-aware content fetcher.
829
+ *
830
+ * @throws ConfigurationError / SourcePolicyViolationError when options or any
831
+ * source policy is invalid.
832
+ */
833
+ export function createContentFetcher(options) {
834
+ return new FetcherImpl(options);
835
+ }
836
+ //# sourceMappingURL=create-content-fetcher.js.map