agentlinkops 0.5.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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Imported discovery rows: the customer's own supplier export, as a first-class source.
|
|
2
|
+
//
|
|
3
|
+
// This is the lane that makes "we do not make you depend on our data" true while our corpus is
|
|
4
|
+
// near empty. The customer keeps paying Ahrefs, or exports Search Console, and AgentLinkOps
|
|
5
|
+
// verifies and watches what they already own.
|
|
6
|
+
//
|
|
7
|
+
// Three rules shape it, and each is a refusal rather than a feature.
|
|
8
|
+
//
|
|
9
|
+
// **An import never claims completeness.** Nothing in an export says whether it is page one
|
|
10
|
+
// of forty or a filtered view, so coverage is `partial` and says so. It is the one claim in
|
|
11
|
+
// the discovery contract we would have no way at all to check.
|
|
12
|
+
//
|
|
13
|
+
// **A malformed row is reported, with its position and what failed.** Never dropped. An
|
|
14
|
+
// import that silently discards forty rows of a paid export produces a number that is wrong
|
|
15
|
+
// in a way the customer finds months later.
|
|
16
|
+
//
|
|
17
|
+
// **The supplier's numbers stay the supplier's.** Ahrefs DR, Moz DA and Majestic Trust Flow
|
|
18
|
+
// measure different things on different scales. They are carried named and unnormalised, and
|
|
19
|
+
// nothing here turns them into an AgentLinkOps score.
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { validatePublicUrl, matchesTarget } from '../verifier/url.js';
|
|
22
|
+
import { DISCOVERY_VERSION, DISCOVERY_LIMITS, IMPORT_SUPPLIERS, DiscoveryCandidate, candidateId, sourceQueryTerms } from './contract.js';
|
|
23
|
+
|
|
24
|
+
export class ImportError extends Error {
|
|
25
|
+
constructor(code, status = 400) { super(code); this.name = 'ImportError'; this.code = code; this.status = status; }
|
|
26
|
+
}
|
|
27
|
+
const fail = (code, status) => { throw new ImportError(code, status); };
|
|
28
|
+
|
|
29
|
+
/** One row as a caller may supply it. Deliberately loose on the supplier's own fields. */
|
|
30
|
+
export const ImportRow = z.object({
|
|
31
|
+
source_url: z.string().max(4096),
|
|
32
|
+
target_url: z.string().max(4096),
|
|
33
|
+
anchor: z.string().max(4096).nullish(),
|
|
34
|
+
rel: z.union([z.string().max(1024), z.array(z.string().max(128)).max(32)]).nullish(),
|
|
35
|
+
dofollow: z.boolean().nullish(),
|
|
36
|
+
link_type: z.string().max(64).nullish(),
|
|
37
|
+
first_seen: z.string().max(64).nullish(),
|
|
38
|
+
last_seen: z.string().max(64).nullish(),
|
|
39
|
+
is_lost: z.boolean().nullish(),
|
|
40
|
+
supplier_row_id: z.string().max(256).nullish(),
|
|
41
|
+
supplier_metrics: z.record(z.string().min(1).max(64), z.union([z.number(), z.string().max(256), z.null()])).nullish(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const REL_TOKEN = /^[a-zA-Z0-9_-]{1,128}$/u;
|
|
45
|
+
const toIso = value => {
|
|
46
|
+
if (value === null || value === undefined || value === '') return null;
|
|
47
|
+
const time = Date.parse(value);
|
|
48
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalizes one supplier row into the candidate shape, or explains why it cannot be.
|
|
53
|
+
*
|
|
54
|
+
* A supplier date that cannot be parsed is an ERROR rather than a null, because a null would
|
|
55
|
+
* read as "the supplier did not say" when in fact it said something we failed to understand.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeRow(row, { runId, workspaceId, projectId, supplier, taskId, retrievedAt, target, scope, generatedAt = null }) {
|
|
58
|
+
const parsed = ImportRow.safeParse(row);
|
|
59
|
+
if (!parsed.success) return { error: parsed.error.issues.map(issue => `${issue.path.join('.') || 'row'}: ${issue.message}`).join('; ') };
|
|
60
|
+
const value = parsed.data;
|
|
61
|
+
// An EMPTY cell and a malformed URL are different problems with different fixes — a column
|
|
62
|
+
// mapped to the wrong field, versus rows that need cleaning — and `invalid_url` for both
|
|
63
|
+
// sends the reader to the wrong one.
|
|
64
|
+
for (const field of ['source_url', 'target_url']) {
|
|
65
|
+
if (!String(value[field] ?? '').trim()) return { error: `${field}: empty` };
|
|
66
|
+
}
|
|
67
|
+
const source = validatePublicUrl(value.source_url);
|
|
68
|
+
if (!source.valid) return { error: `source_url: ${source.reason}` };
|
|
69
|
+
const destination = validatePublicUrl(value.target_url);
|
|
70
|
+
if (!destination.valid) return { error: `target_url: ${destination.reason}` };
|
|
71
|
+
// A row about someone else's site is not a row about this import's target. Accepting it
|
|
72
|
+
// would quietly widen the run's scope past what its coverage statement describes.
|
|
73
|
+
if (!matchesTarget(destination.url, target, scope)) return { error: 'target_url: outside the scope of this import' };
|
|
74
|
+
|
|
75
|
+
const dates = {};
|
|
76
|
+
for (const field of ['first_seen', 'last_seen']) {
|
|
77
|
+
const iso = toIso(value[field]);
|
|
78
|
+
if (iso === undefined) return { error: `${field}: not a date we can read (${String(value[field]).slice(0, 40)})` };
|
|
79
|
+
dates[field] = iso;
|
|
80
|
+
}
|
|
81
|
+
let rel = null;
|
|
82
|
+
if (value.rel !== null && value.rel !== undefined) {
|
|
83
|
+
const tokens = (Array.isArray(value.rel) ? value.rel : String(value.rel).split(/[\s,]+/u)).filter(Boolean);
|
|
84
|
+
if (tokens.some(token => !REL_TOKEN.test(token))) return { error: 'rel: not a list of relation tokens' };
|
|
85
|
+
rel = [...new Set(tokens.map(token => token.toLowerCase()))].sort();
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
candidate: {
|
|
89
|
+
v: DISCOVERY_VERSION, id: null,
|
|
90
|
+
workspace_id: workspaceId, project_id: projectId, discovery_run_id: runId,
|
|
91
|
+
source_url: source.url, target_url: destination.url,
|
|
92
|
+
provider: 'imported', data_mode: 'imported',
|
|
93
|
+
provider_task_id: taskId, provider_retrieved_at: retrievedAt,
|
|
94
|
+
provider_first_seen: dates.first_seen, provider_prev_seen: null, provider_last_seen: dates.last_seen,
|
|
95
|
+
provider_status: { is_lost: value.is_lost ?? null, is_broken: null, is_new: null },
|
|
96
|
+
anchor: value.anchor ?? null, rel,
|
|
97
|
+
// A supplier that states `rel` and no follow flag has still told us the answer; one that
|
|
98
|
+
// states neither has not, and a guess would become a fact the moment it was stored.
|
|
99
|
+
dofollow: value.dofollow ?? (rel ? !rel.includes('nofollow') : null),
|
|
100
|
+
link_type: value.link_type ?? null,
|
|
101
|
+
source_http_status: null, target_http_status: null, links_count: null,
|
|
102
|
+
provider_metrics: { imported: {
|
|
103
|
+
supplier, supplier_row_id: value.supplier_row_id ?? null,
|
|
104
|
+
supplier_generated_at: generatedAt, supplier_metrics: value.supplier_metrics ?? null,
|
|
105
|
+
} },
|
|
106
|
+
verification_status: 'not_checked', verified_at: null, observation_id: null, evidence_id: null,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Turns a supplier export into a run's worth of candidates.
|
|
113
|
+
*
|
|
114
|
+
* Returns accepted candidates, per-row rejections and per-row duplicates. The caller decides
|
|
115
|
+
* what to do with the rejections; this function's only obligation is never to hide them.
|
|
116
|
+
*/
|
|
117
|
+
export async function prepareImport({ runId, workspaceId, projectId, supplier, rows, target, scope, taskId, retrievedAt, generatedAt = null, seen = new Map(), rowOffset = 0 }) {
|
|
118
|
+
if (!IMPORT_SUPPLIERS.includes(supplier)) fail('UNKNOWN_IMPORT_SUPPLIER');
|
|
119
|
+
if (!Array.isArray(rows)) fail('INVALID_IMPORT_ROWS');
|
|
120
|
+
if (rows.length > DISCOVERY_LIMITS.rows) {
|
|
121
|
+
// A bare code tells the customer nothing about a file they are looking at right now.
|
|
122
|
+
const error = new ImportError('IMPORT_TOO_LARGE', 413);
|
|
123
|
+
error.message = `IMPORT_TOO_LARGE: ${rows.length} rows, and one import holds at most ${DISCOVERY_LIMITS.rows}`;
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const accepted = [], rejected = [], duplicates = [];
|
|
127
|
+
// `seen` may be shared across calls so a file larger than one import can still detect a
|
|
128
|
+
// placement repeated in two different chunks of itself.
|
|
129
|
+
for (const [position, row] of rows.entries()) {
|
|
130
|
+
const index = position + rowOffset;
|
|
131
|
+
const result = normalizeRow(row, { runId, workspaceId, projectId, supplier, taskId, retrievedAt, target, scope, generatedAt });
|
|
132
|
+
if (result.error) { rejected.push({ row: index + 1, reason: result.error }); continue; }
|
|
133
|
+
const candidate = result.candidate;
|
|
134
|
+
candidate.id = await candidateId(runId, candidate.source_url, candidate.target_url);
|
|
135
|
+
// The same placement twice in one export is the supplier's duplicate, not a second link.
|
|
136
|
+
if (seen.has(candidate.id)) { duplicates.push({ row: index + 1, first_seen_on_row: seen.get(candidate.id) }); continue; }
|
|
137
|
+
seen.set(candidate.id, index + 1);
|
|
138
|
+
// Parsed rather than trusted: a row that cannot satisfy the candidate contract is a
|
|
139
|
+
// rejection with a reason, even when it came from our own normalizer.
|
|
140
|
+
const checked = DiscoveryCandidate.safeParse(candidate);
|
|
141
|
+
if (!checked.success) { rejected.push({ row: index + 1, reason: checked.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ') }); continue; }
|
|
142
|
+
accepted.push(checked.data);
|
|
143
|
+
}
|
|
144
|
+
return { accepted, rejected, duplicates, returned_rows: rows.length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The query an import describes: this target, as supplied, no ranking claimed. */
|
|
148
|
+
export function importQuery({ targetKind, target, includeSubdomains = false, statusType = 'all', rowLimit = DISCOVERY_LIMITS.rows }) {
|
|
149
|
+
return {
|
|
150
|
+
target_kind: targetKind, target, include_subdomains: includeSubdomains,
|
|
151
|
+
backlinks_status_type: statusType, exclude_internal_backlinks: true,
|
|
152
|
+
mode: 'as_is', filters: null,
|
|
153
|
+
...sourceQueryTerms('imported'),
|
|
154
|
+
page_limit: rowLimit, row_limit: rowLimit,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Toolsets, tiers, the card-catalog manifest, the meta tools and the exposure views, derived
|
|
2
|
+
// from registry metadata. Pure: no cloud, filesystem or service imports, so the measurement
|
|
3
|
+
// script, the MCP router, the CLI index and the generators all read the same definitions.
|
|
4
|
+
// Rules: docs/technical/AGENT-SURFACE-CONTRACT.md (DP-0036).
|
|
5
|
+
|
|
6
|
+
export const TOOLSETS = ['monitoring','evidence','discovery','library','competitors','reports','workspace','notifications','webhooks'];
|
|
7
|
+
|
|
8
|
+
// One line per toolset: what the group is for. Printed by the site brief, the REST index and the
|
|
9
|
+
// catalog snapshot so the same wording reaches every client; a toolset without a summary is a
|
|
10
|
+
// gate failure in the measurement script.
|
|
11
|
+
export const TOOLSET_SUMMARIES = {
|
|
12
|
+
monitoring: 'Watch earned links and destination URLs: create, list, update, import, export, recheck.',
|
|
13
|
+
evidence: 'What a check observed: histories, snapshots, change feeds, check jobs, published contacts.',
|
|
14
|
+
discovery: 'Import candidate rows, read stored runs, verify selected candidates, enroll them.',
|
|
15
|
+
library: 'Read and export the coverage-stated opportunity library.',
|
|
16
|
+
competitors: 'Competitor sets, dated inventories, scheduled refresh, gap and domain-mix reports.',
|
|
17
|
+
reports: 'Profile, anchor and report summaries over the tracked dataset.',
|
|
18
|
+
workspace: 'Workspace, projects, usage, members, invitations, scratch-resource cleanup.',
|
|
19
|
+
notifications: 'Email notification preferences, previews, tests and deliveries.',
|
|
20
|
+
webhooks: 'Webhook endpoints, state, secrets and delivery records.',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Provisional core tier until DP-0036-T13 usage evidence replaces it. At most six.
|
|
24
|
+
export const CORE_DEFAULT = ['get_workspace','list_projects','list_link_watches','monitor_link','list_events'];
|
|
25
|
+
|
|
26
|
+
// Fallback toolset by documentation category and by name, used only for a binding that does not
|
|
27
|
+
// carry an explicit `toolset`. DP-0036-T02 makes every binding explicit; the fallback stays so a
|
|
28
|
+
// new command without metadata is still measured instead of silently uncounted.
|
|
29
|
+
const CATEGORY_TOOLSET = { monitoring:'monitoring', destinations:'monitoring', imports:'discovery', verification:'discovery', competitors:'competitors', reports:'reports', workspace:'workspace', webhooks:'webhooks' };
|
|
30
|
+
const NAME_TOOLSET = {
|
|
31
|
+
get_evidence:'evidence', locate_link:'evidence', get_history:'evidence',
|
|
32
|
+
get_public_contacts:'evidence', list_events:'evidence', list_check_jobs:'evidence', get_check_job:'evidence',
|
|
33
|
+
send_invitation_email:'notifications', preview_notification_email:'notifications', get_notification_preferences:'notifications', save_notification_preferences:'notifications', list_notification_deliveries:'notifications', test_notification_email:'notifications',
|
|
34
|
+
export_link_watches:'monitoring',
|
|
35
|
+
list_opportunities:'library', get_opportunity:'library', export_opportunities:'library',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function toolsetOf(name, binding = {}, category = null) {
|
|
39
|
+
return binding.toolset ?? NAME_TOOLSET[name] ?? CATEGORY_TOOLSET[category] ?? 'workspace';
|
|
40
|
+
}
|
|
41
|
+
export function tierOf(name, binding = {}) {
|
|
42
|
+
return binding.tier ?? (CORE_DEFAULT.includes(name) ? 'core' : 'deferred');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The first sentence of a description, cut to `max` characters on a word boundary.
|
|
46
|
+
export function oneLiner(description, max = 60) {
|
|
47
|
+
const sentence = (description ?? '').split(/(?<=\.)\s/)[0].trim();
|
|
48
|
+
if (sentence.length <= max) return sentence;
|
|
49
|
+
const cut = sentence.slice(0, max + 1);
|
|
50
|
+
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 20)).replace(/[,;:]$/, '')}…`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// entries: [{ name, description, toolset, tier, readOnly }]. Grouped, one line per command.
|
|
54
|
+
export function manifestText(entries, { max = 60 } = {}) {
|
|
55
|
+
const groups = new Map(TOOLSETS.map(t => [t, []]));
|
|
56
|
+
for (const entry of entries) (groups.get(entry.toolset) ?? groups.set(entry.toolset, []).get(entry.toolset)).push(entry);
|
|
57
|
+
const lines = ['Commands by toolset (name: what it does). Use search_tools or describe_tools for the full schema before calling.'];
|
|
58
|
+
for (const [toolset, members] of groups) {
|
|
59
|
+
if (!members.length) continue;
|
|
60
|
+
lines.push(`\n${toolset}:`);
|
|
61
|
+
for (const entry of members.sort((a, b) => a.name.localeCompare(b.name))) lines.push(`- ${entry.name}${entry.tier === 'core' ? ' (core)' : ''}${entry.readOnly ? '' : ' [write]'}: ${oneLiner(entry.description, max)}`);
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The four meta tools of the default view. Their schemas live here so the measurement script
|
|
67
|
+
// counts the real definitions the router will register.
|
|
68
|
+
const argumentsSchema = { type:'object', description:'Arguments for the named command, exactly as its input schema describes them.', additionalProperties:true };
|
|
69
|
+
export const META_TOOLS = [
|
|
70
|
+
{ name:'search_tools', description:`Find AgentLinkOps commands by what you want to do. Searches names, descriptions, parameter names and keywords across the toolsets: ${TOOLSETS.join(', ')}. Returns names with one-line summaries; call describe_tools for a schema.`, readOnly:true,
|
|
71
|
+
inputSchema:{ type:'object', properties:{ query:{ type:'string', minLength:1, maxLength:500, description:'Natural-language task or keywords.' }, toolset:{ type:'string', enum:TOOLSETS, description:'Restrict results to one toolset.' }, limit:{ type:'integer', minimum:1, maximum:25, description:'Maximum results; default 5.' } }, required:['query'], additionalProperties:false } },
|
|
72
|
+
{ name:'describe_tools', description:'Read the full input schema, examples and scopes for one or more commands by name. Include the output schema on request. Use before run_read_command or run_write_command.', readOnly:true,
|
|
73
|
+
inputSchema:{ type:'object', properties:{ names:{ type:'array', items:{ type:'string', minLength:1, maxLength:64 }, minItems:1, maxItems:10, description:'Command names from search_tools or the toolset listing.' }, include:{ type:'string', enum:['input','input+output','examples'], description:'What to include; default input.' } }, required:['names'], additionalProperties:false } },
|
|
74
|
+
{ name:'run_read_command', description:'Run a read-only AgentLinkOps command by name with its arguments. Reads never change workspace data. Arguments are validated against the command\'s schema; describe_tools shows it.', readOnly:true,
|
|
75
|
+
inputSchema:{ type:'object', properties:{ name:{ type:'string', minLength:1, maxLength:64, description:'A read-only command name.' }, arguments:argumentsSchema }, required:['name'], additionalProperties:false } },
|
|
76
|
+
{ name:'run_write_command', description:'Run an AgentLinkOps command that creates, changes or removes workspace data, by name with its arguments. Confirm irreversible changes with the person first. Arguments are validated against the command\'s schema; describe_tools shows it.', readOnly:false,
|
|
77
|
+
inputSchema:{ type:'object', properties:{ name:{ type:'string', minLength:1, maxLength:64, description:'A write command name.' }, arguments:argumentsSchema }, required:['name'], additionalProperties:false } },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// DP-0036-T12: the three tools of the code view (/mcp/code). `execute` runs the model's
|
|
81
|
+
// JavaScript in a Dynamic Worker isolate with no network; the only way out is
|
|
82
|
+
// `agentlinkops.<command>(args)`, which dispatches to the registry under the caller's scopes.
|
|
83
|
+
export const CODE_TOOLS = [
|
|
84
|
+
{ name:'search', description:'Find AgentLinkOps commands by what you want to do; returns names, toolsets and one-line summaries. Then describe for the exact signature.', readOnly:true,
|
|
85
|
+
inputSchema:{ type:'object', properties:{ query:{ type:'string', minLength:1, maxLength:500, description:'Natural-language task or keywords.' }, toolset:{ type:'string', enum:TOOLSETS }, limit:{ type:'integer', minimum:1, maximum:25, description:'Maximum results; default 5.' } }, required:['query'], additionalProperties:false } },
|
|
86
|
+
{ name:'describe', description:'Read the TypeScript signature of one or more commands as they are callable from execute: agentlinkops.<name>(args) with the exact argument and result shapes, scopes and examples.', readOnly:true,
|
|
87
|
+
inputSchema:{ type:'object', properties:{ names:{ type:'array', items:{ type:'string', minLength:1, maxLength:64 }, minItems:1, maxItems:10 } }, required:['names'], additionalProperties:false } },
|
|
88
|
+
{ name:'execute', description:'Run JavaScript against AgentLinkOps in an isolated sandbox. Write the body of an async function; `return` the value you want back; call commands as `await agentlinkops.<name>({...})` (any command, with this connection\'s scopes; retired names resolve). No network, no filesystem, no env: fetch() throws. Results over about 6,000 tokens are truncated with a marker, so filter and aggregate in code. Up to 200 command calls and 30 seconds per execution.', readOnly:false,
|
|
89
|
+
inputSchema:{ type:'object', properties:{ code:{ type:'string', minLength:1, maxLength:64000, description:'JavaScript: the body of an async function, or an async arrow function.' }, readOnly:{ type:'boolean', description:'When true, any write command call fails instead of running. Default false.' } }, required:['code'], additionalProperties:false } },
|
|
90
|
+
];
|
|
91
|
+
export const CODE_LIMITS = { outputChars:24000, timeoutMs:30000, maxCalls:200 };
|
|
92
|
+
|
|
93
|
+
// Views. `entries` carry name, toolset, tier, readOnly and (from tool-exposure.js) unavailable.
|
|
94
|
+
// An unavailable command stays a member of its views: it is listed with its marker, never
|
|
95
|
+
// hidden, so a client that calls it gets the stable configuration error rather than not-found.
|
|
96
|
+
export const VIEW_BUDGETS = {
|
|
97
|
+
default: { definitions:12, tokens:6000 },
|
|
98
|
+
toolset: { definitions:22, tokens:8000 },
|
|
99
|
+
code: { definitions:3, tokens:2000 },
|
|
100
|
+
manifest: { tokens:2500 },
|
|
101
|
+
};
|
|
102
|
+
export function viewMembers(entries, view) {
|
|
103
|
+
const visible = entries;
|
|
104
|
+
const core = visible.filter(e => e.tier === 'core');
|
|
105
|
+
if (view === 'all') return visible;
|
|
106
|
+
if (view === 'readonly') return visible.filter(e => e.readOnly);
|
|
107
|
+
if (view === 'default') return core;
|
|
108
|
+
if (view === 'code') return [];
|
|
109
|
+
const m = /^toolset:([a-z]+)(:readonly)?$/.exec(view);
|
|
110
|
+
if (!m) throw new Error(`Unknown view ${view}`);
|
|
111
|
+
const members = visible.filter(e => e.toolset === m[1] || e.tier === 'core');
|
|
112
|
+
return m[2] ? members.filter(e => e.readOnly) : members;
|
|
113
|
+
}
|
|
114
|
+
export function viewNames(entries) {
|
|
115
|
+
const toolsets = [...new Set(entries.map(e => e.toolset))].sort();
|
|
116
|
+
return ['default','all','readonly','code', ...toolsets.flatMap(t => [`toolset:${t}`, `toolset:${t}:readonly`])];
|
|
117
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { validatePublicUrl } from './url.js';
|
|
2
|
+
import { robotsDecision } from './robots.js';
|
|
3
|
+
|
|
4
|
+
export class VerificationError extends Error {
|
|
5
|
+
constructor(reason, details = {}) {
|
|
6
|
+
super(reason);
|
|
7
|
+
this.name = 'VerificationError';
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
Object.assign(this, details);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cancelBody(response) {
|
|
14
|
+
// Cancellation must not extend the request deadline on a misbehaving transport.
|
|
15
|
+
response.body?.cancel().catch(() => {});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readBounded(response, maxBytes, signal) {
|
|
19
|
+
const length = response.headers.get('content-length');
|
|
20
|
+
const encoded = response.headers.get('content-encoding');
|
|
21
|
+
if (!encoded && length && Number(length) > maxBytes) {
|
|
22
|
+
cancelBody(response);
|
|
23
|
+
throw new VerificationError('body_too_large');
|
|
24
|
+
}
|
|
25
|
+
const reader = response.body?.getReader();
|
|
26
|
+
if (!reader) return { text: '', bytes: 0 };
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let bytes = 0;
|
|
29
|
+
try {
|
|
30
|
+
while (true) {
|
|
31
|
+
const { done, value } = await withAbort(reader.read(), signal);
|
|
32
|
+
if (done) break;
|
|
33
|
+
bytes += value.byteLength;
|
|
34
|
+
if (bytes > maxBytes) throw new VerificationError('body_too_large');
|
|
35
|
+
chunks.push(value);
|
|
36
|
+
}
|
|
37
|
+
if (!encoded && length && Number(length) !== bytes) throw new VerificationError('incomplete_body');
|
|
38
|
+
const combined = new Uint8Array(bytes);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
|
41
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
42
|
+
const charset = /charset\s*=\s*["']?([^\s;"']+)/iu.exec(contentType)?.[1] ?? 'utf-8';
|
|
43
|
+
let text;
|
|
44
|
+
try { text = new TextDecoder(charset, { fatal: true }).decode(combined); }
|
|
45
|
+
catch { throw new VerificationError('unsupported_or_invalid_encoding'); }
|
|
46
|
+
return { text, bytes, data: combined };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
reader.cancel().catch(() => {});
|
|
49
|
+
throw error;
|
|
50
|
+
} finally {
|
|
51
|
+
reader.releaseLock();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function withAbort(promise, signal) {
|
|
56
|
+
if (signal.aborted) return Promise.reject(new VerificationError('timeout'));
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const onAbort = () => reject(new VerificationError('timeout'));
|
|
59
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
60
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One shared budget for robots, redirects, hooks and source-body reads. */
|
|
65
|
+
export const PRODUCT_TOKEN = 'LinktrailBot';
|
|
66
|
+
|
|
67
|
+
// A day. Anything longer is a publisher saying "not today", and a scheduler that honoured a
|
|
68
|
+
// month-long Retry-After would silently retire the check instead of retrying it.
|
|
69
|
+
export const MAX_RETRY_AFTER_SECONDS = 86_400;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* RFC 9110 `Retry-After`: delta-seconds or an HTTP-date. Returns null for anything else.
|
|
73
|
+
*
|
|
74
|
+
* Both forms appear in the wild and a parser that reads only the integer form silently discards
|
|
75
|
+
* the date form, which is the one a CDN tends to send.
|
|
76
|
+
*/
|
|
77
|
+
export function retryAfterSeconds(value, now = Date.now()) {
|
|
78
|
+
if (!value) return null;
|
|
79
|
+
const text = String(value).trim();
|
|
80
|
+
if (/^\d+$/u.test(text)) {
|
|
81
|
+
const seconds = Number(text);
|
|
82
|
+
return Number.isFinite(seconds) ? Math.min(seconds, MAX_RETRY_AFTER_SECONDS) : null;
|
|
83
|
+
}
|
|
84
|
+
// `Date.parse` accepts far more than an HTTP-date — it reads "-5" as a year and returns a real
|
|
85
|
+
// timestamp, which turned a malformed header into a wait of zero. An IMF-fixdate always carries
|
|
86
|
+
// a month and day name, so requiring a letter is enough to keep the loose parser off junk.
|
|
87
|
+
if (!/[A-Za-z]/u.test(text)) return null;
|
|
88
|
+
const at = Date.parse(text);
|
|
89
|
+
if (!Number.isFinite(at)) return null;
|
|
90
|
+
// A date already in the past means "now", never a negative wait.
|
|
91
|
+
return Math.min(Math.max(0, Math.ceil((at - now) / 1000)), MAX_RETRY_AFTER_SECONDS);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function publicFetcher(options, signal) {
|
|
95
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
96
|
+
const robotsByOrigin = new Map();
|
|
97
|
+
// The product token is what robots matches on and never changes. A contact URL may be appended
|
|
98
|
+
// so a publisher who sees us in their logs can find out who we are — the convention every
|
|
99
|
+
// reputable crawler follows, and the thing most likely to move a reflexive 403 or 429.
|
|
100
|
+
//
|
|
101
|
+
// It defaults to ABSENT on purpose. It was written before AgentLinkOps had a public domain, and a bot
|
|
102
|
+
// advertising a URL that does not resolve is worse than one advertising nothing: it reads as
|
|
103
|
+
// impersonation rather than identification. Set it when a real domain exists.
|
|
104
|
+
const userAgent = options.userAgentContact
|
|
105
|
+
? `${PRODUCT_TOKEN}/0.1 (+${String(options.userAgentContact).slice(0, 200)})`
|
|
106
|
+
: `${PRODUCT_TOKEN}/0.1`;
|
|
107
|
+
const maxRedirects = Math.min(options.maxRedirects ?? 5, 5);
|
|
108
|
+
const maxBytes = Math.min(options.maxBytes ?? 2 * 1024 * 1024, 2 * 1024 * 1024);
|
|
109
|
+
|
|
110
|
+
async function request(url, context = {}) {
|
|
111
|
+
const checked = validatePublicUrl(url);
|
|
112
|
+
if (!checked.valid) throw new VerificationError(`unsafe_url:${checked.reason}`);
|
|
113
|
+
if (options.beforeFetch) await withAbort(Promise.resolve().then(() => options.beforeFetch(checked.url, { ...context, signal })), signal);
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await withAbort(Promise.resolve().then(() => {
|
|
117
|
+
if (signal.aborted) throw new VerificationError('timeout');
|
|
118
|
+
return fetchImpl(checked.url, {
|
|
119
|
+
method: 'GET',
|
|
120
|
+
redirect: 'manual',
|
|
121
|
+
credentials: 'omit',
|
|
122
|
+
signal,
|
|
123
|
+
headers: {
|
|
124
|
+
'User-Agent': userAgent,
|
|
125
|
+
Accept: context.kind === 'robots' ? 'text/plain' : 'text/html',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}), signal);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof VerificationError) throw error;
|
|
131
|
+
throw new VerificationError(signal.aborted ? 'timeout' : 'network_error');
|
|
132
|
+
}
|
|
133
|
+
// Custom transports must never silently follow redirects or bypass destination checks.
|
|
134
|
+
if (response.redirected || (response.url && new URL(response.url).href !== checked.url)) {
|
|
135
|
+
cancelBody(response);
|
|
136
|
+
throw new VerificationError('transport_followed_redirect');
|
|
137
|
+
}
|
|
138
|
+
return response;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function fetchRobots(origin, diagnostic) {
|
|
142
|
+
let url = `${origin}/robots.txt`;
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
145
|
+
diagnostic.robotsUrl = url;
|
|
146
|
+
diagnostic.httpStatus = null;
|
|
147
|
+
if (seen.has(url)) throw new VerificationError('robots_redirect_loop');
|
|
148
|
+
seen.add(url);
|
|
149
|
+
const response = await request(url, { kind: 'robots' });
|
|
150
|
+
diagnostic.httpStatus = response.status;
|
|
151
|
+
// A challenge replaces the requested resource, even when its status would
|
|
152
|
+
// otherwise mean missing robots or a redirect. Never infer policy from it.
|
|
153
|
+
if (response.headers.get('cf-mitigated') === 'challenge') {
|
|
154
|
+
cancelBody(response);
|
|
155
|
+
const retry = retryAfterSeconds(response.headers.get('retry-after'));
|
|
156
|
+
if (retry !== null) diagnostic.retryAfterSeconds = retry;
|
|
157
|
+
throw new VerificationError('robots_access_challenge', retry === null ? {} : { retryAfterSeconds: retry });
|
|
158
|
+
}
|
|
159
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
160
|
+
cancelBody(response);
|
|
161
|
+
const location = response.headers.get('location');
|
|
162
|
+
if (!location) throw new VerificationError('robots_redirect_missing_location');
|
|
163
|
+
const next = validatePublicUrl(new URL(location, url).href);
|
|
164
|
+
if (!next.valid) throw new VerificationError(`unsafe_robots_redirect:${next.reason}`);
|
|
165
|
+
if (diagnostic.redirects.length < 5) diagnostic.redirects.push({ from: url, to: next.url, status: response.status });
|
|
166
|
+
url = next.url;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// RFC 9309 §2.3.1.3 and Google's implementation agree: a 4xx on robots.txt means the file is
|
|
170
|
+
// UNAVAILABLE, and an unavailable robots.txt imposes no restrictions. Only 404 and 410 were
|
|
171
|
+
// honoured that way here, so a 401 or 403 — a WAF answering before the file is even looked
|
|
172
|
+
// for — was read as "this publisher forbids us" and refused the whole fetch. Measured on 800
|
|
173
|
+
// real destinations, that was 39 refusals, about 5% of the run, attributed to publishers who
|
|
174
|
+
// had said nothing.
|
|
175
|
+
//
|
|
176
|
+
// Proceeding is also BETTER REPORTING even when the page then fails: `target_http_403` says
|
|
177
|
+
// the publisher refused the page, where `robots_http_403` said we never asked.
|
|
178
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
179
|
+
cancelBody(response);
|
|
180
|
+
return { text: '', reason: response.status === 404 || response.status === 410 ? 'robots_not_found' : `robots_unavailable_${response.status}` };
|
|
181
|
+
}
|
|
182
|
+
// 429 and 5xx are the other half of the same rule and stay a refusal: they mean TEMPORARILY
|
|
183
|
+
// unavailable, and the spec's answer to that is to stay away rather than to assume consent.
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
cancelBody(response);
|
|
186
|
+
const retry = retryAfterSeconds(response.headers.get('retry-after'));
|
|
187
|
+
if (retry !== null) diagnostic.retryAfterSeconds = retry;
|
|
188
|
+
throw new VerificationError(`robots_http_${response.status}`, retry === null ? {} : { retryAfterSeconds: retry });
|
|
189
|
+
}
|
|
190
|
+
const body = await readBounded(response, 512 * 1024, signal);
|
|
191
|
+
if (/text\/html/iu.test(response.headers.get('content-type') ?? '') || /<(?:!doctype|html|body)\b/iu.test(body.text)) {
|
|
192
|
+
throw new VerificationError('robots_not_plain_text');
|
|
193
|
+
}
|
|
194
|
+
return { text: body.text, reason: 'robots_fetched' };
|
|
195
|
+
}
|
|
196
|
+
throw new VerificationError('robots_redirect_limit');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function checkRobots(url, trace) {
|
|
200
|
+
const origin = new URL(url).origin;
|
|
201
|
+
const diagnostic = { sourceUrl: url, robotsUrl: `${origin}/robots.txt`, httpStatus: null,
|
|
202
|
+
productToken: PRODUCT_TOKEN, allowed: null, reason: null, matchedRule: null,
|
|
203
|
+
crawlDelaySeconds: null, fetched: null, redirects: [] };
|
|
204
|
+
// Write before fetching/evaluating so refusals keep the failing hop, not the
|
|
205
|
+
// last allowed origin. Both histories are capped by our redirect budget.
|
|
206
|
+
trace.robots = diagnostic;
|
|
207
|
+
trace.robotsHistory ??= [];
|
|
208
|
+
if (trace.robotsHistory.length < 6) trace.robotsHistory.push(diagnostic);
|
|
209
|
+
try {
|
|
210
|
+
if (!robotsByOrigin.has(origin)) {
|
|
211
|
+
const record = await fetchRobots(origin, diagnostic);
|
|
212
|
+
robotsByOrigin.set(origin, { ...record, diagnostic: structuredClone(diagnostic) });
|
|
213
|
+
}
|
|
214
|
+
const record = robotsByOrigin.get(origin);
|
|
215
|
+
Object.assign(diagnostic, structuredClone(record.diagnostic), { sourceUrl: url });
|
|
216
|
+
const decision = robotsDecision(record.text, url);
|
|
217
|
+
Object.assign(diagnostic, decision, { fetched: record.reason });
|
|
218
|
+
options.onRobots?.(url, decision);
|
|
219
|
+
if (decision.allowed !== true) throw new VerificationError(decision.reason);
|
|
220
|
+
if (decision.crawlDelaySeconds > 0 && !options.beforeFetch) {
|
|
221
|
+
throw new VerificationError('robots_crawl_delay_requires_scheduler');
|
|
222
|
+
}
|
|
223
|
+
return diagnostic;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
diagnostic.reason = error instanceof VerificationError ? error.reason : 'verification_error';
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
// Authorize a native browser document without refetching its body through HTTP.
|
|
232
|
+
// The caller still needs an independently verified browser egress boundary.
|
|
233
|
+
async authorizeNavigation(url, trace = {}) {
|
|
234
|
+
const checked = validatePublicUrl(url);
|
|
235
|
+
if (!checked.valid) throw new VerificationError('unsafe_render_navigation');
|
|
236
|
+
const robots = await checkRobots(checked.url, trace);
|
|
237
|
+
if (signal.aborted) throw new VerificationError('timeout');
|
|
238
|
+
await options.beforeFetch?.(checked.url, { kind: 'source', crawlDelaySeconds: robots.crawlDelaySeconds, signal });
|
|
239
|
+
if (signal.aborted) throw new VerificationError('timeout');
|
|
240
|
+
return robots;
|
|
241
|
+
},
|
|
242
|
+
async source(initialUrl, trace) {
|
|
243
|
+
let url = initialUrl;
|
|
244
|
+
const seen = new Set();
|
|
245
|
+
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
246
|
+
trace.finalUrl = url;
|
|
247
|
+
if (seen.has(url)) throw new VerificationError('redirect_loop');
|
|
248
|
+
seen.add(url);
|
|
249
|
+
const robots = await checkRobots(url, trace);
|
|
250
|
+
trace.robots = robots;
|
|
251
|
+
const response = await request(url, { kind: 'source', crawlDelaySeconds: robots.crawlDelaySeconds });
|
|
252
|
+
trace.httpStatus = response.status;
|
|
253
|
+
trace.sourceResponse = { url, httpStatus: response.status,
|
|
254
|
+
contentType: /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+/iu.exec(response.headers.get('content-type') ?? '')?.[0].slice(0, 128) ?? null,
|
|
255
|
+
retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')),
|
|
256
|
+
challenge: response.headers.get('cf-mitigated') === 'challenge' };
|
|
257
|
+
if (trace.sourceResponse.challenge) {
|
|
258
|
+
cancelBody(response);
|
|
259
|
+
const retry = trace.sourceResponse.retryAfterSeconds;
|
|
260
|
+
throw new VerificationError('access_challenge', retry === null ? {} : { retryAfterSeconds: retry });
|
|
261
|
+
}
|
|
262
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
263
|
+
cancelBody(response);
|
|
264
|
+
const location = response.headers.get('location');
|
|
265
|
+
if (!location) throw new VerificationError('redirect_missing_location');
|
|
266
|
+
let next;
|
|
267
|
+
try { next = validatePublicUrl(new URL(location, url).href); }
|
|
268
|
+
catch { throw new VerificationError('invalid_redirect'); }
|
|
269
|
+
if (!next.valid) throw new VerificationError(`unsafe_redirect:${next.reason}`);
|
|
270
|
+
trace.redirects.push({ from: url, to: next.url, status: response.status });
|
|
271
|
+
url = next.url;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if ([404, 410].includes(response.status)) {
|
|
275
|
+
cancelBody(response);
|
|
276
|
+
return { response, body: null };
|
|
277
|
+
}
|
|
278
|
+
if (response.status === 304) {
|
|
279
|
+
cancelBody(response);
|
|
280
|
+
throw new VerificationError('not_modified_without_baseline');
|
|
281
|
+
}
|
|
282
|
+
if (response.status !== 200) {
|
|
283
|
+
cancelBody(response);
|
|
284
|
+
// A 429 or a 503 may state WHEN to come back. We keep it and never act on it here:
|
|
285
|
+
// sleeping inside a request holds an origin lease and a worker for someone else's
|
|
286
|
+
// backoff, and this system already decided that **the cron is the sleep**. Surfacing it
|
|
287
|
+
// lets a scheduler re-queue at the stated time, the same way a robots crawl delay is
|
|
288
|
+
// handed to the scheduler rather than slept on.
|
|
289
|
+
throw new VerificationError(`source_http_${response.status}`,
|
|
290
|
+
retryAfterSeconds(response.headers.get('retry-after')) === null ? {}
|
|
291
|
+
: { retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')) });
|
|
292
|
+
}
|
|
293
|
+
if (!/^text\/html(?:;|$)/iu.test(response.headers.get('content-type') ?? '')) {
|
|
294
|
+
cancelBody(response);
|
|
295
|
+
throw new VerificationError('unsupported_content_type');
|
|
296
|
+
}
|
|
297
|
+
const body = await readBounded(response, maxBytes, signal);
|
|
298
|
+
return { response, body };
|
|
299
|
+
}
|
|
300
|
+
throw new VerificationError('redirect_limit');
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|