@getopencrawl/cli 0.1.0 → 0.2.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/src/jobs.mjs DELETED
@@ -1,108 +0,0 @@
1
- import { displayLine } from './display.mjs';
2
-
3
- export const JOBS_HELP = `OpenCrawl · follow background jobs
4
-
5
- opencrawl jobs list [--status active|completed|failed|cancelled|discarded] [--limit 20] [--offset 0]
6
- opencrawl jobs show JOB_ID [--json]
7
- opencrawl jobs wait JOB_ID [--timeout 300] [--json]
8
-
9
- Wait stops after 1–3600 seconds (default 300). Ctrl+C stops waiting locally.
10
- It never cancels, retries, or resubmits a job. The job ID remains in the receipt
11
- on timeout, permission failure, or job failure. Only completed exits successfully.
12
- Completion is for this job; source scan coverage and provider cleanup are separate.
13
- Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
14
- `;
15
- export const GLOBAL_OPTIONS = ['account', 'server', 'config-dir', 'json'];
16
- export const ACTIVE = new Set(['queued', 'running', 'waiting', 'processing', 'pending']);
17
- export const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'discarded']);
18
- export function decimal(value, name, min, max, fallback) {
19
- if (value === undefined && fallback !== undefined) return fallback;
20
- if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max)
21
- throw new Error(`--${name} must be a decimal integer between ${min} and ${max}.`);
22
- return Number(value);
23
- }
24
- export function allowedOptions(options, allowed, command) {
25
- for (const key of Object.keys(options)) if (![...GLOBAL_OPTIONS, ...allowed].includes(key)) throw new Error(`Unsupported ${command} option: --${key}. Run opencrawl ${command} --help.`);
26
- }
27
- export function identifier(value, kind = 'job') {
28
- if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(value)) throw new Error(`Supply the exact ${kind} ID from its receipt.`);
29
- return value;
30
- }
31
- export function waitOptions(options) {
32
- if (options.timeout !== undefined && !options.wait) throw new Error('--timeout requires --wait. Use jobs wait to follow an existing job.');
33
- return decimal(options.timeout, 'timeout', 1, 3600, 300);
34
- }
35
- export function requestFailure(error) {
36
- const status = Number.isInteger(error?.status) ? error.status : null;
37
- const message = ({401: 'Sign in with a valid key for the intended workspace.', 403: 'This account cannot access this operation.',
38
- 404: 'This job or upload receipt is unavailable to this account. Check the account and saved ID.',
39
- 409: 'The request conflicts with the saved upload details or current source state. Inspect the receipt and source before explicitly retrying; do not start a duplicate upload.',
40
- 413: 'The file exceeds an upload limit.', 422: 'The upload details or content failed validation. Check the file and its scope.',
41
- 429: 'OpenCrawl is busy or a workspace limit was reached. Inspect the receipt before retrying.',
42
- 507: 'Workspace storage admission failed. Inspect the receipt before retrying.'})[status] || (status ? 'OpenCrawl could not complete this request. Inspect its receipt before retrying.' : 'The request outcome could not be confirmed. Check the connection and inspect the saved receipt.');
43
- return { code: status ? `http_${status}` : 'request_unconfirmed', message, ...(status ? { status } : {}) };
44
- }
45
- export function validateJob(job, id = job?.id) {
46
- if (!job || typeof job.id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(job.id) || job.id !== id || typeof job.status !== 'string' || !ACTIVE.has(job.status) && !TERMINAL.has(job.status)) throw new Error('Server returned an invalid job receipt. No completion was assumed.');
47
- return job;
48
- }
49
- export const getJob = async (client, id, options = {}) => validateJob(await client.request(`/api/v1/jobs/${encodeURIComponent(identifier(id))}`, { maxResponseBytes: 512 * 1024, ...options }), id);
50
-
51
- export async function waitForJob(client, id, { timeout = 300, signal, now = () => performance.now(), sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), onUpdate = () => {}, initial, deadline } = {}) {
52
- identifier(id); const started = now(); deadline ??= started + timeout * 1000;
53
- let job = initial ? validateJob(initial, id) : null;
54
- const result = (outcome, error) => ({ job_id: id, job, wait: { outcome, elapsed_seconds: Math.max(0, Math.round((now() - started) / 1000)), ...(error ? { error } : {}) } });
55
- while (true) {
56
- if (signal?.aborted) return result('interrupted');
57
- if (job && TERMINAL.has(job.status)) return result(job.status);
58
- const remaining = deadline - now();
59
- if (remaining <= 0) return result('timeout');
60
- try { job = await getJob(client, id, { timeout: Math.max(1, Math.min(30000, Math.ceil(remaining))), signal }); }
61
- catch (error) { return result(signal?.aborted ? 'interrupted' : now() >= deadline ? 'timeout' : 'unavailable', requestFailure(error)); }
62
- onUpdate(job);
63
- if (TERMINAL.has(job.status)) return result(job.status);
64
- // Short sleeps keep Ctrl+C responsive without extra server polling.
65
- const next = Math.min(deadline, now() + 2000);
66
- while (!signal?.aborted && now() < next) await sleep(Math.min(100, next - now()));
67
- }
68
- }
69
- export async function withInterrupt(action) {
70
- const controller = new AbortController(); const stop = () => controller.abort();
71
- process.on('SIGINT', stop); process.on('SIGTERM', stop);
72
- try { return await action(controller.signal); }
73
- finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); }
74
- }
75
- export function renderJob(job) {
76
- let text = `${displayLine(job.id)}\t${displayLine(job.status)}\t${displayLine(job.kind)}${job.filename ? '\t' + displayLine(job.filename) : ''}\n`;
77
- if (job.error) text += `Issue: ${displayLine(job.error)}\n`;
78
- if (typeof job.indexed_documents === 'number') text += `Indexed documents: ${job.indexed_documents}\n`;
79
- for (const doc of job.documents || []) text += `${displayLine(doc.id)}\t${displayLine(doc.title)}\n`;
80
- if (job.documents_truncated) text += 'Additional indexed documents are available in OpenCrawl.\n';
81
- if (job.coverage) text += `Source listing coverage: ${displayLine(job.coverage.state)} (separate from this job's status).\n`;
82
- return text;
83
- }
84
- export async function jobsCommand(rest, options, client, { stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
85
- const [command, id, ...extra] = rest;
86
- if (!['list', 'show', 'wait'].includes(command)) throw new Error('Choose jobs list, show, or wait. Run opencrawl jobs --help.');
87
- allowedOptions(options, command === 'list' ? ['status', 'limit', 'offset'] : command === 'wait' ? ['timeout'] : [], 'jobs');
88
- if (extra.length || command === 'list' && id !== undefined) throw new Error('Unexpected jobs argument.');
89
- if (command === 'list') {
90
- if (options.status !== undefined && !['active', ...ACTIVE, ...TERMINAL].includes(options.status)) throw new Error('Choose a job status shown in jobs --help.');
91
- const query = new URLSearchParams({ limit: decimal(options.limit, 'limit', 1, 200, 20), offset: decimal(options.offset, 'offset', 0, 1000000, 0) });
92
- if (options.status) query.set('status', options.status);
93
- const data = await client.request('/api/v1/jobs?' + query, { maxResponseBytes: 2 * 1024 * 1024 });
94
- if (!Array.isArray(data?.items)) throw new Error('Server returned an invalid job list.');
95
- if (options.json) stdout(JSON.stringify(data, null, 2) + '\n');
96
- else { stdout(data.items.map(renderJob).join('') || 'No jobs on this page.\n'); if (data.has_more) stderr(`More jobs are available; use --offset ${Number(data.offset) + data.items.length}.\n`); }
97
- return 0;
98
- }
99
- identifier(id);
100
- if (command === 'show') {
101
- const job = await getJob(client, id); stdout(options.json ? JSON.stringify(job, null, 2) + '\n' : renderJob(job)); return 0;
102
- }
103
- const timeout = decimal(options.timeout, 'timeout', 1, 3600, 300);
104
- stderr(`Following job ${displayLine(id)} for at most ${timeout} seconds.\n`);
105
- const result = await withInterrupt(signal => waitForJob(client, id, { timeout, signal }));
106
- stdout(options.json ? JSON.stringify(result, null, 2) + '\n' : (result.job ? renderJob(result.job) : `Job: ${displayLine(id)}\n`) + `Wait: ${displayLine(result.wait.outcome)}. The job was not cancelled or resubmitted.\n` + (result.wait.error ? result.wait.error.message + '\n' : ''));
107
- return result.wait.outcome === 'completed' ? 0 : 1;
108
- }
package/src/monitors.mjs DELETED
@@ -1,146 +0,0 @@
1
- import { allowedOptions, decimal, validateJob, renderJob } from './jobs.mjs';
2
- import { displayLine } from './display.mjs';
3
-
4
- export const MONITORS_HELP = `OpenCrawl · inspect exact public-page observations
5
-
6
- opencrawl monitors list [--limit 20] [--offset 0] [--json]
7
- opencrawl monitors show SOURCE_ID [--json]
8
- opencrawl monitors check SOURCE_ID --generation N [--json]
9
- opencrawl monitors changes [--source SOURCE_ID] [--limit 20] [--cursor CURSOR] [--json]
10
- opencrawl monitors context CHANGE_ID --revision HASH [--budget 4096] [--json]
11
-
12
- Create monitors in OpenCrawl with 1–5 explicit public static URLs. Checks do not
13
- follow discovered links or execute JavaScript. A failed or missing page never
14
- proves a job closed or content was removed. Inspect show for each page's latest
15
- attempt and last good observation. Check queues work; follow its job separately.
16
- Context requires an exact change ID and immutable version from changes. It
17
- exports before/after evidence, including observation times, within 256–16384
18
- UTF-8 bytes. Truncation and omissions go to stderr; stdout is exact context.
19
- --json preserves the complete receipt. No automatic check or newer substitution.
20
- Global options: --account PROFILE_ID --server URL --config-dir DIRECTORY --json
21
- `;
22
- const SOURCE = /^src_[a-f0-9]{32}$/, CHANGE = /^mchg_[a-f0-9]{32}$/, HASH = /^[a-f0-9]{64}$/;
23
- const integer = (value, min, max) => Number.isSafeInteger(value) && value >= min && value <= max;
24
- const id = (value, pattern, kind) => {
25
- if (typeof value !== 'string' || !pattern.test(value)) throw new Error(`Supply the exact ${kind} ID from its list.`);
26
- return value;
27
- };
28
- const cursor = value => {
29
- if (typeof value !== 'string' || !value || value.length > 256 || /[\x00-\x20\x7f]/.test(value)) throw new Error('Use the unchanged opaque cursor from the previous changes page.');
30
- return value;
31
- };
32
- export function monitorRequest(rest, options) {
33
- const [command, value, ...extra] = rest;
34
- const allowed = {list: ['limit','offset'], show: [], check: ['generation'], changes: ['source','limit','cursor'], context: ['revision','budget']};
35
- if (!Object.hasOwn(allowed, command)) throw new Error('Choose monitors list, show, check, changes, or context. Run opencrawl monitors --help.');
36
- allowedOptions(options, allowed[command], 'monitors');
37
- if (extra.length || ['list','changes'].includes(command) && value !== undefined) throw new Error('Unexpected monitors argument.');
38
- if (command === 'list') {
39
- const limit=decimal(options.limit,'limit',1,20,20), offset=decimal(options.offset,'offset',0,1000000,0);
40
- return {command,limit,offset,route:'/api/v1/monitors?'+new URLSearchParams({limit,offset}),options:{}};
41
- }
42
- if (command === 'changes') {
43
- const limit=decimal(options.limit,'limit',1,20,20), query=new URLSearchParams({limit});
44
- if (options.source !== undefined) query.set('source_id',id(options.source,SOURCE,'monitor'));
45
- if (options.cursor !== undefined) query.set('cursor',cursor(options.cursor));
46
- return {command,limit,source:options.source,route:'/api/v1/monitor-changes?'+query,options:{}};
47
- }
48
- if (command === 'context') {
49
- id(value,CHANGE,'change');
50
- if (typeof options.revision !== 'string' || !HASH.test(options.revision)) throw new Error('--revision must be the exact 64-character lowercase version hash from changes.');
51
- const budget=decimal(options.budget,'budget',256,16384,4096), selections=[{change_id:value,version:options.revision}];
52
- return {command,id:value,selections,budget,route:'/api/v1/monitor-changes/context',options:{method:'POST',body:{selections,budget}}};
53
- }
54
- id(value,SOURCE,'monitor');
55
- return {command,id:value,route:'/api/v1/monitors/'+value+(command==='check'?'/check':''),
56
- options:command==='check'?{method:'POST',body:{expected_generation:decimal(options.generation,'generation',1,Number.MAX_SAFE_INTEGER)}}:{}};
57
- }
58
- function validateMonitor(value, expected = value?.id) {
59
- if (!value || !SOURCE.test(value.id) || value.id!==expected || value.kind!=='monitor' || typeof value.name!=='string' ||
60
- !integer(value.generation,1,Number.MAX_SAFE_INTEGER) || !['active','paused','revoked','error'].includes(value.status) ||
61
- !Array.isArray(value.targets) || value.targets.length<1 || value.targets.length>5 || value.targets.some(target=>
62
- !target || typeof target.requested_url!=='string' || typeof target.state!=='string' || typeof target.enabled!=='boolean'))
63
- throw new Error('Server returned an invalid monitor receipt. No check was assumed.');
64
- return value;
65
- }
66
- function validateChange(value, source) {
67
- if (!value || !CHANGE.test(value.id) || !HASH.test(value.version) || !SOURCE.test(value.source_id) || source && value.source_id!==source ||
68
- typeof value.title!=='string' || typeof value.final_url!=='string' || typeof value.observed_before!=='string' || typeof value.observed_after!=='string')
69
- throw new Error('Server returned an invalid or out-of-scope change receipt.');
70
- return value;
71
- }
72
- export function validateChangeContext(value, request) {
73
- const invalid=()=>{throw new Error('Server returned an invalid historical context receipt. No context was emitted; inspect the change again.');};
74
- if (!value || typeof value.context!=='string' || value.budget!==request.budget || value.budget_unit!=='utf8_bytes' ||
75
- Buffer.byteLength(value.context)!==value.context_bytes || value.context_bytes>request.budget ||
76
- !Array.isArray(value.selections) || value.selections.length!==1 || value.selections[0]?.change_id!==request.id || value.selections[0]?.version!==request.selections[0].version ||
77
- !Array.isArray(value.included) || value.included.length>2 || !Array.isArray(value.omitted) || value.omitted.length>2 ||
78
- !Array.isArray(value.warnings) || value.warnings.length>20 || value.warnings.some(w=>typeof w!=='string') ||
79
- Boolean(value.context)!==Boolean(value.included.length)) invalid();
80
- const seen=new Set(), labels=new Set(), chars=Array.from(value.context); let lastEnd=0;
81
- for (const entry of [...value.included,...value.omitted]) {
82
- if (!entry || entry.change_id!==request.id || entry.version!==request.selections[0].version || !['before','after'].includes(entry.side) || seen.has(entry.side)) invalid();
83
- seen.add(entry.side);
84
- }
85
- if (seen.size!==2 || value.omitted.some(entry=>entry.reason!=='budget')) invalid();
86
- for (const entry of value.included) {
87
- const span=entry.context_span, excerpt=entry.excerpt;
88
- if (typeof excerpt!=='string' || !excerpt || typeof entry.citation!=='string' || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(entry.citation) || labels.has(entry.citation) ||
89
- !integer(entry.full_length,0,131072) || !integer(entry.start_char,0,entry.full_length) || !integer(entry.end_char,entry.start_char+1,entry.full_length) ||
90
- Array.from(excerpt).length!==entry.end_char-entry.start_char || typeof entry.truncated!=='boolean' ||
91
- typeof entry.observed_at!=='string' || typeof entry.url!=='string' || !span || !integer(span.start_char,lastEnd,chars.length) ||
92
- !integer(span.end_char,span.start_char+1,chars.length) || chars.slice(span.start_char,span.end_char).join('')!==excerpt) invalid();
93
- labels.add(entry.citation); lastEnd=span.end_char;
94
- }
95
- return value;
96
- }
97
- export function monitorFailure(error) {
98
- const status=Number.isInteger(error?.status)?error.status:undefined;
99
- const codes={monitor_changed:'Monitor settings changed. Run monitors show and review the current generation before explicitly checking again.',
100
- monitor_not_active:'This monitor is not active. Review its status in OpenCrawl; no check was queued.',
101
- monitor_change_changed:'The historical version does not match. Inspect changes and explicitly select the intended immutable version; no newer change was substituted.',
102
- monitor_evidence_changed:'Historical evidence or its access changed. Inspect the change again before explicitly requesting context.'};
103
- if (status===409 && Object.hasOwn(codes,error.code)) return {code:error.code,status,message:codes[error.code]};
104
- const message=({401:'Sign in with a valid key for the intended workspace.',403:'This account cannot perform this monitor operation.',
105
- 404:'This monitor or historical change is unavailable to this account, or its retained history has expired. No other evidence was substituted.',
106
- 409:'The monitor or historical selection changed. Inspect it before explicitly trying again.',413:'A monitor or retained-history limit was reached. Review capacity in OpenCrawl.',
107
- 422:'The monitor request is invalid. Check its exact ID, generation, version and byte budget.',429:'OpenCrawl is busy or a workspace limit was reached. Inspect the monitor before checking again.',
108
- 507:'Workspace storage admission failed. Review storage before checking again.'})[status] ||
109
- 'The monitor request outcome could not be confirmed. Inspect its current state before explicitly trying again. No automatic retry was made.';
110
- return {code:status?`http_${status}`:'request_unconfirmed',message,...(status?{status}:{})};
111
- }
112
- export async function monitorsCommand(rest, options, client, {stdout=value=>process.stdout.write(value),stderr=value=>process.stderr.write(value)}={}) {
113
- const request=monitorRequest(rest,options); let data;
114
- try {data=await client.request(request.route,{...request.options,maxResponseBytes:1024*1024});}
115
- catch(error){const failure=monitorFailure(error);if(options.json)stdout(JSON.stringify({error:failure},null,2)+'\n');else stderr(failure.message+'\n');return 1;}
116
- if(request.command==='list'){
117
- if(!Array.isArray(data?.items)||data.items.length>request.limit||data.limit!==request.limit||data.offset!==request.offset)throw new Error('Server returned an invalid monitor page.');
118
- data.items.forEach(value=>validateMonitor(value));
119
- if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
120
- else {stdout(data.items.map(value=>`${value.id}\tg${value.generation}\t${value.status}\t${displayLine(value.name)}\n`).join('')||'No monitors on this page.\n');if(data.has_more)stderr(`Next page: --offset ${request.offset+data.items.length}.\n`);}
121
- }else if(request.command==='show'){
122
- validateMonitor(data,request.id);
123
- if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
124
- else {stdout(`${displayLine(data.name)}\nMonitor: ${data.id}\nGeneration: ${data.generation}; status: ${data.status}\n`);
125
- for(const target of data.targets)stdout(`${displayLine(target.requested_url)}\n ${displayLine(target.state)}; latest attempt: ${displayLine(target.last_attempt_at||'none')}; last good observation: ${displayLine(target.last_success_at||'none')}\n${target.issue?.message?' '+displayLine(target.issue.message)+'\n':''}`);
126
- stderr('Only selected static pages are checked. Failure or missing content is not evidence of removal.\n');}
127
- }else if(request.command==='check'){
128
- validateJob(data?.job);
129
- stdout(options.json?JSON.stringify(data,null,2)+'\n':renderJob(data.job));
130
- if(!options.json)stderr(`Check accepted; this does not confirm page coverage. Follow with jobs wait ${data.job.id}, then monitors show ${request.id}.\n`);
131
- }else if(request.command==='changes'){
132
- if(!Array.isArray(data?.items)||data.items.length>request.limit||data.limit!==request.limit||!(data.next_cursor===null||typeof data.next_cursor==='string'))throw new Error('Server returned an invalid change page.');
133
- data.items.forEach(value=>validateChange(value,request.source));if(data.next_cursor!==null)cursor(data.next_cursor);
134
- if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
135
- else {stdout(data.items.map(value=>`${value.id}\t${displayLine(value.title)}\nVersion: ${value.version}\nObserved: ${displayLine(value.observed_before)} → ${displayLine(value.observed_after)}${value.gap?' (failed checks between observations)':''}\n${displayLine(value.final_url)}\n`).join('\n')||'No retained text changes on this page.\n');
136
- if(data.next_cursor)stderr(`Next page: --cursor ${displayLine(data.next_cursor)}${request.source?' --source '+request.source:''}.\n`);}
137
- }else{
138
- validateChangeContext(data,request);
139
- if(options.json)stdout(JSON.stringify(data,null,2)+'\n');
140
- else {stderr(`${data.included.length}/2 sides included; ${data.context_bytes}/${data.budget} UTF-8 bytes. Observation times are not remote publication times.\n`);
141
- for(const entry of data.included)if(entry.truncated)stderr(`${entry.side} excerpt is truncated; inspect the complete retained change in OpenCrawl.\n`);
142
- for(const entry of data.omitted)stderr(`${entry.side} side omitted because of the byte budget.\n`);
143
- for(const warning of data.warnings)stderr(displayLine(warning)+'\n');stdout(data.context);}
144
- }
145
- return 0;
146
- }
@@ -1,71 +0,0 @@
1
- /** Fixed read-only Letter picker scope. No provider selection or neutral re-render. */
2
- export const LETTER_CAPABILITIES_ADAPTER='letter-capabilities-read-v1';
3
- export const LETTER_PICKER='[data-testid="connect-integration"]';
4
- export const LETTER_PICKER_OPEN='[data-testid="connect-integration-open"]';
5
- export function isLetterCapabilitiesPolicy(policy,screen){return policy?.version===3&&policy.request_adapter===LETTER_CAPABILITIES_ADAPTER&&screen.url==='https://app.letterstory.com/letterpress?view=connections'&&screen.account_marker?.required===true&&screen.ready_selector===LETTER_PICKER_OPEN&&screen.scope_selector===LETTER_PICKER;}
6
-
7
- /** Executed in the page before the one fixed opener click. Only finite status leaves. */
8
- export function letterPickerReady({screen,expected}){
9
- const visible=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'&&r.width>0&&r.height>0;};
10
- const all=s=>[...document.querySelectorAll(s)].filter(visible);
11
- if(location.href!==screen.url)return {error:'scope_mismatch'};
12
- if(screen.login_selector&&all(screen.login_selector).length)return {error:'session_expired',login:true};
13
- const markers=all(screen.account_marker.selector);
14
- if(markers.length!==1||typeof expected!=='string'||!expected||markers[0].innerText.trim()!==expected)return {error:'context_mismatch'};
15
- const buttons=all('[data-testid="connect-integration-open"]');
16
- if(buttons.length!==1||buttons[0].tagName!=='BUTTON'||buttons[0].innerText.trim()!=='Connect'||buttons[0].disabled||buttons[0].closest('form')||document.querySelector('[data-testid="connect-integration"]'))return {error:'not_ready'};
17
- return {ready:true};
18
- }
19
-
20
- /** All public text is literal in-place UI text; unknown structure refuses before pixels. */
21
- export function extractLetterCapabilities({screen,policy,expected,freeze=false}){
22
- const visible=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'&&r.width>0&&r.height>0;};
23
- const all=s=>[...document.querySelectorAll(s)].filter(visible);
24
- const one=s=>{const a=all(s);return a.length===1?a[0]:null;};
25
- if(location.href!==screen.url)return {error:'scope_mismatch'};
26
- if(screen.login_selector&&all(screen.login_selector).length)return {error:'session_expired',login:true};
27
- const marker=one(screen.account_marker.selector)?.innerText.trim();
28
- if(!marker||marker!==expected)return {error:'context_mismatch'};
29
- const dialog=one('[data-testid="connect-integration"]');
30
- if(!dialog||dialog.getAttribute('role')!=='dialog')return {error:'not_ready'};
31
- const exact=(e,tags)=>e&&[...e.children].map(n=>n.tagName.toUpperCase()).join(',')===tags&&!Array.from(e.childNodes).some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim());
32
- const leaf=(e,max)=>e&&e.children.length===0&&visible(e)&&e.textContent===e.textContent.trim()&&e.textContent.length>0&&Array.from(e.textContent).length<=max&&!/[\p{C}\n\r]/u.test(e.textContent)&&!/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|https?:\/\/|\b(?:Bearer |sk-|ghp_|AKIA)/i.test(e.textContent);
33
- if(!exact(dialog,'DIV,DIV,BUTTON'))return {error:'mask_unresolved'};
34
- const [header,body,close]=dialog.children;
35
- if(!exact(header,'H2,P')||!leaf(header.children[0],100)||!leaf(header.children[1],100)||header.children[0].textContent!=='Connect an integration'||header.children[1].textContent!=='Connect where you use it.'||!exact(body,'UL')||!exact(close,'SVG,SPAN')||close.children[1]?.textContent!=='Close')return {error:'mask_unresolved'};
36
- const list=body.children[0],rows=[...list.children];
37
- if(rows.length<1||rows.length>32||!exact(list,rows.map(()=> 'LI').join(',')))return {error:'mask_unresolved'};
38
- const masks=[close],publicNodes=[dialog,header,...header.children,body,list],names=new Set(),text=['Connect an integration','Connect where you use it.'];
39
- const badges=[],logos=[],missingBadgeSlots=[];
40
- for(const row of rows){
41
- if(!exact(row,'BUTTON'))return {error:'mask_unresolved'};
42
- const button=row.children[0];if(!exact(button,'IMG,SPAN')&&!exact(button,'IMG,SPAN,SPAN'))return {error:'mask_unresolved'};
43
- const [logo,copy,badge]=button.children;
44
- if(!copy.classList.contains('min-w-0')||!exact(copy,'SPAN,SPAN')||!leaf(copy.children[0],100)||!leaf(copy.children[1],400)||names.has(copy.children[0].textContent))return {error:'mask_unresolved'};
45
- names.add(copy.children[0].textContent);text.push(copy.children[0].textContent,copy.children[1].textContent);
46
- masks.push(logo);logos.push(logo);if(badge){masks.push(badge);badges.push(badge);}else missingBadgeSlots.push(button);publicNodes.push(row,button,copy,...copy.children);
47
- }
48
- // Unknown controls or pseudo/image content in the public structure are not public fields.
49
- if(publicNodes.some(e=>!visible(e)||getComputedStyle(e).backgroundImage!=='none'||getComputedStyle(e).listStyleImage!=='none'||['::before','::after','::marker'].some(p=>!['none','normal'].includes(getComputedStyle(e,p).content))))return {error:'mask_unresolved'};
50
- // Element masks cannot hide direct ancestor text or ancestor-generated paint.
51
- // Include html: a body-only boundary leaves document-level paint outside the mask set.
52
- for(let child=dialog,parent=dialog.parentElement;parent;child=parent,parent=parent.parentElement){
53
- if(Array.from(parent.childNodes).some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim())||getComputedStyle(parent).backgroundImage!=='none'||['::before','::after'].some(p=>!['none','normal'].includes(getComputedStyle(parent,p).content)))return {error:'mask_unresolved'};
54
- for(const sibling of parent.children)if(sibling!==child)masks.push(sibling);
55
- }
56
- for(const selector of [...policy.masks,...policy.noise_regions].map(r=>r.selector)){const matches=all(selector);if(!matches.length||matches.length>100)return {error:'mask_unresolved'};masks.push(...matches);}
57
- if(masks.some(m=>publicNodes.some(n=>m===n||m.contains(n))))return {error:'mask_unresolved'};
58
- const value=text.join('\n');if(new TextEncoder().encode(value).length>16384)return {error:'mask_unresolved'};
59
- if(freeze){
60
- // Fixed private slots make connection-status strings/logos unable to change public layout.
61
- // Missing status badges receive the same empty masked slot before pixels or observation.
62
- for(const button of missingBadgeSlots){const badge=document.createElement('span');badge.style.setProperty('opacity','0','important');button.append(badge);badges.push(badge);masks.push(badge);}
63
- for(const badge of badges){badge.style.setProperty('width','100px','important');badge.style.setProperty('flex','0 0 100px','important');badge.style.setProperty('overflow','hidden','important');badge.style.setProperty('height','20px','important');badge.style.setProperty('white-space','nowrap','important');}
64
- for(const logo of logos){logo.style.setProperty('width','24px','important');logo.style.setProperty('height','24px','important');logo.style.setProperty('flex','0 0 24px','important');}
65
- for(const node of masks)node.style.setProperty('opacity','0','important');
66
- window.__opencrawlMasks=masks;window.__opencrawlChanged=false;window.__opencrawlObserver?.disconnect();
67
- window.__opencrawlObserver=new MutationObserver(rows=>{if(rows.length)window.__opencrawlChanged=true;});
68
- window.__opencrawlObserver.observe(document.documentElement,{attributes:true,childList:true,characterData:true,subtree:true});
69
- }
70
- return {text:value,title:screen.label,marker,maskCount:masks.length};
71
- }
@@ -1,178 +0,0 @@
1
- /** Explicit read adapter. Diagnostics retain finite labels and query-free target hashes only. */
2
- import {createHash} from 'node:crypto';
3
- import {LETTER_CAPABILITIES_ADAPTER} from './product-letter-capabilities.mjs';
4
- export const LETTER_ADAPTER='letter-connections-read-v1';
5
- export const LETTER_SCREEN='https://app.letterstory.com/letterpress?view=connections';
6
- const ORIGIN='https://app.letterstory.com',PREFIX=ORIGIN+'/api/console/tools/';
7
- const READS=['list_cms_connections','list_sites','list_repo_sites'];
8
- const BLOCKED=['prepare_account','refresh_site'];
9
- // Observed unrelated startup inventory. These are denied, never fetched.
10
- const BACKGROUND=['export_org_config','list_phantoms','list_collections','list_kernels','list_kernel_endpoints','list_shredder_endpoints','list_seers','list_arenas','list_lede_endpoints','list_mailers','list_prospect_lists','list_sequences','list_dispatches','list_simulations','letterprove_get_vendor_link','list_api_keys','list_integrations','visitors_list_sites','list_growth_builds'];
11
- const ORG_QUERY=/^\?org_id=([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/;
12
- function toolTarget(url){
13
- for(const op of [...READS,...BLOCKED,...BACKGROUND]){
14
- const base=PREFIX+op;if(url===base)return {op,org:null,url};
15
- if(url.startsWith(base+'?')){const match=ORG_QUERY.exec(url.slice(base.length));if(match)return {op,org:match[1],url};}
16
- }
17
- return null;
18
- }
19
- function blockedTelemetry(url){
20
- // Deny-only endpoints: query parameters cannot grant forwarding authority.
21
- if(url==='https://app.letterprove.com/api/v1/observe')return true;
22
- try{const u=new URL(url);return u.origin===ORIGIN&&u.pathname==='/ingest/flags/'&&!u.hash&&!u.username&&!u.password&&url===u.origin+u.pathname+u.search;}catch{return false;}
23
- }
24
- // Diagnostic classification is separate from toolTarget: it never grants forwarding
25
- // or converts a fatal rejection into a known-background rejection.
26
- function deniedTarget(req){
27
- const value={method:'OTHER',origin_class:'invalid',known_target:'other',target_sha256:null};
28
- try{const method=req.method();if(['GET','HEAD','OPTIONS','POST','PUT','PATCH','DELETE'].includes(method))value.method=method;}catch{}
29
- try{
30
- const raw=req.url();if(typeof raw!=='string'||raw.length>8192)return value;
31
- const url=new URL(raw);if(!['http:','https:'].includes(url.protocol))return value;
32
- value.origin_class=url.origin===ORIGIN?'letter_app':url.origin==='https://app.letterprove.com'?'letter_telemetry':'other';
33
- if(url.origin===ORIGIN){
34
- value.known_target=[...READS,...BLOCKED,...BACKGROUND].find(op=>url.pathname==='/api/console/tools/'+op)||'other';
35
- if(url.pathname==='/ingest/flags/')value.known_target='telemetry_flags';
36
- }else if(url.origin==='https://app.letterprove.com'&&url.pathname==='/api/v1/observe')value.known_target='telemetry_observe';
37
- // URL origin excludes credentials; pathname excludes query and fragment.
38
- // No percent-decoding: parser normalization is diagnostic, not authority.
39
- value.target_sha256=createHash('sha256').update('letter-denied-target-v1\0'+url.origin+url.pathname).digest('hex');
40
- }catch{}
41
- return value;
42
- }
43
- const JSON_TYPE=/^application\/json(?:\s*;\s*charset=utf-8)?$/i;
44
- const PROVIDERS={webflow:['site_id','site_name'],framer:['project_id','project_url','project_name','auto_deploy'],sanity:['project_id','dataset','project_name','workspace_name'],contentful:['space_id','environment_id','space_name','default_locale'],github:['account_login']};
45
- const object=v=>v!==null&&typeof v==='object'&&!Array.isArray(v);
46
- const keys=(v,allowed)=>object(v)&&Object.keys(v).length===allowed.length&&Object.keys(v).every(k=>allowed.includes(k));
47
- const nullable=v=>v===null||typeof v==='string'&&v.length<=2048;
48
- export function validConnections(v){
49
- if(!keys(v,['connections'])||!Array.isArray(v.connections)||v.connections.length!==5)return false;
50
- const seen=new Set();
51
- return v.connections.every(row=>{
52
- if(!keys(row,['provider','connected','label','created_at','fields'])||!Object.hasOwn(PROVIDERS,row.provider)||seen.has(row.provider)||typeof row.connected!=='boolean'||!nullable(row.label)||!nullable(row.created_at))return false;
53
- seen.add(row.provider);
54
- return keys(row.fields,PROVIDERS[row.provider])&&Object.entries(row.fields).every(([k,x])=>k==='auto_deploy'?typeof x==='boolean':nullable(x));
55
- });
56
- }
57
- /** v3 keeps private rows bounded but does not equate capability discovery with five CMS names. */
58
- export function validCapabilityConnections(v){
59
- if(!keys(v,['connections'])||!Array.isArray(v.connections)||v.connections.length<1||v.connections.length>32)return false;
60
- const seen=new Set();return v.connections.every(row=>{
61
- if(!keys(row,['provider','connected','label','created_at','fields'])||typeof row.provider!=='string'||!(/^[a-z][a-z0-9_-]{0,63}$/).test(row.provider)||seen.has(row.provider)||typeof row.connected!=='boolean'||!nullable(row.label)||!nullable(row.created_at)||!object(row.fields)||Object.keys(row.fields).length>16)return false;
62
- seen.add(row.provider);return Object.entries(row.fields).every(([k,x])=>/^[a-z][a-z0-9_]{0,63}$/.test(k)&&(typeof x==='boolean'||nullable(x)));
63
- });
64
- }
65
- function connectionShape(v){
66
- const rows=Array.isArray(v?.connections)?v.connections:[];
67
- return {root_keys:keys(v,['connections']),five_rows:Array.isArray(v?.connections)&&rows.length===5,provider_roster:rows.length===5&&new Set(rows.map(r=>r?.provider)).size===5&&rows.every(r=>object(r)&&Object.hasOwn(PROVIDERS,r.provider)),row_keys:rows.length===5&&rows.every(r=>keys(r,['provider','connected','label','created_at','fields'])),row_types:rows.length===5&&rows.every(r=>object(r)&&typeof r.connected==='boolean'&&nullable(r.label)&&nullable(r.created_at)),field_keys:rows.length===5&&rows.every(r=>object(r)&&Object.hasOwn(PROVIDERS,r.provider)&&keys(r.fields,PROVIDERS[r.provider])),field_types:rows.length===5&&rows.every(r=>object(r?.fields)&&Object.entries(r.fields).every(([k,x])=>k==='auto_deploy'?typeof x==='boolean':nullable(x)))};
68
- }
69
- export function isLetterPolicy(policy,screen){return policy?.version===2&&policy.request_adapter===LETTER_ADAPTER&&screen.url===LETTER_SCREEN&&screen.account_marker?.required===true;}
70
-
71
- export class LetterReadAttempt {
72
- constructor(page,deadline,capabilities=false){this.capabilities=capabilities;this.shared=new Map();this.shareTimer=null;this.coalesced=0;this.page=page;this.deadline=deadline;this.requests=new Map();this.seen=new Set();this.scopeSet=false;this.org=null;this.required='missing';this.requiredChecks={};this.readStates=Object.fromEntries(READS.map(op=>[op,{state:'not_requested',phase:null,reason:null,checks:{}}]));this.readiness={state:'not_checked',in_flight:0};this.invalid=false;this.closed=false;this.inFlight=0;this.counts={allowed_reads:0,blocked_setup:0,blocked_refresh:0,blocked_background:0,blocked_other:0};this.denials={version:1,samples:[],omitted:0};}
73
- /** Called for each non-read-only request before any forwarding. */
74
- request(req){
75
- const url=req.url(),target=toolTarget(url),op=target?.op;
76
- if(this.capabilities&&(this.closed||this.invalid||performance.now()>=this.deadline))return this.reject(req);
77
- let exact=false,reason='unavailable';
78
- try{
79
- reason=req.method()!=='POST'?'method':req.frame()!==this.page.mainFrame()?'frame':req.redirectedFrom()?'redirect':this.page.url()!==LETTER_SCREEN?'selected_page':req.headers().origin!==ORIGIN?'request_origin':null;
80
- exact=reason===null;
81
- }catch{}
82
- if(exact&&(BLOCKED.includes(op)||BACKGROUND.includes(op)||blockedTelemetry(url))){const key=op==='prepare_account'?'blocked_setup':op==='refresh_site'?'blocked_refresh':'blocked_background';this.counts[key]=Math.min(64,this.counts[key]+1);return 'block_known';}
83
- if(!exact)return this.reject(req,reason);
84
- if(!READS.includes(op))return this.reject(req,'target');
85
- if(req.postData()!=='{}')return this.reject(req,'body_shape');
86
- if(!JSON_TYPE.test(req.headers()['content-type']||''))return this.reject(req,'content_type');
87
- if(!this.capabilities&&this.seen.has(op))return this.reject(req,'duplicate_read');
88
- if(this.scopeSet&&this.org!==target.org)return this.reject(req,'organization_scope');
89
- if(this.capabilities){
90
- let shared=this.shared.get(op);
91
- if(shared){
92
- if(shared.url!==url||this.requests.has(req)||shared.followers>=2)return this.reject(req,'duplicate_read');
93
- shared.followers++;this.coalesced++;this.requests.set(req,{...target,shared,follower:true,started:false});this.inFlight++;return 'allow';
94
- }
95
- shared={url,followers:0,value:null};shared.done=new Promise(resolve=>{shared.resolve=resolve});this.shared.set(op,shared);
96
- target.shared=shared;target.started=false;this.inFlight++;
97
- // One absolute timer also releases followers of a slow or never-started leader.
98
- if(this.shareTimer===null)this.shareTimer=setTimeout(()=>{this.invalid=true;this.clearShared();},Math.max(0,this.deadline-performance.now()));
99
- }
100
- this.scopeSet=true;this.org=target.org;this.seen.add(op);this.requests.set(req,target);this.readStates[op].state='accepted';this.counts.allowed_reads++;return 'allow';
101
- }
102
- recordDenial(req,reason='unavailable',outerKind='non_read'){
103
- try{
104
- if(this.closed)return;
105
- const sample={...deniedTarget(req),outer_kind:['non_read','navigation_scope','approved_read_redirect'].includes(outerKind)?outerKind:'non_read',reason:['method','frame','redirect','selected_page','request_origin','target','body_shape','content_type','duplicate_read','organization_scope'].includes(reason)?reason:'unavailable'};
106
- const match=this.denials.samples.find(row=>Object.entries(sample).every(([key,value])=>row[key]===value));
107
- if(match)match.count=Math.min(64,match.count+1);
108
- else if(this.denials.samples.length<8)this.denials.samples.push({...sample,count:1});
109
- else this.denials.omitted=Math.min(64,this.denials.omitted+1);
110
- }catch{} // Diagnostic failure never changes denial or prevents route.abort.
111
- }
112
- clearShared(){for(const item of this.shared.values()){item.value=null;item.resolve(false);}clearTimeout(this.shareTimer);this.shareTimer=null;}
113
- shareActive(req,target){return !this.closed&&!this.invalid&&performance.now()<this.deadline&&this.page.url()===LETTER_SCREEN&&req.frame()===this.page.mainFrame()&&req.url()===target.url;}
114
- reject(req,reason='unavailable',outerKind='non_read'){this.invalid=true;if(this.capabilities)this.clearShared();this.counts.blocked_other=Math.min(64,this.counts.blocked_other+1);this.recordDenial(req,reason,outerKind);return 'block';}
115
- async replay(route,target){
116
- const req=route.request();
117
- try{
118
- if(!this.shareActive(req,target))throw Error();
119
- if(!await target.shared.done)throw Error();
120
- const value=target.shared.value;
121
- if(!value||!this.shareActive(req,target))throw Error();
122
- // Detached validated JSON only: never repeat cookies or other response headers.
123
- await route.fulfill({status:value.status,headers:{'content-type':value.contentType},body:value.bytes});
124
- }catch{this.invalid=true;this.clearShared();await route.abort('blockedbyclient').catch(()=>{});}
125
- finally{this.inFlight--;}
126
- }
127
- async forward(route){
128
- const req=route.request(),target=this.requests.get(req),op=target?.op;
129
- if(this.capabilities){
130
- if(!target||target.started){this.reject(req);await route.abort('blockedbyclient').catch(()=>{});return;}
131
- target.started=true;if(target.follower)return this.replay(route,target);
132
- }
133
- let response,phase='preflight',reason=null;const diagnostic=this.readStates[op];if(diagnostic)diagnostic.state='in_flight';if(!this.capabilities)this.inFlight++;
134
- try{
135
- const remaining=Math.floor(this.deadline-performance.now());
136
- if(!op||this.closed||remaining<=0||this.capabilities&&!this.shareActive(req,target)){reason=!op?'unaccepted':this.closed?'closed':'deadline';throw Error();}
137
- if(op==='list_cms_connections')this.required='pending';
138
- // route.continue may follow redirects outside subsequent route handlers.
139
- // Fetch exactly once with redirects/retries disabled, then fulfill only a valid response.
140
- phase='fetch';response=await route.fetch({maxRedirects:0,maxRetries:0,timeout:remaining});
141
- phase='protocol';const headers=response.headers(),status=response.status();
142
- const checks={url_exact:response.url()===target.url,status_ok:status>=200&&status<300,content_type_json:JSON_TYPE.test(headers['content-type']||''),declared_size_ok:headers['content-length']===undefined||/^[0-9]+$/.test(headers['content-length'])&&Number(headers['content-length'])<=65536};
143
- if(diagnostic)Object.assign(diagnostic.checks,checks);
144
- if(op==='list_cms_connections')Object.assign(this.requiredChecks,checks);
145
- if(this.closed||performance.now()>=this.deadline||!Object.values(checks).every(Boolean)){reason=this.closed?'closed':performance.now()>=this.deadline?'deadline':!checks.url_exact?'response_url':!checks.status_ok?'http_status':!checks.content_type_json?'content_type':'declared_size';throw Error();}
146
- phase='body';const bytes=await response.body();
147
- if(diagnostic)diagnostic.checks.body_size_ok=bytes.length<=65536;
148
- if(op==='list_cms_connections')this.requiredChecks.body_size_ok=bytes.length<=65536;
149
- if(bytes.length>65536){reason='body_size';throw Error();}
150
- if(op==='list_cms_connections')this.requiredChecks.json_valid=false;
151
- phase='json';if(diagnostic)diagnostic.checks.json_valid=false;const value=JSON.parse(bytes.toString('utf8'));if(diagnostic)diagnostic.checks.json_valid=true;
152
- if(op==='list_cms_connections')Object.assign(this.requiredChecks,{json_valid:true,schema_valid:this.capabilities?validCapabilityConnections(value):validConnections(value),schema_shape:this.capabilities?{bounded_rows:Array.isArray(value?.connections)&&value.connections.length>=1&&value.connections.length<=32}:connectionShape(value)});
153
- phase='schema';const valid=op==='list_cms_connections'?(this.capabilities?validCapabilityConnections(value):validConnections(value)):object(value)||Array.isArray(value);
154
- if(diagnostic)diagnostic.checks.schema_valid=valid;
155
- if(!valid){reason='schema';throw Error();}
156
- if(this.closed||performance.now()>=this.deadline||this.capabilities&&!this.shareActive(req,target)){reason=this.closed?'closed':performance.now()>=this.deadline?'deadline':'attempt_invalid';throw Error();}
157
- phase='fulfill';await route.fulfill({response,body:bytes});if(diagnostic)diagnostic.state='verified';
158
- if(op==='list_cms_connections'&&!this.closed)this.required='verified';
159
- if(this.capabilities){
160
- if(!this.shareActive(req,target)){reason='attempt_invalid';throw Error();}
161
- target.shared.value={status,contentType:headers['content-type'],bytes:Buffer.from(bytes)};
162
- target.shared.resolve(true);
163
- }
164
- }catch{
165
- if(diagnostic){diagnostic.state='failed';diagnostic.phase=phase;diagnostic.reason=reason||({preflight:'preflight_failed',fetch:'fetch_failed',protocol:'protocol_failed',body:'body_read_failed',json:'invalid_json',schema:'schema_failed',fulfill:'fulfill_failed'})[phase];}
166
- this.invalid=true;if(this.capabilities)this.clearShared();if(op==='list_cms_connections')this.required='invalid';
167
- await route.abort('blockedbyclient').catch(()=>{});
168
- }finally{await response?.dispose().catch(()=>{});this.inFlight--;}
169
- }
170
- /** Finite wait within capture's original 60s deadline; never reissues a read. */
171
- async ready(signal){
172
- while(!this.closed&&!this.invalid&&!signal?.aborted&&(this.inFlight>0||['missing','pending'].includes(this.required))&&performance.now()<this.deadline)await new Promise(r=>setTimeout(r,Math.min(25,Math.max(0,this.deadline-performance.now()))));
173
- const ready=!this.closed&&!this.invalid&&!signal?.aborted&&performance.now()<this.deadline&&this.inFlight===0&&this.required==='verified';
174
- this.readiness={state:ready?'ready':this.closed?'closed':signal?.aborted?'cancelled':this.invalid?'invalid':'capture_deadline',in_flight:Math.min(this.capabilities?9:3,Math.max(0,this.inFlight))};return ready;
175
- }
176
- report(){return structuredClone({version:1,adapter:this.capabilities?LETTER_CAPABILITIES_ADAPTER:LETTER_ADAPTER,required_read:this.required==='verified'?'verified':this.required==='invalid'?'invalid':'missing',...this.counts,...(this.capabilities?{coalesced_reads:this.coalesced}:{}),required_response_checks:this.requiredChecks,reads:this.readStates,readiness:this.readiness,invalid:this.invalid,in_flight:Math.min(this.capabilities?9:3,Math.max(0,this.inFlight)),...(this.denials.samples.length||this.denials.omitted?{blocked_requests:this.denials}:{})});}
177
- close(){this.closed=true;if(this.capabilities)this.clearShared();}
178
- }