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 = `
+
+
+
+
Create account
+
Get extension
+
Update available
+
+
`;
-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 = `Gitea PAT scopesRead repositories Read issues Read and write issues
+ 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.
+ Create Juice
`;
+ 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 = `Vault name
+ Creates an empty private vault owned by you. Spaces become hyphens.
+ Create vault
`;
+ 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 = `
+ Username
+ Password
+ ${mode === 'register' ? 'Create account' : 'Sign in'}
+
`;
+ 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 = `
+
+
+
+
Create account
+
Get extension
+
Update available
+
+
`;
+
+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 = `Gitea PAT scopesRead repositories Read issues Read and write issues
+ 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.
+ Create Juice
`;
+ 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 = `Vault name
+ Creates an empty private vault owned by you. Spaces become hyphens.
+ Create vault
`;
+ 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 = `
+ Username
+ Password
+ ${mode === 'register' ? 'Create account' : 'Sign in'}
+
`;
+ 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 0000000..d4cb609
Binary files /dev/null and b/assets/extension.zip differ
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.
Download the current extension and extract the ZIP. Keep the folder.Open chrome://extensions , turn on Developer mode , choose Load unpacked , and select the extracted persimmons-extension folder. 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.
+Close the vault popup. Find your existing folder under chrome://extensions → Persimmons → Details → Loaded from.
+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.
+Click Reload on the Persimmons card at chrome://extensions. Version 0.3.4 also offers Update extension → Reload installed files in the popup.
+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('
+