diff --git a/backend/web/app.js b/backend/web/app.js
new file mode 100644
index 0000000..99545d6
--- /dev/null
+++ b/backend/web/app.js
@@ -0,0 +1,550 @@
+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;
+ 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 = '';
+ $('userIdentity').textContent = signedIn ? 'Signed in as ' + identity.name : '';
+ 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 vaultAvailable = Array.isArray(vault?.items);
+ const items = vaultAvailable ? vault.items.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);