From bc7688b62a04bc1f5f1bb70dc7a54dfc6125b8b5 Mon Sep 17 00:00:00 2001 From: "planetrenox@protonmail.com" Date: Tue, 20 Oct 2026 13:17:57 +0000 Subject: [PATCH] Move reply time into a ts field and make reply ids base64url Assistant messages now carry ts, a plain ms timestamp, instead of the time riding at the start of the id. Reply ids, which double as the proxy's run id, are just 128 random bits as unpadded base64url (22 chars). SECURITY.md now says ntfy pings carry a short hash of the run id. Co-Authored-By: Claude Opus 5.5 --- SECURITY.md | 2 +- src/main.js | 4 ++-- src/utils.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 063f804..ea330a6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,4 +8,4 @@ Streaming is relayed through [`us.proxy.sune.chat`](https://github.com/sune-org/ **What it doesn't do:** no prompt logging (your messages are held in memory only while the run is in progress; the generated reply stays in memory for 90 minutes after it ends so you can reconnect, and nothing is written to disk), no reading your chats, no storing keys, no third-party sharing. -**What I see:** an [ntfy](https://ntfy.sh) ping when a run ends/fails, containing only: the run's start time (the timestamp half of its ID, not the secret half), `[provider/model]`, duration, error message (if any), and the sune's non-empty generation settings (temperature, top_p, top_k, frequency/repetition penalty, min_p, top_a, max tokens, reasoning effort, show thoughts, image output/aspect ratio/size). No prompts (system or master prompts included), no responses, no IP, no key. Source is public — audit it [here](https://github.com/sune-org/us.proxy.sune.chat). +**What I see:** an [ntfy](https://ntfy.sh) ping when a run ends/fails, containing only: a short hash of the run's ID (not the ID itself), `[provider/model]`, duration, error message (if any), and the sune's non-empty generation settings (temperature, top_p, top_k, frequency/repetition penalty, min_p, top_a, max tokens, reasoning effort, show thoughts, image output/aspect ratio/size). No prompts (system or master prompts included), no responses, no IP, no key. Source is public — audit it [here](https://github.com/sune-org/us.proxy.sune.chat). diff --git a/src/main.js b/src/main.js index af8f81b..2662359 100644 --- a/src/main.js +++ b/src/main.js @@ -73,7 +73,7 @@ const renderUserUI=window.renderUserUI=()=>{if(!el.userMenuAvatar)return;const a const getSuneLabel=m=>{const name=(m&&m.sune_name)||SUNE.name,modelShort=getModelShort(m&&m.model);return `${name} · ${modelShort}`} function _createMessageRow(m){const role=typeof m==='string'?m:(m&&m.role)||'assistant',meta=typeof m==='string'?{}:m||{},isUser=role==='user',$row=$('
'),$head=$('
'),$avatar=$('
');const uAva=isUser?USER.avatar:meta.avatar;uAva?$avatar.attr('class','msg-avatar shrink-0 h-7 w-7 rounded-full overflow-hidden').html(``):$avatar.attr('class',`${isUser?'bg-gray-900 text-white':'bg-gray-200 text-gray-900'} msg-avatar shrink-0 h-7 w-7 rounded-full flex items-center justify-center`).text(isUser?'👤':'✺');const $name=$('
').text(isUser?USER.name:getSuneLabel(meta));const $deleteBtn=$('').on('click',async e=>{e.stopPropagation();state.messages=state.messages.filter(msg=>msg.id!==m.id);$row.remove();dropReply(m.id);await THREAD.persist()});const $copyBtn=$('').on('click',async function(e){e.stopPropagation();const cur=state.messages.find(x=>x.id===m.id)||m;if(await copyToClipboard(partsToText(cur,true))){$(this).html('');icons();setTimeout(()=>{$(this).html('');icons()},1200)}});$head.append($avatar,$name,$copyBtn,$deleteBtn);const $bubble=$(`
`);$row.append($head,$bubble);const vids=(Array.isArray(meta.content)?meta.content:[]).filter(p=>p?.type==='video_url');if(vids.length)$row.append(videoStrip(vids));return $row} function msgRow(m){const $row=_createMessageRow(m);$(el.messages).append($row);queueMicrotask(()=>{el.chat.scrollTo({top:el.chat.scrollHeight,behavior:'smooth'});icons()});return $row.find('.msg-bubble')[0]} -const addMessage=window.addMessage=function(m,track=true){m.id=m.id||gid();if(!Array.isArray(m.content)&&m.content!=null){m.content=[{type:'text',text:String(m.content)}]}const bubble=msgRow(m);bubble.dataset.mid=m.id;renderMarkdown(bubble,partsToText(m));if(track)state.messages.push(m);if(m.role==='assistant')el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:m}}));return bubble} +const addMessage=window.addMessage=function(m,track=true){m.id=m.id||gid();if(m.role==='assistant')m.ts??=Date.now();if(!Array.isArray(m.content)&&m.content!=null){m.content=[{type:'text',text:String(m.content)}]}const bubble=msgRow(m);bubble.dataset.mid=m.id;renderMarkdown(bubble,partsToText(m));if(track)state.messages.push(m);if(m.role==='assistant')el.composer.dispatchEvent(new CustomEvent('sune:newSuneResponse',{detail:{message:m}}));return bubble} const addSuneBubbleStreaming=(meta,id)=>msgRow(Object.assign({role:'assistant',id},meta)) const clearChat=()=>{el.suneHtml.dispatchEvent(new CustomEvent('sune:unmount'));state.messages=[];el.messages.innerHTML='';clearAttachments();revokeVideoUrls();syncBtn()} const payloadWithSampling=b=>{const o=Object.assign({},b),s=SUNE,p={temperature:num(s.temperature,null),top_p:num(s.top_p,null),top_k:int(s.top_k,null),frequency_penalty:num(s.frequency_penalty,null),repetition_penalty:num(s.repetition_penalty,null),min_p:num(s.min_p,null),top_a:num(s.top_a,null),max_tokens:int(s.max_tokens,null)>0?int(s.max_tokens,null):null};Object.keys(p).forEach(k=>{const v=p[k];if(v!==null)o[k]=v});return o} @@ -138,7 +138,7 @@ $(el.composer).on('paste',async e=>{ addAttachments(files); }) // Shared by send and SUNE.infer: adds the assistant bubble and starts its stream -function reply(){const a=SUNE.active,meta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},id=sid(),msg=Object.assign({id,role:'assistant',content:[{type:'text',text:''}]},meta,{stream:{seq:-1}}),b=addSuneBubbleStreaming(meta,id);b.dataset.mid=id;b.innerHTML=SUNE_LOGO_SVG;state.messages.push(msg);THREAD.persist(false);startReply(msg)} +function reply(){const a=SUNE.active,meta={sune_name:a.name,model:SUNE.model,avatar:a.avatar||''},id=sid(),msg=Object.assign({id,role:'assistant',ts:Date.now(),content:[{type:'text',text:''}]},meta,{stream:{seq:-1}}),b=addSuneBubbleStreaming(meta,id);b.dataset.mid=id;b.innerHTML=SUNE_LOGO_SVG;state.messages.push(msg);THREAD.persist(false);startReply(msg)} // The lock is taken before any await so a double tap can't send twice; a tap on the button just after it flipped from Stop is ignored $(el.composer).on('submit',async e=>{e.preventDefault();if(state.busy||sending||(e.submitter===el.sendBtn&&Date.now()-btnSwapAt<400))return;state.abortRequested=false;sending=true;try{await attachQueue;const text=el.input.value.trim();if(!text&&!state.attachments.length){sending=false;return await SUNE.infer()}el.input.value='';const parts=[];if(text)parts.push({type:'text',text});parts.push(...state.attachments);state.attachments=[];updateAttachBadge();el.fileInput.value='';await ensureThreadOnFirstUser(text||'(attachments)');const th=THREAD.active,shouldGenTitle=th&&!th.title;const userMsg={role:'user',content:parts.length?parts:[{type:'text',text:text||'(sent attachments)'}]};addMessage(userMsg);el.composer.dispatchEvent(new CustomEvent('user:send',{detail:{message:userMsg}}));if(shouldGenTitle)(async()=>{const title=await generateTitleWithAI(state.messages)||partsToText(state.messages.find(m=>m.role==='user')).replace(/!\[\]\(data:[^\)]+\)/g,'[Image]')||'Untitled';await THREAD.setTitle(th.id,title)})();if(SUNE.model)reply()}finally{sending=false}}) let jars={html:null,extension:null}; diff --git a/src/utils.js b/src/utils.js index fb127a7..cc8b25c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -8,8 +8,8 @@ export const positionPopover = (a, p) => { p.style.top = `${r.bottom + p.offsetHeight + 4 > window.innerHeight ? r.top - p.offsetHeight - 4 : r.bottom + 4}px`; p.style.left = `${Math.max(8, Math.min(r.right - p.offsetWidth, window.innerWidth - p.offsetWidth - 8))}px`; }; -// Reply ids double as the proxy's run id, so after the ms timestamp come 128 random bits -export const sid = () => Date.now().toString(36) + '-' + [...crypto.getRandomValues(new Uint8Array(16))].map(b => b.toString(16).padStart(2, '0')).join(''); +// Reply ids double as the proxy's run id, so they're 128 random bits as unpadded base64url +export const sid = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16)))).replace(/[+/=]/g, c => ({ '+': '-', '/': '_' })[c] || ''); export const fmtSize = b => { const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, x = b;