openvibers/openvibe.network Commits
Remove account merge feature (#788)
Changes
@@@ -81,7 +81,7 @@ person's username and picture (a service token, `identity.subject.resolve`).## Owns--- accounts, sessions, anonymous identities, canonical subjects (`usr_…`, `gst_…`) and account merges++- accounts, sessions, anonymous identities, canonical subjects (`usr_…`, `gst_…`)- OAuth2/OIDC (the network's token issuer and its signing key), service principals and their grants,developer projects, apps, credentials, grants and quotas (ADR-014), mod principals- notifications and email, themes and theme preferences, user modules, platform blocks, the follow@@@ -471,7 +471,7 @@ the Network (`server/dock/`, migration `0031_dock_states.sql`, contracts 0.251.0with the CORS allow-list and the CSRF rules the other `/api/v1/me/*` routes use.- A write is limited to 60 a minute per person (`429 dock.rate_limited` past that); the page debounces anyway.- The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge-- (ADR-029) leaves it with the surviving account.++ with the surviving account.The full reference is [docs/dock.md](docs/dock.md).@@@ -573,7 +573,7 @@ Namespaces, schemas, writers, public fields and quotas come from openvibe-contrawhen `OV_EVENTS_INTERNAL_URL` is set; rows wait there otherwise). The payload names the owner subject,namespace, schema version, revision, change, reason and the changed keys; values only for fields thenamespace declares public. Revisions only grow for a (person, namespace), deletes included.--- Accounts: `onSubjectRemoved` / `onSubjectMerged` delete or re-key a subject's records with events; triggers++- Accounts: `onSubjectRemoved` deletes a subject's records with events; guest links move portable preferences to a registered account; triggersrefuse deleting a `users`/`anon_users` row, or changing its `subject_id`, while records remain.- Owning service retired (its manifest `status: retired`): the namespace turns read-only;`delete-after-retention` namespaces are emptied `retentionDays` after Network first saw it (daily sweep).@@@ -842,7 +842,7 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.`npm test` runs the 74 test files listed in `package.json` (temp databases, no network). Theycover OAuth with PKCE, OIDC discovery, refresh-token rotation and reuse, SSO and FedCM, subjects,--principals and grants, account merge, export and deletion, developer projects, export tokens and usage,++principals and grants, export and deletion, developer projects, export tokens and usage,user modules, blocks, follows and their authority switch, analytics privacy, the registry and itsexposure, notifications and events, status and incidents, operator alerts, per-actor limits, and thesecurity suites (`security-session`, `security-redirects`, `security-secrets`, `security-private`,@@@ -960,7 +960,7 @@ The same proof covers a backups-only update (the fingerprint does not change).- **A reset** is for the person who lost both their passphrase and their recovery kit: `PUT /api/v1/me/keys`with `{"reset": true}` and no proof, allowed only when the session's sign-in is recent — the sessiontoken's `auth_time` within the last ten minutes (the OIDC claim Network's `signToken` puts on every-- session; the same rule account-merge and the account deletion use). Without it the write is refused with++ session; the same rule the account deletion uses). Without it the write is refused with`403 locker.reauth_required`. A reset replaces the whole locker: new keys, and the backups that come withthem; the old backups wrapped keys that are gone. It is written down as `reset`.- **Forgetting** (`DELETE /api/v1/me/keys`, with `{"confirm": "forget my locker"}`) needs the same proof, over@@@ -38,7 +38,7 @@"deployment": "Public at https://openvibe.network: deployed with `sudo ovhost deploy network --install-units` (deploy/scripts/deploy.sh is the wrapper), /opt/openvibe.network, unit openvibe-network on 127.0.0.1:4000, env /etc/openvibe/network.env.","features": ["SSO identity provider: OAuth2 code flow with PKCE, OIDC, FedCM, rotating refresh tokens",-- "canonical subjects, anonymous users, multi-account switching, account merge, export and deletion",++ "canonical subjects, anonymous users, multi-account switching, export and deletion","service principals and grants; developer projects, apps, credentials, grants, quotas and usage","notifications (realtime through Events), email and web push","themes and preferences, user modules, platform blocks, follow graph, creator analytics",@@@ -90,5 +90,5 @@ Every object refuses extra keys, so a page cannot smuggle a field into the state## Account dataThe row is the person's own, so it goes into their account export (`network/dock.json` in Network's part of the--zip) and is erased with them (ADR-033). A merge (ADR-029) leaves it with the surviving account: the survivor's row++zip) and is erased with the account (ADR-033). The row remains with its ownerwins, and a folded-in row moves over only when the survivor has none.@@@ -37,7 +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. |++| `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, 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`). |@@@ -51,7 +51,7 @@} catch { return null; }}-- /** The one re-authentication URL. `account` prefills the username; pass '' to leave it blank (merge). */++ /** The one re-authentication URL. `account` prefills the username; pass '' to leave it blank. */function reauthUrl(returnTo, options) {const params = new URLSearchParams();params.set('reauth', '1');@@@ -12,7 +12,7 @@ const CONSENT_KIND_LABEL = {posts: 'Posts', pictures: 'Pictures', code: 'Code',};const CONSENT_BY_LABEL = {-- person: 'you', app_revoked: 'the app revoked', merge: 'an account merge', deletion: 'account deletion', import: 'an import',++ person: 'you', app_revoked: 'the app revoked', merge: 'an earlier account action', deletion: 'account deletion', import: 'an import',};function consentEsc(value) {@@@ -130,11 +130,9 @@ async function loadUser() {renderAccountId(data.user);loadThemes(); loadThemeSubmissions();loadAccounts();-- renderMerge();renderExport(); renderDeletion();loadConsent();loadLabelPrefs();-- if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');loadNotifPrefs(); loadQuietHours();loadLinked();loadSessions();@@@ -385,85 +383,6 @@ function loadAccounts() {`).join('');}--// ── Account merge (ADR-029): 1) the account you keep opens a 10-minute request, 2) you sign in to the other--// account ("Add another account"), 3) it confirms and folds itself in. Both sign-ins within 10 minutes.--const MERGE_KEY = 'ov_merge_intent';--function pendingMerge() {-- try { const m = JSON.parse(localStorage.getItem(MERGE_KEY) || 'null'); return m && Date.parse(m.expires_at) > Date.now() ? m : null; } catch { return null; }--}--function renderMerge() {-- const body = document.getElementById('merge-body');-- if (!body || !currentUser) return;-- body.textContent = '';-- const el = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };-- const row = el('div', '', 'btn-row');-- const pending = pendingMerge();-- if (currentUser.is_anon) { body.appendChild(el('p', 'Guests convert by signing up; merging is for two accounts.')); return; }-- if (!pending) {-- const b = el('button', `Keep @${currentUser.username} and merge another account into it`, 'btn btn-primary');-- b.type = 'button';-- b.onclick = async () => {-- 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 }));-- // 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;-- }-- const mins = Math.max(1, Math.round((Date.parse(pending.expires_at) - Date.now()) / 60000));-- const cancel = el('button', 'Cancel', 'btn btn-outline');-- 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). 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 = () => {-- 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}.`));-- const doIt = el('button', `Merge @${currentUser.username} into @${pending.into_username}`, 'btn btn-danger');-- doIt.type = 'button';-- doIt.onclick = async () => {-- doIt.disabled = true;-- try {-- const r = await apiFetch('/api/v1/account/merge', { method: 'POST', body: JSON.stringify({ intent: pending.intent }) });-- localStorage.removeItem(MERGE_KEY);-- toast(`Merged: @${currentUser.username} is now part of @${pending.into_username} (${r.moved.coins} OpenCoins moved).`, 'success');-- const folded = currentUser.id;-- try { OpenVibeAccountSwitcher.removeAccount(folded); } catch { /* */ }-- 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');-- 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);--}--// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).async function accountApi(path, opts = {}) {@@@ -313,14 +313,8 @@<button class="btn btn-danger" onclick="signOutAll()">Sign Out All</button></div></div>-- <!-- Account merge (ADR-029): fold a second account this person owns into this one. -->-- <div class="card" id="merge-card" style="margin-top:16px">-- <h3><span class="icon"><i class="fa-solid fa-code-fork"></i></span> Merge two accounts</h3>-- <p>Own two accounts? Fold one into the other. Its follows, OpenCoins, linked sign-ins, sessions and settings move to the account you keep, which keeps its name and profile. The other account can't be used on its own afterwards: signing in to it signs you in to the one you kept. If it was a mistake, support can split it by hand within 30 days.</p>-- <div id="merge-body" style="margin-top:12px"></div>-- </div><!-- Your data (ADR-033): a copy of everything, and deleting the account after 30 days. -->-- <div class="card" id="export-card" style="margin-top:16px">++ <div class="card" id="export-card"><h3><span class="icon"><i class="fa-solid fa-box-archive"></i></span> Download your data</h3><p>Get a copy of what OpenVibe keeps about you: your account here, and your part of Live, Chat, Community, Media and Games, as JSON files in one .zip. Each service adds its own files, so it takes up to half an hour. You can download it for 7 days, and ask for one a day.</p><div id="export-body" style="margin-top:12px"></div>@@@ -40,7 +40,7 @@ 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');++const { isFreshSignIn } = require('../identity/reauth');// The nine AI Exchange services (D11), then every other service that holds an owner input (the Setup tab,// 2026-11-07). A service that has not mounted openvibe-shared/config's routes yet answers 404 or does not answer;@@@ -84,8 +84,8 @@ function signInUrl(config = {}) {}/** The session's real sign-in time, the OIDC auth_time Network's signToken puts on every session token. The rule is-- * account-merge's isFreshSignIn (auth_time within ten minutes, never a renewal and never a QR session carrying an-- * old source time), so the owner settings and the account merge cannot drift apart. */++ * reauth's isFreshSignIn (auth_time within ten minutes, never a renewal and never a QR session carrying an++ * old source time), so the owner settings and the email and Locker checks use the same policy. */function isFresh(req, now = Date.now()) {return isFreshSignIn(req.tokenClaims, now);}@@@ -100,7 +100,7 @@ async function verifyDockSession(token, { db, publicKey, config }) {const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);if (!user) return { error: 'User not found', status: 401 };if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };-- if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };++ if (user.merged_into) return { error: 'Invalid session', status: 401 };if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };if (user.token_valid_after) {const validAfter = new Date(String(user.token_valid_after).replace(' ', 'T') + (/[zZ]$/.test(String(user.token_valid_after)) ? '' : 'Z')).getTime();@@@ -25,7 +25,7 @@ const express = require('express');const rateLimit = require('express-rate-limit');const QRCode = require('qrcode');const { setSessionCookies } = require('./session');--const accountMerge = require('../identity/account-merge');++const accountAliases = require('../identity/account-aliases');const COOKIE = 'ov_qr';const COOKIE_PATH = '/auth/qr';@@@ -256,7 +256,7 @@ function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifiawait t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);return { denied: true };}-- const survivor = await accountMerge.effectiveUser(t, user);++ const survivor = await accountAliases.effectiveUser(t, user);if (!survivor || survivor.is_banned || survivor.deleted_at) {await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);return { denied: true };@@@ -266,7 +266,7 @@ function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifiif (!won.changes) return { lost: true };// A QR-created session is not a fresh sign-in: inherit the source session's auth_time (or none),-- // so ADR-029's merge check and D11's key/money check still require a real re-authentication.++ // so the owner's key/money check still require a real re-authentication.const authTime = row.source_auth_time == null ? null : Number(row.source_auth_time);const token = signToken(survivor, privateKey, config, { authTime });// The device summary only: no raw User-Agent and no IP in the target's session row.@@@ -1,6 +1,6 @@'use strict';/**-- * One re-authentication URL for every fresh-sign-in action (account deletion, account merge, the owner's AI++ * One re-authentication URL for every fresh-sign-in action (account deletion, 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.@@@ -17,7 +17,7 @@ const { ids, validate } = require('openvibe-contracts');const eventRelay = require('../developer/event-relay');const EVENT_TYPE = 'network.user.token_valid_after';--const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked', 'account_merged'];++const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked'];const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();@@@ -43,8 +43,8 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {* @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself* @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject*/--// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:--// the folded-in account's fresh sign-in token must not outlive the merge).++// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (a token cutoff:++// a newly minted token must not outlive the cutoff).async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);return await db.tx(async () => {@@@ -45,8 +45,8 @@ const DEFAULT_AUDIENCE = ['openvibe.live', 'openvibe.tools', 'openvibe.games', '* A person's session token. auth_time (OIDC) is when they actually signed in: now for a sign-in, register or* password change; a renewal passes `renew` (the claims of the token it replaces) and keeps that token's* auth_time, or none when it had none. A QR-created session passes `authTime` so it inherits the source's-- * sign-in time (or null for none): it is not itself a fresh sign-in. An account merge and the owner's-- * key/money checks need a real sign-in within 10 minutes (ADR-029, D11).++ * sign-in time (or null for none): it is not itself a fresh sign-in. The owner's++ * key/money checks need a real sign-in within 10 minutes (D11).*/function signToken(user, privateKey, config, { renew = null, authTime } = {}) {const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';@@@ -223,9 +223,10 @@ router.post('/login', async (req, res) => {if (!bcrypt.compareSync(password, user.password_hash)) {return res.status(401).json({ error: 'Invalid username or password' });}-- // An account merged into another (ADR-029) signs in to the survivor.-- const signedIn = await require('../identity/account-merge').effectiveUser(db, user);++ // Existing merged aliases authenticate as their surviving account.++ const signedIn = await require('../identity/account-aliases').effectiveUser(db, user);if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });++ if (signedIn.deleted_at) return res.status(401).json({ error: 'Account deleted' });try {if (await require('./mfa').challengeLogin(db, req, res, signedIn)) return;@@@ -234,9 +235,9 @@ router.post('/login', async (req, res) => {await db.prepare('UPDATE users SET last_seen = ov_now() WHERE id = ?').run(signedIn.id);const token = signToken(signedIn, req.app.locals.privateKey, config);-- console.log(`[Auth] Login: ${user.username}${signedIn.id !== user.id ? ` (merged into ${signedIn.username})` : ''}`);++ console.log(`[Auth] Login: ${user.username}`);require('./session').setSessionCookies(res, token);-- res.json({ token, user: sanitizeUser(signedIn), ...(signedIn.id !== user.id ? { merged_into: signedIn.username } : {}) });++ res.json({ token, user: sanitizeUser(signedIn) });});// ── Forgot Password ─────────────────────────────────────────@@@ -729,8 +730,8 @@ router.post('/anon/:token/link', requireAuth, async (req, res) => {await db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL').run(anon.anon_number, req.user.id);// The guest's user modules (portable preferences) move to the account; where the account already-- // has a record in a namespace, the account's is kept (server/identity/modules.js onSubjectMerged).-- if (anon.subject_id && userSubject) await require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });++ // has a record in a namespace, the account's is kept (server/identity/modules.js onGuestLinked).++ if (anon.subject_id && userSubject) await require('../identity/modules').onGuestLinked(db, { from: anon.subject_id, into: userSubject });});console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);@@@ -41,8 +41,7 @@ async function verifySession(token, { db, publicKey, config }) {const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);if (!user) return { error: 'User not found', status: 401 };if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };-- // An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.-- if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };++ if (user.merged_into) return { error: 'Invalid session', status: 401 };// A deleted account (ADR-033) is a tombstone: none of its tokens is accepted.if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };if (user.token_valid_after) {@@@ -121,7 +120,7 @@ function makeRequireAuth(getCtx, signToken, { dockScope = null } = {}) {if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });req.user = out.user;req.token = token;-- req.tokenClaims = out.decoded; // auth_time: account merge needs a recent real sign-in++ req.tokenClaims = out.decoded; // auth_time: sensitive account settings need a recent real sign-inreq.dockScope = dock ? (out.scopes || []) : null;if (out.renew && !dock && typeof signToken === 'function') {try {@@@ -196,7 +196,7 @@ async function publish(db, payloads = []) {const EVENT_SELECT = `id, consent_id, subject, purpose, client, action, "by", text_version, at`;/**-- * Revoke every active choice of one subject, inside the caller's transaction (account merge or deletion). The rows++ * Revoke every active choice of one subject, inside the caller's transaction (account deletion). The rows* are kept; the returned payloads must be published by the caller after its transaction commits.*/async function revokeForSubject(t, subject, by, at) {@@@ -213,11 +213,6 @@ async function revokeForSubject(t, subject, by, at) {return payloads;}--/** Account merge: the account folded away never carries its choices over. Inside the merge's transaction. */--async function revokeForMerge(t, from, { at }) {-- return await revokeForSubject(t, from, 'merge', at);--}--/** Account deletion: revoke every active choice of the account and its aliases. Inside the deletion's transaction. */async function revokeForDeletion(t, subjects, { at }) {const payloads = [];@@@ -620,6 +615,6 @@ function internalRouter({ consent }) {module.exports = {apps,PURPOSES, PURPOSE_BY_ID, TEXT_VERSION, KINDS, WRITE_LIMIT, WRITE_WINDOW_MS, MAX_SUBJECTS,-- createConsent, router, internalRouter, eventEnvelope, publish, exportOf, revokeForMerge, revokeForDeletion,++ createConsent, router, internalRouter, eventEnvelope, publish, exportOf, revokeForDeletion,ConsentError, CHOICE_CONTRACT, REQUEST_CONTRACT, RESULT_CONTRACT, MINE_CONTRACT,};nnew file mode 100644@@@ -0,0 +1,9 @@++'use strict';++++// Existing merged accounts remain able to sign in to their surviving account.++async function effectiveUser(db, user) {++ if (!user || !user.merged_into) return user;++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;++}++++module.exports = { effectiveUser };@@@ -81,7 +81,6 @@ function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.function assertPerson(user) {if (!user || user.is_anon) throw new AccountDataError(400, 'account.guest', 'a guest account has nothing to export or delete');-- if (user.merged_into) throw new AccountDataError(409, 'account.merged', 'this account was merged into another');if (user.deleted_at) throw new AccountDataError(410, 'account.deleted', 'this account was deleted');if (!ids.isSubjectId('user', user.subject_id)) throw new AccountDataError(400, 'account.no_subject', 'this account has no subject');}@@@ -169,8 +168,6 @@ async function networkPart(db, userId) {'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'subject_id'];const account = {};for (const k of keep) if (k in u) account[k] = u[k];-- account.merged_in = await tableExists(db, 'subject_aliases')-- ? await db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];add('account.json', account);const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };await byUser('sign_in_providers.json', 'linked_accounts');@@@ -230,8 +227,6 @@ async function networkPart(db, userId) {// The agents that act for this person (WS-Z2); who revoked one may be someone else, so revoked_by stays out.agents: (await rowsOf(db, 'dev_agents', 'owner_subject = ?', [u.subject_id], { omit: ['revoked_by'] })).rows,});-- add('account_merges.json', await tableExists(db, 'account_merges')-- ? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);add('moderation.json', (await rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] })).rows);return { files, truncated };}@@@ -355,8 +350,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);const subject = user.subject_id;const folded = await db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);-- const aliases = await tableExists(db, 'subject_aliases') ? (await db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject)).map((r) => r.alias_id) : [];-- const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];++ const subjects = [...new Set([subject, ...folded.map((f) => f.subject_id).filter(Boolean)])];const userIds = [user.id, ...folded.map((f) => f.id)];const inU = `(${userIds.map(() => '?').join(',')})`;const inS = `(${subjects.map(() => '?').join(',')})`;@@@ -368,7 +362,6 @@ async function erase(db, deletion, { now = Date.now() } = {}) {await del('experience_events', `user_id IN ${inU}`, userIds);await del('person_levels', `user_id IN ${inU}`, userIds);await del('chat_app_pending_links', `confirmed_by IN ${inU}`, userIds);-- if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);// Notifications this person sent others stay, without their name or picture.if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);@@@ -407,7 +400,6 @@ async function erase(db, deletion, { now = Date.now() } = {}) {// network.link.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.const nodePrincipals = require('../registry/link-principals');count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));-- if (await tableExists(db, 'account_merges')) await db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.for (const id of userIds) {const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;ddeleted file mode 100644@@@ -1,310 +0,0 @@--'use strict';--const cache = require('openvibe-shared/cache-policy');--/**-- * Account merge (roadmap WS-B task 5, ADR-029; Contracts 0.69.0 network.subject.merged@1,-- * network.account-merge-result@1, staff.identity.merge).-- *-- * Two accounts one person controls become one. The survivor (`into`) keeps its subject, username, profile and-- * settings; the folded-in account (`from`) becomes an alias: subject_aliases (from → into), and resolving `from`,-- * or any legacy id mapped to it, answers the survivor (identity_legacy_map itself is never repointed, ADR-001).-- *-- * Proof, for a person (never staff silently):-- * 1. signed in to the survivor: POST /api/v1/account/merge/intents → a merge intent, valid 10 minutes;-- * 2. signed in to the other account, freshly (the token's auth_time within 10 minutes: a real sign-in, not a-- * renewal): POST /api/v1/account/merge { intent } folds that account into the intent's.-- * Staff (account recovery): POST /api/admin/account-merges { from, into, reason } needs staff.identity.merge-- * (the owner) and a written reason; it writes an audit row.-- *-- * Network moves what it owns in ONE transaction, with the event network.subject.merged in its outbox:-- * linked providers, sessions and devices, OAuth tokens and codes, developer projects and memberships,-- * OpenCoins (one ledger entry per side, reason account_merge, idempotent by merge id), user modules (the-- * survivor's fields win, the folded-in values fill only what it lacks), follows (both directions), blocks,-- * notifications, notification and display preferences, push subscriptions and effects.-- * A row that would collide with the survivor's (the same provider, project membership, follow, preference…) keeps-- * the survivor's and drops the other, and is counted. Every other service follows the event.-- *-- * The folded-in user row stays, marked merged_into, with its tokens revoked; signing in to it signs in to the-- * survivor. The merge record keeps its pre-merge state for 30 days so staff can split a mistake by hand; after-- * that the record is reduced to the alias (reduceExpired). A retried merge of the same pair answers the first.-- */--const express = require('express');--const { ids, validate, staff } = require('openvibe-contracts');--const eventRelay = require('../developer/event-relay');--const wallet = require('../coins/wallet');--const modules = require('./modules');--const follows = require('./follows');--const revocation = require('../auth/revocation');----const INTENT_TTL_MS = 10 * 60 * 1000;--const FRESH_SIGN_IN_S = 10 * 60;--const SPLIT_DAYS = 30;--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;----class MergeError extends Error {-- constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }--}----/** A real sign-in (auth_time within 10 minutes), never a renewal or a QR session carrying an old source time. */--function isFreshSignIn(claims, now = Date.now()) {-- const authTime = Number(claims && claims.auth_time);-- return Number.isFinite(authTime) && now / 1000 - authTime <= FRESH_SIGN_IN_S;--}----async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }----/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */--async function survivorOf(db, subjectId) {-- try {-- const a = await db.prepare('SELECT subject_id FROM subject_aliases WHERE alias_id = ?').get(String(subjectId || ''));-- return a ? a.subject_id : subjectId;-- } catch { return subjectId; } // before the first merge the table may not exist--}----/** A user row, or the survivor's when it was merged (signing in to a folded-in account signs in to the survivor). */--async function effectiveUser(db, user) {-- if (!user || !user.merged_into) return user;-- return await db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;--}----const iso = (ms) => new Date(ms).toISOString();----function buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject }) {-- const payload = { merge_id: mergeId, from, into, merged_at: mergedAt, initiated_by: initiatedBy, split_until: splitUntil };-- const env = {-- event_id: ids.newId('event', Date.parse(mergedAt)), event_type: 'network.subject.merged', version: 1, source: 'network',-- actor: { type: 'user', id: actorSubject }, timestamp: mergedAt, visibility: 'internal',-- subject: { type: 'user', id: into }, payload,-- };-- const v = validate('events.event-envelope@1', env);-- const pv = validate('network.subject.merged@1', payload);-- if (!v.valid || !pv.valid) throw new Error(`account-merge: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);-- return env;--}----/**-- * Move rows keyed by an integer user id. keyCols: the columns that, with the user id, are unique ([] = one row-- * per user); a row whose key the survivor already holds is dropped, or with keepOnClash left on the folded-in-- * account (a linked provider: signing in with it still lands on the survivor through merged_into).-- */--async function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {-- let moved = 0; let dropped = 0;-- if (!await tableExists(db, table)) return { moved, dropped };-- const rows = await db.prepare(`SELECT seq AS _rid, * FROM ${table} WHERE ${column} = ?`).all(fromId);-- for (const r of rows) {-- if (keyCols) {-- const where = keyCols.map((k) => ` AND ${k} = ?`).join('');-- const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));-- if (clash) {-- if (!keepOnClash) await db.prepare(`DELETE FROM ${table} WHERE seq = ?`).run(r._rid);-- dropped++;-- continue;-- }-- }-- await db.prepare(`UPDATE ${table} SET ${column} = ? WHERE seq = ?`).run(intoId, r._rid);-- moved++;-- }-- return { moved, dropped };--}----async function tableExists(db, name) {-- return !!await db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name);--}----/**-- * Fold `fromUser` into `intoUser`. opts: { initiatedBy: 'person'|'staff', actorSubject, reason, now }.-- * → network.account-merge-result@1. Throws MergeError.-- */--async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {-- await ensureSchema(db);-- if (!fromUser || !intoUser) throw new MergeError(404, 'merge.unknown_account', 'no such account');-- if (fromUser.id === intoUser.id) throw new MergeError(400, 'merge.same_account', 'an account cannot be merged into itself');-- const from = fromUser.subject_id; const into = intoUser.subject_id;-- if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new MergeError(400, 'merge.no_subject', 'both accounts need a subject');-- const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ?').get(from);-- if (done) {-- if (done.into_subject !== into) throw new MergeError(409, 'merge.already_merged', 'that account was already merged into another');-- return resultOf(done, true);-- }-- if (fromUser.merged_into || intoUser.merged_into) throw new MergeError(409, 'merge.already_merged', 'one of these accounts was already merged');-- if (fromUser.is_anon || intoUser.is_anon) throw new MergeError(400, 'merge.guest', 'a guest converts by signing up, not by merging');-- if (fromUser.is_banned || intoUser.is_banned) throw new MergeError(403, 'merge.banned', 'a banned account cannot be merged');-- const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();-- if (String(fromUser.username || '').toLowerCase() === owner) throw new MergeError(403, 'merge.owner', "the owner's account cannot be folded into another");---- const mergeId = `mrg_${ids.ulid(now)}`;-- const mergedAt = iso(now);-- const splitUntil = iso(now + SPLIT_DAYS * 86400000);-- const actor = actorSubject || into;-- let consentEvents = [];-- const run = db.txFn(async () => {-- const pre = {-- user: { ...fromUser, password_hash: undefined },-- linked_accounts: await db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),-- sessions: (await db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id)).map((r) => r.id),-- balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,-- projects: await tableExists(db, 'dev_projects') ? (await db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from)).map((r) => r.id) : [],-- modules: await db.prepare('SELECT namespace, revision FROM user_modules WHERE subject_id = ?').all(from),-- };-- const moved = { providers: 0, sessions: 0, oauth_grants: 0, projects: 0, coins: 0, modules: 0 };-- const dropped = {};-- const note = (k, r) => { if (r.dropped) dropped[k] = (dropped[k] || 0) + r.dropped; return r.moved; };---- moved.providers = note('providers', await moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));-- moved.sessions = note('sessions', await moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));-- moved.oauth_grants = (await moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id)).moved-- + (await moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id)).moved;-- if (await tableExists(db, 'dev_projects')) {-- moved.projects = (await db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from)).changes;-- for (const m of await db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {-- const has = await db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);-- if (has) { await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(m.project_id, from); dropped.project_members = (dropped.project_members || 0) + 1; }-- else await db.prepare('UPDATE dev_project_members SET subject_id = ? WHERE project_id = ? AND subject_id = ?').run(into, m.project_id, from);-- }-- }-- // OpenCoins: loyalty (ADR-012), moved by Network itself, one ledger entry per side, once per merge.-- if (pre.balance > 0) {-- await wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });-- await wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });-- moved.coins = pre.balance;-- }-- await require('../levels/store').merge(db, fromUser.id, intoUser.id);-- const mods = await modules.onSubjectMerged(db, { from, into });-- moved.modules = mods.moved + (mods.filled || 0);-- // Consent is the person's own and is never assumed: the folded-in account's active choices are revoked-- // (events by 'merge'), never carried over. The survivor keeps its own choices. Published after this commit.-- consentEvents = await require('../consent').revokeForMerge(db, from, { at: mergedAt });-- // Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.-- if (await tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? (await follows.onSubjectMerged(db, { from, into })).dropped : 0;-- if (await tableExists(db, 'user_blocks')) {-- for (const col of ['blocker_subject', 'blocked_subject']) {-- const other = col === 'blocker_subject' ? 'blocked_subject' : 'blocker_subject';-- for (const b of await db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {-- const clash = b.o === into || await db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);-- if (clash) await db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);-- else await db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);-- }-- }-- }-- // The Dock's state (the Dock plan 2026-10-30, K-D1): the survivor's row wins. A folded-in row moves over-- // only when the survivor has none; when both have one, the survivor's is the one that stays.-- if (await tableExists(db, 'dock_states')) {-- const mine = await db.prepare('SELECT 1 FROM dock_states WHERE subject = ?').get(into);-- if (mine) await db.prepare('DELETE FROM dock_states WHERE subject = ?').run(from);-- else await db.prepare('UPDATE dock_states SET subject = ? WHERE subject = ?').run(into, from);-- }-- await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);-- await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);-- note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));-- note('preferences', await moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));-- note('effects', await moveByUserId(db, 'user_effects', 'user_id', fromUser.id, intoUser.id, ['effect_type', 'effect_id']));---- // The alias (and any alias of the folded-in subject now points at the survivor: aliases stay flat).-- await db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);-- await db.prepare('INSERT INTO subject_aliases (alias_id, subject_id, merge_id, merged_at, merged_by) VALUES (?, ?, ?, ?, ?)').run(from, into, mergeId, mergedAt, actor);-- // The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason-- // account_merged: sites close its sockets). Its sessions already belong to the survivor.-- await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);-- await revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });-- await db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)-- .run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);-- if (initiatedBy === 'staff') {-- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')-- .run(null, 'account_merge', JSON.stringify({ merge_id: mergeId, from, into, actor, reason }));-- }-- await eventRelay.writerFor(db).enqueue(db, buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));-- return await db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);-- });-- const row = await run();-- // Never inside the transaction (L24): the revocations and their events are visible together once it commits.-- await require('../consent').publish(db, consentEvents);-- try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }-- return resultOf(row, false);--}----function resultOf(row, replayed) {-- const m = JSON.parse(row.moved || '{}');-- const moved = {};-- for (const k of ['providers', 'sessions', 'oauth_grants', 'projects', 'coins', 'modules']) moved[k] = Number(m[k]) || 0;-- return { merge_id: row.id, from: row.from_subject, into: row.into_subject, merged_at: row.merged_at, split_until: row.split_until, moved, replayed };--}----/** After 30 days a merge record keeps only the alias facts: its pre-merge state is dropped. */--async function reduceExpired(db, { now = Date.now() } = {}) {-- await ensureSchema(db);-- return (await db.prepare('UPDATE account_merges SET pre_state = NULL, reduced_at = ? WHERE reduced_at IS NULL AND split_until < ?').run(iso(now), iso(now))).changes;--}----async function createIntent(db, intoUser, { now = Date.now() } = {}) {-- await ensureSchema(db);-- const id = `mgi_${ids.ulid(now)}`;-- await db.prepare('INSERT INTO account_merge_intents (id, into_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(id, intoUser.id, now, now + INTENT_TTL_MS);-- return { intent: id, expires_at: iso(now + INTENT_TTL_MS) };--}----/** The person's merge: the caller is the account being folded in (freshly signed in), the intent names the survivor. */--async function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {-- await ensureSchema(db);-- if (!isFreshSignIn(claims, now)) {-- throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');-- }-- const intent = await db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));-- if (!intent || intent.expires_at < now) throw new MergeError(410, 'merge.intent_expired', 'that merge request expired: start again from the account that stays');-- const into = await db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);-- if (intent.used_at) {-- const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ? AND into_user_id = ?').get(caller.subject_id, intent.into_user_id);-- if (done) return resultOf(done, true);-- throw new MergeError(410, 'merge.intent_used', 'that merge request was already used');-- }-- const out = await merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });-- await db.prepare('UPDATE account_merge_intents SET used_at = ? WHERE id = ?').run(now, intent.id);-- return out;--}----function sendError(res, e) {-- if (e instanceof MergeError) return res.status(e.status).json({ error: e.code, detail: e.message });-- console.error('[AccountMerge]', e);-- return res.status(500).json({ error: 'merge.failed', detail: 'the merge did not happen' });--}----/** /api/v1/account/merge… (the person) and /api/admin/account-merges (staff.identity.merge). */--function routers({ requireAuth, staffClaims }) {-- const me = express.Router();-- me.use(express.json({ limit: '8kb' }));-- me.post('/merge/intents', requireAuth, async (req, res) => {-- if (req.user.merged_into) return res.status(409).json({ error: 'merge.already_merged' });-- res.status(201).json(await createIntent(req.app.locals.db, req.user));-- });-- me.post('/merge', requireAuth, async (req, res) => {-- try { res.json(await mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }-- });-- me.get('/merges', requireAuth, async (req, res) => {-- const db = req.app.locals.db; await ensureSchema(db);-- const rows = await db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);-- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ merges: rows.map((r) => resultOf(r, false)) });-- });---- const admin = express.Router();-- admin.use(express.json({ limit: '8kb' }));-- admin.post('/', requireAuth, async (req, res) => {-- const db = req.app.locals.db;-- if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });-- const b = req.body || {};-- const reason = String(b.reason || '').trim();-- if (reason.length < 10) return res.status(400).json({ error: 'merge.reason_required', detail: 'a staff merge needs a written reason (10 characters or more)' });-- const byRef = async (ref) => (SUBJECT_RE.test(String(ref || '')) ? await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : await db.prepare('SELECT * FROM users WHERE lower(username) = lower(?)').get(String(ref || '')));-- try {-- const out = await merge(db, await byRef(b.from), await byRef(b.into), { initiatedBy: 'staff', actorSubject: req.user.subject_id, reason: reason.slice(0, 500) });-- res.json(out);-- } catch (e) { sendError(res, e); }-- });-- admin.get('/', requireAuth, async (req, res) => {-- const db = req.app.locals.db; await ensureSchema(db);-- if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });-- const rows = await db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();-- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ merges: rows });-- });-- return { me, admin };--}----module.exports = { ensureSchema, survivorOf, effectiveUser, merge, mergeWithIntent, createIntent, reduceExpired, routers, isFreshSignIn, MergeError, SPLIT_DAYS };@@@ -7,7 +7,7 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;const TTL_MINUTES = 60;const hash = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');const clean = (email) => String(email || '').trim().toLowerCase();--const { isFreshSignIn } = require('./account-merge');++const { isFreshSignIn } = require('./reauth');function needsFreshSignIn(req, res) {if (isFreshSignIn(req.tokenClaims)) return false;@@@ -295,31 +295,5 @@ function routers({ requireAuth, followsGuard }) {return { me, pub, internal };}--/**-- * Account merge (ADR-029): `from`'s follows become `into`'s, both as the follower and as the followed channel-- * owner. A pair the survivor already has keeps the survivor's row (the other is dropped), and following oneself-- * is dropped. No follow events: services repoint on network.subject.merged. Inside the merge's transaction.-- * → { moved, dropped }-- */--async function onSubjectMerged(db, { from, into }) {-- ensureSchema(db);-- const out = { moved: 0, dropped: 0 };-- const rows = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? OR target_id = ?').all(from, from);-- for (const r of rows) {-- const follower = r.follower_subject === from ? into : r.follower_subject;-- const target = r.target_id === from ? into : r.target_id;-- const clash = follower === target-- || await db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);-- if (clash) {-- await db.prepare('DELETE FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(r.follower_subject, r.target_type, r.target_id);-- out.dropped++;-- } else {-- await db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = ov_now() WHERE follower_subject = ? AND target_type = ? AND target_id = ?')-- .run(follower, target, r.follower_subject, r.target_type, r.target_id);-- out.moved++;-- }-- }-- return out;--}--module.exports = { setNotifier, onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, routers, kick };++module.exports = { setNotifier, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, routers, kick };@@@ -20,8 +20,8 @@* keys top-level fields that were added, changed or removed (names only, sorted)* public only for fields the namespace declares public and that changed: their new value* (null = removed). Absent for private namespaces: a value never leaves otherwise.-- * merged_into / merged_from subject refs, on the two sides of an account merge*++ * merged_from / merged_into are used only when a guest identity is linked to a registered account.* Nothing here reads or writes user_modules; the caller passes before/after.*/const { ids, modules, validate } = require('openvibe-contracts');@@@ -22,8 +22,7 @@ const cache = require('openvibe-shared/cache-policy');* a record deleted and written again continues from there and a delete has a revision of its own.** Lifecycle:-- * - accounts: onSubjectRemoved() deletes a subject's records, onSubjectMerged() re-keys them into the-- * surviving subject; both emit events. Triggers refuse deleting a users/anon_users row, or changing++ * - accounts: onSubjectRemoved() deletes a subject's records and emits events. Triggers refuse deleting a users/anon_users row, or changing* its subject_id, while module rows remain, so an account deletion or merge cannot skip them.* - owning service retired (namespace onOwnerRemoved, contract modules.namespace@1): when the owner's* service manifest says `retired`, the namespace is read-only (retain-readonly), and with@@@ -199,17 +198,18 @@ async function onSubjectRemoved(db, subjectId, { ctx } = {}) {return n;}++/**-- * Two accounts become one (`from` folds into `into`, which survives; ADR-029). Each record of `from` moves to++ * A guest identity is linked to its registered account. Each record of `from` moves to* `into` when `into` has none in that namespace (created there, reason subject_merged, merged_from);* when `into` has one, the survivor's record is kept and gains only the top-level fields it lacks from `from`'s-- * (updated, reason subject_merged, merged_from; its own values always win). Every record of `from` ends deleted++ * (updated, reason subject_merged, merged_from; its own values always win). The guest identity record ends deleted* (reason subject_merged, merged_into). Moved and filled records say updated_by svc:network.* Call inside the merge's transaction, before `from`'s row goes. Returns { moved, filled, dropped }.*/--async function onSubjectMerged(db, { from, into, ctx } = {}) {-- if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onSubjectMerged: from and into must be usr_/gst_ subject ids');-- if (from === into) throw new TypeError('onSubjectMerged: from and into are the same subject');++async function onGuestLinked(db, { from, into, ctx } = {}) {++ if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onGuestLinked: from and into must be usr_/gst_ subject ids');++ if (from === into) throw new TypeError('onGuestLinked: from and into are the same subject');const system = { type: 'system', id: 'network' };const out = await db.tx(async () => {const res = { moved: 0, filled: 0, dropped: 0 };@@@ -377,8 +377,9 @@ function serviceRoutes(router, principals) {});}++module.exports = {OWNER_HANDOFFS, ownerOf, canWrite, ownerRetired,-- ensureSchema, read, write, remove, onSubjectRemoved, onSubjectMerged, sweepRetired,++ ensureSchema, read, write, remove, onSubjectRemoved, onGuestLinked, sweepRetired,userRouter, serviceRoutes,};@@@ -542,7 +542,7 @@ const DEFAULT_GRANTS = [// A community AI offer's "followers" audience: AI checks whether the requester follows the offer's owner (the// owner's channel) before placing a run. Read-only, like Live's.['ai', 'network.follows.read', SELF_AUDIENCE, []],-- // Self-dealing: a community run whose requester is the offer owner (or an account merged into it) earns nothing.++ // Self-dealing: a community run whose requester is the offer owner earns nothing.// AI resolves both subjects to the survivor before it records the seller's ledger.['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],// WS-N task 12: ovhost incident / maintenance post to the status page.nnew file mode 100644@@@ -0,0 +1,8 @@++'use strict';++++const FRESH_SIGN_IN_SECONDS = 10 * 60;++function isFreshSignIn(claims, now = Date.now()) {++ const authTime = Number(claims && claims.auth_time);++ return Number.isFinite(authTime) && now / 1000 - authTime <= FRESH_SIGN_IN_SECONDS;++}++module.exports = { isFreshSignIn };@@@ -119,11 +119,8 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }sid = row && row.subject_id;}}-- // A subject merged into another (ADR-029) resolves to the survivor, saying where it came from.-- const survivor = sid ? await require('./account-merge').survivorOf(db, sid) : sid;-- const p = survivor ? await projection(db, survivor) : null;++ const p = sid ? await projection(db, sid) : null;if (!p) return null;-- if (survivor !== sid) p.merged_from = sid;p.legacy_ids = await db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type').all(p.subject.id);return p;@@@ -136,9 +133,8 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }async function resolveUsername(db, name) {const n = String(name || '').trim().replace(/^@/, '');if (!/^[A-Za-z0-9_.-]{1,40}$/.test(n)) return null;-- const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL').get(n);++ const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL AND merged_into IS NULL').get(n);if (!u) return null;-- // A name folded into another account (ADR-029) resolves to the survivor: judge the survivor, not the alias row.const p = await resolve(db, { subject_id: u.subject_id || await ensureUserSubject(db, u) });return p && !p.banned && !p.deleted ? p : null;}@@@ -538,17 +538,6 @@ app.use('/api/v1/trust', rateLimit({ windowMs: 60_000, max: 60 }), require('./id// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));--// Account merge (roadmap WS-B task 5, ADR-029): the person folds a second account into this one; staff--// (staff.identity.merge) for account recovery, with a reason.--{-- const accountMerge = require('./identity/account-merge');-- await accountMerge.ensureSchema(db);-- const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });-- // 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// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.{@@@ -1301,9 +1290,6 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf} catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }};timers.push(setTimeout(sweepModules, 5 * 60 * 1000), setInterval(sweepModules, 24 * 60 * 60 * 1000));-- // Account merges older than 30 days keep only the alias facts (ADR-029).-- const reduceMerges = async () => { try { const n = await require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };-- timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));// Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).const sweepAccountData = async () => {try {@@@ -80,15 +80,3 @@ async function board(db, userId) {return { all_time: await period(false), week: await period(true) };}module.exports = { total, append, award, person, byUsername, batch, board, ACTIVE, PERSON_DAILY, SERVICE_DAILY };----/** Existing account merge transaction: preserve earned history and the survivor's visibility choice. */--async function merge(db, fromId, intoId) {-- await db.prepare('INSERT INTO person_levels(user_id) VALUES (?), (?) ON CONFLICT DO NOTHING').run(fromId, intoId);-- const rows = await db.prepare('SELECT user_id, xp FROM person_levels WHERE user_id IN (?, ?) ORDER BY user_id FOR UPDATE').all(fromId, intoId);-- const xp = rows.reduce((sum, row) => sum + Number(row.xp), 0);-- if (xp > 1000000000000) fail(409, 'levels.total_limit');-- await db.prepare('UPDATE experience_events SET user_id = ? WHERE user_id = ?').run(intoId, fromId);-- await db.prepare('UPDATE person_levels SET xp = ?, level = ? WHERE user_id = ?').run(xp, levelOf(xp).level, intoId);-- await db.prepare('DELETE FROM person_levels WHERE user_id = ?').run(fromId);--}--module.exports.merge = merge;@@@ -40,9 +40,9 @@ const { fingerprintOf, verifyPersonKeys } = require('openvibe-sdk/locker');const { inspectEnvelope } = require('openvibe-sdk/locker/server');const eventRelay = require('../developer/event-relay');const proof = require('./proof');--// A real sign-in (the session's auth_time within ten minutes), the rule account-merge and the account deletion++// A real sign-in (the session's auth_time within ten minutes), the rule account deletion// already use: never a renewal, never a QR session carrying an old source time.--const { isFreshSignIn } = require('../identity/account-merge');++const { isFreshSignIn } = require('../identity/reauth');/** The event a key change announces (ids and fingerprints only; never a key, never a blob). */const EVENT_TYPE = 'locker.keys.changed';@@@ -52,7 +52,6 @@ const KINDS = Object.freeze([Object.freeze({ id: 'media-request', match: /^Media request: (.+)$/, site: 'live', subject: 'media', text: 'for the stream {name}' }),Object.freeze({ id: 'media-refund', match: /^Refund: (.+)$/, site: 'live', subject: 'media', text: 'for a refund of {name}' }),Object.freeze({ id: 'migration', match: 'migration:hobo-coins', site: 'network', text: 'for OpenCoins moved from the old Live wallet' }),-- Object.freeze({ id: 'account-merge', match: 'account_merge', site: 'network', text: 'for merging your accounts' }),Object.freeze({ id: 'account-deleted', match: 'account_deleted', site: 'network', text: 'for closing your account' }),Object.freeze({ id: 'watch', match: 'watch', site: 'live', text: 'for watching a stream' }),Object.freeze({ id: 'chat', match: 'chat', site: 'live', text: 'for chatting on a stream' }),@@@ -5,7 +5,7 @@// cannot download it, one a day, and it expires after 7 days;// - deletion: refused without a fresh sign-in or the typed username, cancellable, nothing before the date;// - at the date Network's rows are erased, the username is released, the subject resolves as deleted, old tokens--// are refused, and network.account.deleted (with the merged-in aliases) is queued once;++// are refused, and network.account.deleted (with legacy sign-in aliases) is queued once;// - services confirm with counts; staff (staff.users.manage) see what is outstanding and cancel with a reason.// node test/account-data.test.jsconst assert = require('assert');@@@ -22,7 +22,6 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const wallet = require('../server/coins/wallet');--const accountMerge = require('../server/identity/account-merge');const accountData = require('../server/identity/account-data');const zip = require('../server/utils/zip');@@@ -37,7 +36,6 @@ for (const c of ['live', 'chat', 'community', 'space', 'media', 'games', 'tools'const principals = require('../server/identity/principals');await principals.ensureSchema(db);require('../server/identity/follows').ensureSchema(db);--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' } });@@@ -209,15 +207,15 @@ const server = http.createServer(app);r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });const del = r.body.deletion_id;const due = Date.parse(r.body.delete_after);++ const old = await mk('dana_old');++ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(dana.id, old.id);++ assert.strictEqual((await require('../server/identity/account-aliases').effectiveUser(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(old.id))).id, dana.id, 'legacy alias sign-in resolves to the surviving account');assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due - 60000 })).deleted, 0, 'nothing before the date');assert.strictEqual((await db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id)).username, 'dana');-- // dana had folded an older account in (ADR-029): its subject goes with her.-- const old = await mk('dana_old');-- await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });// Two paired machines, one per subject: an account deletion revokes both and announces each (network.link.revoked@1).const pairedNodes = [];-- for (const subjectId of [dana.subject_id, old.subject_id]) {++ for (const subjectId of [dana.subject_id]) {const ulid = ids.ulid();const principal = `lnk_${ulid}`; const nodeId = `n-${ulid.toLowerCase()}`;await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status,@@@ -236,7 +234,7 @@ const server = http.createServer(app);const oldToken = tok(dana);assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);const nodeEvents = await events('network.link.revoked');-- assert.strictEqual(nodeEvents.length, 2, 'each paired machine revoked exactly once');++ assert.strictEqual(nodeEvents.length, 1, 'the paired machine is revoked exactly once');for (const [principal, nodeId, subjectId] of pairedNodes) {const p = await db.prepare('SELECT status, revoked_by FROM platform_node_principals WHERE id = ?').get(principal);assert.deepStrictEqual([p.status, p.revoked_by], ['revoked', 'account_deleted']);@@@ -251,7 +249,7 @@ const server = http.createServer(app);const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);assert.match(gone.username, /^deleted-/);assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);-- assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the merged-in account too');++ assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the legacy alias too');for (const [t, col] of [['user_mfa', 'user_id'], ['mfa_recovery_codes', 'user_id'], ['mfa_challenges', 'user_id'], ['mfa_devices', 'user_id'], ['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id'], ['experience_events', 'user_id'], ['person_levels', 'user_id']]) {assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);}@@@ -263,7 +261,7 @@ const server = http.createServer(app);assert.ok(await mk('dana'), 'the username is released');const p = await subjects.resolve(db, { subject_id: dana.subject_id });assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);-- assert.strictEqual((await subjects.resolve(db, { subject_id: old.subject_id })).deleted, true, 'the alias resolves as deleted too');++ assert.strictEqual((await subjects.resolve(db, { subject_id: old.subject_id })).deleted, true, 'the alias is a tombstone after its survivor is deleted');r = await call('GET', '/api/v1/account/deletion', oldToken);assert.strictEqual(r.status, 401, 'old tokens are refused');const ev = await events('network.account.deleted');@@@ -272,7 +270,7 @@ const server = http.createServer(app);assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');-- assert.strictEqual((await events('network.link.revoked')).length, 2, 'a second sweep emits nothing new');++ assert.strictEqual((await events('network.link.revoked')).length, 1, 'a second sweep emits nothing new');// Services confirm; staff see what is outstanding.const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };@@@ -21,7 +21,6 @@ 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');@@@ -67,7 +66,7 @@ function safetyWindow(location = { origin: 'https://openvibe.network', hostname: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('/my#accounts', { account: '' }), '/login?reauth=1&login_hint=&return=%2Fmy%23accounts', 'blank account hint remains 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');@@@ -115,7 +114,7 @@ assert.match(read('public/js/my/core.js'), /hash === 'delete' \? 'accounts' : ha// 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');++assert.doesNotMatch(index, /account-merges|account-merge/, 'account merge routes and their limiter are removed');// ── /login?reauth=1 always asks for the password ────────────────────────────const login = read('public/js/login.js');@@@ -170,7 +169,6 @@ assert.match(read('public/my.html'), /stays usable for 30 days/);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';ddeleted file mode 100644@@@ -1,211 +0,0 @@--'use strict';--// Account merge (roadmap WS-B task 5, ADR-029; Contracts 0.69.0/0.70.0). The ADR's acceptance tests:--// - a merge needs both sign-ins (an intent from the survivor, then a fresh sign-in to the other account: auth_time--// within 10 minutes, never a renewal) and refuses otherwise;--// - resolving either subject, or an old legacy id, answers the survivor (merged_from);--// - balances sum with one ledger entry per side, and a retried merge applies once;--// - module conflicts keep the survivor's fields (the other fills only what it lacks);--// - follows, blocks, providers, sessions, OAuth tokens, projects, preferences move; a clash keeps the survivor's;--// - network.subject.merged and network.user.token_valid_after (account_merged) are queued; the folded-in account's--// old token is refused and signing in to it signs in to the survivor;--// - a staff merge needs staff.identity.merge (the owner) and a written reason, and is audited;--// - after 30 days the record keeps only the alias facts.--// node test/account-merge.test.js--const assert = require('assert');--const crypto = require('crypto');--const fs = require('fs');--const os = require('os');--const path = require('path');--const http = require('http');--const express = require('express');--const cookieParser = require('cookie-parser');--const jwt = require('jsonwebtoken');--const bcrypt = require('bcryptjs');--const { validate, ids } = require('openvibe-contracts');--const { getDb } = require('../server/db/database');--const subjects = require('../server/identity/subjects');--const wallet = require('../server/coins/wallet');--const accountMerge = require('../server/identity/account-merge');----(async () => {--const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-merge-'));--const log = console.log; console.log = () => {};--const db = getDb();--console.log = log;--await require('../server/identity/principals').ensureSchema(db);--require('../server/identity/follows').ensureSchema(db);--await accountMerge.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' } };--process.env.OWNER_USERNAME = 'boss';----const hash = bcrypt.hashSync('secret12', 4);--const mk = async (name, role = 'user') => {-- const id = (await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(name, hash, role)).lastInsertRowid;-- await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));-- return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);--};--// A session token from 10 s ago (the revocation cutoff has second precision); authAgo: its sign-in time, or null for none.--const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',-- ...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });--const events = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === type);----const authRoutes = require('../server/auth/routes');--const requireAuth = require('../server/auth/session').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(express.json()); app.use(cookieParser());--app.use('/api/auth', authRoutes);--const mr = accountMerge.routers({ requireAuth, staffClaims: require('../server/auth/staff-claims').staffClaims });--app.use('/api/v1/account', mr.me);--app.use('/api/admin/account-merges', mr.admin);--const server = http.createServer(app);----(async () => {-- 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 carol = await mk('carol'); const carol2 = await mk('carol_old');-- const x = await mk('xavier'); const y = await mk('yolanda'); const z = await mk('zed');-- // What the folded-in account has.-- await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-carol'), (?, 'google', 'g-carol-old'), (?, 'discord', 'd-carol-old')").run(carol.id, carol2.id, carol2.id);-- await db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-old', 1, datetime('now', '+1 day'))").run(carol2.id);-- await db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-old', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(carol2.id);-- await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES ('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ?, 'bot', datetime('now'), ?)").run(carol2.subject_id, carol2.subject_id);-- await wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });-- await wallet.credit(db, { user_id: carol2.id, app_id: 'live', amount: 150, reason: 'chat', idempotency_key: 'c-2' });-- const modules = require('../server/identity/modules');-- const live = { type: 'service', id: 'live' };-- const w = async (s, n, d) => { const o = await modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };-- await w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });-- await w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });-- await w(carol2.subject_id, 'live.stats', { streams_30d: 4 });-- const follows = require('../server/identity/follows');-- await follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });-- await follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already-- await follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });-- await follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel-- await follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself-- await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);-- await db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);-- const oldCarol2 = tok(carol2);-- const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });---- // ── Refusals ──-- let r = await call('POST', '/api/v1/account/merge/intents', tok(carol));-- assert.strictEqual(r.status, 201, JSON.stringify(r.body));-- const intent = r.body.intent;-- assert.match(intent, /^mgi_/);-- r = await call('POST', '/api/v1/account/merge', tok(carol2, { authAgo: 11 * 60 }), { intent });-- assert.deepStrictEqual([r.status, r.body.error], [401, 'merge.sign_in_again'], 'a sign-in older than 10 minutes');-- r = await call('POST', '/api/v1/account/merge', tok(carol2, { authAgo: null }), { intent });-- assert.deepStrictEqual([r.status, r.body.error], [401, 'merge.sign_in_again'], 'a token with no sign-in time (older sessions, renewals of them)');-- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: 'mgi_nope' });-- assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired']);-- r = await call('POST', '/api/v1/account/merge', tok(carol), { intent });-- assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.same_account']);-- const expired = await accountMerge.createIntent(db, carol, { now: Date.now() - 11 * 60 * 1000 });-- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: expired.intent });-- assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired'], 'an intent lives 10 minutes');---- // ── The merge ──-- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });-- assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.ok(validate('network.account-merge-result@1', r.body).valid, JSON.stringify(validate('network.account-merge-result@1', r.body).errors));-- assert.deepStrictEqual([r.body.from, r.body.into, r.body.replayed], [carol2.subject_id, carol.subject_id, false]);-- assert.deepStrictEqual(r.body.moved, { providers: 1, sessions: 1, oauth_grants: 1, projects: 1, coins: 150, modules: 2 });-- assert.strictEqual(Date.parse(r.body.split_until) - Date.parse(r.body.merged_at), 30 * 86400000);---- // Resolution: either subject, and the old legacy id, answer the survivor.-- let res = await subjects.resolve(db, { subject_id: carol2.subject_id });-- assert.deepStrictEqual([res.subject.id, res.merged_from, res.username], [carol.subject_id, carol2.subject_id, 'carol']);-- res = await subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '70' });-- assert.deepStrictEqual([res.subject.id, res.merged_from], [carol.subject_id, carol2.subject_id], 'the legacy map is not repointed, the alias answers');-- assert.strictEqual((await db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get()).subject_id, carol2.subject_id);-- assert.strictEqual((await subjects.resolve(db, { subject_id: carol.subject_id })).merged_from, undefined);---- // Coins: summed, one ledger entry per side.-- assert.deepStrictEqual([await wallet.getBalance(db, carol.id), await wallet.getBalance(db, carol2.id)], [200, 0]);-- const ledger = await db.prepare("SELECT user_id, delta FROM coin_transactions WHERE reason = 'account_merge' ORDER BY id").all();-- assert.deepStrictEqual(ledger.map((l) => [l.user_id, l.delta]), [[carol2.id, -150], [carol.id, 150]]);-- // Modules: the survivor's fields win; the other fills only what it lacks; a record only it had moves.-- assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.profile')).data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });-- assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.stats')).data, { streams_30d: 4 });-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id)).n, 0);-- const modEv = (await events('network.module.updated')).filter((e) => e.payload.reason === 'subject_merged');-- assert.ok(modEv.some((e) => e.payload.merged_from && e.payload.change === 'updated' && e.payload.keys.includes('channel_url')), 'the filled record is announced');-- // Follows: moved, the clash and the self-follow dropped, the folded channel's follower now follows the survivor.-- const f = (await db.prepare('SELECT follower_subject AS a, target_id AS b FROM user_follows ORDER BY a, b').all()).map((x2) => `${x2.a === carol.subject_id ? 'carol' : x2.a === z.subject_id ? 'zed' : x2.a}>${x2.b === x.subject_id ? 'x' : x2.b === y.subject_id ? 'y' : x2.b === carol.subject_id ? 'carol' : x2.b}`).sort();-- assert.deepStrictEqual(f, ['carol>x', 'carol>y', 'zed>carol']);-- // Providers: google clashed and stays on the folded-in account (signing in with it lands on the survivor); discord moved.-- assert.deepStrictEqual((await db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id)).map((l) => l.service), ['discord', 'google']);-- assert.strictEqual((await db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get()).user_id, carol2.id);-- assert.strictEqual((await db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get()).user_id, carol.id);-- assert.strictEqual((await db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get()).owner_subject, carol.subject_id);-- assert.deepStrictEqual((await db.prepare('SELECT user_id, language FROM user_preferences ORDER BY user_id').all()).map((p) => [p.user_id, p.language]), [[carol.id, 'en']], 'the survivor keeps its preferences');---- // Events: network.subject.merged, and the folded-in account's tokens end (account_merged).-- const merged = await events('network.subject.merged');-- assert.strictEqual(merged.length, 1);-- assert.ok(validate('events.event-envelope@1', merged[0]).valid && validate('network.subject.merged@1', merged[0].payload).valid);-- assert.deepStrictEqual([merged[0].payload.from, merged[0].payload.into, merged[0].payload.initiated_by, merged[0].visibility], [carol2.subject_id, carol.subject_id, 'person', 'internal']);-- const revoked = (await events('network.user.token_valid_after')).filter((e) => e.subject.id === carol2.subject_id);-- assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);-- assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");-- assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');-- assert.ok(Date.parse(revoked[0].payload.valid_after) > Date.parse(merged[0].payload.merged_at) - 1000, 'services get a cutoff past the merge second');-- // Signing in to the folded-in account signs in to the survivor.-- r = await call('POST', '/api/auth/login', null, { username: 'carol_old', password: 'secret12' });-- assert.deepStrictEqual([r.status, r.body.user && r.body.user.username, r.body.merged_into], [200, 'carol', 'carol']);-- assert.ok(Number.isFinite(jwt.decode(r.body.token).auth_time), 'a sign-in carries auth_time');---- // A retry answers the first merge and moves nothing again. Over HTTP the folded-in account's tokens are gone-- // (401); the survivor lists the merge instead.-- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });-- assert.strictEqual(r.status, 401);-- const again = await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), carol);-- assert.deepStrictEqual([again.replayed, again.merge_id], [true, merged[0].payload.merge_id]);-- r = await call('GET', '/api/v1/account/merges', tok(carol));-- assert.deepStrictEqual(r.body.merges.map((m) => m.merge_id), [merged[0].payload.merge_id]);-- assert.strictEqual(await wallet.getBalance(db, carol.id), 200);-- assert.strictEqual((await events('network.subject.merged')).length, 1);-- await assert.rejects(async () => await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');---- // ── Staff (account recovery) ──-- const admin = await mk('adminy', 'admin'); const boss = await mk('boss', 'admin');-- const d1 = await mk('dora'); const d2 = await mk('dora_lost');-- r = await call('POST', '/api/admin/account-merges', tok(admin), { from: 'dora_lost', into: 'dora', reason: 'lost her password, verified by email' });-- assert.strictEqual(r.status, 403, 'an admin is not the owner');-- r = await call('POST', '/api/admin/account-merges', tok(boss), { from: 'dora_lost', into: 'dora', reason: 'short' });-- assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.reason_required']);-- r = await call('POST', '/api/admin/account-merges', tok(boss), { from: d2.subject_id, into: 'dora', reason: 'lost her password, verified by email' });-- assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.strictEqual((await events('network.subject.merged')).pop().payload.initiated_by, 'staff');-- const audit = (await db.prepare("SELECT details FROM audit_log WHERE action = 'account_merge'").all()).map((a) => JSON.parse(a.details));-- assert.deepStrictEqual([audit.length, audit[0].from, audit[0].reason], [1, d2.subject_id, 'lost her password, verified by email']);-- r = await call('GET', '/api/admin/account-merges', tok(boss));-- assert.strictEqual(r.body.merges.length, 2);-- assert.strictEqual((await call('POST', '/api/admin/account-merges', tok(boss), { from: 'boss', into: 'dora', reason: 'the owner folds away' })).body.error, 'merge.owner');-- assert.ok(d1);---- // ── 30 days later: only the alias facts remain ──-- assert.strictEqual(await accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get()).n, 0);-- assert.strictEqual((await subjects.resolve(db, { subject_id: carol2.subject_id })).subject.id, carol.subject_id, 'the alias still answers');---- // auth_time: a renewal keeps the original sign-in time, a sign-in sets it.-- const renewed = jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: { auth_time: 1234 } }));-- assert.strictEqual(renewed.auth_time, 1234);-- assert.strictEqual(jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: {} })).auth_time, undefined, 'no sign-in time is never invented');-- assert.ok(Math.abs(jwt.decode(authRoutes.signToken(carol, keys.privateKey, config)).auth_time - Date.now() / 1000) < 5);-- assert.ok(ids);-- } finally {-- server.close();-- fs.rmSync(dir, { recursive: true, force: true });-- }-- console.log('account merge: all checks passed');--})().catch((e) => { console.error(e); process.exit(1); });--})().catch(err => { console.error(err); process.exit(1); });@@@ -78,7 +78,7 @@ function summary(service, namespace, extra = {}) {const configWrites = () => configCalls().filter((x) => !['GET', 'HEAD'].includes(x.method));const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });-- const stale = w.users.owner.token; // no auth_time: the account-merge tests' shape++ const stale = w.users.owner.token; // no auth_time: an old session shapeconst call = (who, method, p, body) => w.call(who, method, p, body);const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);@@@ -7,7 +7,7 @@// - 501 subjects are refused, and the service route needs a token with network.consent.read;// - a check's active rows come from one query;// - the change event is emitted after the commit, and the outbox carries the contract's fields only;--// - export keeps every row and every event; deletion revokes and keeps; merge revokes and never carries over;++// - export keeps every row and every event; deletion revokes and keeps; deletion revokes every active choice;// - the account page's "Your data choices" has kit fold rows and nothing pre-ticked.// node --import ./test/helpers/pg-preload.mjs test/consent.test.jsconst assert = require('assert');@@@ -22,7 +22,6 @@ const subjects = require('../server/identity/subjects');const principals = require('../server/identity/principals');const consentMod = require('../server/consent');const accountData = require('../server/identity/account-data');--const accountMerge = require('../server/identity/account-merge');(async () => {const db = getDb();@@@ -245,22 +244,6 @@ const outbox = async (subjectId) => (await db.prepare('SELECT envelope FROM netwconst bobPart = await accountData.networkPart(db, bob.id);assert.ok(bobPart.files.find((f) => f.name === 'consents.json').content.consents.every((x) => x.subject === bob.subject_id), "only bob's");-- // ── Merge: the folded-in account's choices are revoked, never carried over. ──-- const survivor = await mk('conskeep');-- const folded = await mk('consfold');-- await consent.choose({ subject: survivor.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['streams'] });-- await consent.choose({ subject: folded.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['vods'] });-- await accountMerge.merge(db, folded, survivor, { initiatedBy: 'person' });-- const foldedActive = await db.prepare('SELECT COUNT(*) AS n FROM consents WHERE subject = ? AND revoked_at IS NULL').get(folded.subject_id);-- assert.strictEqual(foldedActive.n, 0, "the folded-in account's choice is revoked");-- const foldedRow = await db.prepare('SELECT revoked_by FROM consents WHERE subject = ?').get(folded.subject_id);-- assert.strictEqual(foldedRow.revoked_by, 'merge');-- const survivorActive = await db.prepare('SELECT kinds FROM consents WHERE subject = ? AND revoked_at IS NULL').get(survivor.subject_id);-- assert.deepStrictEqual(survivorActive.kinds, ['streams'], 'the survivor keeps its own');-- const mergeEvent = await db.prepare("SELECT action, \"by\" FROM consent_events WHERE subject = ? AND \"by\" = 'merge'").get(folded.subject_id);-- assert.ok(mergeEvent, 'the merge event is recorded');-- assert.ok((await outbox(folded.subject_id)).some((e) => e.payload.on === false), 'the merge change is emitted');--// ── Deletion: revoke every active row, keep them. ──const gone = await mk('consgone');await consent.choose({ subject: gone.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['clips'] });@@@ -12,7 +12,7 @@// - one person never reads or writes another's state: the subject comes from the token and nowhere else;// - CORS: an OpenVibe site may ask, another origin is refused (and a cross-origin write never runs the route);// - 60 writes a minute per person;--// - the row goes into the person's export and is erased with them, and a merge leaves it with the survivor;++// - the row goes into the person's export and is erased with them, and it is erased with the person;// - the migration is expand-only.// node --import ./test/helpers/pg-preload.mjs test/dock.test.jsconst assert = require('assert');@@@ -27,7 +27,6 @@ const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const dockMod = require('../server/dock');const accountData = require('../server/identity/account-data');--const accountMerge = require('../server/identity/account-merge');const publicCors = require('../server/public-cors');const authRoutes = require('../server/auth/routes');@@@ -236,12 +235,6 @@ try {const bobPart = await accountData.networkPart(db, bob.id);assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'dock.json').content.map((x) => x.subject), [bob.subject_id], "bob's export carries only his own row");-- // The merge (ADR-029) leaves the Dock with the surviving account.-- const survivor = await db.prepare('SELECT * FROM users WHERE id = ?').get(ana.id);-- await accountMerge.merge(db, bob, survivor, { initiatedBy: 'person' });-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(bob.subject_id)).n, 0, "the folded-in account's row is gone");-- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(ana.subject_id)).n, 1, "the survivor keeps the row");--// And the row is erased with the person.const delId = `del_${ids.ulid()}`;await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)@@@ -36,10 +36,6 @@ const { backfill } = require('../scripts/backfill-levels');const dry = await backfill(db); assert.equal(dry.awarded, 0); assert.equal(dry.existing, 1);const applied = await backfill(db, { apply: true }); assert.equal(applied.awarded, 0);await db.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(other.id);-- await db.tx(() => levels.merge(db, user.id, other.id));-- assert.equal((await levels.total(db, other.id)).xp, 110);-- assert.equal((await levels.total(db, user.id)).xp, 0);-- const p = await levels.person(db, other.id); assert(p.recent.every(r => r.name === 'Finished a quest')); assert(!JSON.stringify(p).includes('quest:'));console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');process.exit(0);})().catch(e => { console.error(e); process.exit(1); });@@@ -3,7 +3,7 @@// - every change emits network.module.updated in the same transaction (network_event_outbox), with the// owner, namespace, changed keys and revision, never a private value; revisions survive deletes// - chat.preferences is owned by Chat (the contracts' owner since 0.32.0); Live's old write grant narrows--// - owner-service DELETE; account removal and merge (onSubjectRemoved/onSubjectMerged) with events, and++// - owner-service DELETE; account removal and guest linking with events, and// triggers that refuse deleting or re-keying an account while module rows remain; guest -> account link// - onOwnerRemoved: a retired owner makes its namespace read-only; delete-after-retention sweeps// - the relay publishes rows written while it was off@@@ -154,7 +154,6 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', eawait modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });await modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });await assert.rejects(async () => await db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');-- await assert.rejects(async () => await db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);mark = await lastId();await db.tx(async () => {assert.strictEqual(await modulesLib.onSubjectRemoved(db, BOB), 2);@@@ -168,34 +167,15 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', eassert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB)).n, 0);await assert.rejects(async () => await modulesLib.onSubjectRemoved(db, 2), /subject id/);-- // ── Merge: CAT folds into ANN. Moved where ANN had none, dropped where ANN has one ──-- await modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });-- await modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });-- mark = await lastId();-- const merged = await modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });-- assert.deepStrictEqual(merged, { moved: 1, filled: 0, dropped: 1 }, 'ANN already has followers: nothing to fill (ADR-029 fills only missing fields; test/account-merge.test.js)');-- const annPrefs = await modulesLib.read(db, ANN, 'chat.preferences');-- assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });-- assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');-- assert.strictEqual(annPrefs.updated_by, 'svc:network');-- assert.strictEqual((await modulesLib.read(db, ANN, 'live.profile')).data.followers, 43, 'the survivor\'s record wins');-- ev = await events(mark);-- assert.deepStrictEqual(ev.map(e => [e.payload.owner.id, e.payload.namespace, e.payload.change, (e.payload.merged_into || e.payload.merged_from || {}).id]), [-- [ANN, 'chat.preferences', 'created', CAT], [CAT, 'chat.preferences', 'deleted', ANN], [CAT, 'live.profile', 'deleted', ANN],-- ]);-- ev.forEach(valid);-- assert.ok(!('public' in ev[2].payload), 'a delete carries no values');-- await db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go-- await assert.rejects(async () => await modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);--// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──++ await modulesLib.write(db, ANN, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });await modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });await modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });await assert.rejects(async () => await db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: await tokenOf(1) });assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.tts_defaults')).data, { volume: 15 }, 'moved');-- assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.preferences')).data, { hide_emotes: true, compact: true }, 'the account kept its own values and gained only the field it lacked (ADR-029)');++ assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.preferences')).data, { hide_emotes: true, compact: true }, 'the registered account kept its own values and gained only the guest fields it lacked');assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST)).n, 0);// ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──@@@ -17,7 +17,6 @@ const ok = (cond, msg) => { assert.ok(cond, msg); n++; };const unknown = money.describe('nobody:declared-this', { site: 'fun' });ok(!unknown.known && unknown.text === 'for something on OpenVibe.Fun', 'an undeclared key never shows itself');-- ok(money.describe('account_merge').text === 'for merging your accounts', 'a reason with no page has its own words');const names = await money.questNames(async (url) => {assert.match(url, /\/api\/v1\/quests$/);@@@ -10,7 +10,7 @@ const { bootServer } = require('./helpers/boot-server');const qr = require('../server/auth/qr-signin');const { signToken } = require('../server/auth/routes');const subjects = require('../server/identity/subjects');--const accountMerge = require('../server/identity/account-merge');++const reauth = require('../server/identity/reauth');const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';// The same-origin rule the router applies to every POST (CORS's first-party list is wider than one origin).@@@ -164,7 +164,6 @@ function cookieOf(cookies, name) {// ── Security: a QR session inherits the source's auth_time and is not a fresh sign-in ──const authUser = await mkUser(`qg${suffix}`);-- const mergeSurvivor = await mkUser(`qm${suffix}`);const staleAuthTime = Math.floor(Date.now() / 1000) - 60 * 60;const staleSource = signSourceAt(authUser.user, staleAuthTime);const staleStart = await start(staleSource);@@@ -177,7 +176,7 @@ function cookieOf(cookies, name) {const staleToken = cookieOf(staleTarget.setCookies, 'ov_token');const staleClaims = jwt.decode(staleToken);assert.strictEqual(staleClaims.auth_time, staleAuthTime, 'the QR-created session inherits the source auth_time, not now');-- assert.strictEqual(accountMerge.isFreshSignIn(staleClaims), false, 'the merge fresh check reports the QR session as stale');++ assert.strictEqual(reauth.isFreshSignIn(staleClaims), false, 'fresh-sign-in check reports the QR session as stale');const noAuthSource = signSourceAt(authUser.user, null);const noAuthStart = await start(noAuthSource);@@@ -187,13 +186,6 @@ function cookieOf(cookies, name) {const noAuthTarget = await status(targetCookie(noAuthScanned));assert.strictEqual(jwt.decode(cookieOf(noAuthTarget.setCookies, 'ov_token')).auth_time, undefined, 'a source with no auth_time gives a target with none');-- const survivorToken = signToken(mergeSurvivor.user, privateKey, config);-- const intent = await api('POST', '/api/v1/account/merge/intents', { token: survivorToken, body: {} });-- assert.strictEqual(intent.status, 201, intent.text);-- const refused = await api('POST', '/api/v1/account/merge', { token: staleToken, body: { intent: intent.json.intent } });-- assert.strictEqual(refused.status, 401, refused.text);-- assert.strictEqual(refused.json.error, 'merge.sign_in_again', 'a QR session with a stale source cannot merge accounts');--// ── A second scan is refused with 409 ──let secondStart = await start();let secondScan = await scan(secondStart.id, secondStart.secret);@@@ -15,8 +15,6 @@ assert.deepStrictEqual(owner.staff_caps, staff.capabilitiesOf('owner'));assert.deepStrictEqual(staffClaims({ role: 'user', username: 'theowner' }), {}, 'the owner name without the admin role is nobody');// Services read the issued claims exactly as the map answers.assert.strictEqual(staff.can(owner, 'staff.secrets.manage'), true);--assert.strictEqual(staff.can(owner, 'staff.identity.merge'), true, 'account-recovery merges: the owner (map 1.2.0, ADR-029)');--assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.identity.merge'), false);assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);// Both user-token signers add them.const fs = require('fs');
@@ -81,7 +81,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
@@ -81,7 +81,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
## Owns
## Owns
-- accounts, sessions, anonymous identities, canonical subjects (`usr_…`, `gst_…`) and account merges
+- accounts, sessions, anonymous identities, canonical subjects (`usr_…`, `gst_…`)
- OAuth2/OIDC (the network's token issuer and its signing key), service principals and their grants,
- OAuth2/OIDC (the network's token issuer and its signing key), service principals and their grants,
developer projects, apps, credentials, grants and quotas (ADR-014), mod principals
developer projects, apps, credentials, grants and quotas (ADR-014), mod principals
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow
@@ -471,7 +471,7 @@ the Network (`server/dock/`, migration `0031_dock_states.sql`, contracts 0.251.0
@@ -471,7 +471,7 @@ the Network (`server/dock/`, migration `0031_dock_states.sql`, contracts 0.251.0
with the CORS allow-list and the CSRF rules the other `/api/v1/me/*` routes use.
with the CORS allow-list and the CSRF rules the other `/api/v1/me/*` routes use.
- A write is limited to 60 a minute per person (`429 dock.rate_limited` past that); the page debounces anyway.
- A write is limited to 60 a minute per person (`429 dock.rate_limited` past that); the page debounces anyway.
- The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge
- The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge
- (ADR-029) leaves it with the surviving account.
+ with the surviving account.
The full reference is [docs/dock.md](docs/dock.md).
The full reference is [docs/dock.md](docs/dock.md).
@@ -573,7 +573,7 @@ Namespaces, schemas, writers, public fields and quotas come from openvibe-contra
@@ -573,7 +573,7 @@ Namespaces, schemas, writers, public fields and quotas come from openvibe-contra
when `OV_EVENTS_INTERNAL_URL` is set; rows wait there otherwise). The payload names the owner subject,
when `OV_EVENTS_INTERNAL_URL` is set; rows wait there otherwise). The payload names the owner subject,
namespace, schema version, revision, change, reason and the changed keys; values only for fields the
namespace, schema version, revision, change, reason and the changed keys; values only for fields the
namespace declares public. Revisions only grow for a (person, namespace), deletes included.
namespace declares public. Revisions only grow for a (person, namespace), deletes included.
-- Accounts: `onSubjectRemoved` / `onSubjectMerged` delete or re-key a subject's records with events; triggers
+- Accounts: `onSubjectRemoved` deletes a subject's records with events; guest links move portable preferences to a registered account; triggers
refuse deleting a `users`/`anon_users` row, or changing its `subject_id`, while records remain.
refuse deleting a `users`/`anon_users` row, or changing its `subject_id`, while records remain.
- Owning service retired (its manifest `status: retired`): the namespace turns read-only;
- Owning service retired (its manifest `status: retired`): the namespace turns read-only;
`delete-after-retention` namespaces are emptied `retentionDays` after Network first saw it (daily sweep).
`delete-after-retention` namespaces are emptied `retentionDays` after Network first saw it (daily sweep).
@@ -842,7 +842,7 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
@@ -842,7 +842,7 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
`npm test` runs the 74 test files listed in `package.json` (temp databases, no network). They
`npm test` runs the 74 test files listed in `package.json` (temp databases, no network). They
cover OAuth with PKCE, OIDC discovery, refresh-token rotation and reuse, SSO and FedCM, subjects,
cover OAuth with PKCE, OIDC discovery, refresh-token rotation and reuse, SSO and FedCM, subjects,
-principals and grants, account merge, export and deletion, developer projects, export tokens and usage,
+principals and grants, export and deletion, developer projects, export tokens and usage,
user modules, blocks, follows and their authority switch, analytics privacy, the registry and its
user modules, blocks, follows and their authority switch, analytics privacy, the registry and its
exposure, notifications and events, status and incidents, operator alerts, per-actor limits, and the
exposure, notifications and events, status and incidents, operator alerts, per-actor limits, and the
security suites (`security-session`, `security-redirects`, `security-secrets`, `security-private`,
security suites (`security-session`, `security-redirects`, `security-secrets`, `security-private`,
@@ -960,7 +960,7 @@ The same proof covers a backups-only update (the fingerprint does not change).
@@ -960,7 +960,7 @@ The same proof covers a backups-only update (the fingerprint does not change).
- **A reset** is for the person who lost both their passphrase and their recovery kit: `PUT /api/v1/me/keys`
- **A reset** is for the person who lost both their passphrase and their recovery kit: `PUT /api/v1/me/keys`
with `{"reset": true}` and no proof, allowed only when the session's sign-in is recent — the session
with `{"reset": true}` and no proof, allowed only when the session's sign-in is recent — the session
token's `auth_time` within the last ten minutes (the OIDC claim Network's `signToken` puts on every
token's `auth_time` within the last ten minutes (the OIDC claim Network's `signToken` puts on every
- session; the same rule account-merge and the account deletion use). Without it the write is refused with
+ session; the same rule the account deletion uses). Without it the write is refused with
`403 locker.reauth_required`. A reset replaces the whole locker: new keys, and the backups that come with
`403 locker.reauth_required`. A reset replaces the whole locker: new keys, and the backups that come with
them; the old backups wrapped keys that are gone. It is written down as `reset`.
them; the old backups wrapped keys that are gone. It is written down as `reset`.
- **Forgetting** (`DELETE /api/v1/me/keys`, with `{"confirm": "forget my locker"}`) needs the same proof, over
- **Forgetting** (`DELETE /api/v1/me/keys`, with `{"confirm": "forget my locker"}`) needs the same proof, over
@@ -38,7 +38,7 @@
@@ -38,7 +38,7 @@
"deployment": "Public at https://openvibe.network: deployed with `sudo ovhost deploy network --install-units` (deploy/scripts/deploy.sh is the wrapper), /opt/openvibe.network, unit openvibe-network on 127.0.0.1:4000, env /etc/openvibe/network.env.",
"deployment": "Public at https://openvibe.network: deployed with `sudo ovhost deploy network --install-units` (deploy/scripts/deploy.sh is the wrapper), /opt/openvibe.network, unit openvibe-network on 127.0.0.1:4000, env /etc/openvibe/network.env.",
"features": [
"features": [
"SSO identity provider: OAuth2 code flow with PKCE, OIDC, FedCM, rotating refresh tokens",
"SSO identity provider: OAuth2 code flow with PKCE, OIDC, FedCM, rotating refresh tokens",
- "canonical subjects, anonymous users, multi-account switching, account merge, export and deletion",
+ "canonical subjects, anonymous users, multi-account switching, export and deletion",
"service principals and grants; developer projects, apps, credentials, grants, quotas and usage",
"service principals and grants; developer projects, apps, credentials, grants, quotas and usage",
"notifications (realtime through Events), email and web push",
"notifications (realtime through Events), email and web push",
"themes and preferences, user modules, platform blocks, follow graph, creator analytics",
"themes and preferences, user modules, platform blocks, follow graph, creator analytics",
@@ -90,5 +90,5 @@ Every object refuses extra keys, so a page cannot smuggle a field into the state
@@ -90,5 +90,5 @@ Every object refuses extra keys, so a page cannot smuggle a field into the state
## Account data
## Account data
The row is the person's own, so it goes into their account export (`network/dock.json` in Network's part of the
The row is the person's own, so it goes into their account export (`network/dock.json` in Network's part of the
-zip) and is erased with them (ADR-033). A merge (ADR-029) leaves it with the surviving account: the survivor's row
+zip) and is erased with the account (ADR-033). The row remains with its owner
wins, and a folded-in row moves over only when the survivor has none.
wins, and a folded-in row moves over only when the survivor has none.
@@ -37,7 +37,7 @@ or as an anonymous account.
@@ -37,7 +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. |
+| `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, 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`). |
@@ -51,7 +51,7 @@
@@ -51,7 +51,7 @@
} catch { return null; }
} catch { return null; }
}
}
- /** The one re-authentication URL. `account` prefills the username; pass '' to leave it blank (merge). */
+ /** The one re-authentication URL. `account` prefills the username; pass '' to leave it blank. */
function reauthUrl(returnTo, options) {
function reauthUrl(returnTo, options) {
const params = new URLSearchParams();
const params = new URLSearchParams();
params.set('reauth', '1');
params.set('reauth', '1');
@@ -12,7 +12,7 @@ const CONSENT_KIND_LABEL = {
@@ -12,7 +12,7 @@ const CONSENT_KIND_LABEL = {
posts: 'Posts', pictures: 'Pictures', code: 'Code',
posts: 'Posts', pictures: 'Pictures', code: 'Code',
};
};
const CONSENT_BY_LABEL = {
const CONSENT_BY_LABEL = {
- person: 'you', app_revoked: 'the app revoked', merge: 'an account merge', deletion: 'account deletion', import: 'an import',
+ person: 'you', app_revoked: 'the app revoked', merge: 'an earlier account action', deletion: 'account deletion', import: 'an import',
};
};
function consentEsc(value) {
function consentEsc(value) {
@@ -130,11 +130,9 @@ async function loadUser() {
@@ -130,11 +130,9 @@ async function loadUser() {
renderAccountId(data.user);
renderAccountId(data.user);
loadThemes(); loadThemeSubmissions();
loadThemes(); loadThemeSubmissions();
loadAccounts();
loadAccounts();
- renderMerge();
renderExport(); renderDeletion();
renderExport(); renderDeletion();
loadConsent();
loadConsent();
loadLabelPrefs();
loadLabelPrefs();
- if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
loadNotifPrefs(); loadQuietHours();
loadNotifPrefs(); loadQuietHours();
loadLinked();
loadLinked();
loadSessions();
loadSessions();
@@ -385,85 +383,6 @@ function loadAccounts() {
@@ -385,85 +383,6 @@ function loadAccounts() {
`).join('');
`).join('');
}
}
-// ── Account merge (ADR-029): 1) the account you keep opens a 10-minute request, 2) you sign in to the other
-// account ("Add another account"), 3) it confirms and folds itself in. Both sign-ins within 10 minutes.
-const MERGE_KEY = 'ov_merge_intent';
-function pendingMerge() {
- try { const m = JSON.parse(localStorage.getItem(MERGE_KEY) || 'null'); return m && Date.parse(m.expires_at) > Date.now() ? m : null; } catch { return null; }
-}
-function renderMerge() {
- const body = document.getElementById('merge-body');
- if (!body || !currentUser) return;
- body.textContent = '';
- const el = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };
- const row = el('div', '', 'btn-row');
- const pending = pendingMerge();
- if (currentUser.is_anon) { body.appendChild(el('p', 'Guests convert by signing up; merging is for two accounts.')); return; }
- if (!pending) {
- const b = el('button', `Keep @${currentUser.username} and merge another account into it`, 'btn btn-primary');
- b.type = 'button';
- b.onclick = async () => {
- 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 }));
- // 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;
- }
- const mins = Math.max(1, Math.round((Date.parse(pending.expires_at) - Date.now()) / 60000));
- const cancel = el('button', 'Cancel', 'btn btn-outline');
- 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). 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 = () => {
- 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}.`));
- const doIt = el('button', `Merge @${currentUser.username} into @${pending.into_username}`, 'btn btn-danger');
- doIt.type = 'button';
- doIt.onclick = async () => {
- doIt.disabled = true;
- try {
- const r = await apiFetch('/api/v1/account/merge', { method: 'POST', body: JSON.stringify({ intent: pending.intent }) });
- localStorage.removeItem(MERGE_KEY);
- toast(`Merged: @${currentUser.username} is now part of @${pending.into_username} (${r.moved.coins} OpenCoins moved).`, 'success');
- const folded = currentUser.id;
- try { OpenVibeAccountSwitcher.removeAccount(folded); } catch { /* */ }
- 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');
- 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);
-}
-
// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and
// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and
// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).
// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).
async function accountApi(path, opts = {}) {
async function accountApi(path, opts = {}) {
@@ -313,14 +313,8 @@
@@ -313,14 +313,8 @@
<button class="btn btn-danger" onclick="signOutAll()">Sign Out All</button>
<button class="btn btn-danger" onclick="signOutAll()">Sign Out All</button>
</div>
</div>
</div>
</div>
- <!-- Account merge (ADR-029): fold a second account this person owns into this one. -->
- <div class="card" id="merge-card" style="margin-top:16px">
- <h3><span class="icon"><i class="fa-solid fa-code-fork"></i></span> Merge two accounts</h3>
- <p>Own two accounts? Fold one into the other. Its follows, OpenCoins, linked sign-ins, sessions and settings move to the account you keep, which keeps its name and profile. The other account can't be used on its own afterwards: signing in to it signs you in to the one you kept. If it was a mistake, support can split it by hand within 30 days.</p>
- <div id="merge-body" style="margin-top:12px"></div>
- </div>
<!-- Your data (ADR-033): a copy of everything, and deleting the account after 30 days. -->
<!-- Your data (ADR-033): a copy of everything, and deleting the account after 30 days. -->
- <div class="card" id="export-card" style="margin-top:16px">
+ <div class="card" id="export-card">
<h3><span class="icon"><i class="fa-solid fa-box-archive"></i></span> Download your data</h3>
<h3><span class="icon"><i class="fa-solid fa-box-archive"></i></span> Download your data</h3>
<p>Get a copy of what OpenVibe keeps about you: your account here, and your part of Live, Chat, Community, Media and Games, as JSON files in one .zip. Each service adds its own files, so it takes up to half an hour. You can download it for 7 days, and ask for one a day.</p>
<p>Get a copy of what OpenVibe keeps about you: your account here, and your part of Live, Chat, Community, Media and Games, as JSON files in one .zip. Each service adds its own files, so it takes up to half an hour. You can download it for 7 days, and ask for one a day.</p>
<div id="export-body" style="margin-top:12px"></div>
<div id="export-body" style="margin-top:12px"></div>
@@ -40,7 +40,7 @@ const contracts = require('openvibe-contracts');
@@ -40,7 +40,7 @@ const contracts = require('openvibe-contracts');
const { requireOwner } = require('../auth/owner-guard');
const { requireOwner } = require('../auth/owner-guard');
const { reauthUrl } = require('../auth/reauth');
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/reauth');
// The nine AI Exchange services (D11), then every other service that holds an owner input (the Setup tab,
// The nine AI Exchange services (D11), then every other service that holds an owner input (the Setup tab,
// 2026-11-07). A service that has not mounted openvibe-shared/config's routes yet answers 404 or does not answer;
// 2026-11-07). A service that has not mounted openvibe-shared/config's routes yet answers 404 or does not answer;
@@ -84,8 +84,8 @@ function signInUrl(config = {}) {
@@ -84,8 +84,8 @@ function signInUrl(config = {}) {
}
}
/** 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
- * account-merge's isFreshSignIn (auth_time within ten minutes, never a renewal and never a QR session carrying an
- * old source time), so the owner settings and the account merge cannot drift apart. */
+ * reauth's isFreshSignIn (auth_time within ten minutes, never a renewal and never a QR session carrying an
+ * old source time), so the owner settings and the email and Locker checks use the same policy. */
function isFresh(req, now = Date.now()) {
function isFresh(req, now = Date.now()) {
return isFreshSignIn(req.tokenClaims, now);
return isFreshSignIn(req.tokenClaims, now);
}
}
@@ -100,7 +100,7 @@ async function verifyDockSession(token, { db, publicKey, config }) {
@@ -100,7 +100,7 @@ async function verifyDockSession(token, { db, publicKey, config }) {
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user) return { error: 'User not found', status: 401 };
if (!user) return { error: 'User not found', status: 401 };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
- if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };
+ if (user.merged_into) return { error: 'Invalid session', status: 401 };
if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };
if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };
if (user.token_valid_after) {
if (user.token_valid_after) {
const validAfter = new Date(String(user.token_valid_after).replace(' ', 'T') + (/[zZ]$/.test(String(user.token_valid_after)) ? '' : 'Z')).getTime();
const validAfter = new Date(String(user.token_valid_after).replace(' ', 'T') + (/[zZ]$/.test(String(user.token_valid_after)) ? '' : 'Z')).getTime();
@@ -25,7 +25,7 @@ const express = require('express');
@@ -25,7 +25,7 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const QRCode = require('qrcode');
const QRCode = require('qrcode');
const { setSessionCookies } = require('./session');
const { setSessionCookies } = require('./session');
-const accountMerge = require('../identity/account-merge');
+const accountAliases = require('../identity/account-aliases');
const COOKIE = 'ov_qr';
const COOKIE = 'ov_qr';
const COOKIE_PATH = '/auth/qr';
const COOKIE_PATH = '/auth/qr';
@@ -256,7 +256,7 @@ function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifi
@@ -256,7 +256,7 @@ function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifi
await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);
await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);
return { denied: true };
return { denied: true };
}
}
- const survivor = await accountMerge.effectiveUser(t, user);
+ const survivor = await accountAliases.effectiveUser(t, user);
if (!survivor || survivor.is_banned || survivor.deleted_at) {
if (!survivor || survivor.is_banned || survivor.deleted_at) {
await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);
await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);
return { denied: true };
return { denied: true };
@@ -266,7 +266,7 @@ function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifi
@@ -266,7 +266,7 @@ function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifi
if (!won.changes) return { lost: true };
if (!won.changes) return { lost: true };
// A QR-created session is not a fresh sign-in: inherit the source session's auth_time (or none),
// A QR-created session is not a fresh sign-in: inherit the source session's auth_time (or none),
- // so ADR-029's merge check and D11's key/money check still require a real re-authentication.
+ // so the owner's key/money check still require a real re-authentication.
const authTime = row.source_auth_time == null ? null : Number(row.source_auth_time);
const authTime = row.source_auth_time == null ? null : Number(row.source_auth_time);
const token = signToken(survivor, privateKey, config, { authTime });
const token = signToken(survivor, privateKey, config, { authTime });
// The device summary only: no raw User-Agent and no IP in the target's session row.
// The device summary only: no raw User-Agent and no IP in the target's session row.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * One re-authentication URL for every fresh-sign-in action (account deletion, account merge, the owner's AI
+ * One re-authentication URL for every fresh-sign-in action (account deletion, the owner's AI
* key/money settings, Locker reset/forget, QR transfer rules). The login page's ?reauth=1 always asks for the
* 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
* 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.
* fresh check never passes and the person loops.
@@ -17,7 +17,7 @@ const { ids, validate } = require('openvibe-contracts');
@@ -17,7 +17,7 @@ const { ids, validate } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
const EVENT_TYPE = 'network.user.token_valid_after';
const EVENT_TYPE = 'network.user.token_valid_after';
-const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked', 'account_merged'];
+const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked'];
const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();
const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();
@@ -43,8 +43,8 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
@@ -43,8 +43,8 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
* @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself
* @param {{ reason: string, actor?: object, ctx?: object }} opts actor defaults to the person themself
* @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject
* @returns {{ validAfter: string, event: object|null }} event is null for an account with no subject
*/
*/
-// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:
-// the folded-in account's fresh sign-in token must not outlive the merge).
+// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (a token cutoff:
+// a newly minted token must not outlive the cutoff).
async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {
async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {
if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
return await db.tx(async () => {
return await db.tx(async () => {
@@ -45,8 +45,8 @@ const DEFAULT_AUDIENCE = ['openvibe.live', 'openvibe.tools', 'openvibe.games', '
@@ -45,8 +45,8 @@ const DEFAULT_AUDIENCE = ['openvibe.live', 'openvibe.tools', 'openvibe.games', '
* A person's session token. auth_time (OIDC) is when they actually signed in: now for a sign-in, register or
* A person's session token. auth_time (OIDC) is when they actually signed in: now for a sign-in, register or
* password change; a renewal passes `renew` (the claims of the token it replaces) and keeps that token's
* password change; a renewal passes `renew` (the claims of the token it replaces) and keeps that token's
* auth_time, or none when it had none. A QR-created session passes `authTime` so it inherits the source's
* auth_time, or none when it had none. A QR-created session passes `authTime` so it inherits the source's
- * sign-in time (or null for none): it is not itself a fresh sign-in. An account merge and the owner's
- * key/money checks need a real sign-in within 10 minutes (ADR-029, D11).
+ * sign-in time (or null for none): it is not itself a fresh sign-in. The owner's
+ * key/money checks need a real sign-in within 10 minutes (D11).
*/
*/
function signToken(user, privateKey, config, { renew = null, authTime } = {}) {
function signToken(user, privateKey, config, { renew = null, authTime } = {}) {
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
@@ -223,9 +223,10 @@ router.post('/login', async (req, res) => {
@@ -223,9 +223,10 @@ router.post('/login', async (req, res) => {
if (!bcrypt.compareSync(password, user.password_hash)) {
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid username or password' });
return res.status(401).json({ error: 'Invalid username or password' });
}
}
- // An account merged into another (ADR-029) signs in to the survivor.
- const signedIn = await require('../identity/account-merge').effectiveUser(db, user);
+ // Existing merged aliases authenticate as their surviving account.
+ const signedIn = await require('../identity/account-aliases').effectiveUser(db, user);
if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });
if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });
+ if (signedIn.deleted_at) return res.status(401).json({ error: 'Account deleted' });
try {
try {
if (await require('./mfa').challengeLogin(db, req, res, signedIn)) return;
if (await require('./mfa').challengeLogin(db, req, res, signedIn)) return;
@@ -234,9 +235,9 @@ router.post('/login', async (req, res) => {
@@ -234,9 +235,9 @@ router.post('/login', async (req, res) => {
await db.prepare('UPDATE users SET last_seen = ov_now() WHERE id = ?').run(signedIn.id);
await db.prepare('UPDATE users SET last_seen = ov_now() WHERE id = ?').run(signedIn.id);
const token = signToken(signedIn, req.app.locals.privateKey, config);
const token = signToken(signedIn, req.app.locals.privateKey, config);
- console.log(`[Auth] Login: ${user.username}${signedIn.id !== user.id ? ` (merged into ${signedIn.username})` : ''}`);
+ console.log(`[Auth] Login: ${user.username}`);
require('./session').setSessionCookies(res, token);
require('./session').setSessionCookies(res, token);
- res.json({ token, user: sanitizeUser(signedIn), ...(signedIn.id !== user.id ? { merged_into: signedIn.username } : {}) });
+ res.json({ token, user: sanitizeUser(signedIn) });
});
});
// ── Forgot Password ─────────────────────────────────────────
// ── Forgot Password ─────────────────────────────────────────
@@ -729,8 +730,8 @@ router.post('/anon/:token/link', requireAuth, async (req, res) => {
@@ -729,8 +730,8 @@ router.post('/anon/:token/link', requireAuth, async (req, res) => {
await db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
await db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
.run(anon.anon_number, req.user.id);
.run(anon.anon_number, req.user.id);
// The guest's user modules (portable preferences) move to the account; where the account already
// The guest's user modules (portable preferences) move to the account; where the account already
- // has a record in a namespace, the account's is kept (server/identity/modules.js onSubjectMerged).
- if (anon.subject_id && userSubject) await require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });
+ // has a record in a namespace, the account's is kept (server/identity/modules.js onGuestLinked).
+ if (anon.subject_id && userSubject) await require('../identity/modules').onGuestLinked(db, { from: anon.subject_id, into: userSubject });
});
});
console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);
console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);
@@ -41,8 +41,7 @@ async function verifySession(token, { db, publicKey, config }) {
@@ -41,8 +41,7 @@ async function verifySession(token, { db, publicKey, config }) {
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user) return { error: 'User not found', status: 401 };
if (!user) return { error: 'User not found', status: 401 };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
- // An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.
- if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };
+ if (user.merged_into) return { error: 'Invalid session', status: 401 };
// A deleted account (ADR-033) is a tombstone: none of its tokens is accepted.
// A deleted account (ADR-033) is a tombstone: none of its tokens is accepted.
if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };
if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };
if (user.token_valid_after) {
if (user.token_valid_after) {
@@ -121,7 +120,7 @@ function makeRequireAuth(getCtx, signToken, { dockScope = null } = {}) {
@@ -121,7 +120,7 @@ function makeRequireAuth(getCtx, signToken, { dockScope = null } = {}) {
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
req.user = out.user;
req.user = out.user;
req.token = token;
req.token = token;
- req.tokenClaims = out.decoded; // auth_time: account merge needs a recent real sign-in
+ req.tokenClaims = out.decoded; // auth_time: sensitive account settings need a recent real sign-in
req.dockScope = dock ? (out.scopes || []) : null;
req.dockScope = dock ? (out.scopes || []) : null;
if (out.renew && !dock && typeof signToken === 'function') {
if (out.renew && !dock && typeof signToken === 'function') {
try {
try {
@@ -196,7 +196,7 @@ async function publish(db, payloads = []) {
@@ -196,7 +196,7 @@ async function publish(db, payloads = []) {
const EVENT_SELECT = `id, consent_id, subject, purpose, client, action, "by", text_version, at`;
const EVENT_SELECT = `id, consent_id, subject, purpose, client, action, "by", text_version, at`;
/**
/**
- * Revoke every active choice of one subject, inside the caller's transaction (account merge or deletion). The rows
+ * Revoke every active choice of one subject, inside the caller's transaction (account deletion). The rows
* are kept; the returned payloads must be published by the caller after its transaction commits.
* are kept; the returned payloads must be published by the caller after its transaction commits.
*/
*/
async function revokeForSubject(t, subject, by, at) {
async function revokeForSubject(t, subject, by, at) {
@@ -213,11 +213,6 @@ async function revokeForSubject(t, subject, by, at) {
@@ -213,11 +213,6 @@ async function revokeForSubject(t, subject, by, at) {
return payloads;
return payloads;
}
}
-/** Account merge: the account folded away never carries its choices over. Inside the merge's transaction. */
-async function revokeForMerge(t, from, { at }) {
- return await revokeForSubject(t, from, 'merge', at);
-}
-
/** Account deletion: revoke every active choice of the account and its aliases. Inside the deletion's transaction. */
/** Account deletion: revoke every active choice of the account and its aliases. Inside the deletion's transaction. */
async function revokeForDeletion(t, subjects, { at }) {
async function revokeForDeletion(t, subjects, { at }) {
const payloads = [];
const payloads = [];
@@ -620,6 +615,6 @@ function internalRouter({ consent }) {
@@ -620,6 +615,6 @@ function internalRouter({ consent }) {
module.exports = {
module.exports = {
apps,
apps,
PURPOSES, PURPOSE_BY_ID, TEXT_VERSION, KINDS, WRITE_LIMIT, WRITE_WINDOW_MS, MAX_SUBJECTS,
PURPOSES, PURPOSE_BY_ID, TEXT_VERSION, KINDS, WRITE_LIMIT, WRITE_WINDOW_MS, MAX_SUBJECTS,
- createConsent, router, internalRouter, eventEnvelope, publish, exportOf, revokeForMerge, revokeForDeletion,
+ createConsent, router, internalRouter, eventEnvelope, publish, exportOf, revokeForDeletion,
ConsentError, CHOICE_CONTRACT, REQUEST_CONTRACT, RESULT_CONTRACT, MINE_CONTRACT,
ConsentError, CHOICE_CONTRACT, REQUEST_CONTRACT, RESULT_CONTRACT, MINE_CONTRACT,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+'use strict';
+
+// Existing merged accounts remain able to sign in to their surviving account.
+async function effectiveUser(db, user) {
+ if (!user || !user.merged_into) return user;
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;
+}
+
+module.exports = { effectiveUser };
@@ -81,7 +81,6 @@ function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.
@@ -81,7 +81,6 @@ function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.
function assertPerson(user) {
function assertPerson(user) {
if (!user || user.is_anon) throw new AccountDataError(400, 'account.guest', 'a guest account has nothing to export or delete');
if (!user || user.is_anon) throw new AccountDataError(400, 'account.guest', 'a guest account has nothing to export or delete');
- if (user.merged_into) throw new AccountDataError(409, 'account.merged', 'this account was merged into another');
if (user.deleted_at) throw new AccountDataError(410, 'account.deleted', 'this account was deleted');
if (user.deleted_at) throw new AccountDataError(410, 'account.deleted', 'this account was deleted');
if (!ids.isSubjectId('user', user.subject_id)) throw new AccountDataError(400, 'account.no_subject', 'this account has no subject');
if (!ids.isSubjectId('user', user.subject_id)) throw new AccountDataError(400, 'account.no_subject', 'this account has no subject');
}
}
@@ -169,8 +168,6 @@ async function networkPart(db, userId) {
@@ -169,8 +168,6 @@ async function networkPart(db, userId) {
'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'subject_id'];
'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'subject_id'];
const account = {};
const account = {};
for (const k of keep) if (k in u) account[k] = u[k];
for (const k of keep) if (k in u) account[k] = u[k];
- account.merged_in = await tableExists(db, 'subject_aliases')
- ? await db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];
add('account.json', account);
add('account.json', account);
const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
await byUser('sign_in_providers.json', 'linked_accounts');
await byUser('sign_in_providers.json', 'linked_accounts');
@@ -230,8 +227,6 @@ async function networkPart(db, userId) {
@@ -230,8 +227,6 @@ async function networkPart(db, userId) {
// The agents that act for this person (WS-Z2); who revoked one may be someone else, so revoked_by stays out.
// The agents that act for this person (WS-Z2); who revoked one may be someone else, so revoked_by stays out.
agents: (await rowsOf(db, 'dev_agents', 'owner_subject = ?', [u.subject_id], { omit: ['revoked_by'] })).rows,
agents: (await rowsOf(db, 'dev_agents', 'owner_subject = ?', [u.subject_id], { omit: ['revoked_by'] })).rows,
});
});
- add('account_merges.json', await tableExists(db, 'account_merges')
- ? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);
add('moderation.json', (await rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] })).rows);
add('moderation.json', (await rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] })).rows);
return { files, truncated };
return { files, truncated };
}
}
@@ -355,8 +350,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -355,8 +350,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
const subject = user.subject_id;
const subject = user.subject_id;
const folded = await db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);
const folded = await db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);
- const aliases = await tableExists(db, 'subject_aliases') ? (await db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject)).map((r) => r.alias_id) : [];
- const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];
+ const subjects = [...new Set([subject, ...folded.map((f) => f.subject_id).filter(Boolean)])];
const userIds = [user.id, ...folded.map((f) => f.id)];
const userIds = [user.id, ...folded.map((f) => f.id)];
const inU = `(${userIds.map(() => '?').join(',')})`;
const inU = `(${userIds.map(() => '?').join(',')})`;
const inS = `(${subjects.map(() => '?').join(',')})`;
const inS = `(${subjects.map(() => '?').join(',')})`;
@@ -368,7 +362,6 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -368,7 +362,6 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
await del('experience_events', `user_id IN ${inU}`, userIds);
await del('experience_events', `user_id IN ${inU}`, userIds);
await del('person_levels', `user_id IN ${inU}`, userIds);
await del('person_levels', `user_id IN ${inU}`, userIds);
await del('chat_app_pending_links', `confirmed_by IN ${inU}`, userIds);
await del('chat_app_pending_links', `confirmed_by IN ${inU}`, userIds);
- if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
// Notifications this person sent others stay, without their name or picture.
// Notifications this person sent others stay, without their name or picture.
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
@@ -407,7 +400,6 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -407,7 +400,6 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
// network.link.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.
// network.link.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.
const nodePrincipals = require('../registry/link-principals');
const nodePrincipals = require('../registry/link-principals');
count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));
count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));
- if (await tableExists(db, 'account_merges')) await db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
for (const id of userIds) {
for (const id of userIds) {
const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
deleted file mode 100644
deleted file mode 100644
@@ -1,310 +0,0 @@
@@ -1,310 +0,0 @@
-'use strict';
-const cache = require('openvibe-shared/cache-policy');
-/**
- * Account merge (roadmap WS-B task 5, ADR-029; Contracts 0.69.0 network.subject.merged@1,
- * network.account-merge-result@1, staff.identity.merge).
- *
- * Two accounts one person controls become one. The survivor (`into`) keeps its subject, username, profile and
- * settings; the folded-in account (`from`) becomes an alias: subject_aliases (from → into), and resolving `from`,
- * or any legacy id mapped to it, answers the survivor (identity_legacy_map itself is never repointed, ADR-001).
- *
- * Proof, for a person (never staff silently):
- * 1. signed in to the survivor: POST /api/v1/account/merge/intents → a merge intent, valid 10 minutes;
- * 2. signed in to the other account, freshly (the token's auth_time within 10 minutes: a real sign-in, not a
- * renewal): POST /api/v1/account/merge { intent } folds that account into the intent's.
- * Staff (account recovery): POST /api/admin/account-merges { from, into, reason } needs staff.identity.merge
- * (the owner) and a written reason; it writes an audit row.
- *
- * Network moves what it owns in ONE transaction, with the event network.subject.merged in its outbox:
- * linked providers, sessions and devices, OAuth tokens and codes, developer projects and memberships,
- * OpenCoins (one ledger entry per side, reason account_merge, idempotent by merge id), user modules (the
- * survivor's fields win, the folded-in values fill only what it lacks), follows (both directions), blocks,
- * notifications, notification and display preferences, push subscriptions and effects.
- * A row that would collide with the survivor's (the same provider, project membership, follow, preference…) keeps
- * the survivor's and drops the other, and is counted. Every other service follows the event.
- *
- * The folded-in user row stays, marked merged_into, with its tokens revoked; signing in to it signs in to the
- * survivor. The merge record keeps its pre-merge state for 30 days so staff can split a mistake by hand; after
- * that the record is reduced to the alias (reduceExpired). A retried merge of the same pair answers the first.
- */
-const express = require('express');
-const { ids, validate, staff } = require('openvibe-contracts');
-const eventRelay = require('../developer/event-relay');
-const wallet = require('../coins/wallet');
-const modules = require('./modules');
-const follows = require('./follows');
-const revocation = require('../auth/revocation');
-
-const INTENT_TTL_MS = 10 * 60 * 1000;
-const FRESH_SIGN_IN_S = 10 * 60;
-const SPLIT_DAYS = 30;
-const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-class MergeError extends Error {
- constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
-}
-
-/** A real sign-in (auth_time within 10 minutes), never a renewal or a QR session carrying an old source time. */
-function isFreshSignIn(claims, now = Date.now()) {
- const authTime = Number(claims && claims.auth_time);
- return Number.isFinite(authTime) && now / 1000 - authTime <= FRESH_SIGN_IN_S;
-}
-
-async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
-
-/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */
-async function survivorOf(db, subjectId) {
- try {
- const a = await db.prepare('SELECT subject_id FROM subject_aliases WHERE alias_id = ?').get(String(subjectId || ''));
- return a ? a.subject_id : subjectId;
- } catch { return subjectId; } // before the first merge the table may not exist
-}
-
-/** A user row, or the survivor's when it was merged (signing in to a folded-in account signs in to the survivor). */
-async function effectiveUser(db, user) {
- if (!user || !user.merged_into) return user;
- return await db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;
-}
-
-const iso = (ms) => new Date(ms).toISOString();
-
-function buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject }) {
- const payload = { merge_id: mergeId, from, into, merged_at: mergedAt, initiated_by: initiatedBy, split_until: splitUntil };
- const env = {
- event_id: ids.newId('event', Date.parse(mergedAt)), event_type: 'network.subject.merged', version: 1, source: 'network',
- actor: { type: 'user', id: actorSubject }, timestamp: mergedAt, visibility: 'internal',
- subject: { type: 'user', id: into }, payload,
- };
- const v = validate('events.event-envelope@1', env);
- const pv = validate('network.subject.merged@1', payload);
- if (!v.valid || !pv.valid) throw new Error(`account-merge: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
- return env;
-}
-
-/**
- * Move rows keyed by an integer user id. keyCols: the columns that, with the user id, are unique ([] = one row
- * per user); a row whose key the survivor already holds is dropped, or with keepOnClash left on the folded-in
- * account (a linked provider: signing in with it still lands on the survivor through merged_into).
- */
-async function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {
- let moved = 0; let dropped = 0;
- if (!await tableExists(db, table)) return { moved, dropped };
- const rows = await db.prepare(`SELECT seq AS _rid, * FROM ${table} WHERE ${column} = ?`).all(fromId);
- for (const r of rows) {
- if (keyCols) {
- const where = keyCols.map((k) => ` AND ${k} = ?`).join('');
- const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));
- if (clash) {
- if (!keepOnClash) await db.prepare(`DELETE FROM ${table} WHERE seq = ?`).run(r._rid);
- dropped++;
- continue;
- }
- }
- await db.prepare(`UPDATE ${table} SET ${column} = ? WHERE seq = ?`).run(intoId, r._rid);
- moved++;
- }
- return { moved, dropped };
-}
-
-async function tableExists(db, name) {
- return !!await db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name);
-}
-
-/**
- * Fold `fromUser` into `intoUser`. opts: { initiatedBy: 'person'|'staff', actorSubject, reason, now }.
- * → network.account-merge-result@1. Throws MergeError.
- */
-async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {
- await ensureSchema(db);
- if (!fromUser || !intoUser) throw new MergeError(404, 'merge.unknown_account', 'no such account');
- if (fromUser.id === intoUser.id) throw new MergeError(400, 'merge.same_account', 'an account cannot be merged into itself');
- const from = fromUser.subject_id; const into = intoUser.subject_id;
- if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new MergeError(400, 'merge.no_subject', 'both accounts need a subject');
- const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ?').get(from);
- if (done) {
- if (done.into_subject !== into) throw new MergeError(409, 'merge.already_merged', 'that account was already merged into another');
- return resultOf(done, true);
- }
- if (fromUser.merged_into || intoUser.merged_into) throw new MergeError(409, 'merge.already_merged', 'one of these accounts was already merged');
- if (fromUser.is_anon || intoUser.is_anon) throw new MergeError(400, 'merge.guest', 'a guest converts by signing up, not by merging');
- if (fromUser.is_banned || intoUser.is_banned) throw new MergeError(403, 'merge.banned', 'a banned account cannot be merged');
- const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
- if (String(fromUser.username || '').toLowerCase() === owner) throw new MergeError(403, 'merge.owner', "the owner's account cannot be folded into another");
-
- const mergeId = `mrg_${ids.ulid(now)}`;
- const mergedAt = iso(now);
- const splitUntil = iso(now + SPLIT_DAYS * 86400000);
- const actor = actorSubject || into;
- let consentEvents = [];
- const run = db.txFn(async () => {
- const pre = {
- user: { ...fromUser, password_hash: undefined },
- linked_accounts: await db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),
- sessions: (await db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id)).map((r) => r.id),
- balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,
- projects: await tableExists(db, 'dev_projects') ? (await db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from)).map((r) => r.id) : [],
- modules: await db.prepare('SELECT namespace, revision FROM user_modules WHERE subject_id = ?').all(from),
- };
- const moved = { providers: 0, sessions: 0, oauth_grants: 0, projects: 0, coins: 0, modules: 0 };
- const dropped = {};
- const note = (k, r) => { if (r.dropped) dropped[k] = (dropped[k] || 0) + r.dropped; return r.moved; };
-
- moved.providers = note('providers', await moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));
- moved.sessions = note('sessions', await moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));
- moved.oauth_grants = (await moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id)).moved
- + (await moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id)).moved;
- if (await tableExists(db, 'dev_projects')) {
- moved.projects = (await db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from)).changes;
- for (const m of await db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {
- const has = await db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);
- if (has) { await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(m.project_id, from); dropped.project_members = (dropped.project_members || 0) + 1; }
- else await db.prepare('UPDATE dev_project_members SET subject_id = ? WHERE project_id = ? AND subject_id = ?').run(into, m.project_id, from);
- }
- }
- // OpenCoins: loyalty (ADR-012), moved by Network itself, one ledger entry per side, once per merge.
- if (pre.balance > 0) {
- await wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });
- await wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });
- moved.coins = pre.balance;
- }
- await require('../levels/store').merge(db, fromUser.id, intoUser.id);
- const mods = await modules.onSubjectMerged(db, { from, into });
- moved.modules = mods.moved + (mods.filled || 0);
- // Consent is the person's own and is never assumed: the folded-in account's active choices are revoked
- // (events by 'merge'), never carried over. The survivor keeps its own choices. Published after this commit.
- consentEvents = await require('../consent').revokeForMerge(db, from, { at: mergedAt });
- // Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.
- if (await tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? (await follows.onSubjectMerged(db, { from, into })).dropped : 0;
- if (await tableExists(db, 'user_blocks')) {
- for (const col of ['blocker_subject', 'blocked_subject']) {
- const other = col === 'blocker_subject' ? 'blocked_subject' : 'blocker_subject';
- for (const b of await db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {
- const clash = b.o === into || await db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
- if (clash) await db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
- else await db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
- }
- }
- }
- // The Dock's state (the Dock plan 2026-10-30, K-D1): the survivor's row wins. A folded-in row moves over
- // only when the survivor has none; when both have one, the survivor's is the one that stays.
- if (await tableExists(db, 'dock_states')) {
- const mine = await db.prepare('SELECT 1 FROM dock_states WHERE subject = ?').get(into);
- if (mine) await db.prepare('DELETE FROM dock_states WHERE subject = ?').run(from);
- else await db.prepare('UPDATE dock_states SET subject = ? WHERE subject = ?').run(into, from);
- }
- await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);
- await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);
- note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));
- note('preferences', await moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));
- note('effects', await moveByUserId(db, 'user_effects', 'user_id', fromUser.id, intoUser.id, ['effect_type', 'effect_id']));
-
- // The alias (and any alias of the folded-in subject now points at the survivor: aliases stay flat).
- await db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);
- await db.prepare('INSERT INTO subject_aliases (alias_id, subject_id, merge_id, merged_at, merged_by) VALUES (?, ?, ?, ?, ?)').run(from, into, mergeId, mergedAt, actor);
- // The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason
- // account_merged: sites close its sockets). Its sessions already belong to the survivor.
- await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);
- await revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });
- await db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
- .run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);
- if (initiatedBy === 'staff') {
- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
- .run(null, 'account_merge', JSON.stringify({ merge_id: mergeId, from, into, actor, reason }));
- }
- await eventRelay.writerFor(db).enqueue(db, buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));
- return await db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);
- });
- const row = await run();
- // Never inside the transaction (L24): the revocations and their events are visible together once it commits.
- await require('../consent').publish(db, consentEvents);
- try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
- return resultOf(row, false);
-}
-
-function resultOf(row, replayed) {
- const m = JSON.parse(row.moved || '{}');
- const moved = {};
- for (const k of ['providers', 'sessions', 'oauth_grants', 'projects', 'coins', 'modules']) moved[k] = Number(m[k]) || 0;
- return { merge_id: row.id, from: row.from_subject, into: row.into_subject, merged_at: row.merged_at, split_until: row.split_until, moved, replayed };
-}
-
-/** After 30 days a merge record keeps only the alias facts: its pre-merge state is dropped. */
-async function reduceExpired(db, { now = Date.now() } = {}) {
- await ensureSchema(db);
- return (await db.prepare('UPDATE account_merges SET pre_state = NULL, reduced_at = ? WHERE reduced_at IS NULL AND split_until < ?').run(iso(now), iso(now))).changes;
-}
-
-async function createIntent(db, intoUser, { now = Date.now() } = {}) {
- await ensureSchema(db);
- const id = `mgi_${ids.ulid(now)}`;
- await db.prepare('INSERT INTO account_merge_intents (id, into_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(id, intoUser.id, now, now + INTENT_TTL_MS);
- return { intent: id, expires_at: iso(now + INTENT_TTL_MS) };
-}
-
-/** The person's merge: the caller is the account being folded in (freshly signed in), the intent names the survivor. */
-async function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {
- await ensureSchema(db);
- if (!isFreshSignIn(claims, now)) {
- throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');
- }
- const intent = await db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));
- if (!intent || intent.expires_at < now) throw new MergeError(410, 'merge.intent_expired', 'that merge request expired: start again from the account that stays');
- const into = await db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);
- if (intent.used_at) {
- const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ? AND into_user_id = ?').get(caller.subject_id, intent.into_user_id);
- if (done) return resultOf(done, true);
- throw new MergeError(410, 'merge.intent_used', 'that merge request was already used');
- }
- const out = await merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });
- await db.prepare('UPDATE account_merge_intents SET used_at = ? WHERE id = ?').run(now, intent.id);
- return out;
-}
-
-function sendError(res, e) {
- if (e instanceof MergeError) return res.status(e.status).json({ error: e.code, detail: e.message });
- console.error('[AccountMerge]', e);
- return res.status(500).json({ error: 'merge.failed', detail: 'the merge did not happen' });
-}
-
-/** /api/v1/account/merge… (the person) and /api/admin/account-merges (staff.identity.merge). */
-function routers({ requireAuth, staffClaims }) {
- const me = express.Router();
- me.use(express.json({ limit: '8kb' }));
- me.post('/merge/intents', requireAuth, async (req, res) => {
- if (req.user.merged_into) return res.status(409).json({ error: 'merge.already_merged' });
- res.status(201).json(await createIntent(req.app.locals.db, req.user));
- });
- me.post('/merge', requireAuth, async (req, res) => {
- try { res.json(await mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }
- });
- me.get('/merges', requireAuth, async (req, res) => {
- const db = req.app.locals.db; await ensureSchema(db);
- const rows = await db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);
- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ merges: rows.map((r) => resultOf(r, false)) });
- });
-
- const admin = express.Router();
- admin.use(express.json({ limit: '8kb' }));
- admin.post('/', requireAuth, async (req, res) => {
- const db = req.app.locals.db;
- if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
- const b = req.body || {};
- const reason = String(b.reason || '').trim();
- if (reason.length < 10) return res.status(400).json({ error: 'merge.reason_required', detail: 'a staff merge needs a written reason (10 characters or more)' });
- const byRef = async (ref) => (SUBJECT_RE.test(String(ref || '')) ? await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : await db.prepare('SELECT * FROM users WHERE lower(username) = lower(?)').get(String(ref || '')));
- try {
- const out = await merge(db, await byRef(b.from), await byRef(b.into), { initiatedBy: 'staff', actorSubject: req.user.subject_id, reason: reason.slice(0, 500) });
- res.json(out);
- } catch (e) { sendError(res, e); }
- });
- admin.get('/', requireAuth, async (req, res) => {
- const db = req.app.locals.db; await ensureSchema(db);
- if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
- const rows = await db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();
- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ merges: rows });
- });
- return { me, admin };
-}
-
-module.exports = { ensureSchema, survivorOf, effectiveUser, merge, mergeWithIntent, createIntent, reduceExpired, routers, isFreshSignIn, MergeError, SPLIT_DAYS };
@@ -7,7 +7,7 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -7,7 +7,7 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const TTL_MINUTES = 60;
const TTL_MINUTES = 60;
const hash = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
const hash = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
const clean = (email) => String(email || '').trim().toLowerCase();
const clean = (email) => String(email || '').trim().toLowerCase();
-const { isFreshSignIn } = require('./account-merge');
+const { isFreshSignIn } = require('./reauth');
function needsFreshSignIn(req, res) {
function needsFreshSignIn(req, res) {
if (isFreshSignIn(req.tokenClaims)) return false;
if (isFreshSignIn(req.tokenClaims)) return false;
@@ -295,31 +295,5 @@ function routers({ requireAuth, followsGuard }) {
@@ -295,31 +295,5 @@ function routers({ requireAuth, followsGuard }) {
return { me, pub, internal };
return { me, pub, internal };
}
}
-/**
- * Account merge (ADR-029): `from`'s follows become `into`'s, both as the follower and as the followed channel
- * owner. A pair the survivor already has keeps the survivor's row (the other is dropped), and following oneself
- * is dropped. No follow events: services repoint on network.subject.merged. Inside the merge's transaction.
- * → { moved, dropped }
- */
-async function onSubjectMerged(db, { from, into }) {
- ensureSchema(db);
- const out = { moved: 0, dropped: 0 };
- const rows = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? OR target_id = ?').all(from, from);
- for (const r of rows) {
- const follower = r.follower_subject === from ? into : r.follower_subject;
- const target = r.target_id === from ? into : r.target_id;
- const clash = follower === target
- || await db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);
- if (clash) {
- await db.prepare('DELETE FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(r.follower_subject, r.target_type, r.target_id);
- out.dropped++;
- } else {
- await db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = ov_now() WHERE follower_subject = ? AND target_type = ? AND target_id = ?')
- .run(follower, target, r.follower_subject, r.target_type, r.target_id);
- out.moved++;
- }
- }
- return out;
-}
-module.exports = { setNotifier, onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, routers, kick };
+module.exports = { setNotifier, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, routers, kick };
@@ -20,8 +20,8 @@
@@ -20,8 +20,8 @@
* keys top-level fields that were added, changed or removed (names only, sorted)
* keys top-level fields that were added, changed or removed (names only, sorted)
* public only for fields the namespace declares public and that changed: their new value
* public only for fields the namespace declares public and that changed: their new value
* (null = removed). Absent for private namespaces: a value never leaves otherwise.
* (null = removed). Absent for private namespaces: a value never leaves otherwise.
- * merged_into / merged_from subject refs, on the two sides of an account merge
*
*
+ * merged_from / merged_into are used only when a guest identity is linked to a registered account.
* Nothing here reads or writes user_modules; the caller passes before/after.
* Nothing here reads or writes user_modules; the caller passes before/after.
*/
*/
const { ids, modules, validate } = require('openvibe-contracts');
const { ids, modules, validate } = require('openvibe-contracts');
@@ -22,8 +22,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -22,8 +22,7 @@ const cache = require('openvibe-shared/cache-policy');
* a record deleted and written again continues from there and a delete has a revision of its own.
* a record deleted and written again continues from there and a delete has a revision of its own.
*
*
* Lifecycle:
* Lifecycle:
- * - accounts: onSubjectRemoved() deletes a subject's records, onSubjectMerged() re-keys them into the
- * surviving subject; both emit events. Triggers refuse deleting a users/anon_users row, or changing
+ * - accounts: onSubjectRemoved() deletes a subject's records and emits events. Triggers refuse deleting a users/anon_users row, or changing
* its subject_id, while module rows remain, so an account deletion or merge cannot skip them.
* its subject_id, while module rows remain, so an account deletion or merge cannot skip them.
* - owning service retired (namespace onOwnerRemoved, contract modules.namespace@1): when the owner's
* - owning service retired (namespace onOwnerRemoved, contract modules.namespace@1): when the owner's
* service manifest says `retired`, the namespace is read-only (retain-readonly), and with
* service manifest says `retired`, the namespace is read-only (retain-readonly), and with
@@ -199,17 +198,18 @@ async function onSubjectRemoved(db, subjectId, { ctx } = {}) {
@@ -199,17 +198,18 @@ async function onSubjectRemoved(db, subjectId, { ctx } = {}) {
return n;
return n;
}
}
+
/**
/**
- * Two accounts become one (`from` folds into `into`, which survives; ADR-029). Each record of `from` moves to
+ * A guest identity is linked to its registered account. Each record of `from` moves to
* `into` when `into` has none in that namespace (created there, reason subject_merged, merged_from);
* `into` when `into` has none in that namespace (created there, reason subject_merged, merged_from);
* when `into` has one, the survivor's record is kept and gains only the top-level fields it lacks from `from`'s
* when `into` has one, the survivor's record is kept and gains only the top-level fields it lacks from `from`'s
- * (updated, reason subject_merged, merged_from; its own values always win). Every record of `from` ends deleted
+ * (updated, reason subject_merged, merged_from; its own values always win). The guest identity record ends deleted
* (reason subject_merged, merged_into). Moved and filled records say updated_by svc:network.
* (reason subject_merged, merged_into). Moved and filled records say updated_by svc:network.
* Call inside the merge's transaction, before `from`'s row goes. Returns { moved, filled, dropped }.
* Call inside the merge's transaction, before `from`'s row goes. Returns { moved, filled, dropped }.
*/
*/
-async function onSubjectMerged(db, { from, into, ctx } = {}) {
- if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onSubjectMerged: from and into must be usr_/gst_ subject ids');
- if (from === into) throw new TypeError('onSubjectMerged: from and into are the same subject');
+async function onGuestLinked(db, { from, into, ctx } = {}) {
+ if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onGuestLinked: from and into must be usr_/gst_ subject ids');
+ if (from === into) throw new TypeError('onGuestLinked: from and into are the same subject');
const system = { type: 'system', id: 'network' };
const system = { type: 'system', id: 'network' };
const out = await db.tx(async () => {
const out = await db.tx(async () => {
const res = { moved: 0, filled: 0, dropped: 0 };
const res = { moved: 0, filled: 0, dropped: 0 };
@@ -377,8 +377,9 @@ function serviceRoutes(router, principals) {
@@ -377,8 +377,9 @@ function serviceRoutes(router, principals) {
});
});
}
}
+
module.exports = {
module.exports = {
OWNER_HANDOFFS, ownerOf, canWrite, ownerRetired,
OWNER_HANDOFFS, ownerOf, canWrite, ownerRetired,
- ensureSchema, read, write, remove, onSubjectRemoved, onSubjectMerged, sweepRetired,
+ ensureSchema, read, write, remove, onSubjectRemoved, onGuestLinked, sweepRetired,
userRouter, serviceRoutes,
userRouter, serviceRoutes,
};
};
@@ -542,7 +542,7 @@ const DEFAULT_GRANTS = [
@@ -542,7 +542,7 @@ const DEFAULT_GRANTS = [
// A community AI offer's "followers" audience: AI checks whether the requester follows the offer's owner (the
// A community AI offer's "followers" audience: AI checks whether the requester follows the offer's owner (the
// owner's channel) before placing a run. Read-only, like Live's.
// owner's channel) before placing a run. Read-only, like Live's.
['ai', 'network.follows.read', SELF_AUDIENCE, []],
['ai', 'network.follows.read', SELF_AUDIENCE, []],
- // Self-dealing: a community run whose requester is the offer owner (or an account merged into it) earns nothing.
+ // Self-dealing: a community run whose requester is the offer owner earns nothing.
// AI resolves both subjects to the survivor before it records the seller's ledger.
// AI resolves both subjects to the survivor before it records the seller's ledger.
['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],
['ai', 'identity.subject.resolve', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
// WS-N task 12: ovhost incident / maintenance post to the status page.
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+'use strict';
+
+const FRESH_SIGN_IN_SECONDS = 10 * 60;
+function isFreshSignIn(claims, now = Date.now()) {
+ const authTime = Number(claims && claims.auth_time);
+ return Number.isFinite(authTime) && now / 1000 - authTime <= FRESH_SIGN_IN_SECONDS;
+}
+module.exports = { isFreshSignIn };
@@ -119,11 +119,8 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
@@ -119,11 +119,8 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
sid = row && row.subject_id;
sid = row && row.subject_id;
}
}
}
}
- // A subject merged into another (ADR-029) resolves to the survivor, saying where it came from.
- const survivor = sid ? await require('./account-merge').survivorOf(db, sid) : sid;
- const p = survivor ? await projection(db, survivor) : null;
+ const p = sid ? await projection(db, sid) : null;
if (!p) return null;
if (!p) return null;
- if (survivor !== sid) p.merged_from = sid;
p.legacy_ids = await db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')
p.legacy_ids = await db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')
.all(p.subject.id);
.all(p.subject.id);
return p;
return p;
@@ -136,9 +133,8 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
@@ -136,9 +133,8 @@ async function resolve(db, { subject_id, source_system, source_type, source_id }
async function resolveUsername(db, name) {
async function resolveUsername(db, name) {
const n = String(name || '').trim().replace(/^@/, '');
const n = String(name || '').trim().replace(/^@/, '');
if (!/^[A-Za-z0-9_.-]{1,40}$/.test(n)) return null;
if (!/^[A-Za-z0-9_.-]{1,40}$/.test(n)) return null;
- const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL').get(n);
+ const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL AND merged_into IS NULL').get(n);
if (!u) return null;
if (!u) return null;
- // A name folded into another account (ADR-029) resolves to the survivor: judge the survivor, not the alias row.
const p = await resolve(db, { subject_id: u.subject_id || await ensureUserSubject(db, u) });
const p = await resolve(db, { subject_id: u.subject_id || await ensureUserSubject(db, u) });
return p && !p.banned && !p.deleted ? p : null;
return p && !p.banned && !p.deleted ? p : null;
}
}
@@ -538,17 +538,6 @@ app.use('/api/v1/trust', rateLimit({ windowMs: 60_000, max: 60 }), require('./id
@@ -538,17 +538,6 @@ app.use('/api/v1/trust', rateLimit({ windowMs: 60_000, max: 60 }), require('./id
// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it
// The Exchange remake's uncensored tier: the account's own 18+ confirmation (server/identity/adult.js). AI reads it
// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).
// through /internal/identity/adult; this is the person's side, on the account hub (/my, Content).
app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));
app.use('/api/v1/me/adult', rateLimit({ windowMs: 60_000, max: 20 }), require('./identity/adult').userRouter({ requireAuth }));
-// Account merge (roadmap WS-B task 5, ADR-029): the person folds a second account into this one; staff
-// (staff.identity.merge) for account recovery, with a reason.
-{
- const accountMerge = require('./identity/account-merge');
- await accountMerge.ensureSchema(db);
- const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });
- // 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
// Mod principals (roadmap WS-M task 3, ADR-013): the runtime that installs a mod registers mod:<mod_id> and changes
// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.
// its grants (mods.grant.manage, service token only); staff (staff.games.manage) from /api/admin/mods.
{
{
@@ -1301,9 +1290,6 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
@@ -1301,9 +1290,6 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
} catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }
} catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }
};
};
timers.push(setTimeout(sweepModules, 5 * 60 * 1000), setInterval(sweepModules, 24 * 60 * 60 * 1000));
timers.push(setTimeout(sweepModules, 5 * 60 * 1000), setInterval(sweepModules, 24 * 60 * 60 * 1000));
- // Account merges older than 30 days keep only the alias facts (ADR-029).
- const reduceMerges = async () => { try { const n = await require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };
- timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));
// Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).
// Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).
const sweepAccountData = async () => {
const sweepAccountData = async () => {
try {
try {
@@ -80,15 +80,3 @@ async function board(db, userId) {
@@ -80,15 +80,3 @@ async function board(db, userId) {
return { all_time: await period(false), week: await period(true) };
return { all_time: await period(false), week: await period(true) };
}
}
module.exports = { total, append, award, person, byUsername, batch, board, ACTIVE, PERSON_DAILY, SERVICE_DAILY };
module.exports = { total, append, award, person, byUsername, batch, board, ACTIVE, PERSON_DAILY, SERVICE_DAILY };
-
-/** Existing account merge transaction: preserve earned history and the survivor's visibility choice. */
-async function merge(db, fromId, intoId) {
- await db.prepare('INSERT INTO person_levels(user_id) VALUES (?), (?) ON CONFLICT DO NOTHING').run(fromId, intoId);
- const rows = await db.prepare('SELECT user_id, xp FROM person_levels WHERE user_id IN (?, ?) ORDER BY user_id FOR UPDATE').all(fromId, intoId);
- const xp = rows.reduce((sum, row) => sum + Number(row.xp), 0);
- if (xp > 1000000000000) fail(409, 'levels.total_limit');
- await db.prepare('UPDATE experience_events SET user_id = ? WHERE user_id = ?').run(intoId, fromId);
- await db.prepare('UPDATE person_levels SET xp = ?, level = ? WHERE user_id = ?').run(xp, levelOf(xp).level, intoId);
- await db.prepare('DELETE FROM person_levels WHERE user_id = ?').run(fromId);
-}
-module.exports.merge = merge;
@@ -40,9 +40,9 @@ const { fingerprintOf, verifyPersonKeys } = require('openvibe-sdk/locker');
@@ -40,9 +40,9 @@ const { fingerprintOf, verifyPersonKeys } = require('openvibe-sdk/locker');
const { inspectEnvelope } = require('openvibe-sdk/locker/server');
const { inspectEnvelope } = require('openvibe-sdk/locker/server');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
const proof = require('./proof');
const proof = require('./proof');
-// A real sign-in (the session's auth_time within ten minutes), the rule account-merge and the account deletion
+// A real sign-in (the session's auth_time within ten minutes), the rule account deletion
// already use: never a renewal, never a QR session carrying an old source time.
// already use: never a renewal, never a QR session carrying an old source time.
-const { isFreshSignIn } = require('../identity/account-merge');
+const { isFreshSignIn } = require('../identity/reauth');
/** The event a key change announces (ids and fingerprints only; never a key, never a blob). */
/** The event a key change announces (ids and fingerprints only; never a key, never a blob). */
const EVENT_TYPE = 'locker.keys.changed';
const EVENT_TYPE = 'locker.keys.changed';
@@ -52,7 +52,6 @@ const KINDS = Object.freeze([
@@ -52,7 +52,6 @@ const KINDS = Object.freeze([
Object.freeze({ id: 'media-request', match: /^Media request: (.+)$/, site: 'live', subject: 'media', text: 'for the stream {name}' }),
Object.freeze({ id: 'media-request', match: /^Media request: (.+)$/, site: 'live', subject: 'media', text: 'for the stream {name}' }),
Object.freeze({ id: 'media-refund', match: /^Refund: (.+)$/, site: 'live', subject: 'media', text: 'for a refund of {name}' }),
Object.freeze({ id: 'media-refund', match: /^Refund: (.+)$/, site: 'live', subject: 'media', text: 'for a refund of {name}' }),
Object.freeze({ id: 'migration', match: 'migration:hobo-coins', site: 'network', text: 'for OpenCoins moved from the old Live wallet' }),
Object.freeze({ id: 'migration', match: 'migration:hobo-coins', site: 'network', text: 'for OpenCoins moved from the old Live wallet' }),
- Object.freeze({ id: 'account-merge', match: 'account_merge', site: 'network', text: 'for merging your accounts' }),
Object.freeze({ id: 'account-deleted', match: 'account_deleted', site: 'network', text: 'for closing your account' }),
Object.freeze({ id: 'account-deleted', match: 'account_deleted', site: 'network', text: 'for closing your account' }),
Object.freeze({ id: 'watch', match: 'watch', site: 'live', text: 'for watching a stream' }),
Object.freeze({ id: 'watch', match: 'watch', site: 'live', text: 'for watching a stream' }),
Object.freeze({ id: 'chat', match: 'chat', site: 'live', text: 'for chatting on a stream' }),
Object.freeze({ id: 'chat', match: 'chat', site: 'live', text: 'for chatting on a stream' }),
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
// cannot download it, one a day, and it expires after 7 days;
// cannot download it, one a day, and it expires after 7 days;
// - deletion: refused without a fresh sign-in or the typed username, cancellable, nothing before the date;
// - deletion: refused without a fresh sign-in or the typed username, cancellable, nothing before the date;
// - at the date Network's rows are erased, the username is released, the subject resolves as deleted, old tokens
// - at the date Network's rows are erased, the username is released, the subject resolves as deleted, old tokens
-// are refused, and network.account.deleted (with the merged-in aliases) is queued once;
+// are refused, and network.account.deleted (with legacy sign-in aliases) is queued once;
// - services confirm with counts; staff (staff.users.manage) see what is outstanding and cancel with a reason.
// - services confirm with counts; staff (staff.users.manage) see what is outstanding and cancel with a reason.
// node test/account-data.test.js
// node test/account-data.test.js
const assert = require('assert');
const assert = require('assert');
@@ -22,7 +22,6 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
@@ -22,7 +22,6 @@ const { ids, validate, serviceAuth } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const wallet = require('../server/coins/wallet');
const wallet = require('../server/coins/wallet');
-const accountMerge = require('../server/identity/account-merge');
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
const zip = require('../server/utils/zip');
const zip = require('../server/utils/zip');
@@ -37,7 +36,6 @@ for (const c of ['live', 'chat', 'community', 'space', 'media', 'games', 'tools'
@@ -37,7 +36,6 @@ for (const c of ['live', 'chat', 'community', 'space', 'media', 'games', 'tools'
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
await principals.ensureSchema(db);
await principals.ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
-await accountMerge.ensureSchema(db);
await accountData.ensureSchema(db);
await accountData.ensureSchema(db);
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
@@ -209,15 +207,15 @@ const server = http.createServer(app);
@@ -209,15 +207,15 @@ const server = http.createServer(app);
r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });
r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });
const del = r.body.deletion_id;
const del = r.body.deletion_id;
const due = Date.parse(r.body.delete_after);
const due = Date.parse(r.body.delete_after);
+ const old = await mk('dana_old');
+ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(dana.id, old.id);
+ assert.strictEqual((await require('../server/identity/account-aliases').effectiveUser(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(old.id))).id, dana.id, 'legacy alias sign-in resolves to the surviving account');
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due - 60000 })).deleted, 0, 'nothing before the date');
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due - 60000 })).deleted, 0, 'nothing before the date');
assert.strictEqual((await db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id)).username, 'dana');
assert.strictEqual((await db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id)).username, 'dana');
- // dana had folded an older account in (ADR-029): its subject goes with her.
- const old = await mk('dana_old');
- await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
// Two paired machines, one per subject: an account deletion revokes both and announces each (network.link.revoked@1).
// Two paired machines, one per subject: an account deletion revokes both and announces each (network.link.revoked@1).
const pairedNodes = [];
const pairedNodes = [];
- for (const subjectId of [dana.subject_id, old.subject_id]) {
+ for (const subjectId of [dana.subject_id]) {
const ulid = ids.ulid();
const ulid = ids.ulid();
const principal = `lnk_${ulid}`; const nodeId = `n-${ulid.toLowerCase()}`;
const principal = `lnk_${ulid}`; const nodeId = `n-${ulid.toLowerCase()}`;
await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status,
await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status,
@@ -236,7 +234,7 @@ const server = http.createServer(app);
@@ -236,7 +234,7 @@ const server = http.createServer(app);
const oldToken = tok(dana);
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
const nodeEvents = await events('network.link.revoked');
const nodeEvents = await events('network.link.revoked');
- assert.strictEqual(nodeEvents.length, 2, 'each paired machine revoked exactly once');
+ assert.strictEqual(nodeEvents.length, 1, 'the paired machine is revoked exactly once');
for (const [principal, nodeId, subjectId] of pairedNodes) {
for (const [principal, nodeId, subjectId] of pairedNodes) {
const p = await db.prepare('SELECT status, revoked_by FROM platform_node_principals WHERE id = ?').get(principal);
const p = await db.prepare('SELECT status, revoked_by FROM platform_node_principals WHERE id = ?').get(principal);
assert.deepStrictEqual([p.status, p.revoked_by], ['revoked', 'account_deleted']);
assert.deepStrictEqual([p.status, p.revoked_by], ['revoked', 'account_deleted']);
@@ -251,7 +249,7 @@ const server = http.createServer(app);
@@ -251,7 +249,7 @@ const server = http.createServer(app);
const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
assert.match(gone.username, /^deleted-/);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
- assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the merged-in account too');
+ assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the legacy alias too');
for (const [t, col] of [['user_mfa', 'user_id'], ['mfa_recovery_codes', 'user_id'], ['mfa_challenges', 'user_id'], ['mfa_devices', 'user_id'], ['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id'], ['experience_events', 'user_id'], ['person_levels', 'user_id']]) {
for (const [t, col] of [['user_mfa', 'user_id'], ['mfa_recovery_codes', 'user_id'], ['mfa_challenges', 'user_id'], ['mfa_devices', 'user_id'], ['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id'], ['experience_events', 'user_id'], ['person_levels', 'user_id']]) {
assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
}
}
@@ -263,7 +261,7 @@ const server = http.createServer(app);
@@ -263,7 +261,7 @@ const server = http.createServer(app);
assert.ok(await mk('dana'), 'the username is released');
assert.ok(await mk('dana'), 'the username is released');
const p = await subjects.resolve(db, { subject_id: dana.subject_id });
const p = await subjects.resolve(db, { subject_id: dana.subject_id });
assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);
assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);
- assert.strictEqual((await subjects.resolve(db, { subject_id: old.subject_id })).deleted, true, 'the alias resolves as deleted too');
+ assert.strictEqual((await subjects.resolve(db, { subject_id: old.subject_id })).deleted, true, 'the alias is a tombstone after its survivor is deleted');
r = await call('GET', '/api/v1/account/deletion', oldToken);
r = await call('GET', '/api/v1/account/deletion', oldToken);
assert.strictEqual(r.status, 401, 'old tokens are refused');
assert.strictEqual(r.status, 401, 'old tokens are refused');
const ev = await events('network.account.deleted');
const ev = await events('network.account.deleted');
@@ -272,7 +270,7 @@ const server = http.createServer(app);
@@ -272,7 +270,7 @@ const server = http.createServer(app);
assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));
assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');
- assert.strictEqual((await events('network.link.revoked')).length, 2, 'a second sweep emits nothing new');
+ assert.strictEqual((await events('network.link.revoked')).length, 1, 'a second sweep emits nothing new');
// Services confirm; staff see what is outstanding.
// Services confirm; staff see what is outstanding.
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
@@ -21,7 +21,6 @@ const bcrypt = require('bcryptjs');
@@ -21,7 +21,6 @@ const bcrypt = require('bcryptjs');
const { parseHTML } = require('linkedom');
const { parseHTML } = require('linkedom');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
-const accountMerge = require('../server/identity/account-merge');
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
const authRoutes = require('../server/auth/routes');
const authRoutes = require('../server/auth/routes');
const { makeRequireAuth } = require('../server/auth/session');
const { makeRequireAuth } = require('../server/auth/session');
@@ -67,7 +66,7 @@ function safetyWindow(location = { origin: 'https://openvibe.network', hostname:
@@ -67,7 +66,7 @@ function safetyWindow(location = { origin: 'https://openvibe.network', hostname:
const bannerTest = (async () => {
const bannerTest = (async () => {
const { root, calls, api, reauth } = safetyWindow();
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#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('/my#accounts', { account: '' }), '/login?reauth=1&login_hint=&return=%2Fmy%23accounts', 'blank account hint remains blank');
assert.strictEqual(reauth.url('/admin#ai', { account: 'goosely' }), '/login?reauth=1&login_hint=goosely&return=%2Fadmin%23ai');
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.match(api.bannerText('2030-01-02T03:04:00.000Z'), /^Your account will be deleted on .+\.$/);
assert.strictEqual(api.sessionToken(), null, 'signed out: no banner');
assert.strictEqual(api.sessionToken(), null, 'signed out: no banner');
@@ -115,7 +114,7 @@ assert.match(read('public/js/my/core.js'), /hash === 'delete' \? 'accounts' : ha
@@ -115,7 +114,7 @@ assert.match(read('public/js/my/core.js'), /hash === 'delete' \? 'accounts' : ha
// The banner reads GET /deletion on every page; that read must not spend the 20-a-minute write budget.
// 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');
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\) => 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');
+assert.doesNotMatch(index, /account-merges|account-merge/, 'account merge routes and their limiter are removed');
// ── /login?reauth=1 always asks for the password ────────────────────────────
// ── /login?reauth=1 always asks for the password ────────────────────────────
const login = read('public/js/login.js');
const login = read('public/js/login.js');
@@ -170,7 +169,6 @@ assert.match(read('public/my.html'), /stays usable for 30 days/);
@@ -170,7 +169,6 @@ assert.match(read('public/my.html'), /stays usable for 30 days/);
const serverTest = async () => {
const serverTest = async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-delete-reauth-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-delete-reauth-'));
const db = getDb();
const db = getDb();
- await accountMerge.ensureSchema(db);
await accountData.ensureSchema(db);
await accountData.ensureSchema(db);
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
deleted file mode 100644
deleted file mode 100644
@@ -1,211 +0,0 @@
@@ -1,211 +0,0 @@
-'use strict';
-// Account merge (roadmap WS-B task 5, ADR-029; Contracts 0.69.0/0.70.0). The ADR's acceptance tests:
-// - a merge needs both sign-ins (an intent from the survivor, then a fresh sign-in to the other account: auth_time
-// within 10 minutes, never a renewal) and refuses otherwise;
-// - resolving either subject, or an old legacy id, answers the survivor (merged_from);
-// - balances sum with one ledger entry per side, and a retried merge applies once;
-// - module conflicts keep the survivor's fields (the other fills only what it lacks);
-// - follows, blocks, providers, sessions, OAuth tokens, projects, preferences move; a clash keeps the survivor's;
-// - network.subject.merged and network.user.token_valid_after (account_merged) are queued; the folded-in account's
-// old token is refused and signing in to it signs in to the survivor;
-// - a staff merge needs staff.identity.merge (the owner) and a written reason, and is audited;
-// - after 30 days the record keeps only the alias facts.
-// node test/account-merge.test.js
-const assert = require('assert');
-const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const http = require('http');
-const express = require('express');
-const cookieParser = require('cookie-parser');
-const jwt = require('jsonwebtoken');
-const bcrypt = require('bcryptjs');
-const { validate, ids } = require('openvibe-contracts');
-const { getDb } = require('../server/db/database');
-const subjects = require('../server/identity/subjects');
-const wallet = require('../server/coins/wallet');
-const accountMerge = require('../server/identity/account-merge');
-
-(async () => {
-const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-merge-'));
-const log = console.log; console.log = () => {};
-const db = getDb();
-console.log = log;
-await require('../server/identity/principals').ensureSchema(db);
-require('../server/identity/follows').ensureSchema(db);
-await accountMerge.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' } };
-process.env.OWNER_USERNAME = 'boss';
-
-const hash = bcrypt.hashSync('secret12', 4);
-const mk = async (name, role = 'user') => {
- const id = (await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(name, hash, role)).lastInsertRowid;
- await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
- return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
-};
-// A session token from 10 s ago (the revocation cutoff has second precision); authAgo: its sign-in time, or null for none.
-const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',
- ...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
-const events = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === type);
-
-const authRoutes = require('../server/auth/routes');
-const requireAuth = require('../server/auth/session').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(express.json()); app.use(cookieParser());
-app.use('/api/auth', authRoutes);
-const mr = accountMerge.routers({ requireAuth, staffClaims: require('../server/auth/staff-claims').staffClaims });
-app.use('/api/v1/account', mr.me);
-app.use('/api/admin/account-merges', mr.admin);
-const server = http.createServer(app);
-
-(async () => {
- 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 carol = await mk('carol'); const carol2 = await mk('carol_old');
- const x = await mk('xavier'); const y = await mk('yolanda'); const z = await mk('zed');
- // What the folded-in account has.
- await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-carol'), (?, 'google', 'g-carol-old'), (?, 'discord', 'd-carol-old')").run(carol.id, carol2.id, carol2.id);
- await db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-old', 1, datetime('now', '+1 day'))").run(carol2.id);
- await db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-old', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(carol2.id);
- await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES ('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ?, 'bot', datetime('now'), ?)").run(carol2.subject_id, carol2.subject_id);
- await wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });
- await wallet.credit(db, { user_id: carol2.id, app_id: 'live', amount: 150, reason: 'chat', idempotency_key: 'c-2' });
- const modules = require('../server/identity/modules');
- const live = { type: 'service', id: 'live' };
- const w = async (s, n, d) => { const o = await modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };
- await w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });
- await w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });
- await w(carol2.subject_id, 'live.stats', { streams_30d: 4 });
- const follows = require('../server/identity/follows');
- await follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });
- await follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already
- await follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });
- await follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel
- await follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself
- await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);
- await db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);
- const oldCarol2 = tok(carol2);
- const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
-
- // ── Refusals ──
- let r = await call('POST', '/api/v1/account/merge/intents', tok(carol));
- assert.strictEqual(r.status, 201, JSON.stringify(r.body));
- const intent = r.body.intent;
- assert.match(intent, /^mgi_/);
- r = await call('POST', '/api/v1/account/merge', tok(carol2, { authAgo: 11 * 60 }), { intent });
- assert.deepStrictEqual([r.status, r.body.error], [401, 'merge.sign_in_again'], 'a sign-in older than 10 minutes');
- r = await call('POST', '/api/v1/account/merge', tok(carol2, { authAgo: null }), { intent });
- assert.deepStrictEqual([r.status, r.body.error], [401, 'merge.sign_in_again'], 'a token with no sign-in time (older sessions, renewals of them)');
- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: 'mgi_nope' });
- assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired']);
- r = await call('POST', '/api/v1/account/merge', tok(carol), { intent });
- assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.same_account']);
- const expired = await accountMerge.createIntent(db, carol, { now: Date.now() - 11 * 60 * 1000 });
- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: expired.intent });
- assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired'], 'an intent lives 10 minutes');
-
- // ── The merge ──
- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });
- assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.ok(validate('network.account-merge-result@1', r.body).valid, JSON.stringify(validate('network.account-merge-result@1', r.body).errors));
- assert.deepStrictEqual([r.body.from, r.body.into, r.body.replayed], [carol2.subject_id, carol.subject_id, false]);
- assert.deepStrictEqual(r.body.moved, { providers: 1, sessions: 1, oauth_grants: 1, projects: 1, coins: 150, modules: 2 });
- assert.strictEqual(Date.parse(r.body.split_until) - Date.parse(r.body.merged_at), 30 * 86400000);
-
- // Resolution: either subject, and the old legacy id, answer the survivor.
- let res = await subjects.resolve(db, { subject_id: carol2.subject_id });
- assert.deepStrictEqual([res.subject.id, res.merged_from, res.username], [carol.subject_id, carol2.subject_id, 'carol']);
- res = await subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '70' });
- assert.deepStrictEqual([res.subject.id, res.merged_from], [carol.subject_id, carol2.subject_id], 'the legacy map is not repointed, the alias answers');
- assert.strictEqual((await db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get()).subject_id, carol2.subject_id);
- assert.strictEqual((await subjects.resolve(db, { subject_id: carol.subject_id })).merged_from, undefined);
-
- // Coins: summed, one ledger entry per side.
- assert.deepStrictEqual([await wallet.getBalance(db, carol.id), await wallet.getBalance(db, carol2.id)], [200, 0]);
- const ledger = await db.prepare("SELECT user_id, delta FROM coin_transactions WHERE reason = 'account_merge' ORDER BY id").all();
- assert.deepStrictEqual(ledger.map((l) => [l.user_id, l.delta]), [[carol2.id, -150], [carol.id, 150]]);
- // Modules: the survivor's fields win; the other fills only what it lacks; a record only it had moves.
- assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.profile')).data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });
- assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.stats')).data, { streams_30d: 4 });
- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id)).n, 0);
- const modEv = (await events('network.module.updated')).filter((e) => e.payload.reason === 'subject_merged');
- assert.ok(modEv.some((e) => e.payload.merged_from && e.payload.change === 'updated' && e.payload.keys.includes('channel_url')), 'the filled record is announced');
- // Follows: moved, the clash and the self-follow dropped, the folded channel's follower now follows the survivor.
- const f = (await db.prepare('SELECT follower_subject AS a, target_id AS b FROM user_follows ORDER BY a, b').all()).map((x2) => `${x2.a === carol.subject_id ? 'carol' : x2.a === z.subject_id ? 'zed' : x2.a}>${x2.b === x.subject_id ? 'x' : x2.b === y.subject_id ? 'y' : x2.b === carol.subject_id ? 'carol' : x2.b}`).sort();
- assert.deepStrictEqual(f, ['carol>x', 'carol>y', 'zed>carol']);
- // Providers: google clashed and stays on the folded-in account (signing in with it lands on the survivor); discord moved.
- assert.deepStrictEqual((await db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id)).map((l) => l.service), ['discord', 'google']);
- assert.strictEqual((await db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get()).user_id, carol2.id);
- assert.strictEqual((await db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get()).user_id, carol.id);
- assert.strictEqual((await db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get()).owner_subject, carol.subject_id);
- assert.deepStrictEqual((await db.prepare('SELECT user_id, language FROM user_preferences ORDER BY user_id').all()).map((p) => [p.user_id, p.language]), [[carol.id, 'en']], 'the survivor keeps its preferences');
-
- // Events: network.subject.merged, and the folded-in account's tokens end (account_merged).
- const merged = await events('network.subject.merged');
- assert.strictEqual(merged.length, 1);
- assert.ok(validate('events.event-envelope@1', merged[0]).valid && validate('network.subject.merged@1', merged[0].payload).valid);
- assert.deepStrictEqual([merged[0].payload.from, merged[0].payload.into, merged[0].payload.initiated_by, merged[0].visibility], [carol2.subject_id, carol.subject_id, 'person', 'internal']);
- const revoked = (await events('network.user.token_valid_after')).filter((e) => e.subject.id === carol2.subject_id);
- assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);
- assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");
- assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');
- assert.ok(Date.parse(revoked[0].payload.valid_after) > Date.parse(merged[0].payload.merged_at) - 1000, 'services get a cutoff past the merge second');
- // Signing in to the folded-in account signs in to the survivor.
- r = await call('POST', '/api/auth/login', null, { username: 'carol_old', password: 'secret12' });
- assert.deepStrictEqual([r.status, r.body.user && r.body.user.username, r.body.merged_into], [200, 'carol', 'carol']);
- assert.ok(Number.isFinite(jwt.decode(r.body.token).auth_time), 'a sign-in carries auth_time');
-
- // A retry answers the first merge and moves nothing again. Over HTTP the folded-in account's tokens are gone
- // (401); the survivor lists the merge instead.
- r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });
- assert.strictEqual(r.status, 401);
- const again = await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), carol);
- assert.deepStrictEqual([again.replayed, again.merge_id], [true, merged[0].payload.merge_id]);
- r = await call('GET', '/api/v1/account/merges', tok(carol));
- assert.deepStrictEqual(r.body.merges.map((m) => m.merge_id), [merged[0].payload.merge_id]);
- assert.strictEqual(await wallet.getBalance(db, carol.id), 200);
- assert.strictEqual((await events('network.subject.merged')).length, 1);
- await assert.rejects(async () => await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');
-
- // ── Staff (account recovery) ──
- const admin = await mk('adminy', 'admin'); const boss = await mk('boss', 'admin');
- const d1 = await mk('dora'); const d2 = await mk('dora_lost');
- r = await call('POST', '/api/admin/account-merges', tok(admin), { from: 'dora_lost', into: 'dora', reason: 'lost her password, verified by email' });
- assert.strictEqual(r.status, 403, 'an admin is not the owner');
- r = await call('POST', '/api/admin/account-merges', tok(boss), { from: 'dora_lost', into: 'dora', reason: 'short' });
- assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.reason_required']);
- r = await call('POST', '/api/admin/account-merges', tok(boss), { from: d2.subject_id, into: 'dora', reason: 'lost her password, verified by email' });
- assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.strictEqual((await events('network.subject.merged')).pop().payload.initiated_by, 'staff');
- const audit = (await db.prepare("SELECT details FROM audit_log WHERE action = 'account_merge'").all()).map((a) => JSON.parse(a.details));
- assert.deepStrictEqual([audit.length, audit[0].from, audit[0].reason], [1, d2.subject_id, 'lost her password, verified by email']);
- r = await call('GET', '/api/admin/account-merges', tok(boss));
- assert.strictEqual(r.body.merges.length, 2);
- assert.strictEqual((await call('POST', '/api/admin/account-merges', tok(boss), { from: 'boss', into: 'dora', reason: 'the owner folds away' })).body.error, 'merge.owner');
- assert.ok(d1);
-
- // ── 30 days later: only the alias facts remain ──
- assert.strictEqual(await accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);
- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get()).n, 0);
- assert.strictEqual((await subjects.resolve(db, { subject_id: carol2.subject_id })).subject.id, carol.subject_id, 'the alias still answers');
-
- // auth_time: a renewal keeps the original sign-in time, a sign-in sets it.
- const renewed = jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: { auth_time: 1234 } }));
- assert.strictEqual(renewed.auth_time, 1234);
- assert.strictEqual(jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: {} })).auth_time, undefined, 'no sign-in time is never invented');
- assert.ok(Math.abs(jwt.decode(authRoutes.signToken(carol, keys.privateKey, config)).auth_time - Date.now() / 1000) < 5);
- assert.ok(ids);
- } finally {
- server.close();
- fs.rmSync(dir, { recursive: true, force: true });
- }
- console.log('account merge: all checks passed');
-})().catch((e) => { console.error(e); process.exit(1); });
-})().catch(err => { console.error(err); process.exit(1); });
@@ -78,7 +78,7 @@ function summary(service, namespace, extra = {}) {
@@ -78,7 +78,7 @@ function summary(service, namespace, extra = {}) {
const configWrites = () => configCalls().filter((x) => !['GET', 'HEAD'].includes(x.method));
const configWrites = () => configCalls().filter((x) => !['GET', 'HEAD'].includes(x.method));
const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },
const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },
w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });
w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });
- const stale = w.users.owner.token; // no auth_time: the account-merge tests' shape
+ const stale = w.users.owner.token; // no auth_time: an old session shape
const call = (who, method, p, body) => w.call(who, method, p, body);
const call = (who, method, p, body) => w.call(who, method, p, body);
const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
// - 501 subjects are refused, and the service route needs a token with network.consent.read;
// - 501 subjects are refused, and the service route needs a token with network.consent.read;
// - a check's active rows come from one query;
// - a check's active rows come from one query;
// - the change event is emitted after the commit, and the outbox carries the contract's fields only;
// - the change event is emitted after the commit, and the outbox carries the contract's fields only;
-// - export keeps every row and every event; deletion revokes and keeps; merge revokes and never carries over;
+// - export keeps every row and every event; deletion revokes and keeps; deletion revokes every active choice;
// - the account page's "Your data choices" has kit fold rows and nothing pre-ticked.
// - the account page's "Your data choices" has kit fold rows and nothing pre-ticked.
// node --import ./test/helpers/pg-preload.mjs test/consent.test.js
// node --import ./test/helpers/pg-preload.mjs test/consent.test.js
const assert = require('assert');
const assert = require('assert');
@@ -22,7 +22,6 @@ const subjects = require('../server/identity/subjects');
@@ -22,7 +22,6 @@ const subjects = require('../server/identity/subjects');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const consentMod = require('../server/consent');
const consentMod = require('../server/consent');
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
-const accountMerge = require('../server/identity/account-merge');
(async () => {
(async () => {
const db = getDb();
const db = getDb();
@@ -245,22 +244,6 @@ const outbox = async (subjectId) => (await db.prepare('SELECT envelope FROM netw
@@ -245,22 +244,6 @@ const outbox = async (subjectId) => (await db.prepare('SELECT envelope FROM netw
const bobPart = await accountData.networkPart(db, bob.id);
const bobPart = await accountData.networkPart(db, bob.id);
assert.ok(bobPart.files.find((f) => f.name === 'consents.json').content.consents.every((x) => x.subject === bob.subject_id), "only bob's");
assert.ok(bobPart.files.find((f) => f.name === 'consents.json').content.consents.every((x) => x.subject === bob.subject_id), "only bob's");
- // ── Merge: the folded-in account's choices are revoked, never carried over. ──
- const survivor = await mk('conskeep');
- const folded = await mk('consfold');
- await consent.choose({ subject: survivor.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['streams'] });
- await consent.choose({ subject: folded.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['vods'] });
- await accountMerge.merge(db, folded, survivor, { initiatedBy: 'person' });
- const foldedActive = await db.prepare('SELECT COUNT(*) AS n FROM consents WHERE subject = ? AND revoked_at IS NULL').get(folded.subject_id);
- assert.strictEqual(foldedActive.n, 0, "the folded-in account's choice is revoked");
- const foldedRow = await db.prepare('SELECT revoked_by FROM consents WHERE subject = ?').get(folded.subject_id);
- assert.strictEqual(foldedRow.revoked_by, 'merge');
- const survivorActive = await db.prepare('SELECT kinds FROM consents WHERE subject = ? AND revoked_at IS NULL').get(survivor.subject_id);
- assert.deepStrictEqual(survivorActive.kinds, ['streams'], 'the survivor keeps its own');
- const mergeEvent = await db.prepare("SELECT action, \"by\" FROM consent_events WHERE subject = ? AND \"by\" = 'merge'").get(folded.subject_id);
- assert.ok(mergeEvent, 'the merge event is recorded');
- assert.ok((await outbox(folded.subject_id)).some((e) => e.payload.on === false), 'the merge change is emitted');
-
// ── Deletion: revoke every active row, keep them. ──
// ── Deletion: revoke every active row, keep them. ──
const gone = await mk('consgone');
const gone = await mk('consgone');
await consent.choose({ subject: gone.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['clips'] });
await consent.choose({ subject: gone.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['clips'] });
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
// - one person never reads or writes another's state: the subject comes from the token and nowhere else;
// - one person never reads or writes another's state: the subject comes from the token and nowhere else;
// - CORS: an OpenVibe site may ask, another origin is refused (and a cross-origin write never runs the route);
// - CORS: an OpenVibe site may ask, another origin is refused (and a cross-origin write never runs the route);
// - 60 writes a minute per person;
// - 60 writes a minute per person;
-// - the row goes into the person's export and is erased with them, and a merge leaves it with the survivor;
+// - the row goes into the person's export and is erased with them, and it is erased with the person;
// - the migration is expand-only.
// - the migration is expand-only.
// node --import ./test/helpers/pg-preload.mjs test/dock.test.js
// node --import ./test/helpers/pg-preload.mjs test/dock.test.js
const assert = require('assert');
const assert = require('assert');
@@ -27,7 +27,6 @@ const { getDb } = require('../server/db/database');
@@ -27,7 +27,6 @@ const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const dockMod = require('../server/dock');
const dockMod = require('../server/dock');
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
-const accountMerge = require('../server/identity/account-merge');
const publicCors = require('../server/public-cors');
const publicCors = require('../server/public-cors');
const authRoutes = require('../server/auth/routes');
const authRoutes = require('../server/auth/routes');
@@ -236,12 +235,6 @@ try {
@@ -236,12 +235,6 @@ try {
const bobPart = await accountData.networkPart(db, bob.id);
const bobPart = await accountData.networkPart(db, bob.id);
assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'dock.json').content.map((x) => x.subject), [bob.subject_id], "bob's export carries only his own row");
assert.deepStrictEqual(bobPart.files.find((f) => f.name === 'dock.json').content.map((x) => x.subject), [bob.subject_id], "bob's export carries only his own row");
- // The merge (ADR-029) leaves the Dock with the surviving account.
- const survivor = await db.prepare('SELECT * FROM users WHERE id = ?').get(ana.id);
- await accountMerge.merge(db, bob, survivor, { initiatedBy: 'person' });
- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(bob.subject_id)).n, 0, "the folded-in account's row is gone");
- assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dock_states WHERE subject = ?').get(ana.subject_id)).n, 1, "the survivor keeps the row");
-
// And the row is erased with the person.
// And the row is erased with the person.
const delId = `del_${ids.ulid()}`;
const delId = `del_${ids.ulid()}`;
await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
@@ -36,10 +36,6 @@ const { backfill } = require('../scripts/backfill-levels');
@@ -36,10 +36,6 @@ const { backfill } = require('../scripts/backfill-levels');
const dry = await backfill(db); assert.equal(dry.awarded, 0); assert.equal(dry.existing, 1);
const dry = await backfill(db); assert.equal(dry.awarded, 0); assert.equal(dry.existing, 1);
const applied = await backfill(db, { apply: true }); assert.equal(applied.awarded, 0);
const applied = await backfill(db, { apply: true }); assert.equal(applied.awarded, 0);
await db.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(other.id);
await db.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(other.id);
- await db.tx(() => levels.merge(db, user.id, other.id));
- assert.equal((await levels.total(db, other.id)).xp, 110);
- assert.equal((await levels.total(db, user.id)).xp, 0);
- const p = await levels.person(db, other.id); assert(p.recent.every(r => r.name === 'Finished a quest')); assert(!JSON.stringify(p).includes('quest:'));
console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');
console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');
process.exit(0);
process.exit(0);
})().catch(e => { console.error(e); process.exit(1); });
})().catch(e => { console.error(e); process.exit(1); });
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// - every change emits network.module.updated in the same transaction (network_event_outbox), with the
// - every change emits network.module.updated in the same transaction (network_event_outbox), with the
// owner, namespace, changed keys and revision, never a private value; revisions survive deletes
// owner, namespace, changed keys and revision, never a private value; revisions survive deletes
// - chat.preferences is owned by Chat (the contracts' owner since 0.32.0); Live's old write grant narrows
// - chat.preferences is owned by Chat (the contracts' owner since 0.32.0); Live's old write grant narrows
-// - owner-service DELETE; account removal and merge (onSubjectRemoved/onSubjectMerged) with events, and
+// - owner-service DELETE; account removal and guest linking with events, and
// triggers that refuse deleting or re-keying an account while module rows remain; guest -> account link
// triggers that refuse deleting or re-keying an account while module rows remain; guest -> account link
// - onOwnerRemoved: a retired owner makes its namespace read-only; delete-after-retention sweeps
// - onOwnerRemoved: a retired owner makes its namespace read-only; delete-after-retention sweeps
// - the relay publishes rows written while it was off
// - the relay publishes rows written while it was off
@@ -154,7 +154,6 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -154,7 +154,6 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
await modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
await modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
await modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });
await modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });
await assert.rejects(async () => await db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');
await assert.rejects(async () => await db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');
- await assert.rejects(async () => await db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);
mark = await lastId();
mark = await lastId();
await db.tx(async () => {
await db.tx(async () => {
assert.strictEqual(await modulesLib.onSubjectRemoved(db, BOB), 2);
assert.strictEqual(await modulesLib.onSubjectRemoved(db, BOB), 2);
@@ -168,34 +167,15 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -168,34 +167,15 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB)).n, 0);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB)).n, 0);
await assert.rejects(async () => await modulesLib.onSubjectRemoved(db, 2), /subject id/);
await assert.rejects(async () => await modulesLib.onSubjectRemoved(db, 2), /subject id/);
- // ── Merge: CAT folds into ANN. Moved where ANN had none, dropped where ANN has one ──
- await modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });
- await modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });
- mark = await lastId();
- const merged = await modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });
- assert.deepStrictEqual(merged, { moved: 1, filled: 0, dropped: 1 }, 'ANN already has followers: nothing to fill (ADR-029 fills only missing fields; test/account-merge.test.js)');
- const annPrefs = await modulesLib.read(db, ANN, 'chat.preferences');
- assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });
- assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');
- assert.strictEqual(annPrefs.updated_by, 'svc:network');
- assert.strictEqual((await modulesLib.read(db, ANN, 'live.profile')).data.followers, 43, 'the survivor\'s record wins');
- ev = await events(mark);
- assert.deepStrictEqual(ev.map(e => [e.payload.owner.id, e.payload.namespace, e.payload.change, (e.payload.merged_into || e.payload.merged_from || {}).id]), [
- [ANN, 'chat.preferences', 'created', CAT], [CAT, 'chat.preferences', 'deleted', ANN], [CAT, 'live.profile', 'deleted', ANN],
- ]);
- ev.forEach(valid);
- assert.ok(!('public' in ev[2].payload), 'a delete carries no values');
- await db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go
- await assert.rejects(async () => await modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);
-
// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──
// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──
+ await modulesLib.write(db, ANN, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });
await modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });
await modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });
await modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });
await modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });
await assert.rejects(async () => await db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);
await assert.rejects(async () => await db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);
r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: await tokenOf(1) });
r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: await tokenOf(1) });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.tts_defaults')).data, { volume: 15 }, 'moved');
assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.tts_defaults')).data, { volume: 15 }, 'moved');
- assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.preferences')).data, { hide_emotes: true, compact: true }, 'the account kept its own values and gained only the field it lacked (ADR-029)');
+ assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.preferences')).data, { hide_emotes: true, compact: true }, 'the registered account kept its own values and gained only the guest fields it lacked');
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST)).n, 0);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST)).n, 0);
// ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──
// ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──
@@ -17,7 +17,6 @@ const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
@@ -17,7 +17,6 @@ const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
const unknown = money.describe('nobody:declared-this', { site: 'fun' });
const unknown = money.describe('nobody:declared-this', { site: 'fun' });
ok(!unknown.known && unknown.text === 'for something on OpenVibe.Fun', 'an undeclared key never shows itself');
ok(!unknown.known && unknown.text === 'for something on OpenVibe.Fun', 'an undeclared key never shows itself');
- ok(money.describe('account_merge').text === 'for merging your accounts', 'a reason with no page has its own words');
const names = await money.questNames(async (url) => {
const names = await money.questNames(async (url) => {
assert.match(url, /\/api\/v1\/quests$/);
assert.match(url, /\/api\/v1\/quests$/);
@@ -10,7 +10,7 @@ const { bootServer } = require('./helpers/boot-server');
@@ -10,7 +10,7 @@ const { bootServer } = require('./helpers/boot-server');
const qr = require('../server/auth/qr-signin');
const qr = require('../server/auth/qr-signin');
const { signToken } = require('../server/auth/routes');
const { signToken } = require('../server/auth/routes');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
-const accountMerge = require('../server/identity/account-merge');
+const reauth = require('../server/identity/reauth');
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
// The same-origin rule the router applies to every POST (CORS's first-party list is wider than one origin).
// The same-origin rule the router applies to every POST (CORS's first-party list is wider than one origin).
@@ -164,7 +164,6 @@ function cookieOf(cookies, name) {
@@ -164,7 +164,6 @@ function cookieOf(cookies, name) {
// ── Security: a QR session inherits the source's auth_time and is not a fresh sign-in ──
// ── Security: a QR session inherits the source's auth_time and is not a fresh sign-in ──
const authUser = await mkUser(`qg${suffix}`);
const authUser = await mkUser(`qg${suffix}`);
- const mergeSurvivor = await mkUser(`qm${suffix}`);
const staleAuthTime = Math.floor(Date.now() / 1000) - 60 * 60;
const staleAuthTime = Math.floor(Date.now() / 1000) - 60 * 60;
const staleSource = signSourceAt(authUser.user, staleAuthTime);
const staleSource = signSourceAt(authUser.user, staleAuthTime);
const staleStart = await start(staleSource);
const staleStart = await start(staleSource);
@@ -177,7 +176,7 @@ function cookieOf(cookies, name) {
@@ -177,7 +176,7 @@ function cookieOf(cookies, name) {
const staleToken = cookieOf(staleTarget.setCookies, 'ov_token');
const staleToken = cookieOf(staleTarget.setCookies, 'ov_token');
const staleClaims = jwt.decode(staleToken);
const staleClaims = jwt.decode(staleToken);
assert.strictEqual(staleClaims.auth_time, staleAuthTime, 'the QR-created session inherits the source auth_time, not now');
assert.strictEqual(staleClaims.auth_time, staleAuthTime, 'the QR-created session inherits the source auth_time, not now');
- assert.strictEqual(accountMerge.isFreshSignIn(staleClaims), false, 'the merge fresh check reports the QR session as stale');
+ assert.strictEqual(reauth.isFreshSignIn(staleClaims), false, 'fresh-sign-in check reports the QR session as stale');
const noAuthSource = signSourceAt(authUser.user, null);
const noAuthSource = signSourceAt(authUser.user, null);
const noAuthStart = await start(noAuthSource);
const noAuthStart = await start(noAuthSource);
@@ -187,13 +186,6 @@ function cookieOf(cookies, name) {
@@ -187,13 +186,6 @@ function cookieOf(cookies, name) {
const noAuthTarget = await status(targetCookie(noAuthScanned));
const noAuthTarget = await status(targetCookie(noAuthScanned));
assert.strictEqual(jwt.decode(cookieOf(noAuthTarget.setCookies, 'ov_token')).auth_time, undefined, 'a source with no auth_time gives a target with none');
assert.strictEqual(jwt.decode(cookieOf(noAuthTarget.setCookies, 'ov_token')).auth_time, undefined, 'a source with no auth_time gives a target with none');
- const survivorToken = signToken(mergeSurvivor.user, privateKey, config);
- const intent = await api('POST', '/api/v1/account/merge/intents', { token: survivorToken, body: {} });
- assert.strictEqual(intent.status, 201, intent.text);
- const refused = await api('POST', '/api/v1/account/merge', { token: staleToken, body: { intent: intent.json.intent } });
- assert.strictEqual(refused.status, 401, refused.text);
- assert.strictEqual(refused.json.error, 'merge.sign_in_again', 'a QR session with a stale source cannot merge accounts');
-
// ── A second scan is refused with 409 ──
// ── A second scan is refused with 409 ──
let secondStart = await start();
let secondStart = await start();
let secondScan = await scan(secondStart.id, secondStart.secret);
let secondScan = await scan(secondStart.id, secondStart.secret);
@@ -15,8 +15,6 @@ assert.deepStrictEqual(owner.staff_caps, staff.capabilitiesOf('owner'));
@@ -15,8 +15,6 @@ assert.deepStrictEqual(owner.staff_caps, staff.capabilitiesOf('owner'));
assert.deepStrictEqual(staffClaims({ role: 'user', username: 'theowner' }), {}, 'the owner name without the admin role is nobody');
assert.deepStrictEqual(staffClaims({ role: 'user', username: 'theowner' }), {}, 'the owner name without the admin role is nobody');
// Services read the issued claims exactly as the map answers.
// Services read the issued claims exactly as the map answers.
assert.strictEqual(staff.can(owner, 'staff.secrets.manage'), true);
assert.strictEqual(staff.can(owner, 'staff.secrets.manage'), true);
-assert.strictEqual(staff.can(owner, 'staff.identity.merge'), true, 'account-recovery merges: the owner (map 1.2.0, ADR-029)');
-assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.identity.merge'), false);
assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);
assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);
// Both user-token signers add them.
// Both user-token signers add them.
const fs = require('fs');
const fs = require('fs');