agentlinkops 0.6.11 → 0.6.16
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 +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.mcp.json +0 -0
- package/CHANGELOG.md +35 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/PACK-ARTIFACT.json +16 -0
- package/PACKS.json +591 -0
- package/README.md +34 -48
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +1 -1
- package/cli/admission.js +0 -0
- package/cli/agent-setup.js +101 -17
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/campaign-bridge.js +325 -0
- package/cli/campaign-outcomes.js +107 -0
- package/cli/catalog.js +47686 -16398
- package/cli/check.js +40 -10
- package/cli/chronology.js +39 -0
- package/cli/citation.js +0 -0
- package/cli/client.js +2 -1
- package/cli/cloud-error.js +0 -0
- package/cli/commands.js +17 -8
- package/cli/config.js +0 -0
- package/cli/connection.js +92 -18
- package/cli/contact-adoption.js +91 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +13 -2
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +0 -0
- package/cli/doctor.js +15 -4
- package/cli/env.js +0 -0
- package/cli/fleet.js +30 -19
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +7 -1
- package/cli/index-observations.js +0 -0
- package/cli/index-submissions.js +0 -0
- package/cli/key-store.js +115 -0
- package/cli/ledger.js +1 -0
- package/cli/lifecycle.js +0 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +41 -6
- package/cli/main.js +85 -20
- package/cli/mcp-client.js +2 -2
- package/cli/migration.js +308 -0
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/native-lock.js +0 -0
- package/cli/oauth-credentials.js +0 -0
- package/cli/oauth-flow.js +0 -0
- package/cli/placement-projection.js +106 -0
- package/cli/platforms.js +0 -0
- package/cli/prompt.js +25 -0
- package/cli/public-http.js +168 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +51 -19
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +0 -0
- package/cli/state.js +76 -29
- package/cli/status.js +14 -18
- package/cli/sync-lock.js +0 -0
- package/cli/sync.js +23 -4
- package/package.json +7 -8
- package/references/citation-panels.md +2 -0
- package/references/cli.md +2 -2
- package/references/disavow.md +1 -1
- package/references/discovery-handoff.md +1 -1
- package/references/local-crm-draft-handoff.md +1 -1
- package/references/portable-ledger-handoff.md +11 -10
- package/references/recipes/catalog.json +263 -7
- package/references/recipes/command-notes.md +5 -3
- package/references/recipes/internal-link-improvement.md +25 -0
- package/references/recipes/placement-reconciliation.md +53 -1
- package/references/recipes/qualified-campaign-handoff.md +0 -0
- package/references/recipes/site-context-brief.md +0 -0
- package/references/recipes/sourced-linkable-asset.md +0 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/outreach-crm-draft.py +0 -0
- package/scripts/portable-handoff.py +17 -4
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +3 -2
- package/shared/admission-report-contract.js +3 -2
- package/shared/agent-clients.js +0 -0
- package/shared/analytics-history-contract.js +15 -0
- package/shared/campaign-outcome-contract.js +12 -0
- package/shared/campaign-prospect-contract.js +23 -0
- package/shared/citation-evidence-contract.js +0 -0
- package/shared/classification-contract.js +54 -0
- package/shared/contact-record-contract.js +27 -0
- package/shared/disavow-contract.js +37 -9
- package/shared/exec-grammar.js +0 -0
- package/shared/index-observation-contract.js +5 -4
- package/shared/internal-links-contract.js +143 -0
- package/shared/lifecycle-contract.js +0 -0
- package/shared/lifecycle-money.js +0 -0
- package/shared/link-category-contract.js +35 -0
- package/shared/link-category.js +138 -0
- package/shared/link-labels.js +26 -0
- package/shared/link-summary-contract.js +17 -0
- package/shared/monitoring-chronology-export.js +57 -0
- package/shared/monitoring-read-contract.js +41 -0
- package/shared/observation-event.js +1 -1
- package/shared/onboarding.js +20 -0
- package/shared/opportunity-record.js +0 -0
- package/shared/response-contract.js +41 -5
- package/shared/search-connection-contract.js +2 -1
- package/shared/table-page-contract.js +13 -0
- package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
- package/skills/agentlinkops-assets/SKILL.md +3 -1
- package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
- package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
- package/skills/agentlinkops-campaigns/SKILL.md +7 -5
- package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
- package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
- package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
- package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
- package/skills/agentlinkops-connect/SKILL.md +2 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +164 -61
- package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
- package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
- package/skills/agentlinkops-crm/SKILL.md +26 -9
- package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
- package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
- package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
- package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
- package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
- package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
- package/skills/agentlinkops-discovery/SKILL.md +4 -2
- package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
- package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
- package/src/citations/adapter.js +0 -0
- package/src/citations/alert.js +0 -0
- package/src/citations/browser/chatgpt-citations.js +0 -0
- package/src/citations/browser/engine.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +0 -0
- package/src/citations/browser/proxy-admission.js +0 -0
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +0 -0
- package/src/citations/contract.js +0 -0
- package/src/citations/egress-receipts.js +0 -0
- package/src/citations/evidence-integrity.js +0 -0
- package/src/citations/inventory.js +0 -0
- package/src/citations/login.js +0 -0
- package/src/citations/match.js +0 -0
- package/src/citations/panel-builder.js +0 -0
- package/src/citations/report.js +0 -0
- package/src/citations/runner.js +0 -0
- package/src/citations/stats.js +0 -0
- package/src/citations/sync.js +0 -0
- package/src/citations/watch.js +0 -0
- package/src/competitors/contract.js +17 -1
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +34 -121
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +68 -32
- package/src/corpus/extract.js +147 -0
- package/src/corpus/frontier.js +151 -0
- package/src/corpus/seeds.js +0 -0
- package/src/discovery/contract.js +8 -0
- package/src/discovery/import.js +13 -1
- package/src/evidence-reference.js +0 -0
- package/src/index-observation.js +0 -0
- package/src/locator.js +0 -0
- package/src/mentions.js +193 -0
- package/src/page-acquisition/acquire.js +50 -0
- package/src/page-acquisition/bytes.js +138 -0
- package/src/page-acquisition/capture.js +11 -0
- package/src/page-acquisition/contracts.js +67 -0
- package/src/page-acquisition/documents.js +166 -0
- package/src/page-acquisition/http.js +307 -0
- package/src/page-acquisition/policy.js +20 -0
- package/src/page-acquisition/recovery.js +164 -0
- package/src/page-acquisition/render-failure.js +82 -0
- package/src/page-extraction/html.js +359 -0
- package/src/page-extraction/index.js +30 -0
- package/src/page-extraction/metadata.js +16 -0
- package/src/response-shaping.js +4 -3
- package/src/tool-views.js +23 -12
- package/src/verifier/fetch.js +4 -339
- package/src/verifier/index.js +81 -23
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/page-quality.js +14 -0
- package/src/verifier/parser.js +2 -241
- package/src/verifier/readiness.js +64 -13
- package/src/verifier/render-evidence.js +25 -2
- package/src/verifier/robots.js +0 -0
- package/src/verifier/target.js +201 -0
- package/src/verifier/transition.js +18 -3
- package/src/verifier/url.js +31 -0
- package/src/watch-crawlability.js +144 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/skills/content-defingerprinting/SKILL.md +0 -29
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
- package/skills/internal-linking-optimizer/SKILL.md +0 -56
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
- package/skills/pre-publish-review/SKILL.md +0 -179
- package/skills/source-cited-content-builder/SKILL.md +0 -56
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
- package/skills/source-cited-humanizer/SKILL.md +0 -20
package/cli/migration.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// DP-0069-T02: local migration assessment over the existing verifiers.
|
|
2
|
+
//
|
|
3
|
+
// The customer supplies an old-to-new URL map and a ledger. This module validates the map,
|
|
4
|
+
// joins it to ledger placements, and either plans the work (dry run: no fetch, no check, no
|
|
5
|
+
// enrollment) or runs the EXISTING link verifier and target verifier and reports, per affected
|
|
6
|
+
// placement, source presence and destination continuity as two independent observations.
|
|
7
|
+
//
|
|
8
|
+
// What it deliberately does not do:
|
|
9
|
+
// - It never rewrites a ledger entry. The original source-to-old-target expectation stays.
|
|
10
|
+
// - It never decides equivalence. HTTP success to the mapped URL is route evidence; only the
|
|
11
|
+
// map's recorded decision makes it `continuous`.
|
|
12
|
+
// - It does not invent a failure state machine: destination repeats go through the existing
|
|
13
|
+
// `transitionTargetState` reducer, and a mapping mismatch is not a backlink-loss event.
|
|
14
|
+
// - It does not enroll watches, call the cloud or edit redirects. Monitoring is DP-0069-T04.
|
|
15
|
+
import { readFile } from 'node:fs/promises';
|
|
16
|
+
import { verifyLink } from '../src/verifier/index.js';
|
|
17
|
+
import { verifyTarget, transitionTargetState } from '../src/verifier/target.js';
|
|
18
|
+
import { validatePublicUrl } from '../src/verifier/url.js';
|
|
19
|
+
import { createPacer } from './check.js';
|
|
20
|
+
|
|
21
|
+
export const MIGRATION_REPORT_SCHEMA = 'agentlinkops.migration_report/v1';
|
|
22
|
+
export const DISPOSITIONS = Object.freeze(['move', 'merge', 'retire']);
|
|
23
|
+
export const EQUIVALENCE_DECISIONS = Object.freeze(['equivalent', 'not_equivalent', 'undecided']);
|
|
24
|
+
export const MAX_MAP_ROWS = 5000;
|
|
25
|
+
|
|
26
|
+
const ISO = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));
|
|
27
|
+
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
28
|
+
const plain = value => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
29
|
+
// Fragments are never sent over HTTP, so two URLs differing only there are one fetch identity.
|
|
30
|
+
const fetchKey = href => { const url = new URL(href); url.hash = ''; return url.href; };
|
|
31
|
+
|
|
32
|
+
export class MigrationMapError extends Error {
|
|
33
|
+
constructor(problems) {
|
|
34
|
+
super(`migration map rejected: ${problems.map(p => `${p.row == null ? 'map' : `row ${p.row}`} ${p.code}`).join('; ')}`);
|
|
35
|
+
this.name = 'MigrationMapError';
|
|
36
|
+
this.problems = problems;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves one map URL against the declared base, preserving path, query and fragment, and
|
|
42
|
+
* screens it with the same public-destination rules every redirect hop must pass.
|
|
43
|
+
*/
|
|
44
|
+
function resolveUrl(raw, base) {
|
|
45
|
+
if (!text(raw)) return { error: 'missing_url' };
|
|
46
|
+
let url;
|
|
47
|
+
try { url = new URL(raw); }
|
|
48
|
+
catch {
|
|
49
|
+
if (!base) return { error: 'relative_url_without_base' };
|
|
50
|
+
try { url = new URL(raw, base); } catch { return { error: 'invalid_url' }; }
|
|
51
|
+
}
|
|
52
|
+
const screened = validatePublicUrl(url.href);
|
|
53
|
+
if (!screened.valid) return { error: `non_public_url:${screened.reason}` };
|
|
54
|
+
// validatePublicUrl drops the fragment; keep it as the customer wrote it.
|
|
55
|
+
return { href: url.hash ? `${screened.url}${url.hash}` : screened.url };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validates a supplied migration map. Every problem is collected and the whole map is rejected:
|
|
60
|
+
* a partially accepted map would produce a report whose denominator nobody chose.
|
|
61
|
+
*/
|
|
62
|
+
export function parseMigrationMap(input) {
|
|
63
|
+
const problems = [];
|
|
64
|
+
const fail = (code, row = null, detail) => problems.push({ row, code, ...(detail ? { detail } : {}) });
|
|
65
|
+
if (!plain(input)) throw new MigrationMapError([{ row: null, code: 'map_must_be_object' }]);
|
|
66
|
+
if (typeof input.migration_id !== 'string' || !/^[A-Za-z0-9._:-]{1,80}$/.test(input.migration_id)) fail('invalid_migration_id');
|
|
67
|
+
if (!Number.isInteger(input.version) || input.version < 1) fail('invalid_version');
|
|
68
|
+
if (input.cutover_at != null && !ISO(input.cutover_at)) fail('invalid_cutover_at');
|
|
69
|
+
if (!plain(input.provenance) || !text(input.provenance.source)) fail('missing_provenance');
|
|
70
|
+
else if (input.provenance.prepared_at != null && !ISO(input.provenance.prepared_at)) fail('invalid_provenance_prepared_at');
|
|
71
|
+
|
|
72
|
+
let base = null;
|
|
73
|
+
if (input.base != null) {
|
|
74
|
+
const resolved = resolveUrl(input.base, null);
|
|
75
|
+
if (resolved.error) fail('invalid_base', null, resolved.error); else base = resolved.href;
|
|
76
|
+
}
|
|
77
|
+
const origins = new Set();
|
|
78
|
+
if (!plain(input.site) || !Array.isArray(input.site.origins) || !input.site.origins.length) fail('missing_site_scope');
|
|
79
|
+
else for (const origin of input.site.origins) {
|
|
80
|
+
const resolved = resolveUrl(origin, null);
|
|
81
|
+
if (resolved.error) fail('invalid_site_origin', null, String(origin));
|
|
82
|
+
else origins.add(new URL(resolved.href).origin);
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(input.rows) || !input.rows.length) fail('missing_rows');
|
|
85
|
+
else if (input.rows.length > MAX_MAP_ROWS) fail('too_many_rows', null, `at most ${MAX_MAP_ROWS}`);
|
|
86
|
+
if (problems.length) throw new MigrationMapError(problems);
|
|
87
|
+
|
|
88
|
+
const rows = [];
|
|
89
|
+
input.rows.forEach((raw, index) => {
|
|
90
|
+
const n = index + 1;
|
|
91
|
+
const before = problems.length;
|
|
92
|
+
if (!plain(raw)) { fail('row_must_be_object', n); return; }
|
|
93
|
+
const disposition = DISPOSITIONS.includes(raw.disposition) ? raw.disposition : null;
|
|
94
|
+
if (!disposition) fail('invalid_disposition', n);
|
|
95
|
+
const old = resolveUrl(raw.old_url, base);
|
|
96
|
+
if (old.error) fail('invalid_old_url', n, old.error);
|
|
97
|
+
else if (!origins.has(new URL(old.href).origin)) fail('old_url_outside_site_scope', n);
|
|
98
|
+
let next = null;
|
|
99
|
+
if (disposition === 'retire') {
|
|
100
|
+
if (raw.new_url != null) fail('retire_must_not_have_new_url', n);
|
|
101
|
+
} else if (disposition) {
|
|
102
|
+
const resolved = resolveUrl(raw.new_url, base);
|
|
103
|
+
if (resolved.error) fail('invalid_new_url', n, resolved.error);
|
|
104
|
+
else if (!origins.has(new URL(resolved.href).origin)) fail('new_url_outside_site_scope', n);
|
|
105
|
+
else next = resolved.href;
|
|
106
|
+
}
|
|
107
|
+
if (!old.error && next && fetchKey(old.href) === fetchKey(next)) fail('self_mapping', n);
|
|
108
|
+
let equivalence = null;
|
|
109
|
+
if (disposition && disposition !== 'retire') {
|
|
110
|
+
const decision = raw.equivalence?.decision;
|
|
111
|
+
if (!EQUIVALENCE_DECISIONS.includes(decision)) fail('missing_equivalence_decision', n);
|
|
112
|
+
else if (decision !== 'undecided' && !text(raw.equivalence.reason)) fail('equivalence_reason_required', n);
|
|
113
|
+
else equivalence = { decision, reason: raw.equivalence.reason ?? null };
|
|
114
|
+
if (disposition === 'merge' && decision !== 'equivalent') fail('merge_requires_equivalent_decision', n);
|
|
115
|
+
}
|
|
116
|
+
const variants = [];
|
|
117
|
+
if (raw.approved_variants != null && !Array.isArray(raw.approved_variants)) fail('approved_variants_must_be_array', n);
|
|
118
|
+
for (const variant of Array.isArray(raw.approved_variants) ? raw.approved_variants : []) {
|
|
119
|
+
const resolved = plain(variant) ? resolveUrl(variant.url, base) : { error: 'variant_must_be_object' };
|
|
120
|
+
if (resolved.error) fail('invalid_approved_variant', n, resolved.error);
|
|
121
|
+
// A variant is an accepted final URL for the same row, so it stays inside the declared site.
|
|
122
|
+
else if (!origins.has(new URL(resolved.href).origin)) fail('approved_variant_outside_site_scope', n);
|
|
123
|
+
else if (!text(variant.evidence)) fail('approved_variant_requires_evidence', n);
|
|
124
|
+
else variants.push({ url: resolved.href, evidence: variant.evidence });
|
|
125
|
+
}
|
|
126
|
+
if (variants.length && disposition === 'retire') fail('retire_cannot_have_variants', n);
|
|
127
|
+
if (problems.length > before) return;
|
|
128
|
+
rows.push({ row: n, old_url: old.href, new_url: next, disposition, equivalence,
|
|
129
|
+
approved_variants: variants, evidence: raw.evidence ?? null, note: raw.note ?? null });
|
|
130
|
+
});
|
|
131
|
+
if (problems.length) throw new MigrationMapError(problems);
|
|
132
|
+
|
|
133
|
+
// Identity checks run over the accepted rows so every collision is named at once.
|
|
134
|
+
const byOld = new Map(), byKey = new Map(), byNew = new Map();
|
|
135
|
+
const content = row => JSON.stringify([row.new_url, row.disposition, row.equivalence, row.approved_variants]);
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const exact = byOld.get(row.old_url);
|
|
138
|
+
if (exact) fail(content(exact) === content(row) ? 'duplicate_row' : 'conflicting_row', row.row, `same old_url as row ${exact.row}`);
|
|
139
|
+
else {
|
|
140
|
+
const sibling = byKey.get(fetchKey(row.old_url));
|
|
141
|
+
if (sibling) fail('ambiguous_old_url', row.row, `differs from row ${sibling.row} only by fragment`);
|
|
142
|
+
byOld.set(row.old_url, row);
|
|
143
|
+
byKey.set(fetchKey(row.old_url), row);
|
|
144
|
+
}
|
|
145
|
+
if (row.new_url) {
|
|
146
|
+
const key = fetchKey(row.new_url);
|
|
147
|
+
if (!byNew.has(key)) byNew.set(key, []);
|
|
148
|
+
byNew.get(key).push(row);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (const group of byNew.values()) {
|
|
152
|
+
if (group.length < 2) continue;
|
|
153
|
+
// Many-to-one is a merge, and a merge is a documented decision, never an inference.
|
|
154
|
+
for (const row of group) if (row.disposition !== 'merge') fail('undocumented_merge', row.row, `shares new_url with rows ${group.filter(r => r !== row).map(r => r.row).join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
if (problems.length) throw new MigrationMapError(problems);
|
|
157
|
+
|
|
158
|
+
const warnings = [];
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
if (row.new_url && byKey.has(fetchKey(row.new_url))) warnings.push({ row: row.row, code: 'chained_mapping', detail: `new_url is the old_url of row ${byKey.get(fetchKey(row.new_url)).row}` });
|
|
161
|
+
if (row.new_url && new URL(row.new_url).pathname === '/' && new URL(row.old_url).pathname !== '/') warnings.push({ row: row.row, code: 'homepage_target', detail: 'a homepage redirect does not establish equivalence' });
|
|
162
|
+
}
|
|
163
|
+
return { migration_id: input.migration_id, version: input.version, cutover_at: input.cutover_at ?? null,
|
|
164
|
+
provenance: input.provenance, base, origins: [...origins].sort(), rows, warnings };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function readMigrationMap(path) {
|
|
168
|
+
let raw;
|
|
169
|
+
try { raw = JSON.parse(await readFile(path, 'utf8')); }
|
|
170
|
+
catch (error) { throw new MigrationMapError([{ row: null, code: error.code === 'ENOENT' ? 'map_file_missing' : 'map_not_json', detail: error.message }]); }
|
|
171
|
+
return parseMigrationMap(raw);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Joins the map to ledger placements. Pure: the entries come back by reference to their ids,
|
|
176
|
+
* never modified. `retired` ledger entries are kept for history and not selected.
|
|
177
|
+
*/
|
|
178
|
+
export function selectAffected(map, entries) {
|
|
179
|
+
const byKey = new Map(map.rows.map(row => [fetchKey(row.old_url), row]));
|
|
180
|
+
const newKeys = new Set(map.rows.filter(row => row.new_url).map(row => fetchKey(row.new_url)));
|
|
181
|
+
const affected = [], unmapped = [];
|
|
182
|
+
let retiredExcluded = 0, onNewUrl = 0, outsideScope = 0;
|
|
183
|
+
const usedRows = new Set();
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
let key, origin;
|
|
186
|
+
try { key = fetchKey(entry.target); origin = new URL(entry.target).origin; } catch { continue; }
|
|
187
|
+
if (!map.origins.includes(origin)) { outsideScope++; continue; }
|
|
188
|
+
if (entry.intent === 'retired') { retiredExcluded++; continue; }
|
|
189
|
+
const row = byKey.get(key);
|
|
190
|
+
const identity = { ledger_id: entry.id, intent: entry.intent, group: entry.intent === 'expected' ? 'earned' : 'wanted',
|
|
191
|
+
source: entry.source, original_target: entry.target, scope: entry.scope ?? 'exact' };
|
|
192
|
+
// `expect` rides along for the source check only, so presence is judged exactly as `check` judges it.
|
|
193
|
+
if (row) { usedRows.add(row.row); affected.push({ ...identity, map_row: row.row, row, expect: entry.expect ?? null }); continue; }
|
|
194
|
+
if (newKeys.has(key)) { onNewUrl++; continue; }
|
|
195
|
+
// No row is not a failure: the map simply says nothing about this target.
|
|
196
|
+
unmapped.push(identity);
|
|
197
|
+
}
|
|
198
|
+
return { affected, unmapped, rowsWithoutPlacements: map.rows.filter(row => !usedRows.has(row.row)).map(row => row.row),
|
|
199
|
+
counts: { ledger_entries: entries.length, retired_excluded: retiredExcluded, already_on_new_url: onNewUrl, outside_site_scope: outsideScope } };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const sourceSummary = result => ({ state: result.state, reason: result.reason, complete: result.evidence?.complete === true,
|
|
203
|
+
occurrences: result.occurrences?.length ?? 0,
|
|
204
|
+
expectations: result.expectations ?? null, http_status: result.httpStatus ?? null, final_url: result.finalUrl ?? null, checked_at: result.checkedAt });
|
|
205
|
+
|
|
206
|
+
const destinationSummary = (result, transition) => ({ state: result.state, reason: result.reason,
|
|
207
|
+
complete: result.evidence?.complete === true, http_status: result.httpStatus ?? null, requested_url: result.targetUrl,
|
|
208
|
+
// Exact hops as observed, including partial chains ended by a loop, limit or unsafe hop.
|
|
209
|
+
redirects: (result.redirects ?? []).map(hop => ({ from: hop.from, to: hop.to, status: hop.status })),
|
|
210
|
+
final_url: result.finalUrl ?? null, checked_at: result.checkedAt,
|
|
211
|
+
confirmation: transition ? { state: transition.state, confirmed_state: transition.confirmedState,
|
|
212
|
+
unavailable_count: transition.unavailableCount, next_check_at: transition.nextCheckAt, event: transition.event?.type ?? null } : null });
|
|
213
|
+
|
|
214
|
+
const REFUSED = /^(?:unsafe_redirect|invalid_target|invalid_redirect|unsafe_robots_redirect|public_fetch_not_configured)/;
|
|
215
|
+
|
|
216
|
+
/** Destination continuity for one map row, from the target observation alone. */
|
|
217
|
+
export function classifyContinuity(row, destination) {
|
|
218
|
+
if (row.disposition === 'retire') return { status: 'intentional_retirement', continuity: 'intentional_retirement' };
|
|
219
|
+
if (destination.state === 'unknown') {
|
|
220
|
+
return REFUSED.test(destination.reason) ? { status: 'refused', continuity: 'refused' } : { status: 'unknown', continuity: 'unknown' };
|
|
221
|
+
}
|
|
222
|
+
if (destination.state === 'unavailable') return { status: 'checked', continuity: 'destination_unavailable' };
|
|
223
|
+
const final = fetchKey(destination.final_url);
|
|
224
|
+
const route = final === fetchKey(row.new_url) ? 'mapped'
|
|
225
|
+
: row.approved_variants.some(variant => fetchKey(variant.url) === final) ? 'approved_variant' : null;
|
|
226
|
+
// A reachable wrong destination is a mismatch even when it returned 200.
|
|
227
|
+
if (!route) return { status: 'checked', continuity: 'mismatch', route: null, redirected: destination.redirects.length > 0 };
|
|
228
|
+
// HTTP reached the mapped route; only the customer's recorded decision makes it equivalent.
|
|
229
|
+
// A recorded `not_equivalent` is the customer's decision and is reported as such, not as "unconfirmed".
|
|
230
|
+
const decision = row.equivalence?.decision;
|
|
231
|
+
return { status: 'checked', route, continuity: decision === 'equivalent' ? 'continuous'
|
|
232
|
+
: decision === 'not_equivalent' ? 'route_matched_not_equivalent' : 'route_matched_equivalence_unconfirmed' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function coverage(map, selection, rows) {
|
|
236
|
+
const tally = {};
|
|
237
|
+
for (const row of rows) tally[row.status] = (tally[row.status] ?? 0) + 1;
|
|
238
|
+
return { denominator: 'supplied map rows and ledger entries only', map_rows: map.rows.length,
|
|
239
|
+
map_rows_without_placements: selection.rowsWithoutPlacements.length, ...selection.counts,
|
|
240
|
+
affected_placements: selection.affected.length, unmapped_placements: selection.unmapped.length,
|
|
241
|
+
earned: rows.filter(row => row.group === 'earned').length, wanted: rows.filter(row => row.group === 'wanted').length,
|
|
242
|
+
by_status: tally };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function header(map, mode, generatedAt) {
|
|
246
|
+
return { schema: MIGRATION_REPORT_SCHEMA, mode, generated_at: generatedAt, migration_id: map.migration_id,
|
|
247
|
+
map_version: map.version, cutover_at: map.cutover_at, provenance: map.provenance, warnings: map.warnings,
|
|
248
|
+
boundary: 'Technical continuity only. No ranking, PageRank, index transfer or traffic claim. No watch was created.' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const rowBase = item => ({ ledger_id: item.ledger_id, group: item.group, intent: item.intent, source: item.source,
|
|
252
|
+
original_target: item.original_target, scope: item.scope, expected_target: item.row.new_url, disposition: item.row.disposition,
|
|
253
|
+
map_row: item.map_row, equivalence: item.row.equivalence });
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Dry run. Accepts the supplied inputs and reports what a checked run would do, with no fetch,
|
|
257
|
+
* no check consumed and no enrollment. It takes no transport on purpose.
|
|
258
|
+
*/
|
|
259
|
+
export function planMigration(map, entries, { now = new Date().toISOString() } = {}) {
|
|
260
|
+
const selection = selectAffected(map, entries);
|
|
261
|
+
const rows = selection.affected.map(item => ({ ...rowBase(item),
|
|
262
|
+
status: item.row.disposition === 'retire' ? 'intentional_retirement' : 'unattempted' }));
|
|
263
|
+
for (const item of selection.unmapped) rows.push({ ...item, expected_target: null, map_row: null, status: 'unmapped' });
|
|
264
|
+
const destinations = new Set(selection.affected.map(item => fetchKey(item.row.old_url)));
|
|
265
|
+
return { ...header(map, 'dry_run', now), network: false,
|
|
266
|
+
planned: { source_checks: selection.affected.length, destination_checks: destinations.size, enrollments: 0 },
|
|
267
|
+
coverage: coverage(map, selection, rows), rows };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Checked run. Source presence uses `verifyLink` against the ORIGINAL target; destination uses
|
|
272
|
+
* `verifyTarget` on the old URL, once per distinct old URL. Both use the public-fetch boundary
|
|
273
|
+
* and the same pacing as `agentlinkops check`. `priorTargetStates` (old URL -> reducer state)
|
|
274
|
+
* lets repeated observations reuse the existing confirmation rules; the next states come back
|
|
275
|
+
* in `target_states` for the caller to keep locally.
|
|
276
|
+
*/
|
|
277
|
+
export async function assessMigration(map, entries, {
|
|
278
|
+
now = () => new Date().toISOString(), timeoutMs = 20_000, hostDelayMs = 2000, fetchImpl,
|
|
279
|
+
verify = verifyLink, verifyDestination = verifyTarget, pacer = null, priorTargetStates = {},
|
|
280
|
+
} = {}) {
|
|
281
|
+
const selection = selectAffected(map, entries);
|
|
282
|
+
const paced = pacer ?? createPacer({ hostDelayMs });
|
|
283
|
+
const common = { publicFetchSafe: true, timeoutMs, includeHtml: false, beforeFetch: paced.beforeFetch,
|
|
284
|
+
...(fetchImpl ? { fetchImpl } : {}) };
|
|
285
|
+
const destinations = new Map(), targetStates = {};
|
|
286
|
+
for (const item of selection.affected) {
|
|
287
|
+
const key = fetchKey(item.row.old_url);
|
|
288
|
+
if (destinations.has(key)) continue;
|
|
289
|
+
const result = await verifyDestination({ targetUrl: item.row.old_url }, { ...common, now: now() });
|
|
290
|
+
const transition = transitionTargetState(priorTargetStates[key] ?? null, result, result.checkedAt);
|
|
291
|
+
targetStates[key] = transition;
|
|
292
|
+
destinations.set(key, destinationSummary(result, transition));
|
|
293
|
+
}
|
|
294
|
+
const rows = [];
|
|
295
|
+
for (const item of selection.affected) {
|
|
296
|
+
const destination = destinations.get(fetchKey(item.row.old_url));
|
|
297
|
+
const source = await verify({ sourceUrl: item.source, targetUrl: item.original_target, targetScope: item.scope,
|
|
298
|
+
expectedAnchor: item.expect?.anchor, expectedRel: item.expect?.rel }, { ...common, now: now() });
|
|
299
|
+
rows.push({ ...rowBase(item), ...classifyContinuity(item.row, destination), source_presence: sourceSummary(source), destination });
|
|
300
|
+
}
|
|
301
|
+
for (const item of selection.unmapped) rows.push({ ...item, expected_target: null, map_row: null, status: 'unmapped' });
|
|
302
|
+
const generatedAt = now();
|
|
303
|
+
const failures = rows.filter(row => ['mismatch', 'destination_unavailable'].includes(row.continuity));
|
|
304
|
+
return { ...header(map, 'checked', generatedAt), network: true, coverage: coverage(map, selection, rows), rows,
|
|
305
|
+
target_states: targetStates,
|
|
306
|
+
// A mapping mismatch or failed destination is not a backlink-loss event; loss stays with `check`.
|
|
307
|
+
loss_events: [], exitCode: failures.length ? 1 : 0 };
|
|
308
|
+
}
|
package/cli/mirror.js
CHANGED
|
File without changes
|
package/cli/mix.js
CHANGED
|
File without changes
|
package/cli/native-lock.js
CHANGED
|
File without changes
|
package/cli/oauth-credentials.js
CHANGED
|
File without changes
|
package/cli/oauth-flow.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// One identity-bound projection for local activity and retained observations. A change-only
|
|
2
|
+
// mirror is not a complete probe log, so local absence remains provisional. Hosted watch
|
|
3
|
+
// state is carried independently from the raw check; this module never confirms loss.
|
|
4
|
+
import { mirrorEvidenceReference } from '../src/evidence-reference.js';
|
|
5
|
+
|
|
6
|
+
const conclusive = new Set(['present', 'absent', 'source_unavailable']);
|
|
7
|
+
const watchStates = new Set(['present', 'suspected_missing', 'confirmed_missing', 'source_unavailable']);
|
|
8
|
+
export const validTime = value => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
9
|
+
|
|
10
|
+
export function placementIdentity(value) {
|
|
11
|
+
const record = value?.result ?? value;
|
|
12
|
+
const source = record?.sourceUrl ?? record?.source_url ?? record?.source;
|
|
13
|
+
const target = record?.targetUrl ?? record?.target_url ?? record?.target;
|
|
14
|
+
const scope = record?.targetScope ?? record?.target_scope ?? record?.scope ?? 'exact';
|
|
15
|
+
return typeof source === 'string' && typeof target === 'string'
|
|
16
|
+
? JSON.stringify([source, target, scope]) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function attemptSummary(row, { retained = false } = {}) {
|
|
20
|
+
if (!validTime(row?.checked_at)) return null;
|
|
21
|
+
const cloud = row.source === 'cloud';
|
|
22
|
+
const rawState = cloud && conclusive.has(row.result?.checkState) ? row.result.checkState
|
|
23
|
+
: ['suspected_missing', 'confirmed_missing'].includes(row.state) ? 'absent' : row.state;
|
|
24
|
+
const method = row.result?.evidence?.method ?? null;
|
|
25
|
+
return {
|
|
26
|
+
checked_at: row.checked_at, placement: placementIdentity(row),
|
|
27
|
+
state: rawState, reason: row.reason ?? null, complete: row.complete === true,
|
|
28
|
+
origin: row.source ?? 'local',
|
|
29
|
+
watch_state: cloud && watchStates.has(row.result?.watchState) ? row.result.watchState
|
|
30
|
+
: ['suspected_missing', 'confirmed_missing'].includes(row.state) ? row.state : null,
|
|
31
|
+
link_signature: row.result?.linkSignature ?? null,
|
|
32
|
+
method, checker_version: row.checker_version ?? row.result?.evidence?.checkerVersion ?? null,
|
|
33
|
+
content_hash: row.result?.evidence?.sha256 ?? null,
|
|
34
|
+
evidence_reference: retained ? mirrorEvidenceReference({ entryId: row.id, checkedAt: row.checked_at, origin: row.source ?? 'local' }) : null,
|
|
35
|
+
retained,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const isConclusive = attempt => attempt?.complete === true && conclusive.has(attempt.state);
|
|
40
|
+
export const isLinkVerification = attempt => isConclusive(attempt) && ['present', 'absent'].includes(attempt.state);
|
|
41
|
+
|
|
42
|
+
// Equal timestamps never qualify another probe. Stable tie-breaking makes frozen exports
|
|
43
|
+
// independent of input ordering; an activity summary cannot replace a richer retained receipt.
|
|
44
|
+
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
45
|
+
function latest(items) {
|
|
46
|
+
return items.filter(item => item && validTime(item.checked_at)).sort((a, b) =>
|
|
47
|
+
Date.parse(b.checked_at) - Date.parse(a.checked_at)
|
|
48
|
+
|| Number(b.retained === true) - Number(a.retained === true)
|
|
49
|
+
|| compareText(JSON.stringify(a), JSON.stringify(b)))[0] ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function activityAttempt(record, identity) {
|
|
53
|
+
if (record?.last_placement !== identity) return null;
|
|
54
|
+
if (record.last_attempt?.placement === identity && record.last_attempt.checked_at === record.last_checked
|
|
55
|
+
&& validTime(record.last_attempt.checked_at)) return record.last_attempt;
|
|
56
|
+
// Legacy activity can prove its last conclusive time, but cannot invent a method/hash or
|
|
57
|
+
// reconstruct the overwritten successful check after a later unknown attempt.
|
|
58
|
+
if (!validTime(record.last_checked)) return null;
|
|
59
|
+
return { checked_at: record.last_checked, placement: identity, state: record.last_state ?? 'unknown',
|
|
60
|
+
reason: record.last_reason ?? null, complete: record.last_complete === true, origin: 'local',
|
|
61
|
+
watch_state: null, link_signature: record.last_signature ?? null, method: null,
|
|
62
|
+
checker_version: null, content_hash: null, evidence_reference: null, retained: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function projectPlacement(entry, observations, { state = null } = {}) {
|
|
66
|
+
const identity = placementIdentity(entry);
|
|
67
|
+
const history = identity ? observations.filter(row => row.id === entry.id && placementIdentity(row) === identity && validTime(row.checked_at)) : [];
|
|
68
|
+
const attempts = history.map(row => attemptSummary(row, { retained: true }));
|
|
69
|
+
const record = state?.entries?.[entry.id];
|
|
70
|
+
const locate = item => item?.retained === true && !attempts.some(receipt => receipt.checked_at === item.checked_at
|
|
71
|
+
&& receipt.origin === item.origin && receipt.content_hash === item.content_hash)
|
|
72
|
+
? { ...item, retained: false, evidence_reference: null } : item;
|
|
73
|
+
const activity = locate(identity ? activityAttempt(record, identity) : null);
|
|
74
|
+
const saved = key => locate(identity && record?.last_placement === identity && record[key]?.placement === identity ? record[key] : null);
|
|
75
|
+
const latestAttempt = latest([...attempts, activity]);
|
|
76
|
+
const savedSuccess = saved('last_successful_observation'), savedLink = saved('last_link_verification');
|
|
77
|
+
const lastSuccessful = latest([...attempts.filter(isConclusive), isConclusive(savedSuccess) ? savedSuccess : null, isConclusive(activity) ? activity : null]);
|
|
78
|
+
const lastLink = latest([...attempts.filter(isLinkVerification), isLinkVerification(savedLink) ? savedLink : null, isLinkVerification(activity) ? activity : null]);
|
|
79
|
+
const retained = latest(attempts);
|
|
80
|
+
const row = retained ? history.filter(item => JSON.stringify(attemptSummary(item, { retained: true })) === JSON.stringify(retained))
|
|
81
|
+
.sort((a, b) => compareText(JSON.stringify(a), JSON.stringify(b)))[0] ?? null : null;
|
|
82
|
+
const firstDates = attempts.filter(item => isConclusive(item) && item.state === 'present').map(item => item.checked_at);
|
|
83
|
+
const first = saved('first_present_observation');
|
|
84
|
+
if (isConclusive(first) && first.state === 'present' && validTime(first.checked_at)) firstDates.push(first.checked_at);
|
|
85
|
+
const firstPresent = firstDates.sort((a, b) => Date.parse(a) - Date.parse(b))[0] ?? null;
|
|
86
|
+
const current = latestAttempt?.watch_state ?? lastSuccessful?.watch_state ?? (lastSuccessful?.state === 'absent' ? 'suspected_missing'
|
|
87
|
+
: lastSuccessful?.state ?? (latestAttempt ? 'unknown' : 'unchecked'));
|
|
88
|
+
return {
|
|
89
|
+
entry, row, placement: identity, current_state: current,
|
|
90
|
+
uncertain: !!latestAttempt && !isConclusive(latestAttempt),
|
|
91
|
+
latest_attempt: latestAttempt, last_successful_observation: lastSuccessful,
|
|
92
|
+
last_link_verification: lastLink, first_present: firstPresent,
|
|
93
|
+
evidence_observed_at: retained?.checked_at ?? null,
|
|
94
|
+
activity_available: activity !== null,
|
|
95
|
+
identity_status: history.length || activity ? 'matched' : observations.some(item => item.id === entry.id) || record ? 'not_matched' : 'not_observed',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function projectPlacements(entries, observations, options = {}) {
|
|
100
|
+
const byId = new Map();
|
|
101
|
+
for (const row of observations) {
|
|
102
|
+
if (!byId.has(row.id)) byId.set(row.id, []);
|
|
103
|
+
byId.get(row.id).push(row);
|
|
104
|
+
}
|
|
105
|
+
return entries.map(entry => projectPlacement(entry, byId.get(entry.id) ?? [], options));
|
|
106
|
+
}
|
package/cli/platforms.js
CHANGED
|
File without changes
|
package/cli/prompt.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { Writable } from 'node:stream';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Asks once on the terminal without echoing what is typed or pasted (an API key). The question
|
|
6
|
+
* goes to stderr so stdout stays clean for JSON readers. Resolves '' on Enter, Ctrl+C or Ctrl+D.
|
|
7
|
+
*/
|
|
8
|
+
export function askSecret(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
9
|
+
return new Promise(resolve => {
|
|
10
|
+
let echo = true, settled = false;
|
|
11
|
+
const sink = new Writable({ write(chunk, encoding, done) { if (echo) output.write(chunk, encoding); done(); } });
|
|
12
|
+
const rl = createInterface({ input, output: sink, terminal: true });
|
|
13
|
+
const finish = answer => {
|
|
14
|
+
if (settled) return;
|
|
15
|
+
settled = true;
|
|
16
|
+
rl.close();
|
|
17
|
+
output.write('\n');
|
|
18
|
+
resolve(String(answer ?? '').trim());
|
|
19
|
+
};
|
|
20
|
+
rl.on('SIGINT', () => finish(''));
|
|
21
|
+
rl.on('close', () => finish(''));
|
|
22
|
+
rl.question(question, finish);
|
|
23
|
+
echo = false;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Accountless CLI transport. One fresh connection per hop; the canonical fetcher
|
|
2
|
+
// continues to own robots, redirect policy, parsing and the whole-check deadline.
|
|
3
|
+
import dns from 'node:dns/promises';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import https from 'node:https';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import ipaddr from 'ipaddr.js';
|
|
8
|
+
import { validatePublicUrl } from '../src/verifier/url.js';
|
|
9
|
+
import { VerificationError, withAbort } from '../src/page-acquisition/bytes.js';
|
|
10
|
+
|
|
11
|
+
// Match the qualified public executor policy. Neither a public-looking hostname
|
|
12
|
+
// nor the Workers compatibility flag establishes this boundary in a local CLI.
|
|
13
|
+
const V4_DENIED = ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
|
|
14
|
+
'172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16',
|
|
15
|
+
'198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4'].map(ipaddr.parseCIDR);
|
|
16
|
+
const V6_DENIED = ['2001::/23', '2001:db8::/32', '2002::/16', '3fff::/20'].map(ipaddr.parseCIDR);
|
|
17
|
+
const V6_GLOBAL = ipaddr.parseCIDR('2000::/3');
|
|
18
|
+
const REQUEST_HEADERS = new Set(['user-agent', 'accept', 'if-none-match', 'if-modified-since']);
|
|
19
|
+
const RESPONSE_HEADERS = new Set(['location', 'content-type', 'content-encoding', 'content-length',
|
|
20
|
+
'etag', 'last-modified', 'retry-after', 'link', 'x-robots-tag', 'cf-mitigated']);
|
|
21
|
+
|
|
22
|
+
export function isPublicNodeAddress(value) {
|
|
23
|
+
let address;
|
|
24
|
+
try { if (typeof value !== 'string' || value.includes('%')) return false; address = ipaddr.parse(value); }
|
|
25
|
+
catch { return false; }
|
|
26
|
+
if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) address = address.toIPv4Address();
|
|
27
|
+
return address.kind() === 'ipv4' ? !V4_DENIED.some(range => address.match(range))
|
|
28
|
+
: address.match(V6_GLOBAL) && !V6_DENIED.some(range => address.match(range));
|
|
29
|
+
}
|
|
30
|
+
const normalizeAddress = value => {
|
|
31
|
+
let address = ipaddr.parse(value);
|
|
32
|
+
if (address.kind() === 'ipv6' && address.isIPv4MappedAddress()) address = address.toIPv4Address();
|
|
33
|
+
return address.toString();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function resolveNodePublicDestination(hostname, { lookup = (...args) => dns.lookup(...args), signal } = {}) {
|
|
37
|
+
let answers;
|
|
38
|
+
try { answers = await withAbort(Promise.resolve().then(() => lookup(hostname, { all: true, verbatim: true })), signal); }
|
|
39
|
+
catch (error) { throw error instanceof VerificationError ? error : new VerificationError('dns_resolution_failed'); }
|
|
40
|
+
if (!Array.isArray(answers) || !answers.length || answers.length > 64 || answers.some(answer => {
|
|
41
|
+
if (!answer || ![4, 6].includes(answer.family) || !isPublicNodeAddress(answer.address)) return true;
|
|
42
|
+
return ipaddr.parse(answer.address).kind() !== (answer.family === 4 ? 'ipv4' : 'ipv6');
|
|
43
|
+
})) throw new VerificationError('dns_destination_not_public');
|
|
44
|
+
const address = normalizeAddress(answers[0].address);
|
|
45
|
+
return { address, family: ipaddr.parse(address).kind() === 'ipv4' ? 4 : 6 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fetch-compatible single-hop GET. It never follows a redirect or uses credentials. */
|
|
49
|
+
export function createNodePublicFetch({ lookup, requestImpl } = {}) {
|
|
50
|
+
return async (rawUrl, init = {}) => {
|
|
51
|
+
const checked = validatePublicUrl(rawUrl);
|
|
52
|
+
if (!checked.valid) throw new VerificationError(`unsafe_url:${checked.reason}`);
|
|
53
|
+
if (init.method !== 'GET' || init.redirect !== 'manual' || init.credentials !== 'omit' || init.body !== undefined)
|
|
54
|
+
throw new VerificationError('transport_request_invalid');
|
|
55
|
+
const url = new URL(checked.url), headers = {};
|
|
56
|
+
for (const [name, value] of new Headers(init.headers)) {
|
|
57
|
+
if (!REQUEST_HEADERS.has(name) || value.length > 4096 || /[\r\n\u0000]/u.test(value))
|
|
58
|
+
throw new VerificationError('transport_request_invalid');
|
|
59
|
+
headers[name] = value;
|
|
60
|
+
}
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const onOwnerAbort = () => controller.abort();
|
|
63
|
+
const timer = setTimeout(onOwnerAbort, 20_000);
|
|
64
|
+
const cleanup = () => { clearTimeout(timer); init.signal?.removeEventListener('abort', onOwnerAbort); };
|
|
65
|
+
init.signal?.addEventListener('abort', onOwnerAbort, { once: true });
|
|
66
|
+
if (init.signal?.aborted) controller.abort();
|
|
67
|
+
let outbound, incoming, socket;
|
|
68
|
+
try {
|
|
69
|
+
if (controller.signal.aborted) throw new VerificationError('timeout');
|
|
70
|
+
const pinned = await resolveNodePublicDestination(url.hostname, { lookup, signal: controller.signal });
|
|
71
|
+
if (controller.signal.aborted) throw new VerificationError('timeout');
|
|
72
|
+
const assertSocket = value => {
|
|
73
|
+
try {
|
|
74
|
+
if (!value?.remoteAddress || normalizeAddress(value.remoteAddress) !== pinned.address)
|
|
75
|
+
throw new VerificationError('socket_destination_changed');
|
|
76
|
+
} catch (error) { throw error instanceof VerificationError ? error : new VerificationError('socket_destination_unverified'); }
|
|
77
|
+
};
|
|
78
|
+
return await new Promise((resolve, reject) => {
|
|
79
|
+
let responded = false, stopped = false;
|
|
80
|
+
const stop = error => {
|
|
81
|
+
if (stopped) return;
|
|
82
|
+
stopped = true; cleanup();
|
|
83
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
84
|
+
outbound?.destroy(); incoming?.destroy(error);
|
|
85
|
+
if (!responded) reject(error);
|
|
86
|
+
};
|
|
87
|
+
const onAbort = () => stop(new VerificationError('timeout'));
|
|
88
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
89
|
+
const implementation = requestImpl ?? (url.protocol === 'https:' ? https.request : http.request);
|
|
90
|
+
try {
|
|
91
|
+
outbound = implementation({ protocol: url.protocol, hostname: url.hostname,
|
|
92
|
+
port: url.protocol === 'https:' ? 443 : 80, path: `${url.pathname}${url.search}`, method: 'GET',
|
|
93
|
+
agent: false, servername: url.hostname, rejectUnauthorized: true,
|
|
94
|
+
maxHeaderSize: 32 * 1024, family: pinned.family, autoSelectFamily: false,
|
|
95
|
+
lookup: (name, options, callback) => {
|
|
96
|
+
if (name !== url.hostname) return callback(new VerificationError('socket_hostname_changed'));
|
|
97
|
+
// Node versions may request the callback's all-answer form. Both
|
|
98
|
+
// forms contain only the already screened, pinned literal.
|
|
99
|
+
if (options?.all) return callback(null, [{ address: pinned.address, family: pinned.family }]);
|
|
100
|
+
callback(null, pinned.address, pinned.family);
|
|
101
|
+
},
|
|
102
|
+
headers: { ...headers, Host: url.host, 'Accept-Encoding': 'identity', Connection: 'close' },
|
|
103
|
+
}, response => {
|
|
104
|
+
incoming = response;
|
|
105
|
+
// Validation may destroy the stream before a Web reader attaches.
|
|
106
|
+
response.on('error', () => {});
|
|
107
|
+
if (stopped || controller.signal.aborted) { response.destroy(); return; }
|
|
108
|
+
try {
|
|
109
|
+
assertSocket(response.socket ?? socket);
|
|
110
|
+
const safeHeaders = new Headers();
|
|
111
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
112
|
+
if (!RESPONSE_HEADERS.has(name.toLowerCase()) || value === undefined) continue;
|
|
113
|
+
const text = Array.isArray(value) ? value.join(', ') : String(value);
|
|
114
|
+
if (text.length > 8192 || /[\r\n\u0000]/u.test(text)) throw new VerificationError('response_headers_too_large');
|
|
115
|
+
safeHeaders.set(name, text);
|
|
116
|
+
}
|
|
117
|
+
// Identity is requested explicitly. Unsupported encoded bodies
|
|
118
|
+
// remain unknown rather than being mistaken for empty HTML.
|
|
119
|
+
if (safeHeaders.has('content-encoding') && safeHeaders.get('content-encoding').trim().toLowerCase() !== 'identity')
|
|
120
|
+
throw new VerificationError('content_encoding_unsupported');
|
|
121
|
+
if (safeHeaders.has('content-length') && !/^\d+$/u.test(safeHeaders.get('content-length')))
|
|
122
|
+
throw new VerificationError('transport_response_invalid');
|
|
123
|
+
const status = response.statusCode;
|
|
124
|
+
if (!Number.isInteger(status) || status < 200 || status > 599) throw new VerificationError('transport_response_invalid');
|
|
125
|
+
response.once('end', () => {
|
|
126
|
+
cleanup(); controller.signal.removeEventListener('abort', onAbort);
|
|
127
|
+
});
|
|
128
|
+
response.once('close', () => { cleanup(); controller.signal.removeEventListener('abort', onAbort); });
|
|
129
|
+
response.once('aborted', () => response.destroy(new VerificationError('incomplete_body')));
|
|
130
|
+
let body = null;
|
|
131
|
+
if (![204, 205, 304].includes(status)) {
|
|
132
|
+
const reader = Readable.toWeb(response).getReader();
|
|
133
|
+
body = new ReadableStream({
|
|
134
|
+
async pull(stream) {
|
|
135
|
+
try {
|
|
136
|
+
const item = await reader.read();
|
|
137
|
+
if (item.done) {
|
|
138
|
+
if (!response.complete) throw new VerificationError('incomplete_body');
|
|
139
|
+
reader.releaseLock(); stream.close();
|
|
140
|
+
} else stream.enqueue(item.value);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
stream.error(error);
|
|
143
|
+
reader.cancel().catch(() => {}).finally(() => reader.releaseLock());
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
async cancel(reason) { try { await reader.cancel(reason); } finally { reader.releaseLock(); } },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const result = new Response(body, { status, headers: safeHeaders });
|
|
150
|
+
Object.defineProperty(result, 'url', { value: checked.url });
|
|
151
|
+
responded = true; resolve(result);
|
|
152
|
+
if (!body) { response.destroy(); cleanup(); controller.signal.removeEventListener('abort', onAbort); }
|
|
153
|
+
} catch (error) { stop(error instanceof VerificationError ? error : new VerificationError('transport_response_invalid')); }
|
|
154
|
+
});
|
|
155
|
+
outbound.on('socket', value => {
|
|
156
|
+
socket = value;
|
|
157
|
+
socket.once('connect', () => { try { assertSocket(socket); } catch (error) { stop(error); } });
|
|
158
|
+
});
|
|
159
|
+
outbound.on('error', error => stop(error instanceof VerificationError ? error : new VerificationError('network_error')));
|
|
160
|
+
if (controller.signal.aborted) return onAbort();
|
|
161
|
+
outbound.end();
|
|
162
|
+
} catch (error) { stop(error instanceof VerificationError ? error : new VerificationError('network_error')); }
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
cleanup(); outbound?.destroy(); incoming?.destroy(); throw error;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
File without changes
|
package/cli/receipts.js
CHANGED
|
File without changes
|