From 1aa3cffaf2a6e64459be8531117b89b0fd155e73 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 08:05:18 +0000 Subject: [PATCH] Release cookie-free Persimmons vault link 0.6.0 --- app.js | 990 ++++++++++++++------------ assets/app.js | 554 ++++++++++++++ assets/extension.zip | Bin 0 -> 35253 bytes assets/install.html | 1 + assets/signin.html | 5 + assets/signin.js | 26 + assets/update-extension.py | 94 +++ assets/update.html | 10 + assets/version.json | 1 + assets/version.mjs | 10 + backend/build.py | 66 +- backend/shared/human_vault.py | 103 ++- backend/web/app.js | 12 +- backend/web/install.html | 2 +- extension/connection-state.mjs | 4 +- extension/connection.mjs | 8 +- extension/human-api.mjs | 10 +- extension/human-popup.mjs | 42 +- extension/manifest.json | 5 +- extension/tests/connection.test.mjs | 9 +- extension/tests/core.test.mjs | 2 +- extension/tests/homepage.test.mjs | 2 +- extension/tests/human-crypto.test.mjs | 10 +- extension/tests/human-popup.test.mjs | 13 + extension/tests/session.test.mjs | 1 + extension/tests/worker.test.mjs | 8 +- extension/vault-worker.mjs | 2 - index.html | 77 +- signin | 5 + tests/test_capability_link.py | 67 ++ tests/test_persimmons_update.py | 6 +- tests/test_persimmons_ux.py | 14 +- 32 files changed, 1535 insertions(+), 624 deletions(-) create mode 100644 assets/app.js create mode 100644 assets/extension.zip create mode 100644 assets/install.html create mode 100644 assets/signin.html create mode 100644 assets/signin.js create mode 100644 assets/update-extension.py create mode 100644 assets/update.html create mode 100644 assets/version.json create mode 100644 assets/version.mjs create mode 100644 signin create mode 100644 tests/test_capability_link.py diff --git a/app.js b/app.js index f726f36..495c2f9 100644 --- a/app.js +++ b/app.js @@ -1,458 +1,554 @@ -import { - session as loadSession, - repositories as loadRepositoryOptions, - listUser, - listRepository, - createConnection, - rotateConnection, - revokeConnection, - deleteConnection -} from './store.js'; +import {updateState} from './version.mjs'; -const $=id=>document.getElementById(id); -const userList=$('userList'),userState=$('userState'),repoList=$('repoList'),repoState=$('repoState'); -const repoSelect=$('repoSelect'),composerRepo=$('composerRepo'),scrim=$('composerScrim'); -const nextButton=$('nextButton'),backButton=$('backButton'),saveButton=$('saveButton'),composerState=$('composerState'); +const $ = id => document.getElementById(id); +const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg'; +let generation = 0, currentOwner, currentState, dragging = false, juiceBusy = false; +const masterItems = new Map(); +const openVaults = new Set(); -const providers={ - Gitea:{type:'PAT',account:'Username',accountPlaceholder:'Your Gitea username',value:'Personal access token',scopes:['Repositories','Issues','Wiki']}, - Cloudflare:{type:'API token',account:'Account or label',accountPlaceholder:'Optional account or label',value:'API token',scopes:['DNS','Domains','Account']}, - Fly:{type:'Token',account:'Account or label',accountPlaceholder:'Optional account or label',value:'Access token',scopes:['Apps','Machines','Deploys']}, - Other:{type:'',account:'Account or label',accountPlaceholder:'Optional account or label',value:'Password, key, or token',scopes:[]} -}; +$('signOutButton').hidden = true; +$('signOutButton').textContent = 'Sign out'; +$('userList').innerHTML = ` + +
+

