openvibers/openvibe.network Commits
Account deletion: real re-authentication and a Keep my account banner (#592)
Changes
@@@ -37,6 +37,7 @@ or as an anonymous account.| `prompt=none` | No page: a code for the account this browser is signed in to, or `error=login_required`. Sites use it to sign people in quietly when they open a site. Refused for third-party apps (`interaction_required`). || `prompt=select_account` | The chooser, even when `login_hint` names a saved account. || `prompt=login` | The sign-in form ("use another account"). |++| `reauth=1` | A real re-authentication: the password form every time, even when this browser is already signed in, with `login_hint` prefilled and `return` as the way back. The new session's `auth_time` is now, so it satisfies the 10-minute fresh check (account deletion, merge, the owner's AI settings, Locker reset/forget). Use this rather than `add_account=1` for those. || `login_hint` | The account to continue as: a username (`@` optional) or an account id. Saved in this browser: continues as it. Not saved: the sign-in form with the username filled in. || `nonce` | Echoed in the `id_token` (scope `openid`). |@@@ -636,6 +636,7 @@<!-- Shared scripts --><script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script><script src="/shared/navbar.js"></script>++<script src="/js/account-safety.js" defer></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script><script src="/shared/ov-forms.js" defer></script>@@@ -276,6 +276,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}<div id="ov-footer"></div><script src="/shared/navbar.js"></script>++<script src="/js/account-safety.js" defer></script><script src="/shared/footer.js"></script><script src="/shared/shipped.js" defer></script><script src="/shared/boost.js" data-main="#main" defer></script>nnew file mode 100644@@@ -0,0 +1,175 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — account safety helpers, on every page:++ - OpenVibeReauth: the one re-authentication path. A fresh sign-in action (deleting the account, merging two++ accounts, the owner's AI key/money settings, Locker reset/forget) sends people to++ /login?reauth=1&return=<where to come back to>. That page always asks for the password, even when a session++ already exists; add_account=1 reuses the existing session and leaves auth_time alone, which is exactly the++ loop this replaces.++ - OpenVibeDeletion: while a deletion is scheduled, a banner on every page says when, with one button that++ keeps the account (DELETE /api/v1/account/deletion). The sign-in page shows the same choice before it++ continues (see login.js).++++ Classic script, no modules: it is loaded on the static pages and the server-rendered ones alike.++ ═══════════════════════════════════════════════════════════════ */++(function (root) {++ 'use strict';++ const REAUTH_WINDOW_S = 10 * 60;++ const BANNER_ID = 'ov-deletion-banner';++++ function base() {++ return (root && root.location && root.location.origin) || '';++ }++++ function cookieToken() {++ try {++ const m = String(root.document && root.document.cookie || '').match(/(?:^|; )ov_token=([^;]*)/);++ return m ? decodeURIComponent(m[1]) : null;++ } catch { return null; }++ }++++ function storedAccounts() {++ try { return JSON.parse(root.localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }++ }++++ /** The active person's token: the same rule as /my's getAuthToken (never an anonymous session). */++ function sessionToken() {++ try {++ const activeId = root.localStorage.getItem('openvibe_active_account');++ if (activeId === 'anon') return null;++ const account = storedAccounts().find((a) => String(a.id) === String(activeId));++ if (account && account.is_anon) return null;++ return root.localStorage.getItem('ov_token') || (account && account.token) || cookieToken() || null;++ } catch { return null; }++ }++++ /** The one re-authentication URL. `account` prefills the username; pass '' to leave it blank (merge). */++ function reauthUrl(returnTo, options) {++ const params = new URLSearchParams();++ params.set('reauth', '1');++ if (options && Object.prototype.hasOwnProperty.call(options, 'account')) params.set('login_hint', String(options.account || ''));++ params.set('return', String(returnTo || '/'));++ return '/login?' + params.toString();++ }++++ function goReauth(returnTo, options) {++ if (root.location) root.location.href = reauthUrl(returnTo, options);++ }++++ function formatWhen(value) {++ const d = new Date(value);++ if (!Number.isFinite(d.getTime())) return 'the date in your account settings';++ try { return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); } catch { return d.toISOString(); }++ }++++ function bannerText(deleteAfter) {++ return `Your account will be deleted on ${formatWhen(deleteAfter)}.`;++ }++++ async function deletionStatus(token) {++ const t = token || sessionToken();++ if (!t) return null;++ const res = await fetch(base() + '/api/v1/account/deletion', {++ headers: { Authorization: 'Bearer ' + t },++ credentials: 'include',++ });++ if (!res.ok) return null;++ const body = await res.json().catch(() => ({}));++ return body && body.deletion ? body.deletion : null;++ }++++ async function cancelDeletion(token) {++ const t = token || sessionToken();++ if (!t) return { ok: false, status: 401, body: {} };++ const res = await fetch(base() + '/api/v1/account/deletion', {++ method: 'DELETE',++ headers: { Authorization: 'Bearer ' + t },++ credentials: 'include',++ });++ let body = {};++ try { body = await res.json(); } catch { /* no body */ }++ return { ok: res.ok, status: res.status, body };++ }++++ function removeBanner() {++ const doc = root && root.document;++ if (!doc) return;++ const el = doc.getElementById(BANNER_ID);++ if (el) el.remove();++ if (doc.body) doc.body.style.paddingBottom = '';++ }++++ function buttonStyle() {++ return 'appearance:none;border:0;border-radius:999px;padding:8px 16px;background:var(--accent,#3b82f6);'++ + 'color:#fff;font:600 13px/1.2 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;'++ + 'box-shadow:0 2px 8px rgba(59,130,246,.35);white-space:nowrap';++ }++++ function showBanner(deletion) {++ const doc = root && root.document;++ if (!doc || !doc.body) return;++ let el = doc.getElementById(BANNER_ID);++ if (!el) {++ el = doc.createElement('div');++ el.id = BANNER_ID;++ el.setAttribute('role', 'status');++ el.setAttribute('aria-live', 'polite');++ el.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:10050;display:flex;align-items:center;'++ + 'justify-content:center;gap:12px;flex-wrap:wrap;padding:12px 18px;'++ + 'padding-bottom:max(12px,env(safe-area-inset-bottom));background:var(--bg-secondary,#101828);'++ + 'border-top:1px solid var(--border,#2c3d5c);box-shadow:0 -8px 28px rgba(0,0,0,.35);'++ + 'font:14px/1.4 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text-primary,#e6edf7);'++ + 'text-align:center';++ const text = doc.createElement('span');++ text.setAttribute('data-ov-deletion-text', '');++ const button = doc.createElement('button');++ button.type = 'button';++ button.id = 'ov-deletion-keep';++ button.textContent = 'Keep my account';++ button.style.cssText = buttonStyle();++ button.addEventListener('click', async () => {++ button.disabled = true;++ button.textContent = 'Keeping…';++ const result = await cancelDeletion();++ if (!result.ok && result.status !== 404) {++ button.disabled = false;++ button.textContent = 'Keep my account';++ text.textContent = (result.body && result.body.detail) || 'That did not work. Try again.';++ return;++ }++ text.textContent = 'Kept. Your account stays.';++ setTimeout(removeBanner, 900);++ try { root.dispatchEvent(new root.CustomEvent('ov:deletion-cancelled', { detail: result.body })); } catch { /* no CustomEvent */ }++ });++ el.append(text, button);++ doc.body.appendChild(el);++ const height = Number(el.offsetHeight) || 0;++ if (height) doc.body.style.paddingBottom = height + 'px';++ }++ const text = el.querySelector('[data-ov-deletion-text]');++ if (text) text.textContent = bannerText(deletion.delete_after);++ }++++ async function refreshBanner() {++ if (!sessionToken()) return null;++ const deletion = await deletionStatus();++ if (deletion) showBanner(deletion);++ else removeBanner();++ return deletion;++ }++++ const api = {++ REAUTH_WINDOW_S, BANNER_ID, sessionToken, reauthUrl, goReauth, formatWhen, bannerText,++ deletionStatus, cancelDeletion, refreshBanner, showBanner, removeBanner,++ };++ if (root) {++ root.OpenVibeReauth = { url: reauthUrl, go: goReauth, WINDOW_S: REAUTH_WINDOW_S };++ root.OpenVibeDeletion = api;++ }++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++++ if (root && root.document) {++ const start = () => { refreshBanner().catch(() => { /* signed out, offline: no banner */ }); };++ if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', start, { once: true });++ else start();++ }++})(typeof window !== 'undefined' ? window : globalThis);@@@ -698,7 +698,7 @@ async function loadPlatformAi() {function paiShell(inner) {return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'+ '<p class="muted">Every service\'s AI settings in one place. Keys are sealed and write-only: once saved, nothing here can show one again. Money and key changes ask you to confirm it is you.</p></div>'-- + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?return=%2Fadmin%23ai">Sign in again</a></div>'++ + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?reauth=1&return=%2Fadmin%23ai">Sign in again</a></div>'+ inner;}@@@ -6,6 +6,18 @@const API_BASE = window.location.origin;const params = new URLSearchParams(window.location.search);const isOAuth = params.has('client_id') && params.has('redirect_uri');++// ?reauth=1 is the one real re-authentication path (deleting the account, merging, the owner's AI settings,++// Locker reset/forget). It always shows the password form, even when this browser has a saved session:++// add_account=1 reuses that session and leaves auth_time alone, which is what made "Sign in again" loop.++const isReauth = params.get('reauth') === '1';++/** The username the re-authentication form starts with: login_hint when given ('' means blank), else the active account. */++function reauthUsername(accounts) {++ if (params.has('login_hint')) return String(params.get('login_hint') || '').trim().replace(/^@/, '');++ let activeId = null;++ try { activeId = localStorage.getItem('openvibe_active_account'); } catch { /* private mode */ }++ const account = accounts.find((a) => String(a.id) === String(activeId)) || accounts[0];++ return account ? String(account.username || '') : '';++}// The app's name comes from the Network (GET /oauth/client-info), never from this page's URL:// a crafted link could otherwise claim to be any app. Until it answers, show nothing.let clientName = '';@@@ -366,6 +378,62 @@ async function selectAccount(account) {localStorage.setItem('openvibe_active_account', account.id);setAuthCookie(validToken);++ await guardScheduledDeletion(validToken, () => continueAsAccount(account, validToken));++}++++// ── A scheduled deletion is offered before any sign-in continues ─────────────++// Every sign-in path (a saved account, or the password form) asks the API whether a deletion is scheduled.++// If it is, the person sees the same "Keep my account" choice as the banner before being sent on; the choice++// cancels it through DELETE /api/v1/account/deletion and then continues the sign-in.++let keepNext = null;++function offerKeep(token, deletion, next) {++ keepNext = next;++ const note = document.getElementById('keep-note');++ const when = window.OpenVibeDeletion ? window.OpenVibeDeletion.bannerText(deletion.delete_after) : `Your account will be deleted on ${new Date(deletion.delete_after).toLocaleString()}.`;++ if (note) note.textContent = `${when} It stays usable until then.`;++ const keep = document.getElementById('keep-account-btn');++ const cont = document.getElementById('keep-continue');++ if (keep) {++ keep.disabled = false;++ keep.textContent = 'Keep my account';++ keep.onclick = async () => {++ keep.disabled = true;++ keep.textContent = 'Keeping…';++ let result = { ok: false, body: {} };++ try {++ result = window.OpenVibeDeletion ? await window.OpenVibeDeletion.cancelDeletion(token) : result;++ } catch { /* offline */ }++ if (!result.ok && result.status !== 404) {++ keep.disabled = false;++ keep.textContent = 'Keep my account';++ toast((result.body && result.body.detail) || 'Could not keep the account. Try again.', 'error');++ return;++ }++ keep.textContent = 'Kept. Your account stays.';++ const go = keepNext;++ keepNext = null;++ setTimeout(() => { if (go) go(); }, 650);++ };++ }++ if (cont) cont.onclick = (e) => {++ if (e) e.preventDefault();++ const go = keepNext;++ keepNext = null;++ if (go) go();++ };++ setActivePanel('panel-keep', { showTabs: false, showExtras: false });++}++++async function guardScheduledDeletion(token, next) {++ if (!token || !window.OpenVibeDeletion || typeof window.OpenVibeDeletion.deletionStatus !== 'function') return next();++ let deletion = null;++ try { deletion = await window.OpenVibeDeletion.deletionStatus(token); } catch { /* offline: the sign-in still goes on */ }++ if (!deletion) return next();++ offerKeep(token, deletion, next);++}++++/** The part of a saved-account sign-in that leaves this page (an OAuth app, or back to the site). */++async function continueAsAccount(account, validToken) {if (isOAuth) {// Call /oauth/confirm to get the redirect URLtry {@@@ -548,6 +616,20 @@ async function handleLogin(e) {setAuthCookie(data.token);saveAccount(data.user, data.token);++ // If a deletion is scheduled, the same "Keep my account" choice comes before this sign-in continues.++ await guardScheduledDeletion(data.token, () => finishSignIn(data));++ } catch (err) {++ showMsg('login-msg', err.message, 'error');++ btn.textContent = isReauth ? 'Sign In Again →' : 'Sign In →';++ btn.classList.remove('loading');++ } finally {++ oauthConfirmInFlight = false;++ }++}++++/** Leave the sign-in page for an OAuth app, or back where the person came from (after the password form). */++async function finishSignIn(data) {++ try {if (isOAuth) {oauthConfirmInFlight = true;// Confirm OAuth with the new token@@@ -585,8 +667,11 @@ async function handleLogin(e) {}} catch (err) {showMsg('login-msg', err.message, 'error');-- btn.textContent = 'Sign In →';-- btn.classList.remove('loading');++ const btn = document.getElementById('login-submit');++ if (btn) {++ btn.textContent = isReauth ? 'Sign In Again →' : 'Sign In →';++ btn.classList.remove('loading');++ }} finally {oauthConfirmInFlight = false;}@@@ -806,6 +891,27 @@ async function continueAnonymous() {return;}++ // A real re-authentication: the password form every time, even with saved accounts and even for an app's++ // silent continue. add_account=1 would hand back the existing session with its old auth_time.++ if (isReauth) {++ showAuthForm('login');++ const username = reauthUsername(accounts);++ if (username) prefillUsername(username);++ else {++ const field = document.querySelector('#login-form input[name="username"]');++ if (field) field.focus();++ }++ const tabs = document.getElementById('auth-tabs');++ if (tabs) tabs.style.display = 'none';++ const note = document.getElementById('reauth-note');++ if (note) note.hidden = false;++ const submit = document.getElementById('login-submit');++ if (submit) submit.textContent = 'Sign In Again →';++ const back = document.getElementById('login-back');++ if (back) back.style.display = 'none';++ return;++ }++// A site's account menu (the shared navbar): switch to one of this browser's accounts, then back through the site's// sign-in, which continues as it. An account not saved here (or signed out) gets its sign-in, the name filled in.const switchTo = params.get('switch_to');@@@ -165,7 +165,9 @@ function showSection(id, btn) {function openInitialSection() {const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];-- const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';++ // #delete is the re-authentication return target: the delete step lives in the accounts section.++ const hash = window.location.hash.replace(/^#/, '');++ const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines'].includes(routeSection)) {return showSection('anon');@@@ -8,6 +8,27 @@// site (the report says so); here Network can pass it in.const OV_WALLET_MENU = { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] };++// /my#delete is the return target of the re-authentication path (and a deep link): it opens the accounts++// section and puts the cursor in the delete confirmation field. Read before core.js rewrites the hash.++const OV_DELETE_DEEP_LINK = window.location.hash === '#delete';++/** Seconds left in the 10-minute fresh sign-in window, from the token's auth_time (OIDC; readable, not secret). */++function freshSignInLeft() {++ const token = getAuthToken();++ if (!token) return 0;++ try {++ const part = String(token.split('.')[1] || '').replace(/-/g, '+').replace(/_/g, '/');++ const payload = JSON.parse(atob(part + '='.repeat((4 - part.length % 4) % 4)));++ const at = Number(payload && payload.auth_time);++ return Number.isFinite(at) ? Math.max(0, Math.floor(at + 600 - Date.now() / 1000)) : 0;++ } catch { return 0; }++}++function reauthForDeletion() {++ if (window.OpenVibeReauth) window.OpenVibeReauth.go('/my#delete');++ else window.location.href = '/login?reauth=1&return=' + encodeURIComponent('/my#delete');++}++// The banner's Keep my account has already cancelled it; refresh this page's card too.++window.addEventListener('ov:deletion-cancelled', () => { if (document.getElementById('delete-body')) renderDeletion(); });++// Load user dataasync function loadUser() {// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)@@@ -272,7 +293,10 @@ function renderMerge() {try {const r = await apiFetch('/api/v1/account/merge/intents', { method: 'POST', body: '{}' });localStorage.setItem(MERGE_KEY, JSON.stringify({ intent: r.intent, expires_at: r.expires_at, into_id: currentUser.id, into_username: currentUser.username }));-- addAccount();++ // A real re-authentication for the other account: add_account=1 would hand back an existing++ // session with its old auth_time, which the merge's fresh check refuses.++ if (window.OpenVibeReauth) window.OpenVibeReauth.go('/my#accounts', { account: '' });++ else window.location.href = '/login?reauth=1&login_hint=&return=' + encodeURIComponent('/my#accounts');} catch (err) { toast('Could not start the merge: ' + err.message, 'error'); }};row.appendChild(b); body.appendChild(row); return;@@@ -282,9 +306,13 @@ function renderMerge() {cancel.type = 'button';cancel.onclick = () => { localStorage.removeItem(MERGE_KEY); renderMerge(); };if (String(currentUser.id) === String(pending.into_id)) {-- body.appendChild(el('p', `Now sign in to the account you want to fold into @${pending.into_username} (within ${mins} min).`));++ body.appendChild(el('p', `Now sign in to the account you want to fold into @${pending.into_username} (within ${mins} min). It asks for that account's password even if it is already signed in, because the merge needs a real sign-in from the last 10 minutes.`));const go = el('button', 'Sign in to the other account', 'btn btn-primary');-- go.type = 'button'; go.onclick = addAccount;++ go.type = 'button';++ go.onclick = () => {++ if (window.OpenVibeReauth) window.OpenVibeReauth.go('/my#accounts', { account: '' });++ else window.location.href = '/login?reauth=1&login_hint=&return=' + encodeURIComponent('/my#accounts');++ };row.append(go, cancel); body.appendChild(row); return;}body.appendChild(el('p', `Fold @${currentUser.username} into @${pending.into_username}? Everything above moves to @${pending.into_username}; @${currentUser.username} then signs in as @${pending.into_username}.`));@@@ -301,9 +329,23 @@ function renderMerge() {OpenVibeAccountSwitcher.switchTo(pending.into_id);} catch (err) {doIt.disabled = false;-- if (err.status === 401) toast(`Sign in to @${currentUser.username} again (a fresh sign-in is needed), then merge.`, 'error');-- else if (err.status === 410) { localStorage.removeItem(MERGE_KEY); toast('That merge request expired. Start again from the account you keep.', 'error'); renderMerge(); }-- else toast('The merge did not happen: ' + err.message, 'error');++ if (err.status === 401) {++ toast(`Sign in to @${currentUser.username} again (a fresh sign-in is needed), then merge.`, 'error');++ if (!row.querySelector('.ov-merge-reauth')) {++ const again = el('button', 'Sign in again', 'btn btn-outline ov-merge-reauth');++ again.type = 'button';++ again.onclick = () => {++ const back = '/my#accounts';++ if (window.OpenVibeReauth) window.OpenVibeReauth.go(back, { account: currentUser.username });++ else window.location.href = `/login?reauth=1&login_hint=${encodeURIComponent(currentUser.username)}&return=${encodeURIComponent(back)}`;++ };++ row.insertBefore(again, doIt);++ }++ } else if (err.status === 410) {++ localStorage.removeItem(MERGE_KEY);++ toast('That merge request expired. Start again from the account you keep.', 'error');++ renderMerge();++ } else toast('The merge did not happen: ' + err.message, 'error');}};row.append(doIt, cancel); body.appendChild(row);@@@ -392,18 +434,28 @@ async function renderDeletion() {const d = r.body && r.body.deletion;const row = ovEl('div', '', 'btn-row');if (d) {-- body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Download your data before then if you want a copy.`));++ body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Until then it stays fully usable, and you can keep it at any time before that date. Download your data before then if you want a copy.`));const keep = ovEl('button', 'Keep my account', 'btn btn-primary');keep.type = 'button';keep.onclick = async () => {keep.disabled = true;const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });-- if (c.ok) toast('Deletion cancelled. Your account stays.', 'success');++ if (c.ok) {++ if (window.OpenVibeDeletion) window.OpenVibeDeletion.removeBanner();++ toast('Kept. Your account stays.', 'success');++ }renderDeletion();};row.appendChild(keep); body.appendChild(row);return;}++ body.appendChild(ovEl('p', 'Your account stays usable for 30 days after you ask, and you can keep it at any time before then. On the day, your account and what the OpenVibe services keep about you are erased; that cannot be undone.'));++ const freshLeft = freshSignInLeft();++ const freshLine = ovEl('p', freshLeft > 0++ ? `You signed in again just now: you have about ${Math.max(1, Math.ceil(freshLeft / 60))} minute${Math.ceil(freshLeft / 60) === 1 ? '' : 's'} to confirm.`++ : 'For your security, this asks you to sign in again first. A fresh sign-in gives you 10 minutes to confirm.');++ freshLine.id = 'delete-fresh-line';++ body.appendChild(freshLine);const field = ovEl('div', '', 'form-field');const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);const input = ovEl('input');@@@ -412,23 +464,40 @@ async function renderDeletion() {field.append(label, input);const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');go.type = 'button';++ const addReauth = () => {++ if (row.querySelector('.ov-delete-reauth')) return;++ const again = ovEl('button', 'Sign in again', 'btn btn-outline ov-delete-reauth');++ again.type = 'button';++ again.onclick = reauthForDeletion;++ row.appendChild(again);++ };++ if (freshLeft <= 0) addReauth();go.onclick = async () => {go.disabled = true;const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });go.disabled = false;if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {-- toast('To make sure it is you, sign in again, then come back here and confirm.', 'error');-- const again = ovEl('button', 'Sign in again', 'btn btn-outline');-- again.type = 'button'; again.onclick = addAccount;-- if (!row.querySelector('.btn-outline')) row.appendChild(again);++ toast('To make sure it is you, sign in again. You will then have 10 minutes to confirm, and come straight back here.', 'error');++ addReauth();return;}if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }-- toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. You can cancel until then.`, 'success');++ if (window.OpenVibeDeletion) window.OpenVibeDeletion.showBanner(s.body);++ toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. It stays usable until then, and you can keep it at any time before that date.`, 'success');renderDeletion();};row.appendChild(go);body.append(field, row);++ if (OV_DELETE_DEEP_LINK && !body.dataset.ovDeleteFocused) {++ body.dataset.ovDeleteFocused = '1';++ setTimeout(() => {++ try {++ const card = document.getElementById('delete-card');++ if (card && card.scrollIntoView) card.scrollIntoView({ block: 'center' });++ input.focus();++ } catch { /* */ }++ }, 60);++ }}function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }@@@ -71,6 +71,7 @@<!-- Login Form --><div class="form-panel" id="panel-login">++ <p class="panel-note" id="reauth-note" hidden>For your security, sign in again with your password. You then have 10 minutes to finish.</p><div class="message" id="login-msg"></div><form id="login-form" onsubmit="handleLogin(event)"><div class="field">@@@ -90,6 +91,13 @@<a class="back-link" id="login-back" onclick="showChooser()" style="display:none">← Back to saved accounts</a></div>++ <!-- A scheduled deletion: the same choice as the banner, before this sign-in continues -->++ <div class="form-panel" id="panel-keep">++ <p class="panel-note" id="keep-note"></p>++ <button type="button" class="submit-btn" id="keep-account-btn">Keep my account</button>++ <a class="inline-link" id="keep-continue" href="#" onclick="return false">Continue signing in</a>++ </div>++<!-- Register Form --><div class="form-panel" id="panel-register"><div class="message" id="register-msg"></div>@@@ -182,6 +190,7 @@<div class="login-toast" id="toast"></div><script src="/js/login.js" defer></script>++ <script src="/js/account-safety.js" defer></script><script src="/shared/ov-mark.js" async></script><script src="https://openvibe.live/live-notify.js" async></script></body>@@@ -258,7 +258,7 @@</div><div class="card" id="delete-card" style="margin-top:16px"><h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Delete your account</h3>-- <p>Your account is deleted 30 days after you ask, and you can cancel any time before then; until that date everything works as usual. On the day, your account here is erased and every service erases what it keeps about you: messages, posts, follows, media and settings. Replies other people wrote under your posts stay, without your name. Payment records are kept without your name, as the law requires. After that it cannot be undone, and your username becomes free for someone else.</p>++ <p>Your account stays usable for 30 days after you ask, and you can keep it at any time before then. On the day, your account here is erased and every service erases what it keeps about you: messages, posts, follows, media and settings. Replies other people wrote under your posts stay, without your name. Payment records are kept without your name, as the law requires. After that it cannot be undone, and your username becomes free for someone else.</p><div id="delete-body" style="margin-top:12px"></div></div></div>@@@ -539,6 +539,7 @@<!-- Shared scripts --><script src="/shared/theme-loader.js"></script><script src="/shared/navbar.js"></script>++<script src="/js/account-safety.js" defer></script><script src="/shared/notification-ui.js"></script><script src="/shared/account-switcher.js"></script><!-- The kit's behaviour in the page: the copy button on the Account ID (data-ovk-copy). -->@@@ -32,6 +32,7 @@<script src="/shared/theme-loader.js"></script><script src="/shared/navbar.js"></script>++<script src="/js/account-safety.js" defer></script><script src="/shared/kit-web.js" defer></script><script src="/js/wallet/money.js" defer></script><script src="/js/wallet/common.js" defer></script>@@@ -94,6 +94,7 @@ p{color:var(--text-secondary);font-size:14px;line-height:1.6}</section></main><script src="/shared/navbar.js"></script>++<script src="/js/account-safety.js" defer></script><script>(function () {if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'network', apiBase: location.origin }); } catch (e) { /* */ } }@@@ -172,6 +172,7 @@<script src="/shared/theme-loader.js"></script><script src="/shared/navbar.js"></script>++<script src="/js/account-safety.js" defer></script><script src="/shared/kit-web.js" defer></script><script src="/js/wallet/money.js" defer></script><script src="/js/wallet/common.js" defer></script>@@@ -33,6 +33,7 @@ const express = require('express');const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');const { requireOwner } = require('../auth/owner-guard');++const { reauthUrl } = require('../auth/reauth');const alerts = require('../operator/alerts');const { isFreshSignIn } = require('../identity/account-merge');@@@ -65,8 +66,8 @@ const jsonType = (type) => /^application\/([a-z.+-]+\+)?json\b/i.test(String(typfunction signInUrl(config = {}) {const base = String(config.baseUrl || config.loginUrl || 'https://openvibe.network').replace(/\/+$/, '');-- const login = String(config.loginUrl || base).replace(/\/+$/, '') + '/login';-- return `${login}?return=${encodeURIComponent(`${base}/admin#ai`)}`;++ const login = String(config.loginUrl || base).replace(/\/+$/, '');++ return reauthUrl(login, `${base}/admin#ai`);}/** The session's real sign-in time, the OIDC auth_time Network's signToken puts on every session token. The rule isnnew file mode 100644@@@ -0,0 +1,14 @@++'use strict';++/**++ * One re-authentication URL for every fresh-sign-in action (account deletion, account merge, the owner's AI++ * key/money settings, Locker reset/forget, QR transfer rules). The login page's ?reauth=1 always asks for the++ * password, even when a session exists; ?add_account=1 hands that session back with its old auth_time, so the++ * fresh check never passes and the person loops.++ */++function reauthUrl(baseUrl, returnTo = '/') {++ const base = String(baseUrl || 'https://openvibe.network').replace(/\/+$/, '');++ const back = String(returnTo || '/');++ return `${base}/login?reauth=1&return=${encodeURIComponent(back)}`;++}++++module.exports = { reauthUrl };@@@ -123,6 +123,7 @@ ${snippet('bp-c5', 'Keep a file out (HTTP header)', 'X-Robots-Tag: OpenVibeBot:</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script>${require('../boost').SCRIPT}<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}document.querySelectorAll('.bp-copy').forEach(function(b){b.addEventListener('click',function(){var el=document.getElementById(b.getAttribute('data-copy'));if(!el||!navigator.clipboard)return;navigator.clipboard.writeText(el.textContent).then(function(){b.textContent='Copied';b.classList.add('is-done');setTimeout(function(){b.textContent='Copy';b.classList.remove('is-done');},1600);},function(){});});});});</script>@@@ -475,7 +475,9 @@ app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('.const accountMerge = require('./identity/account-merge');await accountMerge.ensureSchema(db);const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });-- app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);++ // Only the merge calls spend this budget: the same mount also carries the export/deletion routes below their++ // own limiter, and counting those twice was enough to lock a person out while the deletion banner read them.++ app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20, skip: (req) => !String(req.path).startsWith('/merge') }), mergeRouters.me);app.use('/api/admin/account-merges', mergeRouters.admin);}// Mod principals (roadmap WS-M task 3, ADR-013): the runtime that installs a mod registers mod:<mod_id> and changes@@@ -501,7 +503,13 @@ const notifyAccountData = async (userId, n) => await notificationService.create(requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,contributeGuard: guard('network.account.export.contribute'), confirmGuard: guard('network.account.deletion.confirm'),});-- app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), dataRouters.me);++ // The deletion banner on every Network page reads GET /deletion once per page; it is a read-only, signed-in++ // question and must not use up the 20-a-minute budget that the export/deletion writes need (a person who++ // navigates 20 pages would otherwise be unable to schedule or keep the account).++ app.use('/api/v1/account', rateLimit({++ windowMs: 60_000, max: 20,++ skip: (req) => req.method === 'GET' && req.path === '/deletion',++ }), dataRouters.me);app.use('/internal', dataRouters.internal);app.use('/api/admin/account-deletions', dataRouters.admin);}@@@ -63,6 +63,7 @@ ${nav}</main>${footer}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script><script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script></body></html>`;@@@ -451,6 +451,7 @@ ${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsH</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script><script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script></body></html>`;@@@ -81,6 +81,7 @@ ${body}</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script>${require('../boost').SCRIPT}<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script></body>@@@ -369,6 +369,7 @@ ${busy ? `<section class="os-sec" aria-labelledby="h-busy"><h2 id="h-busy">Busie</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script>${require('../boost').SCRIPT}<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script></body>@@@ -238,6 +238,7 @@ ${sloRows}</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script>${boost.SCRIPT}<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script></body>@@@ -165,6 +165,7 @@ ${dayHtml || '<p class="ov-shipped-empty">The update history could not be loaded</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact', updates: '/updates' })}<script src="/shared/navbar.js" defer></script>++<script src="/js/account-safety.js" defer></script><script src="/shared/shipped.js" defer></script>${boost.SCRIPT}<script>document.addEventListener('ov:boost:load',function(){if(window.OpenVibeShipped)OpenVibeShipped.scan(document.getElementById('main'));});</script>nnew file mode 100644@@@ -0,0 +1,257 @@++'use strict';++// The account-deletion loop (moosely, 2026-11-06):++// - "Sign in again" must be a real re-authentication. The new /login?reauth=1 path always shows the password++// form (a saved session is not handed back), the new token carries auth_time = now, and the person comes back++// to /my#delete with the username field ready and the 10-minute window stated.++// - The old path (add_account=1) is what reused the session; these checks fail if the delete step goes back to it.++// - While a deletion is scheduled, every page shows the banner with one "Keep my account" button, and the++// sign-in page offers the same choice first.++// node test/account-deletion-reauth.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const vm = require('vm');++const express = require('express');++const cookieParser = require('cookie-parser');++const jwt = require('jsonwebtoken');++const bcrypt = require('bcryptjs');++const { parseHTML } = require('linkedom');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const accountMerge = require('../server/identity/account-merge');++const accountData = require('../server/identity/account-data');++const authRoutes = require('../server/auth/routes');++const { makeRequireAuth } = require('../server/auth/session');++const staffClaims = require('../server/auth/staff-claims').staffClaims;++const { reauthUrl } = require('../server/auth/reauth');++const { signInUrl } = require('../server/admin/ai-config-proxy');++++const ROOT = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');++++// ── The browser helpers: one re-authentication URL, and the banner ───────────++const safety = read('public/js/account-safety.js');++function safetyWindow() {++ const { window: win } = parseHTML('<!doctype html><html><body></body></html>');++ const store = new Map();++ const calls = [];++ const root = {++ document: win.document,++ location: { origin: 'https://openvibe.network' },++ localStorage: {++ getItem: (k) => (store.has(k) ? store.get(k) : null),++ setItem: (k, v) => store.set(k, String(v)),++ removeItem: (k) => store.delete(k),++ },++ fetch: async (url, opts = {}) => {++ calls.push({ url: String(url), method: opts.method || 'GET', headers: opts.headers || {} });++ if ((opts.method || 'GET') === 'DELETE') {++ return { ok: true, status: 200, json: async () => ({ status: 'cancelled', deletion_id: 'del_x' }) };++ }++ return { ok: true, status: 200, json: async () => ({ deletion: { delete_after: new Date(Date.now() + 30 * 86400000).toISOString() } }) };++ },++ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },++ dispatchEvent: () => {},++ };++ const context = {++ window: root, document: root.document, localStorage: root.localStorage, fetch: root.fetch,++ URLSearchParams, Date, JSON, Number, String, Object, setTimeout, clearTimeout, console,++ };++ vm.runInNewContext(safety, context);++ return { root, calls, api: root.OpenVibeDeletion, reauth: root.OpenVibeReauth };++}++++const bannerTest = (async () => {++ const { root, calls, api, reauth } = safetyWindow();++ assert.strictEqual(reauth.url('/my#delete'), '/login?reauth=1&return=%2Fmy%23delete', 'the deletion return target');++ assert.strictEqual(reauth.url('/my#accounts', { account: '' }), '/login?reauth=1&login_hint=&return=%2Fmy%23accounts', 'merge leaves the username blank');++ assert.strictEqual(reauth.url('/admin#ai', { account: 'goosely' }), '/login?reauth=1&login_hint=goosely&return=%2Fadmin%23ai');++ assert.match(api.bannerText('2030-01-02T03:04:00.000Z'), /^Your account will be deleted on .+\.$/);++ assert.strictEqual(api.sessionToken(), null, 'signed out: no banner');++++ // A saved account (the normal case) is found from localStorage.++ root.localStorage.setItem('openvibe_accounts', JSON.stringify([{ id: 7, username: 'Me', token: 'tok-saved' }]));++ root.localStorage.setItem('openvibe_active_account', '7');++ assert.strictEqual(api.sessionToken(), 'tok-saved');++ root.localStorage.setItem('openvibe_active_account', 'anon');++ assert.strictEqual(api.sessionToken(), null, 'an anonymous session has no account to delete');++++ // The banner itself: the get, the exact sentence, the one Keep button, the DELETE.++ root.localStorage.setItem('openvibe_active_account', '7');++ const shown = await api.refreshBanner();++ assert.ok(shown && shown.delete_after, 'the banner came from GET /api/v1/account/deletion');++ const banner = root.document.getElementById('ov-deletion-banner');++ assert.ok(banner, 'the banner is on the page');++ assert.match(banner.textContent, /Your account will be deleted on /);++ const keep = root.document.getElementById('ov-deletion-keep');++ assert.strictEqual(keep.textContent, 'Keep my account');++ assert.strictEqual(banner.querySelectorAll('button').length, 1, 'exactly one button, which cancels it');++ keep.click();++ await new Promise((r) => setTimeout(r, 20));++ const del = calls.find((c) => c.method === 'DELETE');++ assert.ok(del, 'Keep my account calls DELETE /api/v1/account/deletion');++ assert.strictEqual(del.url, 'https://openvibe.network/api/v1/account/deletion');++ assert.strictEqual(del.headers.Authorization, 'Bearer tok-saved');++ assert.match(root.document.getElementById('ov-deletion-banner').textContent, /Kept\. Your account stays\./);++ api.removeBanner();++ assert.strictEqual(root.document.getElementById('ov-deletion-banner'), null);++})();++++// ── The delete step uses the re-authentication path, never add_account ──────++const profile = read('public/js/my/profile.js');++assert.match(profile, /function reauthForDeletion\(\)[\s\S]*?\/login\?reauth=1&return=/, 'the Sign in again target asks for a real re-authentication');++assert.match(profile, /again\.onclick = reauthForDeletion/, 'the 401 prompt signs in again through reauth');++assert.doesNotMatch(profile, /again\.onclick = addAccount/, 'the old add_account path is gone: it reuses the session and the loop comes back');++assert.match(profile, /Your account stays usable for 30 days after you ask, and you can keep it at any time before then/, 'the step says the account stays usable and can be kept');++assert.match(profile, /A fresh sign-in gives you 10 minutes to confirm/, 'the step states the 10-minute window');++assert.match(profile, /window\.OpenVibeReauth\.go\('\/my#delete'\)/, 'a reauth return to the delete step');++assert.match(profile, /OpenVibeDeletion\.showBanner\(s\.body\)/, 'the banner appears as soon as the deletion is scheduled');++assert.match(profile, /hash === 'delete'|OV_DELETE_DEEP_LINK/, 'the return target is understood');++assert.match(profile, /ov:deletion-cancelled/, 'the banner can refresh this card');++assert.match(read('public/js/my/core.js'), /hash === 'delete' \? 'accounts' : hash/, 'core.js maps the #delete return target to the accounts section');++// The banner reads GET /deletion on every page; that read must not spend the 20-a-minute write budget.++const index = read('server/index.js');++assert.match(index, /skip: \(req\) => req\.method === 'GET' && req\.path === '\/deletion'/, 'GET /deletion is not rate-limited with the writes it feeds');++assert.match(index, /skip: \(req\) => !String\(req\.path\)\.startsWith\('\/merge'\)/, 'the merge limiter does not count the account data routes');++++// ── /login?reauth=1 always asks for the password ────────────────────────────++const login = read('public/js/login.js');++function block(from, name) {++ const start = login.indexOf(from);++ const fnStart = login.indexOf(`function ${name}(`, start);++ assert.ok(start >= 0 && fnStart > start, `${name} exists`);++ let depth = 0;++ for (let i = login.indexOf('{', fnStart); i < login.length; i++) {++ if (login[i] === '{') depth++;++ else if (login[i] === '}' && --depth === 0) return login.slice(start, i + 1);++ }++ throw new Error(`unbalanced ${name}`);++}++function reauthUser(query, accounts, active) {++ const params = new URLSearchParams(query);++ const context = {++ params, accounts, String,++ localStorage: { getItem: (k) => (k === 'openvibe_active_account' ? active : null) },++ };++ vm.runInNewContext(`${block("const isReauth = params.get('reauth') === '1';", 'reauthUsername')}; this.out = reauthUsername(accounts);`, context);++ return context.out;++}++assert.strictEqual(reauthUser('reauth=1', [{ id: 7, username: 'Me' }], '7'), 'Me', 'the active account is filled in');++assert.strictEqual(reauthUser('reauth=1&login_hint=%40Alt', [{ id: 7, username: 'Me' }], '7'), 'Alt', 'login_hint wins (merge uses another account)');++assert.strictEqual(reauthUser('reauth=1&login_hint=', [{ id: 7, username: 'Me' }], '7'), '', 'an empty login_hint leaves the merge username blank');++assert.strictEqual(reauthUser('reauth=1', [{ id: 8, username: 'First' }], null), 'First', 'no active account: the first saved one');++assert.match(login, /const isReauth = params\.get\('reauth'\) === '1'/, 'the page knows reauth mode');++const init = login.slice(login.indexOf('(function init() {'));++assert.match(init, /if \(isReauth\) \{\s*\n\s*showAuthForm\('login'\);[\s\S]*?return;\s*\n\s*\}/, 'reauth shows the password form, never the chooser');++assert.ok(init.indexOf('if (isReauth)') < init.indexOf('if (hasAccounts)'), 'the reauth form is chosen before the saved-account chooser');++assert.match(init, /if \(isReauth\)[\s\S]*?back\.style\.display = 'none'/, 'no "back to saved accounts" shortcut in reauth');++assert.match(login, /guardScheduledDeletion\(data\.token, \(\) => finishSignIn\(data\)\)/, 'a password sign-in checks a scheduled deletion first');++assert.match(login, /guardScheduledDeletion\(validToken, \(\) => continueAsAccount/, 'a saved-account sign-in checks it too');++assert.match(login, /OpenVibeDeletion\.cancelDeletion\(token\)/, 'Keep my account cancels the scheduled deletion there');++assert.match(login, /panel-keep/, 'the sign-in page shows the same choice before continuing');++++// ── Every user-facing page loads the banner and the helper ──────────────────++const pages = [++ 'public/index.html', 'public/my.html', 'public/wallet.html', 'public/pay.html', 'public/admin.html',++ 'public/verify-email.html', 'public/login.html',++ 'server/sites/page.js', 'server/bot/page.js', 'server/stats/open.js', 'server/status/routes.js',++ 'server/profile/public.js', 'server/not-found.js', 'server/updates/routes.js',++];++for (const p of pages) assert.ok(read(p).includes('/js/account-safety.js'), `${p} loads the banner and reauth helper`);++assert.match(read('public/login.html'), /id="keep-account-btn"/);++assert.match(read('public/login.html'), /id="reauth-note"/);++assert.match(read('public/my.html'), /id="delete-card"/);++assert.match(read('public/my.html'), /stays usable for 30 days/);++++// ── Server: a stale token is refused, a real re-login is fresh, the banner GET answers, DELETE keeps ─────++const serverTest = async () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-delete-reauth-'));++ const db = getDb();++ await accountMerge.ensureSchema(db);++ await accountData.ensureSchema(db);++++ const ISSUER = 'https://openvibe.network';++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++ const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, baseUrl: ISSUER, loginUrl: ISSUER };++ process.env.OWNER_USERNAME = 'boss';++++ const hash = bcrypt.hashSync('secret12', 4);++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?) RETURNING id')++ .run('looped', '[email protected]', hash, 'user')).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(id);++++ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);++ const app = express();++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ app.use(cookieParser());++ app.use(express.json());++ app.use('/api/auth', authRoutes);++ app.use('/api/v1/account', accountData.routers({++ requireAuth, staffClaims, dir, notify: () => {},++ contributeGuard: (_req, _res, next) => next(), confirmGuard: (_req, _res, next) => next(),++ }).me);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = (method, p, token, body) => fetch(base + p, {++ method,++ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },++ body: body ? JSON.stringify(body) : undefined,++ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));++++ try {++ const first = await call('POST', '/api/auth/login', null, { username: 'looped', password: 'secret12' });++ assert.strictEqual(first.status, 200, 'the person signs in');++ const firstClaims = jwt.decode(first.body.token);++ assert.ok(Math.abs(firstClaims.auth_time - Math.floor(Date.now() / 1000)) < 10, 'a sign-in carries auth_time = now');++++ // The old path: the session is already signed in (auth_time 11 minutes ago). It is not fresh.++ const stale = jwt.sign({ sub: user.id, id: user.id, subject_id: user.subject_id, username: user.username, role: 'user',++ auth_time: Math.floor(Date.now() / 1000) - 11 * 60, iat: Math.floor(Date.now() / 1000) }, keys.privateKey,++ { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++ const denied = await call('POST', '/api/v1/account/deletion', stale, { confirm_username: 'looped' });++ assert.strictEqual(denied.status, 401, 'an already-signed-in session is refused');++ assert.strictEqual(denied.body.error, 'deletion.sign_in_again');++++ // A real re-authentication: POST /api/auth/login is what /login?reauth=1 does. The new token is fresh.++ const again = await call('POST', '/api/auth/login', null, { username: 'looped', password: 'secret12' });++ assert.strictEqual(again.status, 200, 'reauth signs in again');++ assert.notStrictEqual(again.body.token, first.body.token, 'a new token, not the session handed back');++ const freshClaims = jwt.decode(again.body.token);++ assert.ok(Math.abs(freshClaims.auth_time - Math.floor(Date.now() / 1000)) < 10, 'the reauthenticated token is fresh');++++ const wrong = await call('POST', '/api/v1/account/deletion', again.body.token, { confirm_username: 'not-me' });++ assert.strictEqual(wrong.status, 400, 'the username is still typed exactly');++++ const scheduled = await call('POST', '/api/v1/account/deletion', again.body.token, { confirm_username: 'looped' });++ assert.strictEqual(scheduled.status, 201, 'the fresh re-authentication schedules the deletion');++ assert.strictEqual(scheduled.body.status, 'scheduled');++ assert.ok(Date.parse(scheduled.body.delete_after) > Date.now() + 29 * 86400000, 'it is 30 days ahead');++++ const status = await call('GET', '/api/v1/account/deletion', again.body.token);++ assert.strictEqual(status.status, 200);++ assert.strictEqual(status.body.deletion.deletion_id, scheduled.body.deletion_id, 'the banner GET sees it on every page');++++ const kept = await call('DELETE', '/api/v1/account/deletion', again.body.token);++ assert.strictEqual(kept.status, 200, 'Keep my account cancels it');++ assert.strictEqual(kept.body.status, 'cancelled');++ const after = await call('GET', '/api/v1/account/deletion', again.body.token);++ assert.strictEqual(after.body.deletion, null, 'nothing is left scheduled (nothing ever needed cancelling for moosely)');++ } finally {++ await new Promise((r) => server.close(r));++ }++};++++// ── The server helper: AI's fresh-required link is the same re-auth path ────++assert.strictEqual(reauthUrl('https://openvibe.network', '/my#delete'), 'https://openvibe.network/login?reauth=1&return=%2Fmy%23delete');++assert.strictEqual(signInUrl({ loginUrl: 'https://openvibe.network', baseUrl: 'https://openvibe.network' }),++ 'https://openvibe.network/login?reauth=1&return=https%3A%2F%2Fopenvibe.network%2Fadmin%23ai');++++Promise.all([bannerTest, serverTest()]).then(() => console.log('account deletion reauth: all checks passed')).catch((err) => {++ console.error(err);++ process.exitCode = 1;++});@@@ -149,7 +149,7 @@ function summary(service, namespace, extra = {}) {r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'PUT', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { value: SECRET, reason: 'test' });assert.strictEqual(r.status, 403, r.text);assert.strictEqual(r.body.code, 'config.fresh_required');-- assert.match(r.body.sign_in_url, /\/login\?return=.*%2Fadmin%23ai/, 'the sign-in returns to /admin#ai');++ assert.match(r.body.sign_in_url, /\/login\?reauth=1&return=.*%2Fadmin%23ai/, 'the sign-in asks for the password again and returns to /admin#ai');r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'POST', '/api/admin/ai/config/ai/ai.exchange', { values: { paid_requests: true }, reason: 'test' });assert.strictEqual(r.status, 403, r.text);assert.strictEqual(r.body.code, 'config.fresh_required');@@@ -69,11 +69,16 @@ assert.deepStrictEqual(run({ chosen: 7, query: 'client_id=a&redirect_uri=x&prompassert.deepStrictEqual(run({ accounts: [{ ...me, is_anon: true }], chosen: 7 }).picked, [], 'an anonymous account is never assumed');assert.deepStrictEqual(run({ chosen: 7, query: 'return=/my' }).picked, [], 'not an app sign-in');--// Every way off this page to a site remembers the account, so that site's sign-in continues as it.++// Every way off this page to a site remembers the account, so that site's sign-in continues as it. A scheduled++// deletion is asked first (guardScheduledDeletion), so the continuation lives in its own function.const select = block('async function selectAccount(account)', 'selectAccount');--assert.match(select, /\} else \{[\s\S]*rememberChoice\(account\.id\);[\s\S]*goBack\(/, 'switching (not an app) remembers the account and goes back');++assert.match(select, /guardScheduledDeletion\(validToken, \(\) => continueAsAccount\(account, validToken\)\)/, 'a saved account checks for a scheduled deletion first');++const cont = block('async function continueAsAccount(account, validToken)', 'continueAsAccount');++assert.match(cont, /\} else \{[\s\S]*rememberChoice\(account\.id\);[\s\S]*goBack\(/, 'switching (not an app) remembers the account and goes back');const login = block('async function handleLogin(e)', 'handleLogin');--assert.match(login, /rememberChoice\(data\.user\.id\);\s*\n[^\n]*\n\s*goBack\(800\);/, 'a new sign-in (add another account) too');++assert.match(login, /guardScheduledDeletion\(data\.token, \(\) => finishSignIn\(data\)\)/, 'a password sign-in checks for a scheduled deletion first');++const finish = block('async function finishSignIn(data)', 'finishSignIn');++assert.match(finish, /rememberChoice\(data\.user\.id\);\s*\n[^\n]*\n\s*goBack\(800\);/, 'a new sign-in (add another account) too');// The page's init: switch_to switches (or asks for that account's password), add_account and prompt=login show the form.const init = js.slice(js.indexOf('(function init() {'));@@@ -15,8 +15,8 @@ const BUDGETS = {htmlRawKB: 196, // 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the networkhtmlBrotliKB: 36.5, // 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)-- jsRawKB: 345.1, // 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3-- jsBrotliKB: 84, // 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9++ jsRawKB: 354, // 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3++ jsBrotliKB: 86.5, // 86.2 with the account-safety helper on every page (2026-11-06: +2.2 KB brotli, the same feature); 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon};
@@ -37,6 +37,7 @@ or as an anonymous account.
@@ -37,6 +37,7 @@ or as an anonymous account.
| `prompt=none` | No page: a code for the account this browser is signed in to, or `error=login_required`. Sites use it to sign people in quietly when they open a site. Refused for third-party apps (`interaction_required`). |
| `prompt=none` | No page: a code for the account this browser is signed in to, or `error=login_required`. Sites use it to sign people in quietly when they open a site. Refused for third-party apps (`interaction_required`). |
| `prompt=select_account` | The chooser, even when `login_hint` names a saved account. |
| `prompt=select_account` | The chooser, even when `login_hint` names a saved account. |
| `prompt=login` | The sign-in form ("use another account"). |
| `prompt=login` | The sign-in form ("use another account"). |
+| `reauth=1` | A real re-authentication: the password form every time, even when this browser is already signed in, with `login_hint` prefilled and `return` as the way back. The new session's `auth_time` is now, so it satisfies the 10-minute fresh check (account deletion, merge, the owner's AI settings, Locker reset/forget). Use this rather than `add_account=1` for those. |
| `login_hint` | The account to continue as: a username (`@` optional) or an account id. Saved in this browser: continues as it. Not saved: the sign-in form with the username filled in. |
| `login_hint` | The account to continue as: a username (`@` optional) or an account id. Saved in this browser: continues as it. Not saved: the sign-in form with the username filled in. |
| `nonce` | Echoed in the `id_token` (scope `openid`). |
| `nonce` | Echoed in the `id_token` (scope `openid`). |
@@ -636,6 +636,7 @@
@@ -636,6 +636,7 @@
<!-- Shared scripts -->
<!-- Shared scripts -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
+<script src="/js/account-safety.js" defer></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/ov-forms.js" defer></script>
<script src="/shared/ov-forms.js" defer></script>
@@ -276,6 +276,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -276,6 +276,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<div id="ov-footer"></div>
<div id="ov-footer"></div>
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
+<script src="/js/account-safety.js" defer></script>
<script src="/shared/footer.js"></script>
<script src="/shared/footer.js"></script>
<script src="/shared/shipped.js" defer></script>
<script src="/shared/shipped.js" defer></script>
<script src="/shared/boost.js" data-main="#main" defer></script>
<script src="/shared/boost.js" data-main="#main" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — account safety helpers, on every page:
+ - OpenVibeReauth: the one re-authentication path. A fresh sign-in action (deleting the account, merging two
+ accounts, the owner's AI key/money settings, Locker reset/forget) sends people to
+ /login?reauth=1&return=<where to come back to>. That page always asks for the password, even when a session
+ already exists; add_account=1 reuses the existing session and leaves auth_time alone, which is exactly the
+ loop this replaces.
+ - OpenVibeDeletion: while a deletion is scheduled, a banner on every page says when, with one button that
+ keeps the account (DELETE /api/v1/account/deletion). The sign-in page shows the same choice before it
+ continues (see login.js).
+
+ Classic script, no modules: it is loaded on the static pages and the server-rendered ones alike.
+ ═══════════════════════════════════════════════════════════════ */
+(function (root) {
+ 'use strict';
+ const REAUTH_WINDOW_S = 10 * 60;
+ const BANNER_ID = 'ov-deletion-banner';
+
+ function base() {
+ return (root && root.location && root.location.origin) || '';
+ }
+
+ function cookieToken() {
+ try {
+ const m = String(root.document && root.document.cookie || '').match(/(?:^|; )ov_token=([^;]*)/);
+ return m ? decodeURIComponent(m[1]) : null;
+ } catch { return null; }
+ }
+
+ function storedAccounts() {
+ try { return JSON.parse(root.localStorage.getItem('openvibe_accounts') || '[]'); } catch { return []; }
+ }
+
+ /** The active person's token: the same rule as /my's getAuthToken (never an anonymous session). */
+ function sessionToken() {
+ try {
+ const activeId = root.localStorage.getItem('openvibe_active_account');
+ if (activeId === 'anon') return null;
+ const account = storedAccounts().find((a) => String(a.id) === String(activeId));
+ if (account && account.is_anon) return null;
+ return root.localStorage.getItem('ov_token') || (account && account.token) || cookieToken() || null;
+ } catch { return null; }
+ }
+
+ /** The one re-authentication URL. `account` prefills the username; pass '' to leave it blank (merge). */
+ function reauthUrl(returnTo, options) {
+ const params = new URLSearchParams();
+ params.set('reauth', '1');
+ if (options && Object.prototype.hasOwnProperty.call(options, 'account')) params.set('login_hint', String(options.account || ''));
+ params.set('return', String(returnTo || '/'));
+ return '/login?' + params.toString();
+ }
+
+ function goReauth(returnTo, options) {
+ if (root.location) root.location.href = reauthUrl(returnTo, options);
+ }
+
+ function formatWhen(value) {
+ const d = new Date(value);
+ if (!Number.isFinite(d.getTime())) return 'the date in your account settings';
+ try { return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); } catch { return d.toISOString(); }
+ }
+
+ function bannerText(deleteAfter) {
+ return `Your account will be deleted on ${formatWhen(deleteAfter)}.`;
+ }
+
+ async function deletionStatus(token) {
+ const t = token || sessionToken();
+ if (!t) return null;
+ const res = await fetch(base() + '/api/v1/account/deletion', {
+ headers: { Authorization: 'Bearer ' + t },
+ credentials: 'include',
+ });
+ if (!res.ok) return null;
+ const body = await res.json().catch(() => ({}));
+ return body && body.deletion ? body.deletion : null;
+ }
+
+ async function cancelDeletion(token) {
+ const t = token || sessionToken();
+ if (!t) return { ok: false, status: 401, body: {} };
+ const res = await fetch(base() + '/api/v1/account/deletion', {
+ method: 'DELETE',
+ headers: { Authorization: 'Bearer ' + t },
+ credentials: 'include',
+ });
+ let body = {};
+ try { body = await res.json(); } catch { /* no body */ }
+ return { ok: res.ok, status: res.status, body };
+ }
+
+ function removeBanner() {
+ const doc = root && root.document;
+ if (!doc) return;
+ const el = doc.getElementById(BANNER_ID);
+ if (el) el.remove();
+ if (doc.body) doc.body.style.paddingBottom = '';
+ }
+
+ function buttonStyle() {
+ return 'appearance:none;border:0;border-radius:999px;padding:8px 16px;background:var(--accent,#3b82f6);'
+ + 'color:#fff;font:600 13px/1.2 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;'
+ + 'box-shadow:0 2px 8px rgba(59,130,246,.35);white-space:nowrap';
+ }
+
+ function showBanner(deletion) {
+ const doc = root && root.document;
+ if (!doc || !doc.body) return;
+ let el = doc.getElementById(BANNER_ID);
+ if (!el) {
+ el = doc.createElement('div');
+ el.id = BANNER_ID;
+ el.setAttribute('role', 'status');
+ el.setAttribute('aria-live', 'polite');
+ el.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:10050;display:flex;align-items:center;'
+ + 'justify-content:center;gap:12px;flex-wrap:wrap;padding:12px 18px;'
+ + 'padding-bottom:max(12px,env(safe-area-inset-bottom));background:var(--bg-secondary,#101828);'
+ + 'border-top:1px solid var(--border,#2c3d5c);box-shadow:0 -8px 28px rgba(0,0,0,.35);'
+ + 'font:14px/1.4 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text-primary,#e6edf7);'
+ + 'text-align:center';
+ const text = doc.createElement('span');
+ text.setAttribute('data-ov-deletion-text', '');
+ const button = doc.createElement('button');
+ button.type = 'button';
+ button.id = 'ov-deletion-keep';
+ button.textContent = 'Keep my account';
+ button.style.cssText = buttonStyle();
+ button.addEventListener('click', async () => {
+ button.disabled = true;
+ button.textContent = 'Keeping…';
+ const result = await cancelDeletion();
+ if (!result.ok && result.status !== 404) {
+ button.disabled = false;
+ button.textContent = 'Keep my account';
+ text.textContent = (result.body && result.body.detail) || 'That did not work. Try again.';
+ return;
+ }
+ text.textContent = 'Kept. Your account stays.';
+ setTimeout(removeBanner, 900);
+ try { root.dispatchEvent(new root.CustomEvent('ov:deletion-cancelled', { detail: result.body })); } catch { /* no CustomEvent */ }
+ });
+ el.append(text, button);
+ doc.body.appendChild(el);
+ const height = Number(el.offsetHeight) || 0;
+ if (height) doc.body.style.paddingBottom = height + 'px';
+ }
+ const text = el.querySelector('[data-ov-deletion-text]');
+ if (text) text.textContent = bannerText(deletion.delete_after);
+ }
+
+ async function refreshBanner() {
+ if (!sessionToken()) return null;
+ const deletion = await deletionStatus();
+ if (deletion) showBanner(deletion);
+ else removeBanner();
+ return deletion;
+ }
+
+ const api = {
+ REAUTH_WINDOW_S, BANNER_ID, sessionToken, reauthUrl, goReauth, formatWhen, bannerText,
+ deletionStatus, cancelDeletion, refreshBanner, showBanner, removeBanner,
+ };
+ if (root) {
+ root.OpenVibeReauth = { url: reauthUrl, go: goReauth, WINDOW_S: REAUTH_WINDOW_S };
+ root.OpenVibeDeletion = api;
+ }
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+
+ if (root && root.document) {
+ const start = () => { refreshBanner().catch(() => { /* signed out, offline: no banner */ }); };
+ if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', start, { once: true });
+ else start();
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -698,7 +698,7 @@ async function loadPlatformAi() {
@@ -698,7 +698,7 @@ async function loadPlatformAi() {
function paiShell(inner) {
function paiShell(inner) {
return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'
return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'
+ '<p class="muted">Every service\'s AI settings in one place. Keys are sealed and write-only: once saved, nothing here can show one again. Money and key changes ask you to confirm it is you.</p></div>'
+ '<p class="muted">Every service\'s AI settings in one place. Keys are sealed and write-only: once saved, nothing here can show one again. Money and key changes ask you to confirm it is you.</p></div>'
- + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?return=%2Fadmin%23ai">Sign in again</a></div>'
+ + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?reauth=1&return=%2Fadmin%23ai">Sign in again</a></div>'
+ inner;
+ inner;
}
}
@@ -6,6 +6,18 @@
@@ -6,6 +6,18 @@
const API_BASE = window.location.origin;
const API_BASE = window.location.origin;
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(window.location.search);
const isOAuth = params.has('client_id') && params.has('redirect_uri');
const isOAuth = params.has('client_id') && params.has('redirect_uri');
+// ?reauth=1 is the one real re-authentication path (deleting the account, merging, the owner's AI settings,
+// Locker reset/forget). It always shows the password form, even when this browser has a saved session:
+// add_account=1 reuses that session and leaves auth_time alone, which is what made "Sign in again" loop.
+const isReauth = params.get('reauth') === '1';
+/** The username the re-authentication form starts with: login_hint when given ('' means blank), else the active account. */
+function reauthUsername(accounts) {
+ if (params.has('login_hint')) return String(params.get('login_hint') || '').trim().replace(/^@/, '');
+ let activeId = null;
+ try { activeId = localStorage.getItem('openvibe_active_account'); } catch { /* private mode */ }
+ const account = accounts.find((a) => String(a.id) === String(activeId)) || accounts[0];
+ return account ? String(account.username || '') : '';
+}
// The app's name comes from the Network (GET /oauth/client-info), never from this page's URL:
// The app's name comes from the Network (GET /oauth/client-info), never from this page's URL:
// a crafted link could otherwise claim to be any app. Until it answers, show nothing.
// a crafted link could otherwise claim to be any app. Until it answers, show nothing.
let clientName = '';
let clientName = '';
@@ -366,6 +378,62 @@ async function selectAccount(account) {
@@ -366,6 +378,62 @@ async function selectAccount(account) {
localStorage.setItem('openvibe_active_account', account.id);
localStorage.setItem('openvibe_active_account', account.id);
setAuthCookie(validToken);
setAuthCookie(validToken);
+ await guardScheduledDeletion(validToken, () => continueAsAccount(account, validToken));
+}
+
+// ── A scheduled deletion is offered before any sign-in continues ─────────────
+// Every sign-in path (a saved account, or the password form) asks the API whether a deletion is scheduled.
+// If it is, the person sees the same "Keep my account" choice as the banner before being sent on; the choice
+// cancels it through DELETE /api/v1/account/deletion and then continues the sign-in.
+let keepNext = null;
+function offerKeep(token, deletion, next) {
+ keepNext = next;
+ const note = document.getElementById('keep-note');
+ const when = window.OpenVibeDeletion ? window.OpenVibeDeletion.bannerText(deletion.delete_after) : `Your account will be deleted on ${new Date(deletion.delete_after).toLocaleString()}.`;
+ if (note) note.textContent = `${when} It stays usable until then.`;
+ const keep = document.getElementById('keep-account-btn');
+ const cont = document.getElementById('keep-continue');
+ if (keep) {
+ keep.disabled = false;
+ keep.textContent = 'Keep my account';
+ keep.onclick = async () => {
+ keep.disabled = true;
+ keep.textContent = 'Keeping…';
+ let result = { ok: false, body: {} };
+ try {
+ result = window.OpenVibeDeletion ? await window.OpenVibeDeletion.cancelDeletion(token) : result;
+ } catch { /* offline */ }
+ if (!result.ok && result.status !== 404) {
+ keep.disabled = false;
+ keep.textContent = 'Keep my account';
+ toast((result.body && result.body.detail) || 'Could not keep the account. Try again.', 'error');
+ return;
+ }
+ keep.textContent = 'Kept. Your account stays.';
+ const go = keepNext;
+ keepNext = null;
+ setTimeout(() => { if (go) go(); }, 650);
+ };
+ }
+ if (cont) cont.onclick = (e) => {
+ if (e) e.preventDefault();
+ const go = keepNext;
+ keepNext = null;
+ if (go) go();
+ };
+ setActivePanel('panel-keep', { showTabs: false, showExtras: false });
+}
+
+async function guardScheduledDeletion(token, next) {
+ if (!token || !window.OpenVibeDeletion || typeof window.OpenVibeDeletion.deletionStatus !== 'function') return next();
+ let deletion = null;
+ try { deletion = await window.OpenVibeDeletion.deletionStatus(token); } catch { /* offline: the sign-in still goes on */ }
+ if (!deletion) return next();
+ offerKeep(token, deletion, next);
+}
+
+/** The part of a saved-account sign-in that leaves this page (an OAuth app, or back to the site). */
+async function continueAsAccount(account, validToken) {
if (isOAuth) {
if (isOAuth) {
// Call /oauth/confirm to get the redirect URL
// Call /oauth/confirm to get the redirect URL
try {
try {
@@ -548,6 +616,20 @@ async function handleLogin(e) {
@@ -548,6 +616,20 @@ async function handleLogin(e) {
setAuthCookie(data.token);
setAuthCookie(data.token);
saveAccount(data.user, data.token);
saveAccount(data.user, data.token);
+ // If a deletion is scheduled, the same "Keep my account" choice comes before this sign-in continues.
+ await guardScheduledDeletion(data.token, () => finishSignIn(data));
+ } catch (err) {
+ showMsg('login-msg', err.message, 'error');
+ btn.textContent = isReauth ? 'Sign In Again →' : 'Sign In →';
+ btn.classList.remove('loading');
+ } finally {
+ oauthConfirmInFlight = false;
+ }
+}
+
+/** Leave the sign-in page for an OAuth app, or back where the person came from (after the password form). */
+async function finishSignIn(data) {
+ try {
if (isOAuth) {
if (isOAuth) {
oauthConfirmInFlight = true;
oauthConfirmInFlight = true;
// Confirm OAuth with the new token
// Confirm OAuth with the new token
@@ -585,8 +667,11 @@ async function handleLogin(e) {
@@ -585,8 +667,11 @@ async function handleLogin(e) {
}
}
} catch (err) {
} catch (err) {
showMsg('login-msg', err.message, 'error');
showMsg('login-msg', err.message, 'error');
- btn.textContent = 'Sign In →';
- btn.classList.remove('loading');
+ const btn = document.getElementById('login-submit');
+ if (btn) {
+ btn.textContent = isReauth ? 'Sign In Again →' : 'Sign In →';
+ btn.classList.remove('loading');
+ }
} finally {
} finally {
oauthConfirmInFlight = false;
oauthConfirmInFlight = false;
}
}
@@ -806,6 +891,27 @@ async function continueAnonymous() {
@@ -806,6 +891,27 @@ async function continueAnonymous() {
return;
return;
}
}
+ // A real re-authentication: the password form every time, even with saved accounts and even for an app's
+ // silent continue. add_account=1 would hand back the existing session with its old auth_time.
+ if (isReauth) {
+ showAuthForm('login');
+ const username = reauthUsername(accounts);
+ if (username) prefillUsername(username);
+ else {
+ const field = document.querySelector('#login-form input[name="username"]');
+ if (field) field.focus();
+ }
+ const tabs = document.getElementById('auth-tabs');
+ if (tabs) tabs.style.display = 'none';
+ const note = document.getElementById('reauth-note');
+ if (note) note.hidden = false;
+ const submit = document.getElementById('login-submit');
+ if (submit) submit.textContent = 'Sign In Again →';
+ const back = document.getElementById('login-back');
+ if (back) back.style.display = 'none';
+ return;
+ }
+
// A site's account menu (the shared navbar): switch to one of this browser's accounts, then back through the site's
// A site's account menu (the shared navbar): switch to one of this browser's accounts, then back through the site's
// sign-in, which continues as it. An account not saved here (or signed out) gets its sign-in, the name filled in.
// sign-in, which continues as it. An account not saved here (or signed out) gets its sign-in, the name filled in.
const switchTo = params.get('switch_to');
const switchTo = params.get('switch_to');
@@ -165,7 +165,9 @@ function showSection(id, btn) {
@@ -165,7 +165,9 @@ function showSection(id, btn) {
function openInitialSection() {
function openInitialSection() {
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
- const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
+ // #delete is the re-authentication return target: the delete step lives in the accounts section.
+ const hash = window.location.hash.replace(/^#/, '');
+ const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines'].includes(routeSection)) {
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines'].includes(routeSection)) {
return showSection('anon');
return showSection('anon');
@@ -8,6 +8,27 @@
@@ -8,6 +8,27 @@
// site (the report says so); here Network can pass it in.
// site (the report says so); here Network can pass it in.
const OV_WALLET_MENU = { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] };
const OV_WALLET_MENU = { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] };
+// /my#delete is the return target of the re-authentication path (and a deep link): it opens the accounts
+// section and puts the cursor in the delete confirmation field. Read before core.js rewrites the hash.
+const OV_DELETE_DEEP_LINK = window.location.hash === '#delete';
+/** Seconds left in the 10-minute fresh sign-in window, from the token's auth_time (OIDC; readable, not secret). */
+function freshSignInLeft() {
+ const token = getAuthToken();
+ if (!token) return 0;
+ try {
+ const part = String(token.split('.')[1] || '').replace(/-/g, '+').replace(/_/g, '/');
+ const payload = JSON.parse(atob(part + '='.repeat((4 - part.length % 4) % 4)));
+ const at = Number(payload && payload.auth_time);
+ return Number.isFinite(at) ? Math.max(0, Math.floor(at + 600 - Date.now() / 1000)) : 0;
+ } catch { return 0; }
+}
+function reauthForDeletion() {
+ if (window.OpenVibeReauth) window.OpenVibeReauth.go('/my#delete');
+ else window.location.href = '/login?reauth=1&return=' + encodeURIComponent('/my#delete');
+}
+// The banner's Keep my account has already cancelled it; refresh this page's card too.
+window.addEventListener('ov:deletion-cancelled', () => { if (document.getElementById('delete-body')) renderDeletion(); });
+
// Load user data
// Load user data
async function loadUser() {
async function loadUser() {
// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
@@ -272,7 +293,10 @@ function renderMerge() {
@@ -272,7 +293,10 @@ function renderMerge() {
try {
try {
const r = await apiFetch('/api/v1/account/merge/intents', { method: 'POST', body: '{}' });
const r = await apiFetch('/api/v1/account/merge/intents', { method: 'POST', body: '{}' });
localStorage.setItem(MERGE_KEY, JSON.stringify({ intent: r.intent, expires_at: r.expires_at, into_id: currentUser.id, into_username: currentUser.username }));
localStorage.setItem(MERGE_KEY, JSON.stringify({ intent: r.intent, expires_at: r.expires_at, into_id: currentUser.id, into_username: currentUser.username }));
- addAccount();
+ // A real re-authentication for the other account: add_account=1 would hand back an existing
+ // session with its old auth_time, which the merge's fresh check refuses.
+ if (window.OpenVibeReauth) window.OpenVibeReauth.go('/my#accounts', { account: '' });
+ else window.location.href = '/login?reauth=1&login_hint=&return=' + encodeURIComponent('/my#accounts');
} catch (err) { toast('Could not start the merge: ' + err.message, 'error'); }
} catch (err) { toast('Could not start the merge: ' + err.message, 'error'); }
};
};
row.appendChild(b); body.appendChild(row); return;
row.appendChild(b); body.appendChild(row); return;
@@ -282,9 +306,13 @@ function renderMerge() {
@@ -282,9 +306,13 @@ function renderMerge() {
cancel.type = 'button';
cancel.type = 'button';
cancel.onclick = () => { localStorage.removeItem(MERGE_KEY); renderMerge(); };
cancel.onclick = () => { localStorage.removeItem(MERGE_KEY); renderMerge(); };
if (String(currentUser.id) === String(pending.into_id)) {
if (String(currentUser.id) === String(pending.into_id)) {
- body.appendChild(el('p', `Now sign in to the account you want to fold into @${pending.into_username} (within ${mins} min).`));
+ body.appendChild(el('p', `Now sign in to the account you want to fold into @${pending.into_username} (within ${mins} min). It asks for that account's password even if it is already signed in, because the merge needs a real sign-in from the last 10 minutes.`));
const go = el('button', 'Sign in to the other account', 'btn btn-primary');
const go = el('button', 'Sign in to the other account', 'btn btn-primary');
- go.type = 'button'; go.onclick = addAccount;
+ go.type = 'button';
+ go.onclick = () => {
+ if (window.OpenVibeReauth) window.OpenVibeReauth.go('/my#accounts', { account: '' });
+ else window.location.href = '/login?reauth=1&login_hint=&return=' + encodeURIComponent('/my#accounts');
+ };
row.append(go, cancel); body.appendChild(row); return;
row.append(go, cancel); body.appendChild(row); return;
}
}
body.appendChild(el('p', `Fold @${currentUser.username} into @${pending.into_username}? Everything above moves to @${pending.into_username}; @${currentUser.username} then signs in as @${pending.into_username}.`));
body.appendChild(el('p', `Fold @${currentUser.username} into @${pending.into_username}? Everything above moves to @${pending.into_username}; @${currentUser.username} then signs in as @${pending.into_username}.`));
@@ -301,9 +329,23 @@ function renderMerge() {
@@ -301,9 +329,23 @@ function renderMerge() {
OpenVibeAccountSwitcher.switchTo(pending.into_id);
OpenVibeAccountSwitcher.switchTo(pending.into_id);
} catch (err) {
} catch (err) {
doIt.disabled = false;
doIt.disabled = false;
- if (err.status === 401) toast(`Sign in to @${currentUser.username} again (a fresh sign-in is needed), then merge.`, 'error');
- else if (err.status === 410) { localStorage.removeItem(MERGE_KEY); toast('That merge request expired. Start again from the account you keep.', 'error'); renderMerge(); }
- else toast('The merge did not happen: ' + err.message, 'error');
+ if (err.status === 401) {
+ toast(`Sign in to @${currentUser.username} again (a fresh sign-in is needed), then merge.`, 'error');
+ if (!row.querySelector('.ov-merge-reauth')) {
+ const again = el('button', 'Sign in again', 'btn btn-outline ov-merge-reauth');
+ again.type = 'button';
+ again.onclick = () => {
+ const back = '/my#accounts';
+ if (window.OpenVibeReauth) window.OpenVibeReauth.go(back, { account: currentUser.username });
+ else window.location.href = `/login?reauth=1&login_hint=${encodeURIComponent(currentUser.username)}&return=${encodeURIComponent(back)}`;
+ };
+ row.insertBefore(again, doIt);
+ }
+ } else if (err.status === 410) {
+ localStorage.removeItem(MERGE_KEY);
+ toast('That merge request expired. Start again from the account you keep.', 'error');
+ renderMerge();
+ } else toast('The merge did not happen: ' + err.message, 'error');
}
}
};
};
row.append(doIt, cancel); body.appendChild(row);
row.append(doIt, cancel); body.appendChild(row);
@@ -392,18 +434,28 @@ async function renderDeletion() {
@@ -392,18 +434,28 @@ async function renderDeletion() {
const d = r.body && r.body.deletion;
const d = r.body && r.body.deletion;
const row = ovEl('div', '', 'btn-row');
const row = ovEl('div', '', 'btn-row');
if (d) {
if (d) {
- body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Download your data before then if you want a copy.`));
+ body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Until then it stays fully usable, and you can keep it at any time before that date. Download your data before then if you want a copy.`));
const keep = ovEl('button', 'Keep my account', 'btn btn-primary');
const keep = ovEl('button', 'Keep my account', 'btn btn-primary');
keep.type = 'button';
keep.type = 'button';
keep.onclick = async () => {
keep.onclick = async () => {
keep.disabled = true;
keep.disabled = true;
const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });
const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });
- if (c.ok) toast('Deletion cancelled. Your account stays.', 'success');
+ if (c.ok) {
+ if (window.OpenVibeDeletion) window.OpenVibeDeletion.removeBanner();
+ toast('Kept. Your account stays.', 'success');
+ }
renderDeletion();
renderDeletion();
};
};
row.appendChild(keep); body.appendChild(row);
row.appendChild(keep); body.appendChild(row);
return;
return;
}
}
+ body.appendChild(ovEl('p', 'Your account stays usable for 30 days after you ask, and you can keep it at any time before then. On the day, your account and what the OpenVibe services keep about you are erased; that cannot be undone.'));
+ const freshLeft = freshSignInLeft();
+ const freshLine = ovEl('p', freshLeft > 0
+ ? `You signed in again just now: you have about ${Math.max(1, Math.ceil(freshLeft / 60))} minute${Math.ceil(freshLeft / 60) === 1 ? '' : 's'} to confirm.`
+ : 'For your security, this asks you to sign in again first. A fresh sign-in gives you 10 minutes to confirm.');
+ freshLine.id = 'delete-fresh-line';
+ body.appendChild(freshLine);
const field = ovEl('div', '', 'form-field');
const field = ovEl('div', '', 'form-field');
const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);
const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);
const input = ovEl('input');
const input = ovEl('input');
@@ -412,23 +464,40 @@ async function renderDeletion() {
@@ -412,23 +464,40 @@ async function renderDeletion() {
field.append(label, input);
field.append(label, input);
const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');
const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');
go.type = 'button';
go.type = 'button';
+ const addReauth = () => {
+ if (row.querySelector('.ov-delete-reauth')) return;
+ const again = ovEl('button', 'Sign in again', 'btn btn-outline ov-delete-reauth');
+ again.type = 'button';
+ again.onclick = reauthForDeletion;
+ row.appendChild(again);
+ };
+ if (freshLeft <= 0) addReauth();
go.onclick = async () => {
go.onclick = async () => {
go.disabled = true;
go.disabled = true;
const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });
const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });
go.disabled = false;
go.disabled = false;
if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {
if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {
- toast('To make sure it is you, sign in again, then come back here and confirm.', 'error');
- const again = ovEl('button', 'Sign in again', 'btn btn-outline');
- again.type = 'button'; again.onclick = addAccount;
- if (!row.querySelector('.btn-outline')) row.appendChild(again);
+ toast('To make sure it is you, sign in again. You will then have 10 minutes to confirm, and come straight back here.', 'error');
+ addReauth();
return;
return;
}
}
if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }
if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }
- toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. You can cancel until then.`, 'success');
+ if (window.OpenVibeDeletion) window.OpenVibeDeletion.showBanner(s.body);
+ toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. It stays usable until then, and you can keep it at any time before that date.`, 'success');
renderDeletion();
renderDeletion();
};
};
row.appendChild(go);
row.appendChild(go);
body.append(field, row);
body.append(field, row);
+ if (OV_DELETE_DEEP_LINK && !body.dataset.ovDeleteFocused) {
+ body.dataset.ovDeleteFocused = '1';
+ setTimeout(() => {
+ try {
+ const card = document.getElementById('delete-card');
+ if (card && card.scrollIntoView) card.scrollIntoView({ block: 'center' });
+ input.focus();
+ } catch { /* */ }
+ }, 60);
+ }
}
}
function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
@@ -71,6 +71,7 @@
@@ -71,6 +71,7 @@
<!-- Login Form -->
<!-- Login Form -->
<div class="form-panel" id="panel-login">
<div class="form-panel" id="panel-login">
+ <p class="panel-note" id="reauth-note" hidden>For your security, sign in again with your password. You then have 10 minutes to finish.</p>
<div class="message" id="login-msg"></div>
<div class="message" id="login-msg"></div>
<form id="login-form" onsubmit="handleLogin(event)">
<form id="login-form" onsubmit="handleLogin(event)">
<div class="field">
<div class="field">
@@ -90,6 +91,13 @@
@@ -90,6 +91,13 @@
<a class="back-link" id="login-back" onclick="showChooser()" style="display:none">← Back to saved accounts</a>
<a class="back-link" id="login-back" onclick="showChooser()" style="display:none">← Back to saved accounts</a>
</div>
</div>
+ <!-- A scheduled deletion: the same choice as the banner, before this sign-in continues -->
+ <div class="form-panel" id="panel-keep">
+ <p class="panel-note" id="keep-note"></p>
+ <button type="button" class="submit-btn" id="keep-account-btn">Keep my account</button>
+ <a class="inline-link" id="keep-continue" href="#" onclick="return false">Continue signing in</a>
+ </div>
+
<!-- Register Form -->
<!-- Register Form -->
<div class="form-panel" id="panel-register">
<div class="form-panel" id="panel-register">
<div class="message" id="register-msg"></div>
<div class="message" id="register-msg"></div>
@@ -182,6 +190,7 @@
@@ -182,6 +190,7 @@
<div class="login-toast" id="toast"></div>
<div class="login-toast" id="toast"></div>
<script src="/js/login.js" defer></script>
<script src="/js/login.js" defer></script>
+ <script src="/js/account-safety.js" defer></script>
<script src="/shared/ov-mark.js" async></script>
<script src="/shared/ov-mark.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</body>
@@ -258,7 +258,7 @@
@@ -258,7 +258,7 @@
</div>
</div>
<div class="card" id="delete-card" style="margin-top:16px">
<div class="card" id="delete-card" style="margin-top:16px">
<h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Delete your account</h3>
<h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Delete your account</h3>
- <p>Your account is deleted 30 days after you ask, and you can cancel any time before then; until that date everything works as usual. On the day, your account here is erased and every service erases what it keeps about you: messages, posts, follows, media and settings. Replies other people wrote under your posts stay, without your name. Payment records are kept without your name, as the law requires. After that it cannot be undone, and your username becomes free for someone else.</p>
+ <p>Your account stays usable for 30 days after you ask, and you can keep it at any time before then. On the day, your account here is erased and every service erases what it keeps about you: messages, posts, follows, media and settings. Replies other people wrote under your posts stay, without your name. Payment records are kept without your name, as the law requires. After that it cannot be undone, and your username becomes free for someone else.</p>
<div id="delete-body" style="margin-top:12px"></div>
<div id="delete-body" style="margin-top:12px"></div>
</div>
</div>
</div>
</div>
@@ -539,6 +539,7 @@
@@ -539,6 +539,7 @@
<!-- Shared scripts -->
<!-- Shared scripts -->
<script src="/shared/theme-loader.js"></script>
<script src="/shared/theme-loader.js"></script>
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
+<script src="/js/account-safety.js" defer></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
<script src="/shared/account-switcher.js"></script>
<!-- The kit's behaviour in the page: the copy button on the Account ID (data-ovk-copy). -->
<!-- The kit's behaviour in the page: the copy button on the Account ID (data-ovk-copy). -->
@@ -32,6 +32,7 @@
@@ -32,6 +32,7 @@
<script src="/shared/theme-loader.js"></script>
<script src="/shared/theme-loader.js"></script>
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
+<script src="/js/account-safety.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/js/wallet/money.js" defer></script>
<script src="/js/wallet/money.js" defer></script>
<script src="/js/wallet/common.js" defer></script>
<script src="/js/wallet/common.js" defer></script>
@@ -94,6 +94,7 @@ p{color:var(--text-secondary);font-size:14px;line-height:1.6}
@@ -94,6 +94,7 @@ p{color:var(--text-secondary);font-size:14px;line-height:1.6}
</section>
</section>
</main>
</main>
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
+<script src="/js/account-safety.js" defer></script>
<script>
<script>
(function () {
(function () {
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'network', apiBase: location.origin }); } catch (e) { /* */ } }
if (window.OpenVibeNavbar) { try { OpenVibeNavbar.init({ service: 'network', apiBase: location.origin }); } catch (e) { /* */ } }
@@ -172,6 +172,7 @@
@@ -172,6 +172,7 @@
<script src="/shared/theme-loader.js"></script>
<script src="/shared/theme-loader.js"></script>
<script src="/shared/navbar.js"></script>
<script src="/shared/navbar.js"></script>
+<script src="/js/account-safety.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/shared/kit-web.js" defer></script>
<script src="/js/wallet/money.js" defer></script>
<script src="/js/wallet/money.js" defer></script>
<script src="/js/wallet/common.js" defer></script>
<script src="/js/wallet/common.js" defer></script>
@@ -33,6 +33,7 @@ const express = require('express');
@@ -33,6 +33,7 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { requireOwner } = require('../auth/owner-guard');
const { requireOwner } = require('../auth/owner-guard');
+const { reauthUrl } = require('../auth/reauth');
const alerts = require('../operator/alerts');
const alerts = require('../operator/alerts');
const { isFreshSignIn } = require('../identity/account-merge');
const { isFreshSignIn } = require('../identity/account-merge');
@@ -65,8 +66,8 @@ const jsonType = (type) => /^application\/([a-z.+-]+\+)?json\b/i.test(String(typ
@@ -65,8 +66,8 @@ const jsonType = (type) => /^application\/([a-z.+-]+\+)?json\b/i.test(String(typ
function signInUrl(config = {}) {
function signInUrl(config = {}) {
const base = String(config.baseUrl || config.loginUrl || 'https://openvibe.network').replace(/\/+$/, '');
const base = String(config.baseUrl || config.loginUrl || 'https://openvibe.network').replace(/\/+$/, '');
- const login = String(config.loginUrl || base).replace(/\/+$/, '') + '/login';
- return `${login}?return=${encodeURIComponent(`${base}/admin#ai`)}`;
+ const login = String(config.loginUrl || base).replace(/\/+$/, '');
+ return reauthUrl(login, `${base}/admin#ai`);
}
}
/** The session's real sign-in time, the OIDC auth_time Network's signToken puts on every session token. The rule is
/** The session's real sign-in time, the OIDC auth_time Network's signToken puts on every session token. The rule is
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+'use strict';
+/**
+ * One re-authentication URL for every fresh-sign-in action (account deletion, account merge, the owner's AI
+ * key/money settings, Locker reset/forget, QR transfer rules). The login page's ?reauth=1 always asks for the
+ * password, even when a session exists; ?add_account=1 hands that session back with its old auth_time, so the
+ * fresh check never passes and the person loops.
+ */
+function reauthUrl(baseUrl, returnTo = '/') {
+ const base = String(baseUrl || 'https://openvibe.network').replace(/\/+$/, '');
+ const back = String(returnTo || '/');
+ return `${base}/login?reauth=1&return=${encodeURIComponent(back)}`;
+}
+
+module.exports = { reauthUrl };
@@ -123,6 +123,7 @@ ${snippet('bp-c5', 'Keep a file out (HTTP header)', 'X-Robots-Tag: OpenVibeBot:
@@ -123,6 +123,7 @@ ${snippet('bp-c5', 'Keep a file out (HTTP header)', 'X-Robots-Tag: OpenVibeBot:
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
${require('../boost').SCRIPT}
${require('../boost').SCRIPT}
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}
document.querySelectorAll('.bp-copy').forEach(function(b){b.addEventListener('click',function(){var el=document.getElementById(b.getAttribute('data-copy'));if(!el||!navigator.clipboard)return;navigator.clipboard.writeText(el.textContent).then(function(){b.textContent='Copied';b.classList.add('is-done');setTimeout(function(){b.textContent='Copy';b.classList.remove('is-done');},1600);},function(){});});});});</script>
document.querySelectorAll('.bp-copy').forEach(function(b){b.addEventListener('click',function(){var el=document.getElementById(b.getAttribute('data-copy'));if(!el||!navigator.clipboard)return;navigator.clipboard.writeText(el.textContent).then(function(){b.textContent='Copied';b.classList.add('is-done');setTimeout(function(){b.textContent='Copy';b.classList.remove('is-done');},1600);},function(){});});});});</script>
@@ -475,7 +475,9 @@ app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('.
@@ -475,7 +475,9 @@ app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('.
const accountMerge = require('./identity/account-merge');
const accountMerge = require('./identity/account-merge');
await accountMerge.ensureSchema(db);
await accountMerge.ensureSchema(db);
const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });
const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });
- app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
+ // Only the merge calls spend this budget: the same mount also carries the export/deletion routes below their
+ // own limiter, and counting those twice was enough to lock a person out while the deletion banner read them.
+ app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20, skip: (req) => !String(req.path).startsWith('/merge') }), mergeRouters.me);
app.use('/api/admin/account-merges', mergeRouters.admin);
app.use('/api/admin/account-merges', mergeRouters.admin);
}
}
// Mod principals (roadmap WS-M task 3, ADR-013): the runtime that installs a mod registers mod:<mod_id> and changes
// Mod principals (roadmap WS-M task 3, ADR-013): the runtime that installs a mod registers mod:<mod_id> and changes
@@ -501,7 +503,13 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -501,7 +503,13 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
contributeGuard: guard('network.account.export.contribute'), confirmGuard: guard('network.account.deletion.confirm'),
contributeGuard: guard('network.account.export.contribute'), confirmGuard: guard('network.account.deletion.confirm'),
});
});
- app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), dataRouters.me);
+ // The deletion banner on every Network page reads GET /deletion once per page; it is a read-only, signed-in
+ // question and must not use up the 20-a-minute budget that the export/deletion writes need (a person who
+ // navigates 20 pages would otherwise be unable to schedule or keep the account).
+ app.use('/api/v1/account', rateLimit({
+ windowMs: 60_000, max: 20,
+ skip: (req) => req.method === 'GET' && req.path === '/deletion',
+ }), dataRouters.me);
app.use('/internal', dataRouters.internal);
app.use('/internal', dataRouters.internal);
app.use('/api/admin/account-deletions', dataRouters.admin);
app.use('/api/admin/account-deletions', dataRouters.admin);
}
}
@@ -63,6 +63,7 @@ ${nav}
@@ -63,6 +63,7 @@ ${nav}
</main>
</main>
${footer}
${footer}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
</body>
</body>
</html>`;
</html>`;
@@ -451,6 +451,7 @@ ${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsH
@@ -451,6 +451,7 @@ ${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsH
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
</body>
</body>
</html>`;
</html>`;
@@ -81,6 +81,7 @@ ${body}
@@ -81,6 +81,7 @@ ${body}
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
${require('../boost').SCRIPT}
${require('../boost').SCRIPT}
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
</body>
</body>
@@ -369,6 +369,7 @@ ${busy ? `<section class="os-sec" aria-labelledby="h-busy"><h2 id="h-busy">Busie
@@ -369,6 +369,7 @@ ${busy ? `<section class="os-sec" aria-labelledby="h-busy"><h2 id="h-busy">Busie
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
${require('../boost').SCRIPT}
${require('../boost').SCRIPT}
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
</body>
</body>
@@ -238,6 +238,7 @@ ${sloRows}
@@ -238,6 +238,7 @@ ${sloRows}
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
${boost.SCRIPT}
${boost.SCRIPT}
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
</body>
</body>
@@ -165,6 +165,7 @@ ${dayHtml || '<p class="ov-shipped-empty">The update history could not be loaded
@@ -165,6 +165,7 @@ ${dayHtml || '<p class="ov-shipped-empty">The update history could not be loaded
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact', updates: '/updates' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact', updates: '/updates' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
+<script src="/js/account-safety.js" defer></script>
<script src="/shared/shipped.js" defer></script>
<script src="/shared/shipped.js" defer></script>
${boost.SCRIPT}
${boost.SCRIPT}
<script>document.addEventListener('ov:boost:load',function(){if(window.OpenVibeShipped)OpenVibeShipped.scan(document.getElementById('main'));});</script>
<script>document.addEventListener('ov:boost:load',function(){if(window.OpenVibeShipped)OpenVibeShipped.scan(document.getElementById('main'));});</script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,257 @@
@@ -0,0 +1,257 @@
+'use strict';
+// The account-deletion loop (moosely, 2026-11-06):
+// - "Sign in again" must be a real re-authentication. The new /login?reauth=1 path always shows the password
+// form (a saved session is not handed back), the new token carries auth_time = now, and the person comes back
+// to /my#delete with the username field ready and the 10-minute window stated.
+// - The old path (add_account=1) is what reused the session; these checks fail if the delete step goes back to it.
+// - While a deletion is scheduled, every page shows the banner with one "Keep my account" button, and the
+// sign-in page offers the same choice first.
+// node test/account-deletion-reauth.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const vm = require('vm');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const jwt = require('jsonwebtoken');
+const bcrypt = require('bcryptjs');
+const { parseHTML } = require('linkedom');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const accountMerge = require('../server/identity/account-merge');
+const accountData = require('../server/identity/account-data');
+const authRoutes = require('../server/auth/routes');
+const { makeRequireAuth } = require('../server/auth/session');
+const staffClaims = require('../server/auth/staff-claims').staffClaims;
+const { reauthUrl } = require('../server/auth/reauth');
+const { signInUrl } = require('../server/admin/ai-config-proxy');
+
+const ROOT = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
+
+// ── The browser helpers: one re-authentication URL, and the banner ───────────
+const safety = read('public/js/account-safety.js');
+function safetyWindow() {
+ const { window: win } = parseHTML('<!doctype html><html><body></body></html>');
+ const store = new Map();
+ const calls = [];
+ const root = {
+ document: win.document,
+ location: { origin: 'https://openvibe.network' },
+ localStorage: {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+ },
+ fetch: async (url, opts = {}) => {
+ calls.push({ url: String(url), method: opts.method || 'GET', headers: opts.headers || {} });
+ if ((opts.method || 'GET') === 'DELETE') {
+ return { ok: true, status: 200, json: async () => ({ status: 'cancelled', deletion_id: 'del_x' }) };
+ }
+ return { ok: true, status: 200, json: async () => ({ deletion: { delete_after: new Date(Date.now() + 30 * 86400000).toISOString() } }) };
+ },
+ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
+ dispatchEvent: () => {},
+ };
+ const context = {
+ window: root, document: root.document, localStorage: root.localStorage, fetch: root.fetch,
+ URLSearchParams, Date, JSON, Number, String, Object, setTimeout, clearTimeout, console,
+ };
+ vm.runInNewContext(safety, context);
+ return { root, calls, api: root.OpenVibeDeletion, reauth: root.OpenVibeReauth };
+}
+
+const bannerTest = (async () => {
+ const { root, calls, api, reauth } = safetyWindow();
+ assert.strictEqual(reauth.url('/my#delete'), '/login?reauth=1&return=%2Fmy%23delete', 'the deletion return target');
+ assert.strictEqual(reauth.url('/my#accounts', { account: '' }), '/login?reauth=1&login_hint=&return=%2Fmy%23accounts', 'merge leaves the username blank');
+ assert.strictEqual(reauth.url('/admin#ai', { account: 'goosely' }), '/login?reauth=1&login_hint=goosely&return=%2Fadmin%23ai');
+ assert.match(api.bannerText('2030-01-02T03:04:00.000Z'), /^Your account will be deleted on .+\.$/);
+ assert.strictEqual(api.sessionToken(), null, 'signed out: no banner');
+
+ // A saved account (the normal case) is found from localStorage.
+ root.localStorage.setItem('openvibe_accounts', JSON.stringify([{ id: 7, username: 'Me', token: 'tok-saved' }]));
+ root.localStorage.setItem('openvibe_active_account', '7');
+ assert.strictEqual(api.sessionToken(), 'tok-saved');
+ root.localStorage.setItem('openvibe_active_account', 'anon');
+ assert.strictEqual(api.sessionToken(), null, 'an anonymous session has no account to delete');
+
+ // The banner itself: the get, the exact sentence, the one Keep button, the DELETE.
+ root.localStorage.setItem('openvibe_active_account', '7');
+ const shown = await api.refreshBanner();
+ assert.ok(shown && shown.delete_after, 'the banner came from GET /api/v1/account/deletion');
+ const banner = root.document.getElementById('ov-deletion-banner');
+ assert.ok(banner, 'the banner is on the page');
+ assert.match(banner.textContent, /Your account will be deleted on /);
+ const keep = root.document.getElementById('ov-deletion-keep');
+ assert.strictEqual(keep.textContent, 'Keep my account');
+ assert.strictEqual(banner.querySelectorAll('button').length, 1, 'exactly one button, which cancels it');
+ keep.click();
+ await new Promise((r) => setTimeout(r, 20));
+ const del = calls.find((c) => c.method === 'DELETE');
+ assert.ok(del, 'Keep my account calls DELETE /api/v1/account/deletion');
+ assert.strictEqual(del.url, 'https://openvibe.network/api/v1/account/deletion');
+ assert.strictEqual(del.headers.Authorization, 'Bearer tok-saved');
+ assert.match(root.document.getElementById('ov-deletion-banner').textContent, /Kept\. Your account stays\./);
+ api.removeBanner();
+ assert.strictEqual(root.document.getElementById('ov-deletion-banner'), null);
+})();
+
+// ── The delete step uses the re-authentication path, never add_account ──────
+const profile = read('public/js/my/profile.js');
+assert.match(profile, /function reauthForDeletion\(\)[\s\S]*?\/login\?reauth=1&return=/, 'the Sign in again target asks for a real re-authentication');
+assert.match(profile, /again\.onclick = reauthForDeletion/, 'the 401 prompt signs in again through reauth');
+assert.doesNotMatch(profile, /again\.onclick = addAccount/, 'the old add_account path is gone: it reuses the session and the loop comes back');
+assert.match(profile, /Your account stays usable for 30 days after you ask, and you can keep it at any time before then/, 'the step says the account stays usable and can be kept');
+assert.match(profile, /A fresh sign-in gives you 10 minutes to confirm/, 'the step states the 10-minute window');
+assert.match(profile, /window\.OpenVibeReauth\.go\('\/my#delete'\)/, 'a reauth return to the delete step');
+assert.match(profile, /OpenVibeDeletion\.showBanner\(s\.body\)/, 'the banner appears as soon as the deletion is scheduled');
+assert.match(profile, /hash === 'delete'|OV_DELETE_DEEP_LINK/, 'the return target is understood');
+assert.match(profile, /ov:deletion-cancelled/, 'the banner can refresh this card');
+assert.match(read('public/js/my/core.js'), /hash === 'delete' \? 'accounts' : hash/, 'core.js maps the #delete return target to the accounts section');
+// The banner reads GET /deletion on every page; that read must not spend the 20-a-minute write budget.
+const index = read('server/index.js');
+assert.match(index, /skip: \(req\) => req\.method === 'GET' && req\.path === '\/deletion'/, 'GET /deletion is not rate-limited with the writes it feeds');
+assert.match(index, /skip: \(req\) => !String\(req\.path\)\.startsWith\('\/merge'\)/, 'the merge limiter does not count the account data routes');
+
+// ── /login?reauth=1 always asks for the password ────────────────────────────
+const login = read('public/js/login.js');
+function block(from, name) {
+ const start = login.indexOf(from);
+ const fnStart = login.indexOf(`function ${name}(`, start);
+ assert.ok(start >= 0 && fnStart > start, `${name} exists`);
+ let depth = 0;
+ for (let i = login.indexOf('{', fnStart); i < login.length; i++) {
+ if (login[i] === '{') depth++;
+ else if (login[i] === '}' && --depth === 0) return login.slice(start, i + 1);
+ }
+ throw new Error(`unbalanced ${name}`);
+}
+function reauthUser(query, accounts, active) {
+ const params = new URLSearchParams(query);
+ const context = {
+ params, accounts, String,
+ localStorage: { getItem: (k) => (k === 'openvibe_active_account' ? active : null) },
+ };
+ vm.runInNewContext(`${block("const isReauth = params.get('reauth') === '1';", 'reauthUsername')}; this.out = reauthUsername(accounts);`, context);
+ return context.out;
+}
+assert.strictEqual(reauthUser('reauth=1', [{ id: 7, username: 'Me' }], '7'), 'Me', 'the active account is filled in');
+assert.strictEqual(reauthUser('reauth=1&login_hint=%40Alt', [{ id: 7, username: 'Me' }], '7'), 'Alt', 'login_hint wins (merge uses another account)');
+assert.strictEqual(reauthUser('reauth=1&login_hint=', [{ id: 7, username: 'Me' }], '7'), '', 'an empty login_hint leaves the merge username blank');
+assert.strictEqual(reauthUser('reauth=1', [{ id: 8, username: 'First' }], null), 'First', 'no active account: the first saved one');
+assert.match(login, /const isReauth = params\.get\('reauth'\) === '1'/, 'the page knows reauth mode');
+const init = login.slice(login.indexOf('(function init() {'));
+assert.match(init, /if \(isReauth\) \{\s*\n\s*showAuthForm\('login'\);[\s\S]*?return;\s*\n\s*\}/, 'reauth shows the password form, never the chooser');
+assert.ok(init.indexOf('if (isReauth)') < init.indexOf('if (hasAccounts)'), 'the reauth form is chosen before the saved-account chooser');
+assert.match(init, /if \(isReauth\)[\s\S]*?back\.style\.display = 'none'/, 'no "back to saved accounts" shortcut in reauth');
+assert.match(login, /guardScheduledDeletion\(data\.token, \(\) => finishSignIn\(data\)\)/, 'a password sign-in checks a scheduled deletion first');
+assert.match(login, /guardScheduledDeletion\(validToken, \(\) => continueAsAccount/, 'a saved-account sign-in checks it too');
+assert.match(login, /OpenVibeDeletion\.cancelDeletion\(token\)/, 'Keep my account cancels the scheduled deletion there');
+assert.match(login, /panel-keep/, 'the sign-in page shows the same choice before continuing');
+
+// ── Every user-facing page loads the banner and the helper ──────────────────
+const pages = [
+ 'public/index.html', 'public/my.html', 'public/wallet.html', 'public/pay.html', 'public/admin.html',
+ 'public/verify-email.html', 'public/login.html',
+ 'server/sites/page.js', 'server/bot/page.js', 'server/stats/open.js', 'server/status/routes.js',
+ 'server/profile/public.js', 'server/not-found.js', 'server/updates/routes.js',
+];
+for (const p of pages) assert.ok(read(p).includes('/js/account-safety.js'), `${p} loads the banner and reauth helper`);
+assert.match(read('public/login.html'), /id="keep-account-btn"/);
+assert.match(read('public/login.html'), /id="reauth-note"/);
+assert.match(read('public/my.html'), /id="delete-card"/);
+assert.match(read('public/my.html'), /stays usable for 30 days/);
+
+// ── Server: a stale token is refused, a real re-login is fresh, the banner GET answers, DELETE keeps ─────
+const serverTest = async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-delete-reauth-'));
+ const db = getDb();
+ await accountMerge.ensureSchema(db);
+ await accountData.ensureSchema(db);
+
+ const ISSUER = 'https://openvibe.network';
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+ const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, baseUrl: ISSUER, loginUrl: ISSUER };
+ process.env.OWNER_USERNAME = 'boss';
+
+ const hash = bcrypt.hashSync('secret12', 4);
+ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?) RETURNING id')
+ .run('looped', '[email protected]', hash, 'user')).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+
+ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
+ const app = express();
+ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ app.use(cookieParser());
+ app.use(express.json());
+ app.use('/api/auth', authRoutes);
+ app.use('/api/v1/account', accountData.routers({
+ requireAuth, staffClaims, dir, notify: () => {},
+ contributeGuard: (_req, _res, next) => next(), confirmGuard: (_req, _res, next) => next(),
+ }).me);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = (method, p, token, body) => fetch(base + p, {
+ method,
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+
+ try {
+ const first = await call('POST', '/api/auth/login', null, { username: 'looped', password: 'secret12' });
+ assert.strictEqual(first.status, 200, 'the person signs in');
+ const firstClaims = jwt.decode(first.body.token);
+ assert.ok(Math.abs(firstClaims.auth_time - Math.floor(Date.now() / 1000)) < 10, 'a sign-in carries auth_time = now');
+
+ // The old path: the session is already signed in (auth_time 11 minutes ago). It is not fresh.
+ const stale = jwt.sign({ sub: user.id, id: user.id, subject_id: user.subject_id, username: user.username, role: 'user',
+ auth_time: Math.floor(Date.now() / 1000) - 11 * 60, iat: Math.floor(Date.now() / 1000) }, keys.privateKey,
+ { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+ const denied = await call('POST', '/api/v1/account/deletion', stale, { confirm_username: 'looped' });
+ assert.strictEqual(denied.status, 401, 'an already-signed-in session is refused');
+ assert.strictEqual(denied.body.error, 'deletion.sign_in_again');
+
+ // A real re-authentication: POST /api/auth/login is what /login?reauth=1 does. The new token is fresh.
+ const again = await call('POST', '/api/auth/login', null, { username: 'looped', password: 'secret12' });
+ assert.strictEqual(again.status, 200, 'reauth signs in again');
+ assert.notStrictEqual(again.body.token, first.body.token, 'a new token, not the session handed back');
+ const freshClaims = jwt.decode(again.body.token);
+ assert.ok(Math.abs(freshClaims.auth_time - Math.floor(Date.now() / 1000)) < 10, 'the reauthenticated token is fresh');
+
+ const wrong = await call('POST', '/api/v1/account/deletion', again.body.token, { confirm_username: 'not-me' });
+ assert.strictEqual(wrong.status, 400, 'the username is still typed exactly');
+
+ const scheduled = await call('POST', '/api/v1/account/deletion', again.body.token, { confirm_username: 'looped' });
+ assert.strictEqual(scheduled.status, 201, 'the fresh re-authentication schedules the deletion');
+ assert.strictEqual(scheduled.body.status, 'scheduled');
+ assert.ok(Date.parse(scheduled.body.delete_after) > Date.now() + 29 * 86400000, 'it is 30 days ahead');
+
+ const status = await call('GET', '/api/v1/account/deletion', again.body.token);
+ assert.strictEqual(status.status, 200);
+ assert.strictEqual(status.body.deletion.deletion_id, scheduled.body.deletion_id, 'the banner GET sees it on every page');
+
+ const kept = await call('DELETE', '/api/v1/account/deletion', again.body.token);
+ assert.strictEqual(kept.status, 200, 'Keep my account cancels it');
+ assert.strictEqual(kept.body.status, 'cancelled');
+ const after = await call('GET', '/api/v1/account/deletion', again.body.token);
+ assert.strictEqual(after.body.deletion, null, 'nothing is left scheduled (nothing ever needed cancelling for moosely)');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+};
+
+// ── The server helper: AI's fresh-required link is the same re-auth path ────
+assert.strictEqual(reauthUrl('https://openvibe.network', '/my#delete'), 'https://openvibe.network/login?reauth=1&return=%2Fmy%23delete');
+assert.strictEqual(signInUrl({ loginUrl: 'https://openvibe.network', baseUrl: 'https://openvibe.network' }),
+ 'https://openvibe.network/login?reauth=1&return=https%3A%2F%2Fopenvibe.network%2Fadmin%23ai');
+
+Promise.all([bannerTest, serverTest()]).then(() => console.log('account deletion reauth: all checks passed')).catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+});
@@ -149,7 +149,7 @@ function summary(service, namespace, extra = {}) {
@@ -149,7 +149,7 @@ function summary(service, namespace, extra = {}) {
r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'PUT', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { value: SECRET, reason: 'test' });
r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'PUT', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { value: SECRET, reason: 'test' });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.body.code, 'config.fresh_required');
assert.strictEqual(r.body.code, 'config.fresh_required');
- assert.match(r.body.sign_in_url, /\/login\?return=.*%2Fadmin%23ai/, 'the sign-in returns to /admin#ai');
+ assert.match(r.body.sign_in_url, /\/login\?reauth=1&return=.*%2Fadmin%23ai/, 'the sign-in asks for the password again and returns to /admin#ai');
r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'POST', '/api/admin/ai/config/ai/ai.exchange', { values: { paid_requests: true }, reason: 'test' });
r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'POST', '/api/admin/ai/config/ai/ai.exchange', { values: { paid_requests: true }, reason: 'test' });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.body.code, 'config.fresh_required');
assert.strictEqual(r.body.code, 'config.fresh_required');
@@ -69,11 +69,16 @@ assert.deepStrictEqual(run({ chosen: 7, query: 'client_id=a&redirect_uri=x&promp
@@ -69,11 +69,16 @@ assert.deepStrictEqual(run({ chosen: 7, query: 'client_id=a&redirect_uri=x&promp
assert.deepStrictEqual(run({ accounts: [{ ...me, is_anon: true }], chosen: 7 }).picked, [], 'an anonymous account is never assumed');
assert.deepStrictEqual(run({ accounts: [{ ...me, is_anon: true }], chosen: 7 }).picked, [], 'an anonymous account is never assumed');
assert.deepStrictEqual(run({ chosen: 7, query: 'return=/my' }).picked, [], 'not an app sign-in');
assert.deepStrictEqual(run({ chosen: 7, query: 'return=/my' }).picked, [], 'not an app sign-in');
-// Every way off this page to a site remembers the account, so that site's sign-in continues as it.
+// Every way off this page to a site remembers the account, so that site's sign-in continues as it. A scheduled
+// deletion is asked first (guardScheduledDeletion), so the continuation lives in its own function.
const select = block('async function selectAccount(account)', 'selectAccount');
const select = block('async function selectAccount(account)', 'selectAccount');
-assert.match(select, /\} else \{[\s\S]*rememberChoice\(account\.id\);[\s\S]*goBack\(/, 'switching (not an app) remembers the account and goes back');
+assert.match(select, /guardScheduledDeletion\(validToken, \(\) => continueAsAccount\(account, validToken\)\)/, 'a saved account checks for a scheduled deletion first');
+const cont = block('async function continueAsAccount(account, validToken)', 'continueAsAccount');
+assert.match(cont, /\} else \{[\s\S]*rememberChoice\(account\.id\);[\s\S]*goBack\(/, 'switching (not an app) remembers the account and goes back');
const login = block('async function handleLogin(e)', 'handleLogin');
const login = block('async function handleLogin(e)', 'handleLogin');
-assert.match(login, /rememberChoice\(data\.user\.id\);\s*\n[^\n]*\n\s*goBack\(800\);/, 'a new sign-in (add another account) too');
+assert.match(login, /guardScheduledDeletion\(data\.token, \(\) => finishSignIn\(data\)\)/, 'a password sign-in checks for a scheduled deletion first');
+const finish = block('async function finishSignIn(data)', 'finishSignIn');
+assert.match(finish, /rememberChoice\(data\.user\.id\);\s*\n[^\n]*\n\s*goBack\(800\);/, 'a new sign-in (add another account) too');
// The page's init: switch_to switches (or asks for that account's password), add_account and prompt=login show the form.
// The page's init: switch_to switches (or asks for that account's password), add_account and prompt=login show the form.
const init = js.slice(js.indexOf('(function init() {'));
const init = js.slice(js.indexOf('(function init() {'));
@@ -15,8 +15,8 @@ const BUDGETS = {
@@ -15,8 +15,8 @@ const BUDGETS = {
htmlRawKB: 196, // 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
htmlRawKB: 196, // 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
htmlBrotliKB: 36.5, // 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
htmlBrotliKB: 36.5, // 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
- jsRawKB: 345.1, // 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
- jsBrotliKB: 84, // 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9
+ jsRawKB: 354, // 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
+ jsBrotliKB: 86.5, // 86.2 with the account-safety helper on every page (2026-11-06: +2.2 KB brotli, the same feature); 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9
cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)
cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)
externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon
externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon
};
};