Spaces:
Running
Running
File size: 13,834 Bytes
66b4dc1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | /** Pages Gmail transport. Inactive until OAuth, signing and responder are provisioned.
* No secrets, destinations or account identities are embedded in this module.
* Tokens and CryptoKey handles remain in memory. Every send requires reviewed:true.
*/
export const SCOPES=['https://www.googleapis.com/auth/gmail.send','https://www.googleapis.com/auth/gmail.readonly'];
export const VERSION='adaptapro.instinct/1';
const agents=['ALPHA','BETA','GAMMA'];
const email=x=>typeof x==='string'&&/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$/.test(x);
const id=x=>typeof x==='string'&&/^[a-zA-Z0-9_-]{16,80}$/.test(x);
export function canonical(v){if(v===null||typeof v!=='object')return JSON.stringify(v);if(Array.isArray(v))return '['+v.map(canonical).join(',')+']';return '{'+Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+canonical(v[k])).join(',')+'}';}
const bytes=s=>new TextEncoder().encode(s);
export function base64url(s){const b=typeof s==='string'?bytes(s):s;let raw='';for(const x of b)raw+=String.fromCharCode(x);return btoa(raw).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');}
export function decode64(s){if(typeof s!=='string'||!/^[A-Za-z0-9_=-]*$/.test(s)||s.length>200000)throw Error('Invalid encoded body');return new TextDecoder('utf-8',{fatal:true}).decode(Uint8Array.from(atob(s.replaceAll('-','+').replaceAll('_','/')),x=>x.charCodeAt(0)));}
export async function sign(value,key){if(!key||key.type!=='secret'||key.algorithm.name!=='HMAC'||key.extractable||!key.usages.includes('sign'))throw Error('Private nonextractable HMAC CryptoKey required');const {signature,...body}=value;const sig=await crypto.subtle.sign('HMAC',key,bytes(canonical(body)));return {...body,signature:Array.from(new Uint8Array(sig),b=>b.toString(16).padStart(2,'0')).join('')};}
export async function verify(value,key){if(!key||key.type!=='secret'||key.algorithm.name!=='HMAC'||key.extractable||!key.usages.includes('verify')||!/^[a-f0-9]{64}$/.test(value?.signature||''))return false;const {signature,...body}=value;return crypto.subtle.verify('HMAC',key,Uint8Array.from(signature.match(/../g),b=>parseInt(b,16)),bytes(canonical(body)));}
const fields={stock:['sku','on_hand','reserved','available','reorder_point','target_stock','needs_restock'],orders:['order_id','sku','quantity','status','region'],proposals:['id','sku','proposed_qty','status'],suppliers:['id','sku','name','lead_days','unit_cost_cents','min_order_qty']};
export function compactContext(agent,context,skus=[]){if(!agents.includes(agent)||!context||typeof context!=='object'||Array.isArray(context)||!Array.isArray(skus)||skus.length>20)throw Error('Invalid context');const tables=agent==='GAMMA'?['stock','proposals','suppliers']:agent==='BETA'?['stock','orders','proposals']:Object.keys(fields);const out={mode:String(context.mode||'supervisado').slice(0,30),currency:'USD',snapshot_at:new Date().toISOString(),tables:{},omitted:{}};for(const t of tables){const rows=context[t]||[];if(!Array.isArray(rows)||rows.length>5000)throw Error('Table size exceeded');const relevant=skus.length?rows.filter(r=>skus.includes(r.sku)):rows;out.tables[t]=relevant.slice(0,80).map(r=>Object.fromEntries(fields[t].filter(k=>r[k]!==undefined).map(k=>[k,r[k]])));out.omitted[t]={outside_scope:rows.length-relevant.length,over_limit:Math.max(0,relevant.length-80)};}if(bytes(JSON.stringify(out)).length>40000)throw Error('Narrow context scope');return out;}
export function makeRequest({conversation,agent,text,context,skus=[],sequence,history=[],replyTo}){if(!id(conversation)||!agents.includes(agent)||!Number.isSafeInteger(sequence)||sequence<1||typeof text!=='string'||!text.trim()||text.length>6000||!email(replyTo)||!Array.isArray(history)||history.length>6)throw Error('Invalid request');for(const h of history)if(!['user','assistant'].includes(h.role)||typeof h.text!=='string'||h.text.length>3000)throw Error('Invalid history');return {v:VERSION,type:'request',scope:'analysis-only',id:crypto.randomUUID().replaceAll('-',''),nonce:crypto.randomUUID().replaceAll('-',''),conversation,agent,sequence,created_at:new Date().toISOString(),expires_at:new Date(Date.now()+900000).toISOString(),reply_to:replyTo,question:text,context:compactContext(agent,context,skus),history:history.map(h=>({role:h.role,text:h.text}))};}
export function mime(value,from,to){if(!email(from)||!email(to))throw Error('Invalid mailbox');const body=base64url(JSON.stringify(value)).replaceAll('-','+').replaceAll('_','/');const padded=body+'='.repeat((4-body.length%4)%4);return `From: ${from}\r\nTo: ${to}\r\nSubject: AdaptaPro ${value.agent} ${value.id}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n${padded.match(/.{1,76}/g).join('\r\n')}\r\n`;}
export function plaintext(payload){const parts=[];function walk(p){if(p.mimeType==='text/plain'&&p.body?.data)parts.push(decode64(p.body.data));for(const child of p.parts||[])walk(child);}walk(payload);if(parts.length!==1)throw Error('One text/plain response required');return parts[0];}
export async function validateResponse(value,pending,key,now=Date.now()){if(!await verify(value,key)||value.v!==VERSION||value.type!=='response'||value.scope!=='analysis-only')throw Error('Unverified response');for(const k of ['id','nonce','conversation','agent','sequence'])if(value[k]!==pending[k])throw Error('Wrong response route');if(!Number.isFinite(Date.parse(value.created_at))||!Number.isFinite(Date.parse(value.expires_at))||Date.parse(value.expires_at)>Date.parse(pending.expires_at)||Date.parse(value.expires_at)<now||Date.parse(value.created_at)>now+60000||Date.parse(pending.expires_at)<now)throw Error('Expired response');if(typeof value.text!=='string'||!value.text.trim()||value.text.length>16000)throw Error('Invalid text');return value.text;}
export class GmailBridge{
constructor({clientId,mailbox,destination,key,manualUnauthenticated=false,fetchImpl=fetch}){if(typeof clientId!=='string'||!clientId.endsWith('.apps.googleusercontent.com')||!email(mailbox)||!email(destination))throw Error('Configure OAuth client, ERP mailbox and confirmed destination');this.clientId=clientId;this.mailbox=mailbox;this.destination=destination;this.key=key;this.manualUnauthenticated=manualUnauthenticated===true;this.fetch=fetchImpl;this.jobs=new Map();this.busy=false;this.token=null;}
authorize(){if(!globalThis.google?.accounts?.oauth2)throw Error('Load Google Identity Services first');return new Promise((resolve,reject)=>{const client=google.accounts.oauth2.initTokenClient({client_id:this.clientId,scope:SCOPES.join(' '),callback:async r=>{try{if(r.error||!google.accounts.oauth2.hasGrantedAllScopes(r,...SCOPES))throw Error('Both Gmail scopes required');this.token=r.access_token;this.expires=Date.now()+Number(r.expires_in)*1000;const profile=await this.api('profile');if(profile.emailAddress.toLowerCase()!==this.mailbox.toLowerCase()){this.disconnect();throw Error('Wrong Gmail account');}resolve(profile.emailAddress);}catch(e){this.disconnect();reject(e);}},error_callback:()=>reject(Error('OAuth popup closed or failed'))});client.requestAccessToken({prompt:'select_account'});});}
disconnect(){this.token=null;this.expires=0;}
async api(path,options={}){if(!this.token||Date.now()>=this.expires-30000){this.disconnect();throw Error('Click Authorize to renew Gmail access');}const r=await this.fetch('https://gmail.googleapis.com/gmail/v1/users/me/'+path,{...options,headers:{Authorization:'Bearer '+this.token,...options.headers},signal:AbortSignal.timeout(20000)});if(!r.ok){if(r.status===401)this.disconnect();throw Error('Gmail HTTP '+r.status);}return r.json();}
async send(request,{reviewed=false}={}){if(!reviewed)throw Error('Review account, recipient and final message before send');if(this.busy)throw Error('Wait for active Gmail operation');if(request.reply_to!==this.mailbox||(!this.manualUnauthenticated&&!await verify(request,this.key)))throw Error('Signed request required');if(this.manualUnauthenticated&&request.authentication!=='none-manual-test')throw Error('Manual test marker required');if(this.jobs.has(request.id))throw Error('Already submitted: inspect status, never resend blindly');if([...this.jobs.values()].some(j=>j.request.conversation===request.conversation&&!['answered','expired'].includes(j.status)))throw Error('Conversation has pending or uncertain turn');if(this.jobs.size>=200)throw Error('Session queue full');this.busy=true;const j={request,status:'sending',submittedAt:new Date().toISOString()};this.jobs.set(request.id,j);try{const start=performance.now();const result=await this.api('messages/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({raw:base64url(mime(request,this.mailbox,this.destination))})});j.status='sent';j.gmailId=result.id;j.sentAt=new Date().toISOString();j.sendMs=performance.now()-start;return j;}catch(e){j.status='uncertain';throw Error('Send outcome uncertain; inspect Gmail Sent before any retry');}finally{this.busy=false;}}
async check(){if(this.busy)return [];const active=[...this.jobs.values()].filter(j=>['sent','uncertain'].includes(j.status));if(!active.length)return [];this.busy=true;const answered=[];try{for(const j of active){if(Date.parse(j.request.expires_at)<Date.now()){j.status='expired';continue;}const q=`from:${this.destination} to:${this.mailbox} subject:${j.request.id}`;let page='',count=0;do{const list=await this.api('messages?maxResults=25&q='+encodeURIComponent(q)+(page?'&pageToken='+encodeURIComponent(page):''));for(const m of list.messages||[]){if(++count>100)throw Error('Too many candidate responses; narrow manually');const message=await this.api('messages/'+m.id+'?format=full');if((message.labelIds||[]).some(l=>['SPAM','TRASH'].includes(l)))continue;try{const value=JSON.parse(plaintext(message.payload));const text=this.manualUnauthenticated?validateManualResponse(value,j.request):await validateResponse(value,j.request,this.key);j.status='answered';j.text=text;j.authentication=this.manualUnauthenticated?'unverified':'hmac';j.receivedAt=new Date().toISOString();j.responseGmailId=m.id;answered.push(j);break;}catch{j.rejected=(j.rejected||0)+1;}}page=j.status==='answered'?'':list.nextPageToken||'';}while(page);}return answered;}finally{this.busy=false;}}
}
// Local metadata audit, not an immutable or independently trusted ledger.
export class ActivityLog{
constructor(storage=globalThis.localStorage,{key='adaptapro-instinct-log-v1',limit=2000}={}){this.storage=storage;this.key=key;this.limit=limit;const raw=storage.getItem(key);this.state=raw?JSON.parse(raw):{v:1,next:1,dropped:0,events:[]};if(this.state.v!==1||!Array.isArray(this.state.events)||!Number.isSafeInteger(this.state.next)||this.state.events.length>limit)throw Error('Invalid activity log');}
append({event,agent,requestId,conversation,sequence,status,durationMs,code}){if(!/^[a-z_]{1,40}$/.test(event)||agent&&!agents.includes(agent)||requestId&&!id(requestId)||conversation&&!id(conversation))throw Error('Invalid log event');const row={n:this.state.next++,at:new Date().toISOString(),event};for(const [k,v]of Object.entries({agent,requestId,conversation,sequence,status,durationMs,code}))if(v!==undefined){if(['status','code'].includes(k)&&!/^[a-zA-Z0-9_-]{1,40}$/.test(v))throw Error('Invalid log metadata');if(['sequence','durationMs'].includes(k)&&(!Number.isFinite(v)||v<0))throw Error('Invalid log metric');row[k]=v;}this.state.events.push(row);if(this.state.events.length>this.limit){this.state.events.shift();this.state.dropped++;}this.storage.setItem(this.key,JSON.stringify(this.state));return row;}
summary(after=0){const rows=this.state.events.filter(e=>e.n>after),counts={},anomalies=[],turns=new Map();for(const e of rows){counts[e.event]=(counts[e.event]||0)+1;if(e.requestId){const old=turns.get(e.requestId);if(old&&(old.agent!==e.agent||old.conversation!==e.conversation))anomalies.push({id:e.requestId,code:'routing_conflict'});turns.set(e.requestId,e);}if(['send_uncertain','response_rejected','oauth_expired','log_write_failed'].includes(e.event))anomalies.push({id:e.requestId,code:e.event});}return {v:'adaptapro.audit/1',scope:'analysis-only',from:rows[0]?.n||after,to:rows.at(-1)?.n||after,generated_at:new Date().toISOString(),retained_from:this.state.events[0]?.n||0,dropped:this.state.dropped,counts,turns:[...turns.values()].slice(-100).map(e=>({id:e.requestId,agent:e.agent,conversation:e.conversation,sequence:e.sequence,status:e.status,at:e.at})),anomalies:anomalies.slice(-100),truncated_turns:Math.max(0,turns.size-100),note:'Client-side metadata only. Browser storage is editable and may be cleared. Not proof of business actions.'};}
scheduleReview(onSummary,intervalMinutes=60){if(!Number.isInteger(intervalMinutes)||intervalMinutes<5||intervalMinutes>1440)throw Error('Review interval must be 5..1440 minutes');let cursor=0;const timer=setInterval(()=>{const summary=this.summary(cursor);if(summary.to>cursor){onSummary(summary);cursor=summary.to;}},intervalMinutes*60000);return ()=>clearInterval(timer);}
}
export function validateManualResponse(value,pending,now=Date.now()){
if(!value||value.v!==VERSION||value.type!=='response'||value.scope!=='analysis-only'||value.authentication!=='none-manual-test')throw Error('Not a manual test response');
for(const k of ['id','nonce','conversation','agent','sequence'])if(value[k]!==pending[k])throw Error('Wrong response route');
if(!Number.isFinite(Date.parse(value.created_at))||!Number.isFinite(Date.parse(value.expires_at))||Date.parse(value.expires_at)>Date.parse(pending.expires_at)||Date.parse(value.expires_at)<now||Date.parse(value.created_at)>now+60000||Date.parse(pending.expires_at)<now)throw Error('Expired response');
if(typeof value.text!=='string'||!value.text.trim()||value.text.length>16000)throw Error('Invalid text');return value.text;
}
|