+ + + +
+
`; -const ALL_REPOS='__all__'; -let repos=[]; -let flow={step:1,provider:'',vault_kind:'',repository:''}; -let authPopup=null; -let authPoll=null; - -function node(tag,text='',cls=''){ - const el=document.createElement(tag); - if(cls)el.className=cls; - el.textContent=text; - return el; -} -function fmt(value){ - if(!value)return '—'; - const d=new Date(value); - return Number.isNaN(d.getTime())?String(value):d.toLocaleString(); -} -function setState(el,message,kind=''){ - el.textContent=message; - el.className='state'+(kind?' '+kind:''); -} -function normalizeSecretName(value){ - let name=String(value||'').trim().toUpperCase().replace(/[^A-Z0-9_]+/g,'_').replace(/^_+|_+$/g,''); - if(/^\d/.test(name))name='P_'+name; - return name.slice(0,100); -} -async function reloadFor(item){ - if((item.vault_kind||'user')==='user')return loadUserVault(); - if(repoSelect.value===ALL_REPOS||repoSelect.value===item.repository)return loadRepoVault(); -} - -function renderList(target,items,emptyText){ - target.replaceChildren(); - if(!items.length){ - target.append(node('div',emptyText,'empty')); - return; - } - for(const item of items){ - const card=node('article','', 'connection'); - const top=node('div','', 'cardtop'),left=node('div',''); - left.append(node('div','🍊 '+(item.secret_name||item.name||item.provider||'Connection'),'title')); - left.append(node('div',[item.provider,item.account,item.credential_type].filter(Boolean).join(' · '),'account')); - const status=node('div',item.available?'active ✓':'revoked','badge '+(item.available?'active':'revoked')); - top.append(left,status);card.append(top); - - card.append( - node('div',item.vault_kind==='repository'?'📦 '+item.repository:'👤 My vault','where'), - node('div','••••••••••••••••','mask') - ); - - const scopes=node('div',''); - for(const scope of item.scopes||[])scopes.append(node('span',scope,'scope')); - if(!(item.scopes||[]).length)scopes.append(node('span','No capabilities recorded','meta')); - card.append(scopes,node('div','Updated '+fmt(item.updated_at)+' · Expires '+(item.expires||'—'),'meta')); - if(item.notes)card.append(node('div',item.notes,'meta')); - - const actions=node('div','', 'actions'); - const rotate=node('button',item.available?'Rotate':'Add new value'); - const revoke=node('button','Revoke'); - const remove=node('button','Delete','danger'); - rotate.type=revoke.type=remove.type='button';revoke.disabled=!item.available; - actions.append(rotate,revoke,remove);card.append(actions); - - const rotateBox=node('div','', 'rotatebox'); - const value=document.createElement('input'); - value.type='password';value.autocomplete='new-password';value.spellcheck=false;value.placeholder='New value'; - const save=node('button','Save','primary');save.type='button'; - rotateBox.append(value,save);card.append(rotateBox); - - rotate.onclick=()=>{rotateBox.classList.toggle('open');if(rotateBox.classList.contains('open'))value.focus()}; - save.onclick=async()=>{ - if(!value.value)return; - save.disabled=true; - try{await rotateConnection(item,value.value);value.value='';await reloadFor(item)} - catch(error){alert(error.message)} - finally{save.disabled=false} - }; - revoke.onclick=async()=>{ - if(!confirm('Revoke this connection? Its metadata will remain.'))return; - revoke.disabled=true; - try{await revokeConnection(item);await reloadFor(item)} - catch(error){alert(error.message)} - finally{revoke.disabled=false} - }; - remove.onclick=async()=>{ - if(!confirm('Delete this Persimmon and its stored value?'))return; - remove.disabled=true; - try{await deleteConnection(item);await reloadFor(item)} - catch(error){alert(error.message)} - finally{remove.disabled=false} - }; - target.append(card); - } -} - -function setSignedOut(){ - $('userIdentity').textContent='Signed out'; - $('signOutButton').hidden=true; - $('addButton').hidden=true; -} - -function setSignedIn(login){ - $('userIdentity').textContent=login?'Signed in as '+login:'Signed in'; - $('signOutButton').hidden=false; - $('addButton').hidden=false; -} - -async function loadIdentity(){ - try{ - const data=await loadSession(); - setSignedIn(data.login||''); - return true; - }catch(error){ - if(error.status===401){setSignedOut();return false} - $('userIdentity').textContent='Identity unavailable'; - return false; - } -} - -async function loadUserVault(){ - setState(userState,'Loading…'); - try{ - const data=await listUser(); - const items=data.connections||[]; - renderList(userList,items,'No Persimmons yet. Use + Add to store your first useful connection.'); - setState(userState,items.length?items.length+' in your vault':'','good'); - }catch(error){ - if(error.status===401){ - const wrap=node('div','', 'empty emptyCta'); - const signIn=node('button','Sign in to use your vault.','ctaOutline'); - signIn.type='button'; - signIn.onclick=beginSignIn; - wrap.append(signIn); - userList.replaceChildren(wrap); - setState(userState,''); - return; +function extensionMessage(type, owner, extra = {}) { + return new Promise(resolve => { + const timer = setTimeout(() => resolve(null), type === 'persimmons.juice.v1' ? 180000 : 2500); + try { + chrome.runtime.sendMessage(EXTENSION_ID, {type, owner, ...extra}, result => { + const failed = chrome.runtime.lastError; + clearTimeout(timer); + resolve(failed ? null : result); + }); + } catch { + clearTimeout(timer); + resolve(null); } - const wrap=node('div','', 'empty emptyCta'); - wrap.append(node('div','Unable to load your vault.','meta')); - const retry=node('button','Retry','ctaOutline'); - retry.type='button'; - retry.onclick=refreshEverything; - wrap.append(retry); - userList.replaceChildren(wrap); - setState(userState,'Unavailable','bad'); - } -} - -async function loadRepoVault(){ - const repository=repoSelect.value; - repoList.replaceChildren(); - if(!repository){setState(repoState,'Choose a repository to see its Persimmons.');return} - - if(repository===ALL_REPOS){ - if(!repos.length){ - renderList(repoList,[],'No writable repository vaults are available.'); - setState(repoState,'0 writable repositories','good'); - return; - } - setState(repoState,'Loading all repository vaults…'); - const items=[]; - const failed=[]; - const queue=[...repos]; - const workers=Array.from({length:Math.min(6,queue.length)},async()=>{ - while(queue.length){ - const repo=queue.shift(); - try{ - const data=await listRepository(repo.full_name); - items.push(...(data.connections||[])); - }catch(error){ - failed.push({repository:repo.full_name,error}); - } - } - }); - await Promise.all(workers); - items.sort((a,b)=>String(b.updated_at||b.created_at||'').localeCompare(String(a.updated_at||a.created_at||''))); - renderList(repoList,items,'No Persimmons in your repository vaults yet.'); - if(failed.length){ - setState(repoState,items.length+' Persimmon'+(items.length===1?'':'s')+' across '+repos.length+' repos · '+failed.length+' vault'+(failed.length===1?'':'s')+' unavailable','bad'); - }else{ - setState(repoState,items.length+' Persimmon'+(items.length===1?'':'s')+' across '+repos.length+' repository vault'+(repos.length===1?'':'s'),'good'); - } - return; - } - - setState(repoState,'Loading…'); - try{ - const data=await listRepository(repository); - const items=data.connections||[]; - renderList(repoList,items,'No Persimmons in this repository yet.'); - setState(repoState,items.length?items.length+' in '+repository:'Empty vault','good'); - }catch(error){ - repoList.replaceChildren(node('div','Unable to load this repository vault.','empty')); - setState(repoState,'Unavailable','bad'); - } -} - -function fillRepoSelect(select,current='',includeAll=false){ - select.replaceChildren(); - if(includeAll)select.append(new Option('All repository vaults',ALL_REPOS)); - else select.append(new Option('Choose a repository…','')); - for(const repo of repos)select.append(new Option(repo.full_name,repo.full_name)); - if(includeAll){ - select.value=current&&repos.some(x=>x.full_name===current)?current:ALL_REPOS; - }else if(current&&repos.some(x=>x.full_name===current)){ - select.value=current; - } -} -async function loadRepositories(){ - setState(repoState,'Discovering writable repositories…'); - try{ - const data=await loadRepositoryOptions(); - repos=data.repositories||[]; - repoSelect.disabled=false; - fillRepoSelect(repoSelect,repoSelect.value,true); - fillRepoSelect(composerRepo,flow.repository,false); - await loadRepoVault(); - }catch(error){ - repos=[];fillRepoSelect(repoSelect,'',true);fillRepoSelect(composerRepo,'',false); - if(error.status===401){ - repoList.replaceChildren(); - repoSelect.disabled=true; - setState(repoState,''); - return; - } - repoSelect.disabled=false; - setState(repoState,'Repository discovery unavailable.','bad'); - } -} - -function stopAuthPoll(){ - if(authPoll){clearInterval(authPoll);authPoll=null} -} - -function openAuthWindow(){ - const width=520,height=680; - const left=Math.max(0,(window.screenX||0)+((window.outerWidth||screen.width)-width)/2); - const top=Math.max(0,(window.screenY||0)+((window.outerHeight||screen.height)-height)/2); - authPopup=window.open( - 'https://teahub.xyz/user/login?redirect_to=%2Ftea%2Fchat%2Fpersimmons%2Fauth-complete', - 'persimmons-auth', - 'popup=yes,width='+width+',height='+height+',left='+Math.round(left)+',top='+Math.round(top) - ); - if(!authPopup){ - setState(userState,'Pop-up blocked. Allow pop-ups and try again.','bad'); - return; - } - setState(userState,'Waiting for sign-in…'); - authPopup.focus(); - - stopAuthPoll(); - const started=Date.now(); - authPoll=setInterval(async()=>{ - try{ - const data=await loadSession(); - if(data&&data.login){ - stopAuthPoll(); - try{authPopup&&authPopup.close()}catch{} - authPopup=null; - setSignedIn(data.login); - setState(userState,''); - await Promise.all([loadRepositories(),loadUserVault()]); - return; - } - }catch(error){ - if(error.status!==401){ - setState(userState,'Sign-in check unavailable. Try again.','bad'); - } - } - - if(Date.now()-started>120000){ - stopAuthPoll(); - authPopup=null; - setState(userState,'Sign-in timed out. Try again.'); - } - },700); -} - -async function beginSignIn(){ - setState(userState,'Checking sign-in…'); - try{ - const data=await loadSession(); - if(data&&data.login){ - setSignedIn(data.login); - setState(userState,''); - await Promise.all([loadRepositories(),loadUserVault()]); - return; - } - }catch(error){ - if(error.status!==401){ - setState(userState,'Sign-in check unavailable. Try again.','bad'); - return; - } - } - setState(userState,''); - openAuthWindow(); -} - -const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); - -async function waitForSignedOut(){ - for(let attempt=0;attempt<15;attempt++){ - await sleep(200); - try{ - await loadSession(); - }catch(error){ - if(error.status===401)return true; - } - } - return false; -} - -async function signOut(){ - const button=$('signOutButton'); - button.disabled=true; - setState(userState,'Signing out…'); - try{ - await fetch('https://teahub.xyz/user/logout',{ - credentials:'include', - mode:'no-cors', - cache:'no-store' - }); - if(!await waitForSignedOut())throw new Error('Sign out did not complete. Try again.'); - setSignedOut(); - await Promise.all([loadRepositories(),loadUserVault()]); - }catch(error){ - setState(userState,'Sign out failed. Try again.','bad'); - }finally{ - button.disabled=false; - } -} - -function resetComposer(){ - flow={step:1,provider:'',vault_kind:'',repository:''}; - document.querySelectorAll('.choice').forEach(x=>x.classList.remove('selected')); - $('connectionName').value='';$('accountInput').value='';$('valueInput').value=''; - $('expiresInput').value='';$('notesInput').value='';$('typeInput').value='Password'; - $('composerRepoField').hidden=true;composerRepo.value=''; - setState(composerState,'');showStep(1); -} -function openComposer(){resetComposer();scrim.hidden=false} -function closeComposer(){$('valueInput').value='';scrim.hidden=true} -function showStep(step){ - flow.step=step; - $('step1').hidden=step!==1;$('step2').hidden=step!==2;$('step3').hidden=step!==3; - backButton.hidden=step===1;nextButton.hidden=step===3;saveButton.hidden=step!==3; - $('stepSummary').textContent={1:'What are you connecting?',2:'Where should it be available?',3:'Add only what this connection needs.'}[step]; - updateNext();if(step===3)prepareDetails(); -} -function updateNext(){ - if(flow.step===1)nextButton.disabled=!flow.provider; - if(flow.step===2)nextButton.disabled=!flow.vault_kind||(flow.vault_kind==='repository'&&!composerRepo.value); -} -function selectChoice(selector,clicked){ - document.querySelectorAll(selector).forEach(x=>x.classList.toggle('selected',x===clicked)); -} - -document.querySelectorAll('.provider').forEach(button=>{ - button.onclick=()=>{flow.provider=button.dataset.provider;selectChoice('.provider',button);updateNext()}; -}); -document.querySelectorAll('.vault').forEach(button=>{ - button.onclick=()=>{ - flow.vault_kind=button.dataset.vault;selectChoice('.vault',button); - $('composerRepoField').hidden=flow.vault_kind!=='repository'; - if(flow.vault_kind!=='repository'){flow.repository='';composerRepo.value=''} - updateNext(); - }; -}); -composerRepo.onchange=()=>{flow.repository=composerRepo.value;updateNext()}; - -function prepareDetails(){ - const cfg=providers[flow.provider]; - $('connectionName').placeholder=flow.provider==='Other'?'e.g. MY_PASSWORD':flow.provider.toUpperCase()+'_TOKEN'; - $('accountLabel').textContent=cfg.account;$('accountInput').placeholder=cfg.accountPlaceholder; - $('valueLabel').textContent=cfg.value;$('typeField').hidden=flow.provider!=='Other'; - if(flow.provider!=='Other')$('typeInput').value=cfg.type; - $('scopeChoices').replaceChildren();$('scopeField').hidden=!cfg.scopes.length; - cfg.scopes.forEach((scope,index)=>{ - const wrap=node('span','', 'check'),input=document.createElement('input'),label=document.createElement('label'); - input.type='checkbox';input.id='scope_'+index;input.value=scope; - label.htmlFor=input.id;label.textContent=scope;wrap.append(input,label);$('scopeChoices').append(wrap); }); } -const selectedScopes=()=>[...$('scopeChoices').querySelectorAll('input:checked')].map(x=>x.value); -nextButton.onclick=()=>{ - if(flow.step===1&&flow.provider)return showStep(2); - if(flow.step===2&&flow.vault_kind&&(flow.vault_kind!=='repository'||composerRepo.value)){ - flow.repository=composerRepo.value;return showStep(3); - } -}; -backButton.onclick=()=>showStep(Math.max(1,flow.step-1)); +const extensionStatus = owner => extensionMessage('persimmons.connection.v1', owner); +const extensionInventory = owner => extensionMessage('persimmons.inventory.v1', owner); -saveButton.onclick=async()=>{ - const value=$('valueInput').value; - const name=normalizeSecretName($('connectionName').value); - if(!name){setState(composerState,'Name this Persimmon first.','bad');$('connectionName').focus();return} - if(!value){setState(composerState,'Enter the new value.','bad');return} - $('connectionName').value=name; - const cfg=providers[flow.provider]; - const metadata={ - name, - provider:flow.provider, - account:$('accountInput').value.trim(), - credential_type:flow.provider==='Other'?$('typeInput').value:cfg.type, - scopes:selectedScopes(), - vault_kind:flow.vault_kind, - expires:$('expiresInput').value.trim(), - notes:$('notesInput').value.trim() - }; - if(flow.vault_kind==='repository')metadata.repository=flow.repository; - saveButton.disabled=true;setState(composerState,'Saving…'); - try{ - await createConnection(metadata,value); - $('valueInput').value=''; - const savedVault=flow.vault_kind,savedRepo=flow.repository; - closeComposer();await loadUserVault(); - if(savedVault==='repository'){repoSelect.value=savedRepo;await loadRepoVault()} - }catch(error){setState(composerState,error.message,'bad')} - finally{saveButton.disabled=false} -}; - -async function refreshEverything(){ - await Promise.all([loadIdentity(),loadRepositories(),loadUserVault()]); +async function latestVersion() { + try { + const response = await fetch('/assets/version.json', {cache: 'no-store'}); + return response.ok ? (await response.json()).version : null; + } catch { return null; } } -$('connectionName').addEventListener('blur',()=>{$('connectionName').value=normalizeSecretName($('connectionName').value)}); -$('addButton').onclick=openComposer;$('closeComposer').onclick=closeComposer; -$('refreshAll').onclick=refreshEverything;$('signOutButton').onclick=signOut;repoSelect.onchange=loadRepoVault; -scrim.addEventListener('click',event=>{if(event.target===scrim)closeComposer()}); -document.addEventListener('keydown',event=>{ - if(event.key==='Escape'&&!scrim.hidden)closeComposer(); -}); +async function refresh() { + if (dragging || juiceBusy) return; + const current = ++generation; + const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}); + if (current !== generation) return; + if (!response.ok && response.status !== 401) throw new Error(); -refreshEverything(); + const signedIn = response.ok; + const identity = signedIn ? await response.json() : null; + const owner = identity?.owner || identity?.login?.toLowerCase() || identity?.user?.login?.toLowerCase() || identity?.name?.toLowerCase(); + const [state, latest, vault, named] = await Promise.all([ + extensionStatus(owner), + latestVersion(), + signedIn + ? fetch('/api/vault-index', {credentials: 'same-origin', cache: 'no-store'}) + .then(async response => response.ok ? response.json() : null) + .catch(() => null) + : Promise.resolve(null), + signedIn ? fetch('/api/vaults', {credentials: 'same-origin', cache: 'no-store'}) + .then(async response => response.ok ? response.json() : null).catch(() => null) : null + ]); + if (current !== generation) return; + const decrypted = state?.unlocked ? await extensionInventory(owner) : null; + if (current !== generation || dragging || juiceBusy) return; + + $('signInLink').hidden = signedIn; + $('signOutButton').hidden = !signedIn; + $('addCredential').hidden = !signedIn || !state?.connected || !state?.initialized; + $('userState').hidden = true; + $('userState').textContent = ''; + const displayName = identity?.name || identity?.login || identity?.user?.login || owner || ''; + $('userIdentity').textContent = signedIn ? 'Signed in as ' + displayName : ''; + currentOwner = owner; currentState = state; + if (!await renderVaults(signedIn, named, current)) return; + if (current !== generation || dragging || juiceBusy) return; + + const stale = !!state && updateState(state.version, latest) === 'Update available'; + $('updateExtension').hidden = !signedIn || !stale; + $('createAccount').hidden = signedIn; + $('getExtension').hidden = !signedIn || !!state; + + if (!signedIn) { + $('vaultSummary').hidden = true; + $('onboarding').hidden = false; + $('onboardingHelp').textContent = 'Sign in or create an account to use your encrypted vault.'; + $('vaultItems').replaceChildren(); + return; + } + + const linked = state?.connected === true; + const ready = linked && state.initialized; + $('vaultSummary').hidden = !ready; + $('onboarding').hidden = ready; + $('extensionState').textContent = state?.unlocked ? '🔓' : '🔒'; + $('extensionState').ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked'; + $('extensionState').title = $('extensionState').ariaLabel; + $('extensionHelp').textContent = state?.unlocked ? 'Drag a credential to a named vault to create Juice.' : 'Open 🍊 to unlock. Choose a timed auto-lock in Settings for Juice.'; + $('onboardingHelp').textContent = + !state ? 'Install Persimmons in this browser.' : + !linked ? 'Open 🍊 and choose Link vault.' : + !state.initialized ? 'Open 🍊 and choose a vault passphrase.' : + ''; + + const inventory = $('vaultItems'); + inventory.replaceChildren(); + const serverItems = Array.isArray(vault?.items) ? vault.items : null; + const extensionItems = Array.isArray(decrypted?.items) ? decrypted.items : null; + const sourceItems = serverItems || extensionItems; + const vaultAvailable = Array.isArray(sourceItems); + const items = vaultAvailable ? sourceItems.filter(item => { + try { return /^[0-9a-f]{32}$/.test(item.id) && new URL(item.origin).protocol === 'https:'; } + catch { return false; } + }) : []; + $('vaultCount').textContent = !vaultAvailable ? 'Credential list unavailable' : + items.length === 1 ? '1 saved credential' : items.length + ' saved credentials'; + masterItems.clear(); + for (const item of items) masterItems.set(item.id, item); + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = vaultAvailable ? + 'No saved credentials yet. Add one from the site where you use it.' : + 'Credentials could not be loaded. The page will retry automatically.'; + inventory.append(empty); + } else { + const usernames = new Map((decrypted?.items || []).map(item => [item.id, item.username])); + for (const item of items) { + const url = new URL(item.origin); + const username = item.username || usernames.get(item.id); + const card = document.createElement('article'); + card.className = 'connection'; + card.dataset.sourceId = item.id; + card.draggable = !!(state?.connected && state?.initialized && state?.unlocked); + card.ondragstart = event => { + dragging = true; + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('application/x-persimmons-source', item.id); + }; + card.ondragend = () => { dragging = false; }; + + const top = document.createElement('div'); + top.className = 'cardtop'; + const description = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'title'; + name.textContent = '🔑 ' + (username || url.hostname); + const label = document.createElement('div'); + label.className = 'account'; + label.textContent = username ? 'Website credential' : 'Credential · ID ' + item.id.slice(-6).toUpperCase(); + description.append(name, label); + const status = document.createElement('span'); + status.className = 'badge'; + status.textContent = state?.unlocked ? '🔓' : '🔒'; + status.ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked'; + status.title = status.ariaLabel; + top.append(description, status); + + const source = document.createElement('div'); + source.className = 'credentialSource'; + const sourceLabel = document.createElement('span'); + sourceLabel.textContent = 'Source'; + const sourceLink = document.createElement('a'); + sourceLink.href = item.origin; + sourceLink.target = '_blank'; + sourceLink.rel = 'noreferrer'; + sourceLink.textContent = item.origin; + source.append(sourceLabel, sourceLink); + + const mask = document.createElement('div'); + mask.className = 'mask'; + mask.textContent = '••••••••••••••••'; + const capability = document.createElement('span'); + capability.className = 'scope'; + capability.textContent = 'Fill only on ' + url.hostname; + const metadata = document.createElement('div'); + metadata.className = 'meta credentialId'; + metadata.textContent = 'ID ' + item.id.slice(-6); + + const actions = document.createElement('div'); + actions.className = 'actions'; + const open = document.createElement('a'); + open.className = 'buttonLink'; + open.href = item.origin; + open.target = '_blank'; + open.rel = 'noreferrer'; + open.textContent = 'Open site'; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'danger'; + remove.textContent = 'Delete'; + remove.onclick = async () => { + if (!confirm('Delete this saved credential? This cannot be undone.')) return; + remove.disabled = true; + try { + const deleted = await fetch('/api/vault-index/' + item.id, { + method: 'DELETE', credentials: 'same-origin', headers: {'X-Requested-With': 'Persimmons'} + }); + if (!deleted.ok) throw new Error(); + await refresh(); + } catch { + remove.disabled = false; + $('userState').hidden = false; + $('userState').textContent = 'Delete unavailable. Retry shortly.'; + } + }; + actions.append(open, remove); + card.append(top, source, mask, capability, metadata, actions); + inventory.append(card); + } + } +} + +async function renderVaults(signedIn, result, current) { + const contents = new Map(); + if (signedIn && Array.isArray(result?.vaults)) await Promise.all(result.vaults.map(async vault => { + try { contents.set(vault.id, await pageJuice('list', vault)); } catch {} + })); + // Resolve all network reads before touching cards. Never replace a drag target mid-gesture. + if (current !== generation || dragging || juiceBusy) return false; + $('namedVaults').hidden = !signedIn; + const list = $('namedVaultList'); + list.replaceChildren(); + if (!signedIn) return true; + if (!Array.isArray(result?.vaults) || !result.vaults.length) { + const empty = document.createElement('p'); + empty.className = 'empty'; + empty.textContent = Array.isArray(result?.vaults) ? 'No named vaults yet. Create a private vault when you need one.' : 'Vaults could not be loaded. Retrying shortly.'; + list.append(empty); + return true; + } + for (const vault of result.vaults) { + const card = document.createElement('article'); + card.className = 'connection namedVaultCard'; + card.dataset.vaultId = vault.id; + card.ondragover = event => { + if (!currentState?.connected || !currentState?.initialized || !currentState?.unlocked || + !event.dataTransfer.types.includes('application/x-persimmons-source')) return; + event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; card.classList.add('juiceHover'); + }; + card.ondragleave = event => { if (!card.contains(event.relatedTarget)) card.classList.remove('juiceHover'); }; + card.ondrop = event => { + event.preventDefault(); dragging = false; card.classList.remove('juiceHover'); + const item = masterItems.get(event.dataTransfer.getData('application/x-persimmons-source')); + if (item && currentState?.connected && currentState?.initialized && currentState?.unlocked) openJuice(item, vault); + }; + + + const top = document.createElement('div'); + top.className = 'cardtop'; + const description = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'title'; + name.textContent = '📦 ' + vault.name; + const owner = document.createElement('div'); + owner.className = 'account'; + owner.textContent = vault.owner + '/' + vault.name; + description.append(name, owner); + + const badge = document.createElement('span'); + badge.className = 'badge active'; + badge.textContent = 'private ✓'; + top.append(description, badge); + + const meta = document.createElement('div'); + meta.className = 'namedVaultMeta'; + const state = document.createElement('span'); + state.className = 'namedVaultBadge'; + const juiceCount = contents.get(vault.id)?.items.length; + state.textContent = juiceCount ? juiceCount + ' Juice' : vault.empty ? 'Empty vault' : 'Vault'; + meta.append(state); + + const actions = document.createElement('div'); + actions.className = 'namedVaultActions'; + const open = document.createElement('button'); + open.type = 'button'; + open.textContent = 'Open vault'; + + const body = document.createElement('div'); + body.className = 'namedVaultBody'; + body.hidden = !openVaults.has(vault.owner + '/' + vault.name); + const bodyTitle = document.createElement('strong'); + bodyTitle.textContent = vault.name; + const bodyText = document.createElement('span'); + bodyText.textContent = 'Drop an unlocked Master credential here to create Juice.'; + body.append(bodyTitle, bodyText); + try { + const result = contents.get(vault.id); + for (const item of result.items) body.append(juiceCard(item, vault)); + if (result.items.length) bodyText.remove(); + } catch { bodyText.textContent = 'Juice metadata unavailable. Retry shortly.'; } + + + open.textContent = body.hidden ? 'Open vault' : 'Close vault'; + open.onclick = () => { + const key = vault.owner + '/' + vault.name; + if (openVaults.has(key)) openVaults.delete(key); + else openVaults.add(key); + body.hidden = !openVaults.has(key); + open.textContent = body.hidden ? 'Open vault' : 'Close vault'; + }; + actions.append(open); + + card.append(top, meta, actions, body); + list.append(card); + } + return true; +} + +async function pageJuice(operation, vault, extra = {}) { + const response = await fetch('/api/juice/' + operation, {method: 'POST', credentials: 'same-origin', cache: 'no-store', + headers: {'Content-Type': 'application/json', 'X-Vault-Owner': currentOwner, 'X-Requested-With': 'Persimmons'}, + body: JSON.stringify({target: {id: vault.id, name: vault.name}, ...extra})}); + if (!response.ok) throw new Error(); + return response.json(); +} + +function juiceCard(item, vault) { + const card = document.createElement('article'); + card.className = 'juiceCard'; card.dataset.juiceId = item.id; + const title = document.createElement('strong'); + title.textContent = '💧 Gitea · ' + item.source_username + '@' + new URL(item.source_origin).hostname; + const details = document.createElement('p'); + details.textContent = item.scopes.join(', ') + ' · ' + item.status + ' · ' + item.created_at; + const boundary = document.createElement('p'); + boundary.textContent = 'Provider API-class scopes; not restricted to this vault or a single repository.'; + const status = document.createElement('p'); + status.textContent = 'Target secret: ' + item.target_status + ' · Provider token: ' + item.provider_status; + if (item.status === 'partial-failure' || item.status === 'pending') status.textContent += ' · ANDON: cleanup may be required. Retry Revoke.'; + const actions = document.createElement('div'); actions.className = 'actions'; + for (const action of ['rotate', 'revoke']) { + const button = document.createElement('button'); button.textContent = action === 'rotate' ? 'Rotate' : 'Revoke'; + button.disabled = action === 'rotate' && (item.status !== 'active' || !currentState?.unlocked); + if (action === 'revoke' && item.status === 'revoked') button.disabled = true; + button.onclick = async () => { + juiceBusy = true; button.disabled = true; + try { + if (action === 'revoke') await pageJuice('remove', vault, {id: item.id}); + const result = await extensionMessage('persimmons.juice.v1', currentOwner, + {action, target: {id: vault.id, name: vault.name}, id: item.id}); + if (!result && action === 'revoke') { + status.textContent = 'ANDON: Target secret removed; provider revocation unconfirmed. Connect and unlock the source to retry.'; + } else if (!result?.ok) { + status.textContent = (action === 'revoke' ? 'ANDON: Target secret removed; provider cleanup unconfirmed. ' : '') + + (result?.message || 'ANDON: Operation outcome unknown. Refresh and inspect Juice before retrying.'); + } else { + openVaults.add(vault.owner + '/' + vault.name); + juiceBusy = false; await refresh(); + } + } catch { status.textContent = 'ANDON: Operation outcome unknown. Inspect Juice before retrying.'; } + finally { juiceBusy = false; button.disabled = false; } + }; + actions.append(button); + } + card.append(title, details, boundary, status, actions); + return card; +} + +function openJuice(item, vault) { + const dialog = ensureDialog(), body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.style.cssText = 'font-size:20px;overflow-wrap:anywhere;line-height:1.3'; + title.textContent = 'Juice ' + (item.username || 'credential') + '@' + new URL(item.origin).hostname + ' → ' + vault.name; + body.append(title); + if (item.origin !== 'https://teahub.xyz') { + const note = document.createElement('p'); note.textContent = 'Juice adapter not available yet.'; body.append(note); + } else { + const form = document.createElement('form'); + form.innerHTML = ` +

