import { redactOperationPayload } from '../src/redactor.ts'; import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts'; export const UPSTREAM_BASELINE='58e2204'; export const DENY_REASONS:Record={ '/api/network/operators/scan':'active radio/network scan', '/api/connectivity':'handler performs active connectivity ping', '/api/sms/list':'message list can expose body; intentionally not collected', '/api/sms/conversation':'requires correspondent query and exposes message bodies', '/api/call/history':'query limit is not encoded in Registry path contract', '/api/notifications/logs':'query limit is not encoded in Registry path contract', '/api/notifications/queue':'query limit is not encoded in Registry path contract', '/api/automation/logs':'query limit is not encoded in Registry path contract', '/api/device-network/ddns/logs':'no safe limit contract', '/api/esim/euicc':'query-bearing endpoint omitted', '/api/esim/profiles':'query-bearing endpoint omitted', }; const registryById=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,op])); // Capture the complete imported JSON contract before any caller can mutate it. // Exact identity alone does not protect mutable Registry objects. const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)])); function isPrivateLan(host:string){const p=host.split('.').map(Number);return p.length===4&&p.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(p[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||(p[0]===192&&p[1]===168));} export function validateInstanceOrigin(origin:string){ let u:URL;try{u=new URL(origin);}catch{throw new Error('invalid collector instance origin');} if(!['http:','https:'].includes(u.protocol)||!isPrivateLan(u.hostname)||u.pathname!=='/'||u.search||u.hash||u.username||u.password)throw new Error('invalid collector instance origin'); return u.origin; } function validatePath(path:string){ if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.test(path)||path.includes('//')) throw new Error('unsafe operation path'); let decoded=path; for(let i=0;i<2;i++){const next=decodeURIComponent(decoded);if(next!==decoded)throw new Error('encoded operation path');decoded=next;} if(path.split('/').some(x=>x==='.'||x==='..'))throw new Error('dot segment operation path'); return path; } export function validateReadonlyOperation(op:any){ const exact=registryById.get(op?.operationId); if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity'); validatePath(op.pathTemplate); if(op.method!=='GET'||op.riskLevel!=='R0'||/[{}]/.test(op.pathTemplate))throw new Error('operation is not readonly R0'); if(DENY_REASONS[op.pathTemplate])throw new Error('operation is explicitly denied'); return op; } export function selectReadonlyOperations(registry:any[]){ const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate)); for(const op of candidates) validatePath(op.pathTemplate); return {selected:candidates.filter(o=>!DENY_REASONS[o.pathTemplate]).map(validateReadonlyOperation),denied:candidates.filter(o=>DENY_REASONS[o.pathTemplate]).map(o=>({...o,denyReason:DENY_REASONS[o.pathTemplate]}))}; } function latency(ms:number,timedOut=false){if(timedOut)return 'timeout';if(ms<250)return '<250ms';if(ms<1000)return '250ms-1s';if(ms<3000)return '1-3s';return '>3s';} function statusCategory(status:number,isJson:boolean){if(status===401||status===403)return 'auth-required';if(status===404||status===405||status===501)return 'unsupported';if(status>=200&&status<300)return isJson?'success':'non-json';return 'http-error';} export async function collectOne(instance:{origin:string,alias:string},op:any,transport:any=fetch){ validateReadonlyOperation(op); if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias'); const origin=validateInstanceOrigin(instance?.origin); const requestUrl=new URL(op.pathTemplate,origin); if(requestUrl.origin!==origin||requestUrl.pathname!==op.pathTemplate||requestUrl.search||requestUrl.hash)throw new Error('unsafe collector request URL'); const started=Date.now();const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),6000); const base={schemaVersion:1,upstreamBaseline:UPSTREAM_BASELINE,capturedAt:new Date().toISOString().slice(0,10),sourceInstanceAlias:instance.alias,operationId:op.operationId,method:'GET',pathTemplate:op.pathTemplate,redacted:true}; try{ validateReadonlyOperation(op); const response=await transport(requestUrl,{method:'GET',headers:{accept:'application/json'},body:undefined,credentials:'omit',redirect:'manual',signal:controller.signal}); const contentType=(response.headers.get('content-type')||'').split(';')[0].trim().toLowerCase()||null;const claimsJson=contentType==='application/json'||contentType?.endsWith('+json'); let parsedJson=false;let payload:any;if(claimsJson){try{payload=await response.json();parsedJson=true;}catch{payload={error:'invalid-json'};}}else{await response.text();payload={text:'[REDACTED]'};} return {...base,statusCategory:statusCategory(response.status,parsedJson),httpStatus:response.status,contentType,latencyBucket:latency(Date.now()-started),payload:redactOperationPayload(payload,op)}; }catch(error:any){if(String(error?.message||'').includes('Registry')||String(error?.message||'').includes('operation'))throw error;const timeout=error?.name==='AbortError';return {...base,statusCategory:timeout?'timeout':'network-error',httpStatus:null,contentType:null,latencyBucket:latency(Date.now()-started,timeout),payload:{error:timeout?'timeout':'network-error'}};}finally{clearTimeout(timer);} }