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; } } // One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension) // takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so // the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus), // and the poll simply skips while one is running. let refreshing = null, refreshAgain = false; function refresh() { if (refreshing) { refreshAgain = true; return refreshing; } refreshing = (async () => { try { do { refreshAgain = false; await refreshOnce(); } while (refreshAgain); } finally { refreshing = null; } })(); return refreshing; } async function refreshOnce() { 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 (!refreshing && !document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); refresh().catch(failed);