Scopes apply across the source account’s accessible resources in that API category. They are not limited to this vault or one repository. No automatic expiry.

+

`; + form.onsubmit = async event => { + event.preventDefault(); juiceBusy = true; form.querySelector('button').disabled = true; + const result = await extensionMessage('persimmons.juice.v1', currentOwner, {action: 'create', source_id: item.id, + target: {id: vault.id, name: vault.name}, policy: {scopes: [form.elements.scope.value]}}); + juiceBusy = false; + if (result?.ok) { + openVaults.add(vault.owner + '/' + vault.name); dialog.close(); await refresh(); + } else { + form.querySelector('[role=status]').textContent = result?.message || 'ANDON: Outcome unknown. Close and inspect Juice before retrying.'; + form.querySelector('button').disabled = false; + } + }; + body.append(form); + } + dialog.showModal(); +} + +function openNewVault() { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = 'New vault'; + const form = document.createElement('form'); + form.innerHTML = ` +

Creates an empty private vault owned by you. Spaces become hyphens.

+

`; + form.onsubmit = async event => { + event.preventDefault(); + form.querySelector('button').disabled = true; + try { + const response = await fetch('/api/vaults', {method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json', 'X-Requested-With': 'Persimmons'}, + body: JSON.stringify({name: form.querySelector('#vaultName').value})}); + const result = await response.json(); + if (!response.ok) { + form.querySelector('#vaultStatus').textContent = result.error || 'Vault creation unavailable. Retry shortly.'; + form.querySelector('button').disabled = false; + return; + } + dialog.close(); + await refresh(); + } catch { + form.querySelector('#vaultStatus').textContent = 'Vault creation unavailable. Refresh the page before retrying.'; + form.querySelector('button').disabled = false; + } + }; + body.append(title, form); + dialog.showModal(); +} + +function ensureDialog() { + let dialog = $('siteModal'); + if (dialog) return dialog; + dialog = document.createElement('dialog'); + dialog.id = 'siteModal'; + dialog.style.cssText = 'border:0;border-radius:16px;padding:0;width:min(460px,calc(100vw - 32px));box-shadow:0 24px 80px rgba(36,26,20,.24)'; + dialog.innerHTML = '
'; + document.body.append(dialog); + dialog.addEventListener('close', () => refresh().catch(failed)); + return dialog; +} + +function closeButton(dialog) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '×'; + button.setAttribute('aria-label', 'Close'); + button.style.cssText = 'float:right;border:0;background:none;font-size:22px'; + button.onclick = () => dialog.close(); + return button; +} + +function openAuth(mode = 'login') { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = mode === 'register' ? 'Create account' : 'Sign in'; + const form = document.createElement('form'); + form.autocomplete = 'on'; + form.innerHTML = ` + + + +

