openvibers/openvibe.network Commits

Account deletion: real re-authentication and a Keep my account banner (#592)

OP OpenVibers committed 7 days ago · eb6ad41a9d · 27 files, +688 −28

The delete step's 'Sign in again' used add_account=1, which can hand back the signed-in session with its old auth_time; the 10-minute fresh check then never passed and the person looped between /my and /login. Add /login?reauth=1, which always shows the password form even when a session exists (hiding the chooser/back shortcut), prefills the username, returns to /my#delete and states the 10-minute window. One OpenVibeReauth helper now serves deletion, account merge and the owner's AI settings; server/auth/reauth.js gives the same URL to AI's fresh-required response.

While a deletion is scheduled, every user-facing Network page loads account-safety.js and shows 'Your account will be deleted on <date>. Keep my account', which cancels through DELETE /api/v1/account/deletion. Every sign-in path (saved account or password, OAuth or Network) shows the same Keep choice before it continues. GET /deletion is exempt from the account write limiter and the merge limiter no longer counts the export/deletion routes, so the page-wide read cannot lock a person out of keeping their account.

Tests: test/account-deletion-reauth.test.js covers the stale-session refusal, the fresh re-login, the banner DOM and Keep, the +30-day schedule, the status read and the cancel; login-auto-continue and ai-config-proxy updated for the new continuation points and sign-in URL. Full suite 196/196.

Changes

ddiff --git a/docs/sign-in.md b/docs/sign-in.md
iindex c7c6662a..97af552b 100644
---- a/docs/sign-in.md
++++ b/docs/sign-in.md
@@@ -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`). |
ddiff --git a/public/admin.html b/public/admin.html
iindex d43a3157..9d5cb72d 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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>
ddiff --git a/public/index.html b/public/index.html
iindex 0035c0a9..0b3ae238 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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>
ddiff --git a/public/js/account-safety.js b/public/js/account-safety.js
nnew file mode 100644
iindex 00000000..149d612a
---- /dev/null
++++ b/public/js/account-safety.js
@@@ -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);
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex 72d66fd8..cacc5fa0 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -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;
}
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 4d117b4f..64025c7e 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -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 URL
try {
@@@ -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');
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex 39423105..6669716e 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -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');
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 811afbf1..2e250dc0 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -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 data
async 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); }
ddiff --git a/public/login.html b/public/login.html
iindex a64fd371..88c4d1ae 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -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>
ddiff --git a/public/my.html b/public/my.html
iindex 7ad0f3f6..5997ce97 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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). -->
ddiff --git a/public/pay.html b/public/pay.html
iindex 09980e10..fe8a676e 100644
---- a/public/pay.html
++++ b/public/pay.html
@@@ -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>
ddiff --git a/public/verify-email.html b/public/verify-email.html
iindex eb763e91..dd55e128 100644
---- a/public/verify-email.html
++++ b/public/verify-email.html
@@@ -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) { /* */ } }
ddiff --git a/public/wallet.html b/public/wallet.html
iindex 247d8f85..a9f342cb 100644
---- a/public/wallet.html
++++ b/public/wallet.html
@@@ -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>
ddiff --git a/server/admin/ai-config-proxy.js b/server/admin/ai-config-proxy.js
iindex d324b873..a4ab7fdf 100644
---- a/server/admin/ai-config-proxy.js
++++ b/server/admin/ai-config-proxy.js
@@@ -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(typ
function 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 is
ddiff --git a/server/auth/reauth.js b/server/auth/reauth.js
nnew file mode 100644
iindex 00000000..c4dfaae2
---- /dev/null
++++ b/server/auth/reauth.js
@@@ -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 };
ddiff --git a/server/bot/page.js b/server/bot/page.js
iindex 4808d75e..266ddb4a 100644
---- a/server/bot/page.js
++++ b/server/bot/page.js
@@@ -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>
ddiff --git a/server/index.js b/server/index.js
iindex 9a6e2db2..8c3e0b17 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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);
}
ddiff --git a/server/not-found.js b/server/not-found.js
iindex c8ee6cc2..34c41fa4 100644
---- a/server/not-found.js
++++ b/server/not-found.js
@@@ -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>`;
ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex c824b99a..ef76f956 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -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>`;
ddiff --git a/server/sites/page.js b/server/sites/page.js
iindex 78694a20..373e4361 100644
---- a/server/sites/page.js
++++ b/server/sites/page.js
@@@ -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>
ddiff --git a/server/stats/open.js b/server/stats/open.js
iindex 0c65739e..b8cfaeb6 100644
---- a/server/stats/open.js
++++ b/server/stats/open.js
@@@ -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>
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex e01d42f4..461be5b7 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -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>
ddiff --git a/server/updates/routes.js b/server/updates/routes.js
iindex 2ab381e8..425e9093 100644
---- a/server/updates/routes.js
++++ b/server/updates/routes.js
@@@ -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>
ddiff --git a/test/account-deletion-reauth.test.js b/test/account-deletion-reauth.test.js
nnew file mode 100644
iindex 00000000..cddd0710
---- /dev/null
++++ b/test/account-deletion-reauth.test.js
@@@ -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;
++});
ddiff --git a/test/ai-config-proxy.test.js b/test/ai-config-proxy.test.js
iindex 8d2891a8..c75f4a16 100644
---- a/test/ai-config-proxy.test.js
++++ b/test/ai-config-proxy.test.js
@@@ -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');
ddiff --git a/test/login-auto-continue.test.js b/test/login-auto-continue.test.js
iindex 7934ff93..5cfb0ca9 100644
---- a/test/login-auto-continue.test.js
++++ b/test/login-auto-continue.test.js
@@@ -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({ 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() {'));
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 15bc7a15..f4b6ccfc 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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
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)
-- 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)
externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon
};