openvibers/openvibe.network Commits

Account merge (roadmap WS-B task 5, ADR-029; openvibe-contracts 0.68.0 -> 0.70.0). server/identity/account-merge.js folds a second account into the one a person keeps. The person: the account they keep opens a 10-minute intent (POST /api/v1/account/merge/intents); then, freshly signed in to the other account (auth_time within 10 minutes: a real sign-in, never a renewal), POST /api/v1/account/merge { intent }. Staff, for account recovery: POST /api/admin/account-merges { from, into, reason }, which needs staff.identity.merge (the owner), a written reason and an audit row. One transaction moves what Network owns: linked providers (a clashing one stays on the folded account and still signs in to the survivor), sessions, OAuth tokens and codes, developer projects and memberships, OpenCoins (debit and credit with reason account_merge, idempotent by merge id), user modules (the survivor's fields win; the other fills only what it lacks, network.module.updated subject_merged), follows both ways (a clash or a self-follow is dropped), blocks, notifications, preferences, push subscriptions and effects. It writes the alias (subject_aliases, kept flat), marks the folded account merged_into, revokes its tokens (network.user.token_valid_after, reason account_merged) and queues network.subject.merged. A retry answers the first merge. subjects.resolve answers the survivor, with merged_from, for the alias and any legacy id mapped to it (the legacy map is never repointed). Password sign-in to a folded account signs in to the survivor. Merge records drop their pre-merge state after 30 days (a daily reduce). User session tokens carry auth_time (OIDC): set at sign-in, and every renewal (requireAuth, refresh, OAuth authorize, themes, add-account sessions, the upstream re-mint) keeps the original or none; requireAuth exposes req.tokenClaims. my.html: a "Merge two accounts" card in Your Accounts runs the flow through Add another account. test/account-merge.test.js covers the ADR's acceptance tests (both sign-ins, resolution, coins once, module fill, moves and clashes, events, the folded token refused, sign-in lands on the survivor, staff reason and owner only, the 30-day reduction, auth_time on renewal). The module and staff-claims tests follow the field fill and staff map 1.2.0.

OP OpenVibers committed 27 Sept 2026 · 899ca7af5a · 16 files, +703 −34


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 38a03659..6f90d32f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.68.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
@@@ -1465,9 +1465,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.68.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.68.0",
-- "integrity": "sha512-5XJ/MwzJj4vQlDyMqB/zZf59Bw/Cq7M14UTBJ23uPzXLjRTTQx1jO6/tV76l8/j1C95iuM/Hm7gkFn72X843og==",
++ "version": "0.70.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.0",
++ "integrity": "sha512-NUjKoaP66vsRfyurIXhjzNja/9ELwD8smsMGJ0BuZ0LFpRocTc3yc9rCtHfIztwiuO50sC3E26J3DDlJUvpJyg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 0ce74a57..390f1b8c 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.68.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"uuid": "^11.1.0",
ddiff --git a/public/my.html b/public/my.html
iindex 3dbdded6..303d84db 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -406,6 +406,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<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-merge"></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>
</div>
<!-- Notifications Section -->
@@@ -1205,6 +1211,8 @@ async function loadUser() {
renderProfile(data.user);
loadThemes(); loadThemeSubmissions();
loadAccounts();
++ renderMerge();
++ if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
loadNotifPrefs();
loadLinked();
loadSessions();
@@@ -1386,6 +1394,64 @@ 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 }));
++ addAccount();
++ } 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).`));
++ const go = el('button', 'Sign in to the other account', 'btn btn-primary');
++ go.type = 'button'; go.onclick = addAccount;
++ 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');
++ 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);
++}
++
function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 699b0e2c..ffb0cbd7 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -101,7 +101,7 @@ router.get('/authorize', (req, res) => {
const have = req.cookies?.ov_token || req.cookies?.ov_sso;
const out = verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
if (!out.error) {
-- const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
++ const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req), { renew: out.decoded }) : have;
setSessionCookies(res, token);
const code = issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });
return res.redirect(withCode(redirect_uri, code, state));
ddiff --git a/server/auth/revocation.js b/server/auth/revocation.js
iindex 3effd31e..cc482187 100644
---- a/server/auth/revocation.js
++++ b/server/auth/revocation.js
@@@ -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'];
++const REASONS = ['password_changed', 'password_reset', 'signed_out_everywhere', 'banned', 'account_deleted', 'staff_revoked', 'account_merged'];
const iso = (v) => new Date(String(v) + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).toISOString();
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 185d0e1d..1fe4dc3b 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -41,7 +41,12 @@ function findValidResetToken(db, token) {
// Contract audience (CONTRACTS.md): every first-party service domain.
const DEFAULT_AUDIENCE = ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'];
--function signToken(user, privateKey, config) {
++/**
++ * 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. An account merge needs a real sign-in within 10 minutes (ADR-029).
++ */
++function signToken(user, privateKey, config, { renew = null } = {}) {
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
// Audience always includes the contract service domains, plus the issuer
// host and any registry-configured service hosts (white-label installs).
@@@ -68,6 +73,7 @@ function signToken(user, privateKey, config) {
avatar_url: user.avatar_url,
profile_color: user.profile_color,
...staffClaims(user),
++ auth_time: renew ? (Number.isFinite(renew.auth_time) ? renew.auth_time : undefined) : Math.floor(Date.now() / 1000),
},
privateKey,
{
@@@ -203,13 +209,16 @@ router.post('/login', (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 = require('../identity/account-merge').effectiveUser(db, user);
++ if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });
-- db.prepare('UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
-- const token = signToken(user, req.app.locals.privateKey, config);
++ db.prepare('UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(signedIn.id);
++ const token = signToken(signedIn, req.app.locals.privateKey, config);
-- console.log(`[Auth] Login: ${user.username}`);
++ console.log(`[Auth] Login: ${user.username}${signedIn.id !== user.id ? ` (merged into ${signedIn.username})` : ''}`);
require('./session').setSessionCookies(res, token);
-- res.json({ token, user: sanitizeUser(user) });
++ res.json({ token, user: sanitizeUser(signedIn), ...(signedIn.id !== user.id ? { merged_into: signedIn.username } : {}) });
});
// ── Forgot Password ─────────────────────────────────────────
@@@ -356,8 +365,8 @@ router.post('/refresh', (req, res) => {
if (tokenIat < validAfter) return res.status(401).json({ error: 'Token revoked' });
}
-- // Issue fresh token
-- const newToken = signToken(user, req.app.locals.privateKey, config);
++ // Issue fresh token (a renewal: the sign-in time stays)
++ const newToken = signToken(user, req.app.locals.privateKey, config, { renew: decoded });
// Update session cookies — host-only on openvibe.network (NO Domain attribute)
require('./session').setSessionCookies(res, newToken);
@@@ -732,8 +741,8 @@ router.post('/sessions', requireAuth, (req, res) => {
VALUES (?, ?, ?, ?, ?, ?)
`).run(req.user.id, sessionToken, deviceName, ip, req.headers['user-agent'] || '', expiresAt);
-- // Issue a JWT for this session
-- const token = signToken(req.user, req.app.locals.privateKey, config);
++ // Issue a JWT for this session (the sign-in time stays)
++ const token = signToken(req.user, req.app.locals.privateKey, config, { renew: req.tokenClaims || {} });
res.json({ ok: true, session_token: sessionToken, token });
});
@@@ -757,7 +766,7 @@ router.delete('/sessions', requireAuth, (req, res) => {
revocation.kick(db);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
res.set('Cache-Control', 'no-store');
-- res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req)) });
++ res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req), { renew: req.tokenClaims || {} }) });
});
// ═══════════════════════════════════════════════════════════════
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex e153fb25..53ff08d8 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -88,9 +88,10 @@ function makeRequireAuth(getCtx, signToken) {
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
if (out.renew && typeof signToken === 'function') {
try {
-- const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config);
++ const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config, { renew: out.decoded });
req.token = fresh;
setSessionCookies(res, fresh);
res.set('X-OV-Token', fresh); // JS clients swap their stored token
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
nnew file mode 100644
iindex 00000000..a22a4c47
---- /dev/null
++++ b/server/identity/account-merge.js
@@@ -0,0 +1,325 @@
++'use strict';
++/**
++ * 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; }
++}
++
++function ensureSchema(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS subject_aliases (
++ alias_id TEXT PRIMARY KEY,
++ subject_id TEXT NOT NULL,
++ merge_id TEXT NOT NULL,
++ merged_at TEXT NOT NULL,
++ merged_by TEXT NOT NULL
++ );
++ CREATE INDEX IF NOT EXISTS idx_subject_aliases_subject ON subject_aliases(subject_id);
++ CREATE TABLE IF NOT EXISTS account_merges (
++ id TEXT PRIMARY KEY,
++ from_subject TEXT NOT NULL UNIQUE,
++ into_subject TEXT NOT NULL,
++ from_user_id INTEGER NOT NULL,
++ into_user_id INTEGER NOT NULL,
++ initiated_by TEXT NOT NULL CHECK (initiated_by IN ('person', 'staff')),
++ actor_subject TEXT NOT NULL,
++ reason TEXT,
++ moved TEXT NOT NULL,
++ pre_state TEXT,
++ merged_at TEXT NOT NULL,
++ split_until TEXT NOT NULL,
++ reduced_at TEXT
++ );
++ CREATE TABLE IF NOT EXISTS account_merge_intents (
++ id TEXT PRIMARY KEY,
++ into_user_id INTEGER NOT NULL,
++ created_at INTEGER NOT NULL,
++ expires_at INTEGER NOT NULL,
++ used_at INTEGER
++ );
++ `);
++ const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
++ if (!cols.includes('merged_into')) db.exec('ALTER TABLE users ADD COLUMN merged_into INTEGER');
++}
++
++/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */
++function survivorOf(db, subjectId) {
++ try {
++ const a = 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). */
++function effectiveUser(db, user) {
++ if (!user || !user.merged_into) return user;
++ return 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).
++ */
++function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {
++ let moved = 0; let dropped = 0;
++ if (!tableExists(db, table)) return { moved, dropped };
++ const rows = db.prepare(`SELECT rowid 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 = db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));
++ if (clash) {
++ if (!keepOnClash) db.prepare(`DELETE FROM ${table} WHERE rowid = ?`).run(r._rid);
++ dropped++;
++ continue;
++ }
++ }
++ db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`).run(intoId, r._rid);
++ moved++;
++ }
++ return { moved, dropped };
++}
++
++function tableExists(db, name) {
++ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
++}
++
++/**
++ * Fold `fromUser` into `intoUser`. opts: { initiatedBy: 'person'|'staff', actorSubject, reason, now }.
++ * → network.account-merge-result@1. Throws MergeError.
++ */
++function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {
++ 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 = 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;
++ const run = db.transaction(() => {
++ const pre = {
++ user: { ...fromUser, password_hash: undefined },
++ linked_accounts: db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),
++ sessions: db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id).map((r) => r.id),
++ balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,
++ projects: tableExists(db, 'dev_projects') ? db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from).map((r) => r.id) : [],
++ modules: 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', moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));
++ moved.sessions = note('sessions', moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));
++ moved.oauth_grants = moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id).moved
++ + moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id).moved;
++ if (tableExists(db, 'dev_projects')) {
++ moved.projects = db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from).changes;
++ for (const m of db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {
++ const has = db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);
++ if (has) { 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 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) {
++ wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });
++ 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;
++ }
++ const mods = modules.onSubjectMerged(db, { from, into });
++ moved.modules = mods.moved + (mods.filled || 0);
++ // Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.
++ if (tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? follows.onSubjectMerged(db, { from, into }).dropped : 0;
++ if (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 db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {
++ const clash = b.o === into || db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
++ if (clash) db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
++ else db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
++ }
++ }
++ }
++ moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);
++ moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);
++ note('preferences', moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));
++ note('preferences', moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));
++ note('effects', 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).
++ db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);
++ 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.
++ db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);
++ revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor } });
++ 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') {
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(null, 'account_merge', JSON.stringify({ merge_id: mergeId, from, into, actor, reason }));
++ }
++ eventRelay.writerFor(db).enqueue(buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));
++ return db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);
++ });
++ const row = run();
++ 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. */
++function reduceExpired(db, { now = Date.now() } = {}) {
++ ensureSchema(db);
++ return 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;
++}
++
++function createIntent(db, intoUser, { now = Date.now() } = {}) {
++ ensureSchema(db);
++ const id = `mgi_${ids.ulid(now)}`;
++ 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. */
++function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {
++ ensureSchema(db);
++ const authTime = Number(claims && claims.auth_time);
++ if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {
++ throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');
++ }
++ const intent = 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 = db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);
++ if (intent.used_at) {
++ const done = 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 = merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });
++ 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, (req, res) => {
++ if (req.user.merged_into) return res.status(409).json({ error: 'merge.already_merged' });
++ res.status(201).json(createIntent(req.app.locals.db, req.user));
++ });
++ me.post('/merge', requireAuth, (req, res) => {
++ try { res.json(mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }
++ });
++ me.get('/merges', requireAuth, (req, res) => {
++ const db = req.app.locals.db; ensureSchema(db);
++ const rows = 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', 'private, no-store').json({ merges: rows.map((r) => resultOf(r, false)) });
++ });
++
++ const admin = express.Router();
++ admin.use(express.json({ limit: '8kb' }));
++ admin.post('/', requireAuth, (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 = (ref) => (SUBJECT_RE.test(String(ref || '')) ? db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(String(ref || '')));
++ try {
++ const out = merge(db, byRef(b.from), 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, (req, res) => {
++ const db = req.app.locals.db; 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 = 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', 'private, no-store').json({ merges: rows });
++ });
++ return { me, admin };
++}
++
++module.exports = { ensureSchema, survivorOf, effectiveUser, merge, mergeWithIntent, createIntent, reduceExpired, routers, MergeError, SPLIT_DAYS };
ddiff --git a/server/identity/follows.js b/server/identity/follows.js
iindex 6b4a66f8..dd271be5 100644
---- a/server/identity/follows.js
++++ b/server/identity/follows.js
@@@ -347,4 +347,31 @@ function routers({ requireAuth, followsGuard }) {
return { me, pub, internal };
}
--module.exports = { TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
++/**
++ * 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 }
++ */
++function onSubjectMerged(db, { from, into }) {
++ ensureSchema(db);
++ const out = { moved: 0, dropped: 0 };
++ const rows = 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
++ || db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);
++ if (clash) {
++ 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 {
++ db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = CURRENT_TIMESTAMP 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 = { onSubjectMerged, TYPES, FollowError, ensureSchema, setFollow, status, list, count, findTarget, buildEnvelope, onSubjectRemoved, importFollows, routers, kick };
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex 67cda4f0..6701b84f 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -240,18 +240,19 @@ function onSubjectRemoved(db, subjectId, { ctx } = {}) {
}
/**
-- * Two accounts become one (`from` folds into `into`, which survives). Each record of `from` moves to
++ * Two accounts become one (`from` folds into `into`, which survives; ADR-029). 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 `from`'s is dropped. Every record of `from`
-- * ends deleted (reason subject_merged, merged_into). Moved records say updated_by svc:network.
-- * Call inside the merge's transaction, before `from`'s row goes. Returns { moved, dropped }.
++ * 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
++ * (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 }.
*/
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');
const system = { type: 'system', id: 'network' };
const out = db.transaction(() => {
-- const res = { moved: 0, dropped: 0 };
++ const res = { moved: 0, filled: 0, dropped: 0 };
for (const row of db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(from)) {
const has = db.prepare('SELECT 1 FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
if (!has) {
@@@ -264,14 +265,31 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
});
res.moved++;
} else {
-- res.dropped++;
++ // The survivor's fields win; the other record fills only what it lacks (objects only).
++ const mine = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
++ let a = null; let b = null;
++ try { a = JSON.parse(mine.data); b = JSON.parse(row.data); } catch { /* unreadable: keep the survivor's */ }
++ const isObj = (x) => x && typeof x === 'object' && !Array.isArray(x);
++ const gaps = isObj(a) && isObj(b) ? Object.keys(b).filter((k) => !Object.prototype.hasOwnProperty.call(a, k)) : [];
++ if (gaps.length) {
++ const next = { ...a };
++ for (const k of gaps) next[k] = b[k];
++ const revision = nextRevision(db, into, row.namespace, mine.revision);
++ db.prepare("UPDATE user_modules SET data = ?, revision = ?, updated_at = CURRENT_TIMESTAMP, updated_by = 'svc:network' WHERE subject_id = ? AND namespace = ?")
++ .run(JSON.stringify(next), revision, into, row.namespace);
++ moduleEvents.record(db, {
++ subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: mine.version, revision,
++ change: 'updated', reason: 'subject_merged', before: a, after: next, actor: system, mergedFrom: from, ctx,
++ });
++ res.filled++;
++ } else res.dropped++;
}
removeRow(db, from, row.namespace, { actor: system, reason: 'subject_merged', mergedInto: into, ctx });
}
db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(from);
return res;
})();
-- if (out.moved || out.dropped) moduleEvents.kick(db);
++ if (out.moved || out.filled || out.dropped) moduleEvents.kick(db);
return out;
}
ddiff --git a/server/identity/subjects.js b/server/identity/subjects.js
iindex bcf33d52..db6c689b 100644
---- a/server/identity/subjects.js
++++ b/server/identity/subjects.js
@@@ -136,8 +136,11 @@ function resolve(db, { subject_id, source_system, source_type, source_id }) {
sid = row && row.subject_id;
}
}
-- const p = sid ? projection(db, sid) : null;
++ // A subject merged into another (ADR-029) resolves to the survivor, saying where it came from.
++ const survivor = sid ? require('./account-merge').survivorOf(db, sid) : sid;
++ const p = survivor ? projection(db, survivor) : null;
if (!p) return null;
++ if (survivor !== sid) p.merged_from = sid;
p.legacy_ids = 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;
ddiff --git a/server/index.js b/server/index.js
iindex fb90de36..7108ed21 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -89,7 +89,7 @@ async function proxyJsonRequest(req, res, targetUrl, errorLabel) {
let upstreamToken = req.token;
try {
if (req.user && req.app.locals.privateKey) {
-- upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config);
++ upstreamToken = signToken(req.user, req.app.locals.privateKey, req.app.locals.config, { renew: req.tokenClaims || {} });
}
} catch (e) {
console.error('[AdminProxy] token re-mint failed, forwarding original:', e.message);
@@@ -408,6 +408,15 @@ app.get('/internal/integrations/github-token', require('./identity/principals').
// Platform blocks (WS-E task 5): a person's own list, and who blocked whom for Chat and Community
// (network.blocks.read, service token only). Every change is network.block.changed (server/identity/blocks.js).
app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('./identity/blocks').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');
++ accountMerge.ensureSchema(db);
++ const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });
++ app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
++ app.use('/api/admin/account-merges', mergeRouters.admin);
++}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
@@@ -825,6 +834,9 @@ const server = app.listen(config.port, config.host, () => {
} 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 = () => { try { const n = 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));
// Clean expired sessions daily
timers.push(setInterval(() => {
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 747bda55..a9933725 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -47,7 +47,7 @@ function optionalAuth(req, res, next) {
// Routes here read both req.user.sub (JWT shape) and req.user.id (row shape) — give them both.
req.user = { ...out.decoded, ...out.user, sub: out.user.id, id: out.user.id }; req.token = token;
if (out.renew) {
-- try { const fresh = require('../auth/routes').signToken(out.user, req.app.locals.privateKey, req.app.locals.config); res.cookie('ov_token', fresh, COOKIE); res.set('X-OV-Token', fresh); res.set('Access-Control-Expose-Headers', 'X-OV-Token'); } catch { /* */ }
++ try { const fresh = require('../auth/routes').signToken(out.user, req.app.locals.privateKey, req.app.locals.config, { renew: out.decoded }); res.cookie('ov_token', fresh, COOKIE); res.set('X-OV-Token', fresh); res.set('Access-Control-Expose-Headers', 'X-OV-Token'); } catch { /* */ }
}
} catch { /* treat as anonymous */ }
next();
ddiff --git a/test/account-merge.test.js b/test/account-merge.test.js
nnew file mode 100644
iindex 00000000..984ebc4b
---- /dev/null
++++ b/test/account-merge.test.js
@@@ -0,0 +1,206 @@
++'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 { initDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const wallet = require('../server/coins/wallet');
++const accountMerge = require('../server/identity/account-merge');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-merge-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++require('../server/identity/principals').ensureSchema(db);
++require('../server/identity/follows').ensureSchema(db);
++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 = (name, role = 'user') => {
++ const id = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(name, hash, role).lastInsertRowid;
++ subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
++ return 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 = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(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 = mk('carol'); const carol2 = mk('carol_old');
++ const x = mk('xavier'); const y = mk('yolanda'); const z = mk('zed');
++ // What the folded-in account has.
++ 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);
++ 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);
++ 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);
++ 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);
++ wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });
++ 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 = (s, n, d) => { const o = modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };
++ w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });
++ w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });
++ w(carol2.subject_id, 'live.stats', { streams_30d: 4 });
++ const follows = require('../server/identity/follows');
++ follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });
++ follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already
++ follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });
++ follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel
++ follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself
++ db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);
++ 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);
++
++ // ── 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 = 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 = 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 = 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(db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get().subject_id, carol2.subject_id);
++ assert.strictEqual(subjects.resolve(db, { subject_id: carol.subject_id }).merged_from, undefined);
++
++ // Coins: summed, one ledger entry per side.
++ assert.deepStrictEqual([wallet.getBalance(db, carol.id), wallet.getBalance(db, carol2.id)], [200, 0]);
++ const ledger = 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(modules.read(db, carol.subject_id, 'live.profile').data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });
++ assert.deepStrictEqual(modules.read(db, carol.subject_id, 'live.stats').data, { streams_30d: 4 });
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id).n, 0);
++ const modEv = 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 = 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(db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id).map((l) => l.service), ['discord', 'google']);
++ assert.strictEqual(db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get().user_id, carol2.id);
++ assert.strictEqual(db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get().user_id, carol.id);
++ assert.strictEqual(db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get().owner_subject, carol.subject_id);
++ assert.deepStrictEqual(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 = 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 = 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");
++ // 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 = accountMerge.merge(db, 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(wallet.getBalance(db, carol.id), 200);
++ assert.strictEqual(events('network.subject.merged').length, 1);
++ assert.throws(() => accountMerge.merge(db, db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');
++
++ // ── Staff (account recovery) ──
++ const admin = mk('adminy', 'admin'); const boss = mk('boss', 'admin');
++ const d1 = mk('dora'); const d2 = 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(events('network.subject.merged').pop().payload.initiated_by, 'staff');
++ const audit = 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(accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get().n, 0);
++ assert.strictEqual(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); });
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
iindex 1bab31a5..ccef03d3 100644
---- a/test/module-events.test.js
++++ b/test/module-events.test.js
@@@ -169,7 +169,7 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });
mark = lastId();
const merged = modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });
-- assert.deepStrictEqual(merged, { moved: 1, dropped: 1 });
++ 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 = modulesLib.read(db, ANN, 'chat.preferences');
assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });
assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');
@@@ -191,7 +191,7 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: tokenOf(1) });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.tts_defaults').data, { volume: 15 }, 'moved');
-- assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.preferences').data, { hide_emotes: true }, 'the account kept its own');
++ assert.deepStrictEqual(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.strictEqual(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 ──
ddiff --git a/test/staff-claims.test.js b/test/staff-claims.test.js
iindex e940aed9..ed663a2b 100644
---- a/test/staff-claims.test.js
++++ b/test/staff-claims.test.js
@@@ -7,14 +7,16 @@ const { staffClaims } = require('../server/auth/staff-claims');
assert.deepStrictEqual(staffClaims({ role: 'user', username: 'a' }), {}, 'not staff: no claims, small token');
assert.deepStrictEqual(staffClaims({ role: 'streamer', username: 'b' }), {});
--assert.deepStrictEqual(staffClaims({ role: 'global_mod', username: 'm' }), { staff_caps: staff.capabilitiesOf('global_mod'), staff_map: '1.1.0' });
--assert.deepStrictEqual(staffClaims({ role: 'admin', username: 'boss' }), { staff_caps: staff.capabilitiesOf('admin'), staff_map: '1.1.0' });
++assert.deepStrictEqual(staffClaims({ role: 'global_mod', username: 'm' }), { staff_caps: staff.capabilitiesOf('global_mod'), staff_map: staff.map.version });
++assert.deepStrictEqual(staffClaims({ role: 'admin', username: 'boss' }), { staff_caps: staff.capabilitiesOf('admin'), staff_map: staff.map.version });
const owner = staffClaims({ role: 'admin', username: 'TheOwner' });
assert.strictEqual(owner.is_owner, true);
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');