`; + form.onsubmit = async event => { + event.preventDefault(); + const password = form.querySelector('#modalPassword'); + const payload = {name: form.querySelector('#modalName').value, password: password.value}; + password.value = ''; + form.querySelector('button').disabled = true; + try { + const response = await fetch('/api/auth/' + mode, { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }); + payload.password = ''; + if (!response.ok) throw new Error(); + dialog.close(); + await refresh(); + } catch { + payload.password = ''; + form.querySelector('#modalStatus').textContent = 'Sign-in unavailable. Check details and retry.'; + form.querySelector('button').disabled = false; + } + }; + body.append(title, form); + dialog.showModal(); +} + +function openInstall(update = false) { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = update ? 'Update Persimmons' : 'Get Persimmons'; + const text = document.createElement('p'); + text.textContent = update ? 'Install the current build into your unpacked extension folder, then reload it in Chromium.' : 'Download the current build, unzip it, then use Chromium → Extensions → Developer mode → Load unpacked.'; + const link = document.createElement('a'); + link.href = '/assets/extension.zip'; + link.className = 'buttonLink primary'; + link.textContent = update ? 'Download current build' : 'Download extension'; + body.append(title, text, link); + dialog.showModal(); +} + +function openAddCredential() { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = 'Add a credential'; + const text = document.createElement('p'); + text.textContent = 'Open the HTTPS site where you use the credential, enter it without submitting, then open 🍊 and choose Save for this site.'; + const note = document.createElement('p'); + note.className = 'hint'; + note.textContent = 'The page updates automatically after the extension saves it.'; + body.append(title, text, note); + dialog.showModal(); +} + +const failed = () => { + $('userState').hidden = false; + $('userState').textContent = 'Service unavailable. Retry shortly.'; + $('extensionState').hidden = false; + $('extensionState').textContent = 'Connection could not be checked.'; +}; + +$('signOutButton').onclick = async () => { + try { + const response = await fetch('/api/logout', {method: 'POST'}); + if (!response.ok) throw new Error(); + await refresh(); + } catch { + $('userState').hidden = false; + $('userState').textContent = 'Sign-out unavailable. Retry shortly.'; + } +}; +$('signInLink').onclick = event => { event.preventDefault(); openAuth('login'); }; +$('createAccount').onclick = () => openAuth('register'); +$('getExtension').onclick = () => openInstall(false); +$('updateExtension').onclick = () => openInstall(true); +$('addCredential').onclick = openAddCredential; +$('newVault').onclick = openNewVault; + +window.addEventListener('pointerdown', event => { + if (event.target.closest?.('.connection[draggable="true"]')) dragging = true; +}, true); +window.addEventListener('pointerup', () => { dragging = false; }, true); +window.addEventListener('pageshow', () => refresh().catch(failed)); +window.addEventListener('focus', () => refresh().catch(failed)); +setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); +refresh().catch(failed); diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..495c2f9 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,554 @@ +import {updateState} from './version.mjs'; + +const $ = id => document.getElementById(id); +const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg'; +let generation = 0, currentOwner, currentState, dragging = false, juiceBusy = false; +const masterItems = new Map(); +const openVaults = new Set(); + +$('signOutButton').hidden = true; +$('signOutButton').textContent = 'Sign out'; +$('userList').innerHTML = ` + +
+

+ + + +
+
`; + +function extensionMessage(type, owner, extra = {}) { + return new Promise(resolve => { + const timer = setTimeout(() => resolve(null), type === 'persimmons.juice.v1' ? 180000 : 2500); + try { + chrome.runtime.sendMessage(EXTENSION_ID, {type, owner, ...extra}, result => { + const failed = chrome.runtime.lastError; + clearTimeout(timer); + resolve(failed ? null : result); + }); + } catch { + clearTimeout(timer); + resolve(null); + } + }); +} + +const extensionStatus = owner => extensionMessage('persimmons.connection.v1', owner); +const extensionInventory = owner => extensionMessage('persimmons.inventory.v1', owner); + +async function latestVersion() { + try { + const response = await fetch('/assets/version.json', {cache: 'no-store'}); + return response.ok ? (await response.json()).version : null; + } catch { return null; } +} + +async function refresh() { + if (dragging || juiceBusy) return; + const current = ++generation; + const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}); + if (current !== generation) return; + if (!response.ok && response.status !== 401) throw new Error(); + + const signedIn = response.ok; + const identity = signedIn ? await response.json() : null; + const owner = identity?.owner || identity?.login?.toLowerCase() || identity?.user?.login?.toLowerCase() || identity?.name?.toLowerCase(); + const [state, latest, vault, named] = await Promise.all([ + extensionStatus(owner), + latestVersion(), + signedIn + ? fetch('/api/vault-index', {credentials: 'same-origin', cache: 'no-store'}) + .then(async response => response.ok ? response.json() : null) + .catch(() => null) + : Promise.resolve(null), + signedIn ? fetch('/api/vaults', {credentials: 'same-origin', cache: 'no-store'}) + .then(async response => response.ok ? response.json() : null).catch(() => null) : null + ]); + if (current !== generation) return; + const decrypted = state?.unlocked ? await extensionInventory(owner) : null; + if (current !== generation || dragging || juiceBusy) return; + + $('signInLink').hidden = signedIn; + $('signOutButton').hidden = !signedIn; + $('addCredential').hidden = !signedIn || !state?.connected || !state?.initialized; + $('userState').hidden = true; + $('userState').textContent = ''; + const displayName = identity?.name || identity?.login || identity?.user?.login || owner || ''; + $('userIdentity').textContent = signedIn ? 'Signed in as ' + displayName : ''; + currentOwner = owner; currentState = state; + if (!await renderVaults(signedIn, named, current)) return; + if (current !== generation || dragging || juiceBusy) return; + + const stale = !!state && updateState(state.version, latest) === 'Update available'; + $('updateExtension').hidden = !signedIn || !stale; + $('createAccount').hidden = signedIn; + $('getExtension').hidden = !signedIn || !!state; + + if (!signedIn) { + $('vaultSummary').hidden = true; + $('onboarding').hidden = false; + $('onboardingHelp').textContent = 'Sign in or create an account to use your encrypted vault.'; + $('vaultItems').replaceChildren(); + return; + } + + const linked = state?.connected === true; + const ready = linked && state.initialized; + $('vaultSummary').hidden = !ready; + $('onboarding').hidden = ready; + $('extensionState').textContent = state?.unlocked ? '🔓' : '🔒'; + $('extensionState').ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked'; + $('extensionState').title = $('extensionState').ariaLabel; + $('extensionHelp').textContent = state?.unlocked ? 'Drag a credential to a named vault to create Juice.' : 'Open 🍊 to unlock. Choose a timed auto-lock in Settings for Juice.'; + $('onboardingHelp').textContent = + !state ? 'Install Persimmons in this browser.' : + !linked ? 'Open 🍊 and choose Link vault.' : + !state.initialized ? 'Open 🍊 and choose a vault passphrase.' : + ''; + + const inventory = $('vaultItems'); + inventory.replaceChildren(); + const serverItems = Array.isArray(vault?.items) ? vault.items : null; + const extensionItems = Array.isArray(decrypted?.items) ? decrypted.items : null; + const sourceItems = serverItems || extensionItems; + const vaultAvailable = Array.isArray(sourceItems); + const items = vaultAvailable ? sourceItems.filter(item => { + try { return /^[0-9a-f]{32}$/.test(item.id) && new URL(item.origin).protocol === 'https:'; } + catch { return false; } + }) : []; + $('vaultCount').textContent = !vaultAvailable ? 'Credential list unavailable' : + items.length === 1 ? '1 saved credential' : items.length + ' saved credentials'; + masterItems.clear(); + for (const item of items) masterItems.set(item.id, item); + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = vaultAvailable ? + 'No saved credentials yet. Add one from the site where you use it.' : + 'Credentials could not be loaded. The page will retry automatically.'; + inventory.append(empty); + } else { + const usernames = new Map((decrypted?.items || []).map(item => [item.id, item.username])); + for (const item of items) { + const url = new URL(item.origin); + const username = item.username || usernames.get(item.id); + const card = document.createElement('article'); + card.className = 'connection'; + card.dataset.sourceId = item.id; + card.draggable = !!(state?.connected && state?.initialized && state?.unlocked); + card.ondragstart = event => { + dragging = true; + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('application/x-persimmons-source', item.id); + }; + card.ondragend = () => { dragging = false; }; + + const top = document.createElement('div'); + top.className = 'cardtop'; + const description = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'title'; + name.textContent = '🔑 ' + (username || url.hostname); + const label = document.createElement('div'); + label.className = 'account'; + label.textContent = username ? 'Website credential' : 'Credential · ID ' + item.id.slice(-6).toUpperCase(); + description.append(name, label); + const status = document.createElement('span'); + status.className = 'badge'; + status.textContent = state?.unlocked ? '🔓' : '🔒'; + status.ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked'; + status.title = status.ariaLabel; + top.append(description, status); + + const source = document.createElement('div'); + source.className = 'credentialSource'; + const sourceLabel = document.createElement('span'); + sourceLabel.textContent = 'Source'; + const sourceLink = document.createElement('a'); + sourceLink.href = item.origin; + sourceLink.target = '_blank'; + sourceLink.rel = 'noreferrer'; + sourceLink.textContent = item.origin; + source.append(sourceLabel, sourceLink); + + const mask = document.createElement('div'); + mask.className = 'mask'; + mask.textContent = '••••••••••••••••'; + const capability = document.createElement('span'); + capability.className = 'scope'; + capability.textContent = 'Fill only on ' + url.hostname; + const metadata = document.createElement('div'); + metadata.className = 'meta credentialId'; + metadata.textContent = 'ID ' + item.id.slice(-6); + + const actions = document.createElement('div'); + actions.className = 'actions'; + const open = document.createElement('a'); + open.className = 'buttonLink'; + open.href = item.origin; + open.target = '_blank'; + open.rel = 'noreferrer'; + open.textContent = 'Open site'; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'danger'; + remove.textContent = 'Delete'; + remove.onclick = async () => { + if (!confirm('Delete this saved credential? This cannot be undone.')) return; + remove.disabled = true; + try { + const deleted = await fetch('/api/vault-index/' + item.id, { + method: 'DELETE', credentials: 'same-origin', headers: {'X-Requested-With': 'Persimmons'} + }); + if (!deleted.ok) throw new Error(); + await refresh(); + } catch { + remove.disabled = false; + $('userState').hidden = false; + $('userState').textContent = 'Delete unavailable. Retry shortly.'; + } + }; + actions.append(open, remove); + card.append(top, source, mask, capability, metadata, actions); + inventory.append(card); + } + } +} + +async function renderVaults(signedIn, result, current) { + const contents = new Map(); + if (signedIn && Array.isArray(result?.vaults)) await Promise.all(result.vaults.map(async vault => { + try { contents.set(vault.id, await pageJuice('list', vault)); } catch {} + })); + // Resolve all network reads before touching cards. Never replace a drag target mid-gesture. + if (current !== generation || dragging || juiceBusy) return false; + $('namedVaults').hidden = !signedIn; + const list = $('namedVaultList'); + list.replaceChildren(); + if (!signedIn) return true; + if (!Array.isArray(result?.vaults) || !result.vaults.length) { + const empty = document.createElement('p'); + empty.className = 'empty'; + empty.textContent = Array.isArray(result?.vaults) ? 'No named vaults yet. Create a private vault when you need one.' : 'Vaults could not be loaded. Retrying shortly.'; + list.append(empty); + return true; + } + for (const vault of result.vaults) { + const card = document.createElement('article'); + card.className = 'connection namedVaultCard'; + card.dataset.vaultId = vault.id; + card.ondragover = event => { + if (!currentState?.connected || !currentState?.initialized || !currentState?.unlocked || + !event.dataTransfer.types.includes('application/x-persimmons-source')) return; + event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; card.classList.add('juiceHover'); + }; + card.ondragleave = event => { if (!card.contains(event.relatedTarget)) card.classList.remove('juiceHover'); }; + card.ondrop = event => { + event.preventDefault(); dragging = false; card.classList.remove('juiceHover'); + const item = masterItems.get(event.dataTransfer.getData('application/x-persimmons-source')); + if (item && currentState?.connected && currentState?.initialized && currentState?.unlocked) openJuice(item, vault); + }; + + + const top = document.createElement('div'); + top.className = 'cardtop'; + const description = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'title'; + name.textContent = '📦 ' + vault.name; + const owner = document.createElement('div'); + owner.className = 'account'; + owner.textContent = vault.owner + '/' + vault.name; + description.append(name, owner); + + const badge = document.createElement('span'); + badge.className = 'badge active'; + badge.textContent = 'private ✓'; + top.append(description, badge); + + const meta = document.createElement('div'); + meta.className = 'namedVaultMeta'; + const state = document.createElement('span'); + state.className = 'namedVaultBadge'; + const juiceCount = contents.get(vault.id)?.items.length; + state.textContent = juiceCount ? juiceCount + ' Juice' : vault.empty ? 'Empty vault' : 'Vault'; + meta.append(state); + + const actions = document.createElement('div'); + actions.className = 'namedVaultActions'; + const open = document.createElement('button'); + open.type = 'button'; + open.textContent = 'Open vault'; + + const body = document.createElement('div'); + body.className = 'namedVaultBody'; + body.hidden = !openVaults.has(vault.owner + '/' + vault.name); + const bodyTitle = document.createElement('strong'); + bodyTitle.textContent = vault.name; + const bodyText = document.createElement('span'); + bodyText.textContent = 'Drop an unlocked Master credential here to create Juice.'; + body.append(bodyTitle, bodyText); + try { + const result = contents.get(vault.id); + for (const item of result.items) body.append(juiceCard(item, vault)); + if (result.items.length) bodyText.remove(); + } catch { bodyText.textContent = 'Juice metadata unavailable. Retry shortly.'; } + + + open.textContent = body.hidden ? 'Open vault' : 'Close vault'; + open.onclick = () => { + const key = vault.owner + '/' + vault.name; + if (openVaults.has(key)) openVaults.delete(key); + else openVaults.add(key); + body.hidden = !openVaults.has(key); + open.textContent = body.hidden ? 'Open vault' : 'Close vault'; + }; + actions.append(open); + + card.append(top, meta, actions, body); + list.append(card); + } + return true; +} + +async function pageJuice(operation, vault, extra = {}) { + const response = await fetch('/api/juice/' + operation, {method: 'POST', credentials: 'same-origin', cache: 'no-store', + headers: {'Content-Type': 'application/json', 'X-Vault-Owner': currentOwner, 'X-Requested-With': 'Persimmons'}, + body: JSON.stringify({target: {id: vault.id, name: vault.name}, ...extra})}); + if (!response.ok) throw new Error(); + return response.json(); +} + +function juiceCard(item, vault) { + const card = document.createElement('article'); + card.className = 'juiceCard'; card.dataset.juiceId = item.id; + const title = document.createElement('strong'); + title.textContent = '💧 Gitea · ' + item.source_username + '@' + new URL(item.source_origin).hostname; + const details = document.createElement('p'); + details.textContent = item.scopes.join(', ') + ' · ' + item.status + ' · ' + item.created_at; + const boundary = document.createElement('p'); + boundary.textContent = 'Provider API-class scopes; not restricted to this vault or a single repository.'; + const status = document.createElement('p'); + status.textContent = 'Target secret: ' + item.target_status + ' · Provider token: ' + item.provider_status; + if (item.status === 'partial-failure' || item.status === 'pending') status.textContent += ' · ANDON: cleanup may be required. Retry Revoke.'; + const actions = document.createElement('div'); actions.className = 'actions'; + for (const action of ['rotate', 'revoke']) { + const button = document.createElement('button'); button.textContent = action === 'rotate' ? 'Rotate' : 'Revoke'; + button.disabled = action === 'rotate' && (item.status !== 'active' || !currentState?.unlocked); + if (action === 'revoke' && item.status === 'revoked') button.disabled = true; + button.onclick = async () => { + juiceBusy = true; button.disabled = true; + try { + if (action === 'revoke') await pageJuice('remove', vault, {id: item.id}); + const result = await extensionMessage('persimmons.juice.v1', currentOwner, + {action, target: {id: vault.id, name: vault.name}, id: item.id}); + if (!result && action === 'revoke') { + status.textContent = 'ANDON: Target secret removed; provider revocation unconfirmed. Connect and unlock the source to retry.'; + } else if (!result?.ok) { + status.textContent = (action === 'revoke' ? 'ANDON: Target secret removed; provider cleanup unconfirmed. ' : '') + + (result?.message || 'ANDON: Operation outcome unknown. Refresh and inspect Juice before retrying.'); + } else { + openVaults.add(vault.owner + '/' + vault.name); + juiceBusy = false; await refresh(); + } + } catch { status.textContent = 'ANDON: Operation outcome unknown. Inspect Juice before retrying.'; } + finally { juiceBusy = false; button.disabled = false; } + }; + actions.append(button); + } + card.append(title, details, boundary, status, actions); + return card; +} + +function openJuice(item, vault) { + const dialog = ensureDialog(), body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.style.cssText = 'font-size:20px;overflow-wrap:anywhere;line-height:1.3'; + title.textContent = 'Juice ' + (item.username || 'credential') + '@' + new URL(item.origin).hostname + ' → ' + vault.name; + body.append(title); + if (item.origin !== 'https://teahub.xyz') { + const note = document.createElement('p'); note.textContent = 'Juice adapter not available yet.'; body.append(note); + } else { + const form = document.createElement('form'); + form.innerHTML = ` +

Scopes apply across the source account’s accessible resources in that API category. They are not limited to this vault or one repository. No automatic expiry.

+

`; + form.onsubmit = async event => { + event.preventDefault(); juiceBusy = true; form.querySelector('button').disabled = true; + const result = await extensionMessage('persimmons.juice.v1', currentOwner, {action: 'create', source_id: item.id, + target: {id: vault.id, name: vault.name}, policy: {scopes: [form.elements.scope.value]}}); + juiceBusy = false; + if (result?.ok) { + openVaults.add(vault.owner + '/' + vault.name); dialog.close(); await refresh(); + } else { + form.querySelector('[role=status]').textContent = result?.message || 'ANDON: Outcome unknown. Close and inspect Juice before retrying.'; + form.querySelector('button').disabled = false; + } + }; + body.append(form); + } + dialog.showModal(); +} + +function openNewVault() { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = 'New vault'; + const form = document.createElement('form'); + form.innerHTML = ` +

Creates an empty private vault owned by you. Spaces become hyphens.

+

`; + form.onsubmit = async event => { + event.preventDefault(); + form.querySelector('button').disabled = true; + try { + const response = await fetch('/api/vaults', {method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json', 'X-Requested-With': 'Persimmons'}, + body: JSON.stringify({name: form.querySelector('#vaultName').value})}); + const result = await response.json(); + if (!response.ok) { + form.querySelector('#vaultStatus').textContent = result.error || 'Vault creation unavailable. Retry shortly.'; + form.querySelector('button').disabled = false; + return; + } + dialog.close(); + await refresh(); + } catch { + form.querySelector('#vaultStatus').textContent = 'Vault creation unavailable. Refresh the page before retrying.'; + form.querySelector('button').disabled = false; + } + }; + body.append(title, form); + dialog.showModal(); +} + +function ensureDialog() { + let dialog = $('siteModal'); + if (dialog) return dialog; + dialog = document.createElement('dialog'); + dialog.id = 'siteModal'; + dialog.style.cssText = 'border:0;border-radius:16px;padding:0;width:min(460px,calc(100vw - 32px));box-shadow:0 24px 80px rgba(36,26,20,.24)'; + dialog.innerHTML = '
'; + document.body.append(dialog); + dialog.addEventListener('close', () => refresh().catch(failed)); + return dialog; +} + +function closeButton(dialog) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '×'; + button.setAttribute('aria-label', 'Close'); + button.style.cssText = 'float:right;border:0;background:none;font-size:22px'; + button.onclick = () => dialog.close(); + return button; +} + +function openAuth(mode = 'login') { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = mode === 'register' ? 'Create account' : 'Sign in'; + const form = document.createElement('form'); + form.autocomplete = 'on'; + form.innerHTML = ` + + + +

`; + form.onsubmit = async event => { + event.preventDefault(); + const password = form.querySelector('#modalPassword'); + const payload = {name: form.querySelector('#modalName').value, password: password.value}; + password.value = ''; + form.querySelector('button').disabled = true; + try { + const response = await fetch('/api/auth/' + mode, { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }); + payload.password = ''; + if (!response.ok) throw new Error(); + dialog.close(); + await refresh(); + } catch { + payload.password = ''; + form.querySelector('#modalStatus').textContent = 'Sign-in unavailable. Check details and retry.'; + form.querySelector('button').disabled = false; + } + }; + body.append(title, form); + dialog.showModal(); +} + +function openInstall(update = false) { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = update ? 'Update Persimmons' : 'Get Persimmons'; + const text = document.createElement('p'); + text.textContent = update ? 'Install the current build into your unpacked extension folder, then reload it in Chromium.' : 'Download the current build, unzip it, then use Chromium → Extensions → Developer mode → Load unpacked.'; + const link = document.createElement('a'); + link.href = '/assets/extension.zip'; + link.className = 'buttonLink primary'; + link.textContent = update ? 'Download current build' : 'Download extension'; + body.append(title, text, link); + dialog.showModal(); +} + +function openAddCredential() { + const dialog = ensureDialog(); + const body = $('siteModalBody'); + body.replaceChildren(closeButton(dialog)); + const title = document.createElement('h2'); + title.textContent = 'Add a credential'; + const text = document.createElement('p'); + text.textContent = 'Open the HTTPS site where you use the credential, enter it without submitting, then open 🍊 and choose Save for this site.'; + const note = document.createElement('p'); + note.className = 'hint'; + note.textContent = 'The page updates automatically after the extension saves it.'; + body.append(title, text, note); + dialog.showModal(); +} + +const failed = () => { + $('userState').hidden = false; + $('userState').textContent = 'Service unavailable. Retry shortly.'; + $('extensionState').hidden = false; + $('extensionState').textContent = 'Connection could not be checked.'; +}; + +$('signOutButton').onclick = async () => { + try { + const response = await fetch('/api/logout', {method: 'POST'}); + if (!response.ok) throw new Error(); + await refresh(); + } catch { + $('userState').hidden = false; + $('userState').textContent = 'Sign-out unavailable. Retry shortly.'; + } +}; +$('signInLink').onclick = event => { event.preventDefault(); openAuth('login'); }; +$('createAccount').onclick = () => openAuth('register'); +$('getExtension').onclick = () => openInstall(false); +$('updateExtension').onclick = () => openInstall(true); +$('addCredential').onclick = openAddCredential; +$('newVault').onclick = openNewVault; + +window.addEventListener('pointerdown', event => { + if (event.target.closest?.('.connection[draggable="true"]')) dragging = true; +}, true); +window.addEventListener('pointerup', () => { dragging = false; }, true); +window.addEventListener('pageshow', () => refresh().catch(failed)); +window.addEventListener('focus', () => refresh().catch(failed)); +setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); +refresh().catch(failed); diff --git a/assets/extension.zip b/assets/extension.zip new file mode 100644 index 0000000000000000000000000000000000000000..d4cb609a2c129c21eeb7f1d9f1a70788ddc7e6f0 GIT binary patch literal 35253 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 Ue^k-{pnN{TKaWxa|MBks16f+ri2wiq literal 0 HcmV?d00001 diff --git a/assets/install.html b/assets/install.html new file mode 100644 index 0000000..47ddf6b --- /dev/null +++ b/assets/install.html @@ -0,0 +1 @@ +Install · Persimmons

Install Persimmons

This MVP installs as an unpacked Chromium extension.

  1. Download the current extension and extract the ZIP. Keep the folder.
  2. Open chrome://extensions, turn on Developer mode, choose Load unpacked, and select the extracted persimmons-extension folder.
  3. Keep Persimmons open, pin 🍊 from your browser’s extensions menu, then open it and choose Link vault. Choose a vault passphrase and press Enter.

Keep this browser profile, extension folder, and passphrase. Vault key sync and recovery are not available.

Return to your vault diff --git a/assets/signin.html b/assets/signin.html new file mode 100644 index 0000000..bc07f2f --- /dev/null +++ b/assets/signin.html @@ -0,0 +1,5 @@ +Sign in · Persimmons + +

🍊 Persimmons

+
+

Back to Persimmons diff --git a/assets/signin.js b/assets/signin.js new file mode 100644 index 0000000..829179c --- /dev/null +++ b/assets/signin.js @@ -0,0 +1,26 @@ +document.getElementById('signin').onsubmit = async event => { + event.preventDefault(); + const form = event.currentTarget; + const status = document.getElementById('status'); + const action = event.submitter.value; + const password = document.getElementById('password'); + const payload = {name: document.getElementById('name').value, password: password.value}; + password.value = ''; + for (const button of form.querySelectorAll('button')) button.disabled = true; + try { + const response = await fetch('/api/auth/' + action, { + method: 'POST', credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }); + payload.password = ''; + if (!response.ok) throw new Error(); + location.assign('/'); + } catch { + payload.password = ''; + status.textContent = 'Sign-in unavailable. Check details and retry.'; + for (const button of form.querySelectorAll('button')) button.disabled = false; + } +}; + +fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}) + .then(response => { if (response.ok) location.replace('/'); }).catch(() => {}); diff --git a/assets/update-extension.py b/assets/update-extension.py new file mode 100644 index 0000000..cfa09f0 --- /dev/null +++ b/assets/update-extension.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Replace a loaded Persimmons folder from the dedicated HTTPS release. + +Run: python3 update-extension.py /path/to/persimmons-extension +Close the popup first; afterward use chrome://extensions -> Reload. +No browser profile, local vault storage, credentials or native host is accessed. +""" +import argparse +import base64 +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import tempfile +import urllib.request +import zipfile + +EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg' +URL = 'https://persimmons.app/assets/extension.zip' +LIMIT = 4 * 1024 * 1024 + + +def identity(manifest): + digest = hashlib.sha256(base64.b64decode(manifest['key'], validate=True)).hexdigest()[:32] + return ''.join(chr(ord('a') + int(c, 16)) for c in digest) + + +def update(folder, raw): + folder = Path(folder).expanduser().absolute() + if folder.is_symlink() or any(p.is_symlink() for p in folder.parents): + raise ValueError('Use a real extension folder, not a symbolic link.') + installed = json.loads((folder / 'manifest.json').read_text()) + if identity(installed) != EXTENSION_ID: + raise ValueError('The installed extension identity does not match.') + files = {} + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + entries = archive.infolist() + if len(entries) > 100 or sum(e.file_size for e in entries) > LIMIT: + raise ValueError('Release exceeds size limit.') + for entry in entries: + path = PurePosixPath(entry.filename) + if (not path.parts or path.parts[0] != 'persimmons-extension' or + '..' in path.parts or '\\' in entry.filename or entry.is_dir() or + (entry.external_attr >> 16) & 0o170000 == 0o120000): + raise ValueError('Unexpected release path.') + relative = Path(*path.parts[1:]) + if relative.suffix not in {'.mjs', '.json', '.html', '.css', '.png'} or relative in files: + raise ValueError('Unexpected release file.') + destination = folder / relative + if destination.is_symlink() or any(p.is_symlink() for p in destination.parents): + raise ValueError('Refusing to replace symbolic links.') + files[relative] = archive.read(entry) + candidate = json.loads(files[Path('manifest.json')]) + if identity(candidate) != EXTENSION_ID or candidate['key'] != installed['key']: + raise ValueError('Release identity does not match; no files replaced.') + version = lambda value: tuple(int(n) for n in value.split('.')) + if version(candidate['version']) < version(installed['version']): + raise ValueError('Refusing an older release.') + # Validate the entire archive before any write. Stage beside the destination + # for atomic per-file replacements; manifest goes last. Retry after interruption. + with tempfile.TemporaryDirectory(prefix='.persimmons-update-', dir=folder.parent) as staging: + stage = Path(staging) + for relative, data in files.items(): + (stage / relative).parent.mkdir(parents=True, exist_ok=True) + (stage / relative).write_bytes(data) + for relative in sorted(files, key=lambda p: (p.name == 'manifest.json', str(p))): + (folder / relative).parent.mkdir(parents=True, exist_ok=True) + os.replace(stage / relative, folder / relative) + return candidate['version'] + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + raise ValueError('Release URL redirected; no files replaced.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('folder', help='Existing folder shown under chrome://extensions -> Details -> Loaded from') + args = parser.parse_args() + try: + with urllib.request.build_opener(NoRedirect).open(URL, timeout=30) as response: + raw = response.read(LIMIT + 1) + if len(raw) > LIMIT: + raise ValueError('Release exceeds size limit.') + release = update(args.folder, raw) + print(f'Installed files for {release}. Now click Reload at chrome://extensions, or Reload installed files in the popup.') + except Exception: + parser.exit(1, 'Update failed safely. Check the folder, network and release; retry before reloading.\n') + + +if __name__ == '__main__': + main() diff --git a/assets/update.html b/assets/update.html new file mode 100644 index 0000000..20d8890 --- /dev/null +++ b/assets/update.html @@ -0,0 +1,10 @@ +Update Persimmons + +

Update your extension

+

Keep the same folder and browser profile. Your local wrapped vault key stays in the browser.

+
  1. Close the vault popup. Find your existing folder under chrome://extensions → Persimmons → Details → Loaded from.
  2. +
  3. Save the update helper once. With Python 3 installed, run:

    python3 update-extension.py "/path/to/persimmons-extension"

    On Windows, use py in place of python3. Repeat this same command for future releases; no manual ZIP extraction or folder selection.
  4. +
  5. Click Reload on the Persimmons card at chrome://extensions. Version 0.3.4 also offers Update extension → Reload installed files in the popup.
  6. +
  7. Reopen 🍊. Connection is checked automatically; enter your passphrase to unlock.
+
Prefer to replace files yourself?

Download the latest ZIP and copy its extension files over the same loaded folder, then Reload. Do not uninstall the extension or clear its storage.

+
Why a helper?

An unpacked extension cannot overwrite its installed source directory itself. Reload rereads the files already on disk; it does not download an update. This helper downloads only from persimmons.app over HTTPS, checks the pinned identity and replaces extension files. It never accesses your browser profile or vault. Close the popup during replacement; if interrupted, rerun before Reload.

Automatic Chrome Web Store updates are a future distribution step, pending approval and identity verification.

diff --git a/assets/version.json b/assets/version.json new file mode 100644 index 0000000..17100ea --- /dev/null +++ b/assets/version.json @@ -0,0 +1 @@ +{"version":"0.6.0"} diff --git a/assets/version.mjs b/assets/version.mjs new file mode 100644 index 0000000..391191c --- /dev/null +++ b/assets/version.mjs @@ -0,0 +1,10 @@ +export function updateState(installed, latest) { + const parse = value => typeof value === 'string' && /^\d+(\.\d+){0,3}$/.test(value) ? value.split('.').map(Number) : null; + const a = parse(installed), b = parse(latest); + if (!a || !b) return ''; + for (let i = 0; i < 4; i++) { + if ((a[i] || 0) < (b[i] || 0)) return 'Update available'; + if ((a[i] || 0) > (b[i] || 0)) return ''; + } + return ''; +} diff --git a/backend/build.py b/backend/build.py index 3997b08..62fcfcf 100644 --- a/backend/build.py +++ b/backend/build.py @@ -1,56 +1,28 @@ -"""Extract accepted assets without changing the Tea fallback; deterministic ZIP.""" +"""Build the source-owned static release and deterministic extension ZIP.""" from pathlib import Path -import zipfile import json -import re +import shutil +import zipfile + ROOT = Path(__file__).resolve().parent REPO = ROOT.parent -(ROOT / 'human_vault.py').write_bytes((REPO / 'tea_mcp/human_vault.py').read_bytes()) -html = (REPO / 'tea/web/🍊/index.html').read_text() -html = html.replace(' + - \ No newline at end of file + diff --git a/signin b/signin new file mode 100644 index 0000000..bc07f2f --- /dev/null +++ b/signin @@ -0,0 +1,5 @@ +Sign in · Persimmons + +

🍊 Persimmons

+
+

Back to Persimmons diff --git a/tests/test_capability_link.py b/tests/test_capability_link.py new file mode 100644 index 0000000..c21ed86 --- /dev/null +++ b/tests/test_capability_link.py @@ -0,0 +1,67 @@ +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import AsyncMock, patch + +import httpx +from starlette.applications import Starlette + +from backend.shared import human_vault as vault + + +class CapabilityLinkTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.temp = tempfile.TemporaryDirectory() + self.path = Path(self.temp.name) / 'vault.sqlite' + self.env = patch.dict(os.environ, PERSIMMONS_HUMAN_VAULT_DB=str(self.path)) + self.env.start() + self.actor = AsyncMock(return_value='fixture') + app = Starlette(routes=vault.routes(self.actor)) + self.client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url='https://teahub.xyz', + headers={'Cookie': 'session=fixture'}, + ) + + async def asyncTearDown(self): + await self.client.aclose() + self.env.stop() + self.temp.cleanup() + + async def test_signed_in_page_mints_owner_capability_used_without_cookie(self): + response = await self.client.post( + '/chat/persimmons/human-vault/link', + headers={'Origin': 'https://persimmons.app', 'X-Requested-With': 'Persimmons'}, + ) + self.assertEqual(response.status_code, 200) + linked = response.json() + self.assertEqual(linked['owner'], 'fixture') + self.assertRegex(linked['capability'], vault.CAPABILITY) + self.assertNotIn(linked['capability'].encode(), self.path.read_bytes()) + + self.actor.reset_mock() + result = await self.client.post( + vault.PREFIX + '/read', + headers={ + 'Origin': vault.EXTENSION_ORIGIN, + 'Cookie': '', + 'X-Vault-Owner': 'fixture', + 'X-Vault-Capability': linked['capability'], + }, + ) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.json(), {'owner': 'fixture', 'items': []}) + self.actor.assert_not_awaited() + + async def test_link_is_exact_origin_cookie_and_request_header_gated(self): + endpoint = '/chat/persimmons/human-vault/link' + for headers in ( + {'Origin': 'https://evil.example', 'X-Requested-With': 'Persimmons'}, + {'Origin': 'https://persimmons.app'}, + {'Origin': 'https://persimmons.app', 'X-Requested-With': 'Persimmons', 'Cookie': ''}, + ): + self.assertIn((await self.client.post(endpoint, headers=headers)).status_code, (401, 403)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_persimmons_update.py b/tests/test_persimmons_update.py index 4213837..1fac053 100644 --- a/tests/test_persimmons_update.py +++ b/tests/test_persimmons_update.py @@ -7,7 +7,7 @@ import unittest import zipfile ROOT = Path(__file__).resolve().parents[1] -spec = importlib.util.spec_from_file_location('updater', ROOT / 'persimmons/web/update-extension.py') +spec = importlib.util.spec_from_file_location('updater', ROOT / 'backend/web/update-extension.py') updater = importlib.util.module_from_spec(spec) spec.loader.exec_module(updater) @@ -16,10 +16,10 @@ class UpdateTests(unittest.TestCase): def test_same_folder_preserves_identity_and_unrelated_files(self): with tempfile.TemporaryDirectory() as name: folder = Path(name) - manifest = (ROOT / 'persimmons-extension/manifest.json').read_bytes() + manifest = (ROOT / 'extension/manifest.json').read_bytes() (folder / 'manifest.json').write_bytes(manifest) (folder / 'keep.txt').write_text('unrelated') - raw = (ROOT / 'persimmons/web/extension.zip').read_bytes() + raw = (ROOT / 'assets/extension.zip').read_bytes() self.assertEqual(updater.update(folder, raw), json.loads(manifest)['version']) self.assertEqual((folder / 'keep.txt').read_text(), 'unrelated') self.assertEqual((folder / 'manifest.json').read_bytes(), manifest) diff --git a/tests/test_persimmons_ux.py b/tests/test_persimmons_ux.py index 88d3088..8c8f618 100644 --- a/tests/test_persimmons_ux.py +++ b/tests/test_persimmons_ux.py @@ -12,14 +12,14 @@ ROOT = Path(__file__).resolve().parents[1] class UxTests(unittest.TestCase): def test_packaged_icons_and_pinned_identity(self): import base64 - with zipfile.ZipFile(ROOT / 'persimmons/web/extension.zip') as archive: + with zipfile.ZipFile(ROOT / 'assets/extension.zip') as archive: manifest = json.loads(archive.read('persimmons-extension/manifest.json')) digest = hashlib.sha256(base64.b64decode(manifest['key'])).hexdigest()[:32] self.assertEqual(''.join(chr(ord('a') + int(c, 16)) for c in digest), 'kiolgjoohhgdohcafgelhikpgkbmckbg') self.assertEqual( manifest['version'], - json.loads((ROOT / 'persimmons-extension/manifest.json').read_text())['version'], + json.loads((ROOT / 'extension/manifest.json').read_text())['version'], ) for size in (16, 32, 48, 128): name = manifest['icons'][str(size)] @@ -27,10 +27,12 @@ class UxTests(unittest.TestCase): raw = archive.read('persimmons-extension/' + name) self.assertEqual(raw[:8], b'\x89PNG\r\n\x1a\n') self.assertEqual(struct.unpack('>II', raw[16:24]), (size, size)) - self.assertEqual(raw, (ROOT / 'persimmons-extension' / name).read_bytes()) + self.assertEqual(raw, (ROOT / 'extension' / name).read_bytes()) self.assertFalse(any('/tests/' in name for name in archive.namelist())) self.assertEqual(manifest['externally_connectable'], {'matches': ['https://persimmons.app/*']}) self.assertNotIn('content_scripts', manifest) + self.assertNotIn('optional_host_permissions', manifest) + self.assertEqual(manifest['host_permissions'], ['https://teahub.xyz/*']) self.assertEqual(manifest['permissions'], ['activeTab', 'scripting', 'storage']) popup = archive.read('persimmons-extension/popup.html').decode() self.assertIn('Save autofilled login', popup) @@ -42,14 +44,14 @@ class UxTests(unittest.TestCase): self.assertNotIn('juice-panel', popup) def test_footer_css_and_hero_copy(self): - for name in ('tea/web/🍊/index.html', 'persimmons/web/index.html'): + for name in ('index.html',): html = (ROOT / name).read_text() self.assertIn('🍊 → 💧 → 🤖', html) self.assertIn('>put plainly', html) self.assertNotIn('Persimmons are', html) self.assertNotIn('without paperwork', html) self.assertNotIn('
', html) - hosted = (ROOT / 'persimmons/web/index.html').read_text() + hosted = (ROOT / 'index.html').read_text() self.assertIn('Master vault', hosted) self.assertIn('id="namedVaults"', hosted) self.assertIn('+ New vault', hosted) @@ -70,7 +72,7 @@ class UxTests(unittest.TestCase): if 'id' in attrs: self.nodes[attrs['id']] = attrs page = Parser() - page.feed((ROOT / 'persimmons/web/signin.html').read_text()) + page.feed((ROOT / 'backend/web/signin.html').read_text()) for name, value in [('signin', 'on'), ('name', 'username'), ('password', 'current-password')]: self.assertEqual(page.nodes[name]['autocomplete'], value) self.assertEqual(page.nodes['password']['type'], 'password')