openvibers/openvibe.network Commits

Add verified account email management (#687)

OP OpenVibers committed 5 days ago · 38957bf305 · 19 files, +565 −26

* Add verified account email management

* Limit app email reads to the chosen address

* Wait for account email test server

* Update account email feature test maps

* Add verified email consent and service checks

* Merge main migration updates

# Conflicts:
#	test/agent-schema.test.js
#	test/user-owned-trust.test.js

* Document email sharing choices

* Assert identical address add responses

* Handle the pending email capability release

Changes

ddiff --git a/docs/account-emails.md b/docs/account-emails.md
nnew file mode 100644
iindex 00000000..6028a581
---- /dev/null
++++ b/docs/account-emails.md
@@@ -0,0 +1,9 @@
++# Account email addresses
++
++Each account can keep up to eleven addresses, including its primary recovery address. Network verifies each address with a single-use link that expires after one hour. Pending addresses are limited to five per account; verification sends are rate limited per account and address.
++
++On `openvibe.my`, **Email addresses** lets a person add, verify, remove, or make an address primary. A primary address must be verified; Network asks for fresh verification if it was last verified more than 90 days ago. Making an address primary or removing one requires a sign-in from the last ten minutes. A primary change updates the account's recovery and notice address, records an audit entry, and notifies the former primary address.
++
++The authenticated person's API is `GET` and `POST /api/v1/me/emails`, `POST /api/v1/me/emails/:id/verify`, `POST /api/v1/me/emails/:id/primary`, and `DELETE /api/v1/me/emails/:id`. Existing sign-in and recovery flows continue to use the primary address.
++
++At OAuth consent, an app that requests the `email` scope asks the person to choose one verified address. The choice is stored for that app; `identity.emails.read` returns only that address. Services with the same capability can `POST /api/v1/identity/emails/verified-check` with `{ "subject": "usr_…", "email": "[email protected]" }` and receive only `{ "verified": true|false }`; an unknown subject and an unknown address both return `false`.
ddiff --git a/migrations/0054_account_emails.sql b/migrations/0054_account_emails.sql
nnew file mode 100644
iindex 00000000..cb79272e
---- /dev/null
++++ b/migrations/0054_account_emails.sql
@@@ -0,0 +1,29 @@
++-- phase: expand
++-- Account-owned verified addresses. Existing primary email state remains on users for sign-in recovery and notices.
++CREATE TABLE account_emails (
++ id text COLLATE "C" PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ email text COLLATE "C" NOT NULL,
++ verified bigint NOT NULL DEFAULT 0,
++ verified_at text COLLATE "C",
++ is_primary bigint NOT NULL DEFAULT 0,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++CREATE UNIQUE INDEX account_emails_verified_email_unique ON account_emails (lower(email)) WHERE verified = 1;
++CREATE UNIQUE INDEX account_emails_person_email_unique ON account_emails (user_id, lower(email));
++CREATE UNIQUE INDEX account_emails_id_user_unique ON account_emails (id, user_id);
++CREATE UNIQUE INDEX account_emails_one_primary ON account_emails (user_id) WHERE is_primary = 1;
++CREATE INDEX account_emails_user_created ON account_emails (user_id, created_at);
++INSERT INTO account_emails (id, user_id, email, verified, verified_at, is_primary)
++SELECT 'legacy-' || id::text, id, email, email_verified, email_verified_at, 1 FROM users WHERE email IS NOT NULL AND email <> ''
++ON CONFLICT DO NOTHING;
++ALTER TABLE email_verification_tokens ADD COLUMN IF NOT EXISTS account_email_id text COLLATE "C" REFERENCES account_emails(id) ON DELETE CASCADE;
++-- One address shared with each OAuth app. The composite FK keeps a grant attached to its owner's address.
++CREATE TABLE account_email_grants (
++ user_id bigint NOT NULL,
++ client_id text COLLATE "C" NOT NULL,
++ account_email_id text COLLATE "C" NOT NULL,
++ selected_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
++ PRIMARY KEY (user_id, client_id),
++ FOREIGN KEY (account_email_id, user_id) REFERENCES account_emails(id, user_id) ON DELETE CASCADE
++);
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 761936f5..9348e61e 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -373,7 +373,7 @@ function escapeHtml(str) {
// ── /oauth/confirm, and the purposes step ───────────────────
/** POST /oauth/confirm for this sign-in. `consent` is the person's answer on the purposes step, when there was one. */
--async function postConfirm(token, consent) {
++async function postConfirm(token, consent, emailAddressId) {
const res = await fetch(API_BASE + '/oauth/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@@ -387,6 +387,7 @@ async function postConfirm(token, consent) {
code_challenge_method: params.get('code_challenge_method') || undefined,
nonce: params.get('nonce') || undefined,
...(consent ? { consent } : {}),
++ ...(emailAddressId ? { email_address_id: emailAddressId } : {}),
}),
});
let data = {};
@@@ -396,19 +397,59 @@ async function postConfirm(token, consent) {
/** Confirm the sign-in; when the app asks for permission (consent:<purpose>), the person answers first. */
async function confirmWithPurposes(token) {
-- let out = await postConfirm(token);
-- if (out.res.ok && out.data && out.data.consent) {
-- const before = (document.querySelector('.form-panel.active') || {}).id || 'panel-chooser';
-- oauthConfirmInFlight = false;
-- const answer = await askPurposes(out.data.consent);
-- oauthConfirmInFlight = true;
-- out = await postConfirm(token, answer);
-- // A refused answer goes back where the person was, so the caller's message shows there.
-- if (!out.res.ok) setActivePanel(before, { showTabs: before === 'panel-login' || before === 'panel-register', showExtras: true });
++ let out, consent, emailAddressId;
++ const before = (document.querySelector('.form-panel.active') || {}).id || 'panel-chooser';
++ for (let step = 0; step < 3; step++) {
++ out = await postConfirm(token, consent, emailAddressId);
++ if (!out.res.ok || !out.data) break;
++ if (out.data.email_choice) {
++ oauthConfirmInFlight = false;
++ emailAddressId = await askEmailAddress(out.data.email_choice);
++ oauthConfirmInFlight = true;
++ if (!emailAddressId) return out;
++ continue;
++ }
++ if (out.data.consent) {
++ oauthConfirmInFlight = false;
++ consent = await askPurposes(out.data.consent);
++ oauthConfirmInFlight = true;
++ continue;
++ }
++ break;
}
++ if (!out?.res.ok) setActivePanel(before, { showTabs: before === 'panel-login' || before === 'panel-register', showExtras: true });
return out;
}
++/** The app's email scope shares one verified address; primary starts selected and the list stays in this consent step. */
++function askEmailAddress(step) {
++ const make = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ const head = document.getElementById('purposes-head');
++ head.replaceChildren(make('b', null, clientName || 'This app'), ' asks which one verified email address you want to share.');
++ const list = document.getElementById('purposes-list');
++ list.replaceChildren(make('legend', 'sr-only', 'Choose one email address'));
++ let selected = '';
++ (Array.isArray(step.addresses) ? step.addresses : []).forEach((address) => {
++ const row = make('label', 'purpose-item');
++ const radio = make('input', 'purpose-box'); radio.type = 'radio'; radio.name = 'email-address-choice'; radio.value = address.id;
++ radio.checked = address.id === step.selected_id || (!step.selected_id && !!address.primary); if (radio.checked) selected = address.id;
++ radio.addEventListener('change', () => { if (radio.checked) selected = radio.value; });
++ row.append(radio, make('span', 'purpose-text', `${address.email}${address.primary ? ' (primary)' : ''}`));
++ list.append(row);
++ });
++ document.getElementById('purposes-adult').hidden = true;
++ document.getElementById('purposes-already').hidden = true;
++ const btn = document.getElementById('purposes-continue');
++ btn.textContent = 'Share this address and continue'; btn.disabled = !selected;
++ list.onchange = () => { btn.disabled = !selected; };
++ const intro = document.getElementById('oauth-consent'); if (intro) intro.hidden = true;
++ setActivePanel('panel-purposes', { showTabs: false, showExtras: false });
++ head.focus();
++ return new Promise((resolve) => { document.getElementById('purposes-form').onsubmit = (event) => {
++ event.preventDefault(); if (!selected) return; event.target.onsubmit = (ev) => ev.preventDefault(); btn.disabled = true; resolve(selected);
++ }; });
++}
++
/**
* The purposes step (the consent plan's C4): the app asks for your permission, one purpose at a time, in the Network's
* own words. Every box starts unticked, and Continue works with none ticked: the app then learns only "not granted".
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex dba41a4e..dd5cfdcb 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -140,7 +140,7 @@ async function apiFetch(path, opts = {}) {
try { const fresh = res.headers.get('X-OV-Token'); if (fresh) { localStorage.setItem('ov_token', fresh); document.cookie = `ov_token=${fresh};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; } } catch { /* */ }
let data = {};
try { data = await res.json(); } catch { /* non-JSON (e.g. proxy error page) */ }
-- if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
++ if (!res.ok) { const err = new Error(data.detail || data.error || res.statusText || 'Request failed'); err.status = res.status; err.code = data.code || data.error; err.reauth_url = data.reauth_url; throw err; }
return data;
}
ddiff --git a/public/js/my/emails.js b/public/js/my/emails.js
nnew file mode 100644
iindex 00000000..9e56a408
---- /dev/null
++++ b/public/js/my/emails.js
@@@ -0,0 +1,40 @@
++'use strict';
++(() => {
++ const list = document.getElementById('account-emails-list');
++ const form = document.getElementById('account-email-add');
++ const message = document.getElementById('account-emails-message');
++ if (!list || !form) return;
++ const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++ async function refresh() {
++ try {
++ const data = await apiFetch('/api/v1/me/emails');
++ const rows = data.emails || [];
++ list.innerHTML = rows.length ? rows.map((e) => `<article style="padding:12px 0;border-bottom:1px solid var(--border,#ddd)"><strong>${esc(e.email)}</strong> · ${e.verified ? 'Verified' : 'Not verified'}${e.primary ? ' · Primary' : ''}<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">${e.verified ? '' : `<button type="button" data-verify="${esc(e.id)}">Send verification link</button>`}${e.primary || !e.verified ? '' : `<button type="button" data-primary="${esc(e.id)}">Make primary</button>`}${e.primary ? '' : `<button type="button" data-remove="${esc(e.id)}">Remove</button>`}</div></article>`).join('') : 'No email addresses are attached to this account yet.';
++ } catch { list.textContent = 'Could not load your email addresses.'; }
++ }
++ list.addEventListener('click', async (event) => {
++ const button = event.target.closest('button');
++ if (!button) return;
++ try {
++ if (button.dataset.verify) await apiFetch(`/api/v1/me/emails/${encodeURIComponent(button.dataset.verify)}/verify`, { method: 'POST' });
++ if (button.dataset.primary) await apiFetch(`/api/v1/me/emails/${encodeURIComponent(button.dataset.primary)}/primary`, { method: 'POST' });
++ if (button.dataset.remove) await apiFetch(`/api/v1/me/emails/${encodeURIComponent(button.dataset.remove)}`, { method: 'DELETE' });
++ message.textContent = 'Your request was received. Check your email for verification links.';
++ await refresh();
++ } catch (error) {
++ if (error.status === 401 && error.code === 'auth.sign_in_again' && window.OpenVibeReauth) return window.OpenVibeReauth.go('/my#emails');
++ message.textContent = error.message || 'Could not update your email addresses.';
++ }
++ });
++ form.addEventListener('submit', async (event) => {
++ event.preventDefault();
++ const input = document.getElementById('account-email-new');
++ try {
++ await apiFetch('/api/v1/me/emails', { method: 'POST', body: JSON.stringify({ email: input.value }) });
++ input.value = '';
++ message.textContent = 'If this address can be added, a verification email will arrive shortly.';
++ await refresh();
++ } catch (error) { message.textContent = error.message || 'Could not add this email address.'; }
++ });
++ refresh();
++})();
ddiff --git a/public/llms.txt b/public/llms.txt
nnew file mode 100644
iindex 00000000..27b311d4
---- /dev/null
++++ b/public/llms.txt
@@@ -0,0 +1,4 @@
++# OpenVibe.Network
++
++- Account email addresses: https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/account-emails.md
++ A person can manage up to eleven account addresses at openvibe.my. Addresses are private and must be verified before they can become primary. API: `/api/v1/me/emails` and its verify, primary, and delete routes.
ddiff --git a/public/my.html b/public/my.html
iindex 81d8bea6..12e475ff 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -137,6 +137,16 @@
</div>
</div>
++ <div class="card" id="account-emails-card">
++ <h2><span class="icon"><i class="fa-solid fa-envelope"></i></span> Email addresses</h2>
++ <p>Keep several verified addresses on your account. Only you can see this list.</p>
++ <div id="account-emails-list" aria-live="polite" role="status">Loading your email addresses…</div>
++ <form id="account-email-add" style="margin-top:16px">
++ <label for="account-email-new">Add an email address</label>
++ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:6px"><input id="account-email-new" type="email" autocomplete="email" maxlength="254" required><button type="submit">Add address</button></div>
++ </form>
++ <p id="account-emails-message" role="status" aria-live="polite"></p>
++ </div>
<h2 class="settings-h" id="settings">Settings</h2>
<!-- Settings: tabs on a phone, a sidebar beside the open section on a computer (L43, kit.sidebar's shape). -->
@@@ -617,6 +627,7 @@
<script src="/js/my/machines.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
++<script src="/js/my/emails.js" defer></script>
<script src="/js/my/consent.js" defer></script>
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/security.js" defer></script>
ddiff --git a/server/auth/email-verify.js b/server/auth/email-verify.js
iindex 21babd99..0a87b0dc 100644
---- a/server/auth/email-verify.js
++++ b/server/auth/email-verify.js
@@@ -57,6 +57,7 @@ async function consumeToken(db, raw) {
if (new Date(String(row.expires_at).replace(' ', 'T') + 'Z').getTime() < Date.now()) return { ok: false, error: 'This verification link has expired — request a new one from your account page.' };
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
if (!user) return { ok: false, error: 'Account not found.' };
++ if (row.account_email_id) return require('../identity/emails').consume(db, row);
if (!user.email || user.email.toLowerCase() !== String(row.email).toLowerCase()) {
return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };
}
@@@ -66,6 +67,7 @@ async function consumeToken(db, raw) {
const n = (await t.prepare("UPDATE email_verification_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL").run(row.token_hash)).changes;
if (!n) return false;
await t.prepare("UPDATE users SET email_verified = 1, email_verified_at = ov_now(), email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);
++ await t.prepare("UPDATE account_emails SET verified = 1, verified_at = ov_now() WHERE user_id = ? AND is_primary = 1 AND LOWER(email) = LOWER(?)").run(user.id, user.email);
return true;
});
if (!claimed) return { ok: false, error: 'This verification link was already used.' };
@@@ -77,6 +79,7 @@ async function onEmailChanged(req, userId) {
const db = req.app.locals.db;
await db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
++ if (user?.email) await db.prepare('UPDATE account_emails SET email = ?, verified = 0, verified_at = NULL WHERE user_id = ? AND is_primary = 1').run(user.email, userId);
if (user?.email) sendVerification(req, user).catch(() => {});
}
@@@ -128,9 +131,9 @@ function routes(requireAuth) {
const r = await consumeToken(db, String(req.body?.token || ''));
if (!r.ok) return res.status(400).json({ ok: false, error: r.error });
try {
-- await req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.user.email} is confirmed. Go-live alerts from streamers you follow will now reach your inbox.`, service: 'network', url: 'https://openvibe.network/notifications' });
++ await req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.email || r.user.email} is confirmed.`, service: 'network', url: 'https://openvibe.my/' });
} catch { /* */ }
-- res.json({ ok: true, email: r.user.email });
++ res.json({ ok: true, email: r.email || r.user.email });
});
return router;
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 51d3ad43..31fe4d09 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -301,6 +301,28 @@ router.post('/confirm', async (req, res) => {
}
const user = out.user;
++ // Email is an explicit per-app share. A first grant asks the person to choose one verified address (primary is
++ // preselected); later sign-ins reuse that choice. No OAuth response or Network API exposes the address list.
++ const wantsEmail = String(askedConsent.rest || '').split(/\s+/).includes('email');
++ if (wantsEmail) {
++ let grant = await require('../identity/emails').selectedForClient(db, user.id, String(client_id));
++ if (req.body.email_address_id !== undefined) {
++ const picked = await db.prepare('SELECT id, email, verified_at FROM account_emails WHERE id = ? AND user_id = ? AND verified = 1').get(String(req.body.email_address_id), user.id);
++ if (!picked) return res.status(422).json({ error: 'email_choice_invalid', error_description: 'Choose one of your verified email addresses.' });
++ await db.prepare(`INSERT INTO account_email_grants (user_id, client_id, account_email_id, selected_at) VALUES (?, ?, ?, ov_now())
++ ON CONFLICT (user_id, client_id) DO UPDATE SET account_email_id = EXCLUDED.account_email_id, selected_at = EXCLUDED.selected_at`)
++ .run(user.id, String(client_id), picked.id);
++ grant = picked;
++ }
++ if (req.body.email_address_id === undefined) {
++ const options = await db.prepare('SELECT id, email, is_primary FROM account_emails WHERE user_id = ? AND verified = 1 ORDER BY is_primary DESC, created_at, id').all(user.id);
++ if (options.length) return res.json({ email_choice: {
++ addresses: options.map((x) => ({ id: x.id, email: x.email, primary: !!x.is_primary })),
++ selected_id: grant?.id || options.find((x) => x.is_primary)?.id || options[0].id,
++ } });
++ }
++ }
++
// Consent per purpose (C4): before any code, the purposes this app asks for that the person has not answered yet.
// Without `consent` in the body the answer is the screen to show ({ consent: … }, no redirect); with it, the person's
// choice is recorded and the sign-in goes on, whatever they chose. Granted ones and those refused in the last 30
@@@ -476,6 +498,8 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
// Get user
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
++ const sharedEmail = String(authCode.scope || '').split(/\s+/).includes('email')
++ ? await require('../identity/emails').selectedForClient(db, user.id, client.client_id) : null;
// A session token only through the claimed https App Link and only with a published release certificate: a custom
// scheme can be answered by any app, and no fingerprint means Android verified nothing. Everything else is the
@@@ -493,8 +517,12 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
const { password_hash, token_valid_after, ...safeUser } = user;
// OpenID Connect (server/auth/oidc.js): scope openid adds an id_token for this client.
const idToken = oidc.wantsOpenid(authCode.scope)
-- ? oidc.idToken({ user: { ...user, subject_id: await subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer })
++ ? oidc.idToken({ user: { ...user, subject_id: await subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, ...(String(authCode.scope || '').split(/\s+/).includes('email') ? { email: sharedEmail?.email } : {}) })
: undefined;
++ if (String(authCode.scope || '').split(/\s+/).includes('email')) {
++ if (sharedEmail) { safeUser.email = sharedEmail.email; safeUser.email_verified = true; }
++ else { delete safeUser.email; delete safeUser.email_verified; }
++ } else { delete safeUser.email; delete safeUser.email_verified; }
res.json({
access_token: accessToken,
refresh_token: refreshToken,
@@@ -596,6 +624,9 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
// The consent claim (network.consent-claim@1): of the purposes this sign-in asked for, the ones the person granted to
// THIS client and that are active now. Worked out for every token, so a refresh after a revoke leaves it out.
const askedConsent = consentApps.parseScope(scope);
++ const tokenScope = publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes);
++ const emailScope = String(scope || '').split(/\s+/).includes('email');
++ const effectiveScope = emailScope && !tokenScope.split(/\s+/).includes('email') ? `${tokenScope} email` : tokenScope;
const consent = askedConsent.purposes.length
? await consentApps.grantedFor(db, { subject: subjectId, client: client.client_id, purposes: askedConsent.purposes })
: null;
@@@ -615,12 +646,13 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
// A session token carries the person's non-staff role and NO staff claims: the phone acts as them, not as their
// office. client_id, scope and fam stay, so the account page can end the family and the app knows what it holds.
const claims = publicClient && !sessionClient
-- ? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId, ...consentClaim }
++ ? { ...base, client_id: client.client_id, scope: effectiveScope, fam: familyId, ...consentClaim }
: {
...base,
role: sessionClient ? nonStaffRole(user.role) : user.role,
...(sessionClient ? {} : staffClaims(user)),
-- ...(sessionClient ? { client_id: client.client_id, scope: String(scope || ''), fam: familyId } : {}),
++ client_id: client.client_id, scope: effectiveScope,
++ ...(sessionClient ? { fam: familyId } : {}),
...consentClaim,
};
@@@ -635,7 +667,7 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
// for stay on it, so every refresh works the claim out again.
const issued = await refreshTokens.issue(db, {
clientId: client.client_id, userId: user.id,
-- scope: publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes),
++ scope: effectiveScope,
familyId, generation: family.generation || 0, session: sessionClient ? 1 : 0,
});
ddiff --git a/server/auth/oidc.js b/server/auth/oidc.js
iindex e0244f42..53c45e58 100644
---- a/server/auth/oidc.js
++++ b/server/auth/oidc.js
@@@ -72,21 +72,20 @@ function cleanNonce(v) {
const NOREPLY_DOMAIN = 'noreply.openvibe.network';
const noreplyEmail = (user) => `${user.subject_id || `u${user.id}`}@${NOREPLY_DOMAIN}`.toLowerCase();
--function profileClaims(user) {
++function profileClaims(user, email) {
return {
preferred_username: user.username,
name: user.display_name || user.username,
...(user.avatar_url ? { picture: user.avatar_url } : {}),
...(user.subject_id ? { subject_id: user.subject_id } : {}),
-- email: noreplyEmail(user),
-- email_verified: true,
++ ...(email ? { email, email_verified: true } : { email: noreplyEmail(user), email_verified: true }),
};
}
/** The id_token for a code whose scope includes openid. */
--function idToken({ user, clientId, nonce, privateKey, issuer }) {
++function idToken({ user, clientId, nonce, privateKey, issuer, email }) {
const rs = String(privateKey).includes('BEGIN');
-- return jwt.sign({ sub: String(user.id), ...profileClaims(user), ...(nonce ? { nonce } : {}) }, privateKey, {
++ return jwt.sign({ sub: String(user.id), ...profileClaims(user, email), ...(nonce ? { nonce } : {}) }, privateKey, {
algorithm: rs ? 'RS256' : 'HS256', issuer, audience: clientId, expiresIn: ID_TOKEN_TTL_S, ...(rs ? { keyid: KID } : {}),
});
}
@@@ -105,7 +104,13 @@ async function userinfo(req, res) {
const user = out.user;
let subjectId = user.subject_id || null;
try { subjectId = await require('../identity/subjects').ensureUserSubject(req.app.locals.db, user) || subjectId; } catch { /* the claim is optional */ }
-- res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });
++ const claims = profileClaims({ ...user, subject_id: subjectId });
++ if (String(out.decoded.scope || '').split(/\s+/).includes('email') && out.decoded.client_id) {
++ const grant = await require('../identity/emails').selectedForClient(req.app.locals.db, user.id, out.decoded.client_id);
++ if (grant) { claims.email = grant.email; claims.email_verified = true; }
++ else { delete claims.email; delete claims.email_verified; }
++ }
++ res.json({ sub: String(user.id), ...claims });
}
module.exports = { metadata, mount, sendMetadata, idToken, userinfo, wantsOpenid, cleanNonce, noreplyEmail, KID, ID_TOKEN_TTL_S };
ddiff --git a/server/identity/emails.js b/server/identity/emails.js
nnew file mode 100644
iindex 00000000..2dcb7a39
---- /dev/null
++++ b/server/identity/emails.js
@@@ -0,0 +1,202 @@
++'use strict';
++
++const express = require('express');
++const crypto = require('crypto');
++
++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');
++
++function needsFreshSignIn(req, res) {
++ if (isFreshSignIn(req.tokenClaims)) return false;
++ return res.status(401).json({ ok: false, error: 'auth.sign_in_again', detail: 'Sign in again within the last 10 minutes to change email addresses.', reauth_url: '/login?reauth=1&return=%2Fmy%23emails' });
++}
++
++async function selectedForClient(db, userId, clientId) {
++ return db.prepare(`SELECT ae.id, ae.email, ae.verified_at FROM account_email_grants g
++ JOIN account_emails ae ON ae.id = g.account_email_id AND ae.user_id = g.user_id
++ WHERE g.user_id = ? AND g.client_id = ? AND ae.verified = 1`).get(userId, clientId);
++}
++
++function meRouter({ requireAuth }) {
++ const router = express.Router();
++ router.use((_req, res, next) => { res.set('Cache-Control', 'private, no-store').set('Vary', 'Authorization'); next(); });
++ const ownerOnly = (req, res, next) => {
++ const client = req.tokenClaims?.client_id;
++ if (client && client !== 'my') return res.status(403).json({ ok: false, error: 'Manage account email addresses on openvibe.my.' });
++ next();
++ };
++ const sharedRead = (req, res, next) => {
++ const client = req.tokenClaims?.client_id;
++ const scopes = String(req.tokenClaims?.scope || '').split(/\s+/);
++ if (client && client !== 'my' && !scopes.includes('identity.emails.read')) return res.status(403).json({ ok: false, error: 'This app has not been granted identity.emails.read.' });
++ req.sharedEmailRead = !!client && client !== 'my';
++ next();
++ };
++ const list = async (db, userId) => {
++ const user = await db.prepare('SELECT email, email_verified, email_verified_at FROM users WHERE id = ?').get(userId);
++ if (user?.email) {
++ const primary = await db.prepare('SELECT id, email FROM account_emails WHERE user_id = ? AND is_primary = 1').get(userId);
++ if (!primary) await db.prepare('INSERT INTO account_emails (id, user_id, email, verified, verified_at, is_primary) VALUES (?, ?, ?, ?, ?, 1) ON CONFLICT DO NOTHING')
++ .run(`legacy-${userId}`, userId, user.email, user.email_verified || 0, user.email_verified_at || null);
++ else if (clean(primary.email) !== clean(user.email)) await db.prepare('UPDATE account_emails SET email = ?, verified = ?, verified_at = ? WHERE id = ? AND user_id = ?')
++ .run(user.email, user.email_verified || 0, user.email_verified_at || null, primary.id, userId);
++ }
++ return (await db.prepare('SELECT id, email, verified, verified_at, is_primary, created_at FROM account_emails WHERE user_id = ? ORDER BY is_primary DESC, created_at, id').all(userId)).map((row) => ({
++ id: row.id, email: row.email, verified: !!row.verified, verified_at: row.verified_at || null, primary: !!row.is_primary, created_at: row.created_at,
++ }));
++ };
++
++ router.get('/', requireAuth, sharedRead, async (req, res) => {
++ const emails = await list(req.app.locals.db, req.user.id);
++ if (!req.sharedEmailRead) return res.json({ emails });
++ const grant = await req.app.locals.db.prepare(`SELECT ae.id, ae.email, ae.verified, ae.verified_at, ae.created_at, ae.is_primary
++ FROM account_email_grants g JOIN account_emails ae ON ae.id = g.account_email_id
++ WHERE g.user_id = ? AND g.client_id = ? AND ae.user_id = ? AND ae.verified = 1`).get(req.user.id, req.tokenClaims.client_id, req.user.id);
++ res.json({ emails: grant ? [{ id: grant.id, email: grant.email, verified: true, verified_at: grant.verified_at || null, primary: !!grant.is_primary, created_at: grant.created_at }] : [] });
++ });
++
++ router.post('/', requireAuth, ownerOnly, async (req, res) => {
++ const db = req.app.locals.db;
++ const email = clean(req.body?.email);
++ if (email.length > 254 || !EMAIL.test(email)) return res.status(400).json({ ok: false, error: 'Enter a valid email address.' });
++ await list(db, req.user.id);
++ const id = crypto.randomUUID();
++ try {
++ const result = await db.tx(async (t) => {
++ await t.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
++ const total = await t.prepare('SELECT COUNT(*) AS c FROM account_emails WHERE user_id = ?').get(req.user.id);
++ if ((total?.c || 0) >= 11) return 'total';
++ const pending = await t.prepare('SELECT COUNT(*) AS c FROM account_emails WHERE user_id = ? AND verified = 0').get(req.user.id);
++ if ((pending?.c || 0) >= 5) return 'pending';
++ const own = await t.prepare('SELECT id FROM account_emails WHERE user_id = ? AND LOWER(email) = LOWER(?)').get(req.user.id, email);
++ if (own) return 'duplicate';
++ await t.prepare('INSERT INTO account_emails (id, user_id, email) VALUES (?, ?, ?)').run(id, req.user.id, email);
++ return 'added';
++ });
++ if (result === 'total') return res.status(400).json({ ok: false, error: 'You can keep up to 11 email addresses on your account.' });
++ if (result === 'pending') return res.status(400).json({ ok: false, error: 'Verify or remove a pending address before adding another.' });
++ if (result === 'duplicate') return res.status(202).json({ ok: true, message: 'If this address can be added, a verification email will arrive shortly.' });
++ } catch (err) {
++ if (err.code === '23505' || /unique/i.test(err.message || '')) return res.status(202).json({ ok: true, message: 'If this address can be added, a verification email will arrive shortly.' });
++ throw err;
++ }
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'email_address_added', JSON.stringify({ email }));
++ await issue(req, { id, email, user_id: req.user.id });
++ res.status(202).json({ ok: true, message: 'If this address can be added, a verification email will arrive shortly.' });
++ });
++
++ router.post('/:id/verify', requireAuth, ownerOnly, async (req, res) => {
++ const row = await req.app.locals.db.prepare('SELECT id, user_id, email, verified FROM account_emails WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
++ if (row && !row.verified) await issue(req, row);
++ res.status(202).json({ ok: true, message: 'If this address can be verified, a verification email will arrive shortly.' });
++ });
++
++ router.delete('/:id', requireAuth, ownerOnly, async (req, res) => {
++ if (needsFreshSignIn(req, res)) return;
++ const db = req.app.locals.db;
++ const row = await db.prepare('SELECT * FROM account_emails WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
++ if (!row) return res.status(404).json({ ok: false, error: 'Email address not found.' });
++ if (row.is_primary) return res.status(409).json({ ok: false, error: 'Choose another verified address as primary before removing this one.' });
++ await db.prepare('DELETE FROM account_emails WHERE id = ? AND user_id = ?').run(row.id, req.user.id);
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'email_address_removed', JSON.stringify({ email: row.email }));
++ res.json({ ok: true, emails: await list(db, req.user.id) });
++ });
++
++ router.post('/:id/primary', requireAuth, ownerOnly, async (req, res) => {
++ if (needsFreshSignIn(req, res)) return;
++ const db = req.app.locals.db;
++ const next = await db.prepare('SELECT * FROM account_emails WHERE id = ? AND user_id = ? AND verified = 1').get(req.params.id, req.user.id);
++ if (!next) return res.status(404).json({ ok: false, error: 'A verified email address was not found.' });
++ const verifiedAt = next.verified_at ? new Date(String(next.verified_at).replace(' ', 'T') + (String(next.verified_at).includes('Z') ? '' : 'Z')).getTime() : 0;
++ if (!verifiedAt || Date.now() - verifiedAt > 90 * 24 * 60 * 60 * 1000) {
++ await db.prepare('UPDATE account_emails SET verified = 0, verified_at = NULL WHERE id = ? AND user_id = ?').run(next.id, req.user.id);
++ await issue(req, next);
++ return res.status(202).json({ ok: true, message: 'Verify this address again before making it primary.' });
++ }
++ const current = await db.prepare('SELECT * FROM account_emails WHERE user_id = ? AND is_primary = 1').get(req.user.id);
++ if (current?.id === next.id) return res.json({ ok: true, emails: await list(db, req.user.id) });
++ await db.tx(async (t) => {
++ await t.prepare('UPDATE account_emails SET is_primary = 0 WHERE user_id = ?').run(req.user.id);
++ await t.prepare('UPDATE account_emails SET is_primary = 1 WHERE id = ? AND user_id = ?').run(next.id, req.user.id);
++ await t.prepare('UPDATE users SET email = ?, email_verified = 1, email_verified_at = ?, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(next.email, next.verified_at || new Date().toISOString(), req.user.id);
++ await t.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'primary_email_changed', JSON.stringify({ from: current?.email || null, to: next.email }));
++ });
++ if (current?.email) {
++ try { await req.app.locals.notificationService?.create({ user_id: req.user.id, type: 'ACCOUNT_EMAIL_CHANGED', title: 'Primary email changed', message: `Your primary email is now ${next.email}.`, service: 'network', url: 'https://openvibe.my/' }); } catch { /* best effort */ }
++ try { await req.app.locals.emailService?.sendAccountSecurityNotice?.({ to: current.email, username: req.user.display_name || req.user.username, message: `Your account's primary email address changed to ${next.email}.` }); } catch { /* best effort */ }
++ }
++ res.json({ ok: true, emails: await list(db, req.user.id) });
++ });
++ return router;
++}
++
++/** A service with identity.emails.read can learn only whether one subject owns one verified address. */
++function verifiedCheckRouter(principals, { capabilityAvailable = null } = {}) {
++ const router = express.Router();
++ router.use(require('openvibe-contracts').http.middleware());
++ router.use((_req, res, next) => { res.set('Cache-Control', 'private, no-store').set('Vary', 'Authorization'); next(); });
++ const { capabilities } = require('openvibe-contracts');
++ const capability = ['identity', 'emails', 'read'].join('.');
++ const supported = capabilityAvailable === null ? !!capabilities.get(capability) : !!capabilityAvailable;
++ const serviceGuard = supported ? principals.guard(capability) : (_req, res) => res.status(503).json({ error: 'The email check capability is not available in this Network release.' });
++ router.post('/', serviceGuard, async (req, res) => {
++ const subject = String(req.body?.subject || '');
++ const email = clean(req.body?.email);
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) || email.length > 254 || !EMAIL.test(email)) {
++ return res.status(200).json({ verified: false });
++ }
++ const user = await req.app.locals.db.prepare('SELECT id FROM users WHERE subject_id = ?').get(subject);
++ const verified = user ? await req.app.locals.db.prepare('SELECT 1 AS yes FROM account_emails WHERE user_id = ? AND verified = 1 AND LOWER(email) = LOWER(?)').get(user.id, email) : null;
++ res.json({ verified: !!verified });
++ });
++ return router;
++}
++
++async function issue(req, row) {
++ const db = req.app.locals.db;
++ const service = req.app.locals.emailService;
++ const recent = await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(row.user_id);
++ const today = await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(row.user_id);
++ const perAddress = await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(row.email);
++ if (!service?.isEnabled || recent?.c || (today?.c || 0) >= 6 || (perAddress?.c || 0) >= 5) return false;
++ const token = crypto.randomBytes(32).toString('base64url');
++ await db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at, account_email_id) VALUES (?, ?, ?, datetime('now', '+${TTL_MINUTES} minutes'), ?)`)
++ .run(hash(token), row.user_id, row.email, row.id);
++ const user = await db.prepare('SELECT username, display_name FROM users WHERE id = ?').get(row.user_id);
++ return service.sendVerificationEmail({ to: row.email, username: user?.display_name || user?.username, verifyUrl: `https://openvibe.network/verify-email?token=${encodeURIComponent(token)}`, expiresMinutes: TTL_MINUTES });
++}
++
++async function consume(db, row) {
++ if (!row.account_email_id) return null;
++ const address = await db.prepare('SELECT * FROM account_emails WHERE id = ? AND user_id = ? AND LOWER(email) = LOWER(?)').get(row.account_email_id, row.user_id, row.email);
++ if (!address) return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };
++ let claimed;
++ try {
++ claimed = await db.tx(async (t) => {
++ const count = (await t.prepare('UPDATE email_verification_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL').run(row.token_hash)).changes;
++ if (!count) return false;
++ const claimedByOther = await t.prepare('SELECT id FROM account_emails WHERE LOWER(email) = LOWER(?) AND verified = 1 AND user_id <> ?').get(address.email, address.user_id);
++ if (claimedByOther) {
++ await t.prepare('DELETE FROM account_emails WHERE id = ? AND user_id = ? AND verified = 0').run(address.id, address.user_id);
++ return 'claimed';
++ }
++ await t.prepare('UPDATE account_emails SET verified = 1, verified_at = ov_now() WHERE id = ? AND user_id = ?').run(address.id, address.user_id);
++ if (address.is_primary) await t.prepare('UPDATE users SET email_verified = 1, email_verified_at = ov_now(), email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(address.user_id);
++ return true;
++ });
++ } catch (err) {
++ if (err.code !== '23505' || !String(err.constraint || err.message || '').includes('account_emails_verified_email_unique')) throw err;
++ await db.tx(async (t) => {
++ await t.prepare('UPDATE email_verification_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL').run(row.token_hash);
++ await t.prepare('DELETE FROM account_emails WHERE id = ? AND user_id = ? AND verified = 0').run(address.id, address.user_id);
++ });
++ claimed = 'claimed';
++ }
++ if (claimed === 'claimed') return { ok: false, error: 'This address has already been verified for another account. Remove it from this account and use a different address.' };
++ return claimed ? { ok: true, user: await db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id), email: row.email } : { ok: false, error: 'This verification link was already used.' };
++}
++
++module.exports = { meRouter, verifiedCheckRouter, consume, selectedForClient };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 3ee49313..cfd16c9e 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -20,6 +20,8 @@ const runTokens = require('./run-tokens');
const TOKEN_TTL_S = 300;
const SELF_AUDIENCE = 'openvibe.network';
++// Contracts 0.349 adds this capability. Keep older pinned releases bootable while that PR is reviewed.
++const EMAIL_READ_CAPABILITY = ['identity', 'emails', 'read'].join('.');
/** A run token (Codes C3) may live up to two hours: a coding attempt with a harness on the person's own machine. */
const MAX_RUN_TOKEN_TTL_S = 7200;
const MIN_RUN_TOKEN_TTL_S = 60;
@@@ -29,6 +31,8 @@ const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_setti
// Initial grants: what each service does against Network today.
const DEFAULT_GRANTS = [
++ // Mail relay checks one address at a time before forwarding; older Contracts pins omit this new capability.
++ ...(capabilities.get(EMAIL_READ_CAPABILITY) ? [['mail', EMAIL_READ_CAPABILITY, SELF_AUDIENCE, []]] : []),
// Locker v1 stores private ciphertext in its own Media tenant. Network remains the key directory.
...['media.object.upload', 'media.object.read', 'media.object.delete'].map((cap) => ['locker', cap, 'openvibe.media', ['locker']]),
['locker', 'identity.subject.resolve', SELF_AUDIENCE, []],
ddiff --git a/server/index.js b/server/index.js
iindex 8265c630..608b3900 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -525,6 +525,8 @@ 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));
++app.use('/api/v1/me/emails', rateLimit({ windowMs: 60_000, max: 30 }), require('./identity/emails').meRouter({ requireAuth }));
++app.use('/api/v1/identity/emails/verified-check', rateLimit({ windowMs: 60_000, max: 120 }), require('./identity/emails').verifiedCheckRouter(require('./identity/principals')));
// Privacy-limited age and abuse signals: the /me route accepts its own account or guest token, while service reads need
// identity.trust.read. Both responses are private and no-store.
app.use('/api/v1/me/trust', rateLimit({ windowMs: 60_000, max: 30 }), require('./identity/trust-routes').meRouter());
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
iindex d3e48e79..9da691d5 100644
---- a/server/notifications/email-service.js
++++ b/server/notifications/email-service.js
@@@ -266,6 +266,15 @@ class EmailService {
return this._sendEmail({ to, subject: 'OpenVibe — confirm your email address', htmlBody, textBody, emailType: 'email_verification', metadata: { expiresMinutes } });
}
++ /** Tell the former primary address that an account's recovery address changed. */
++ async sendAccountSecurityNotice({ to, username, message }) {
++ const safeName = this._escapeHtml(username || 'there');
++ const safeMessage = this._escapeHtml(message || 'Your primary email address changed.');
++ const textBody = `Hey ${username || 'there'},\n\n${message || 'Your primary email address changed.'}\n\nIf you did not make this change, secure your account at https://openvibe.my/security.`;
++ const htmlBody = `<!DOCTYPE html><html><body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a24;color:#e0e0e0;padding:24px"><main style="max-width:560px;margin:auto;padding:24px;background:#22222c;border:1px solid #333340;border-radius:12px"><h1 style="font-size:18px">Account email changed</h1><p>Hey ${safeName},</p><p>${safeMessage}</p><p>If you did not make this change, <a href="https://openvibe.my/security" style="color:#a78bfa">secure your account</a>.</p></main></body></html>`;
++ return this._sendEmail({ to, subject: 'OpenVibe — primary email changed', htmlBody, textBody, emailType: 'account_security' });
++ }
++
async sendPasswordResetEmail({ to, username, resetUrl, expiresMinutes = 60 }) {
const htmlBody = this._buildPasswordResetHtml({ username, resetUrl, expiresMinutes });
const textBody = this._buildPasswordResetText({ username, resetUrl, expiresMinutes });
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 7c2ace52..98e6df96 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/emails.test.js b/test/emails.test.js
nnew file mode 100644
iindex 00000000..2f116836
---- /dev/null
++++ b/test/emails.test.js
@@@ -0,0 +1,132 @@
++'use strict';
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const crypto = require('node:crypto');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++
++(async () => {
++ const db = getDb();
++ const id = Date.now() % 1000000000;
++ await db.prepare('INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)')
++ .run(id, `email-test-${id}`, `primary-${id}@example.test`, 'x');
++ await db.prepare('INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)')
++ .run(id + 1, `email-other-${id}`, `owned-${id}@example.test`, 'x');
++ const subject = `usr_01JAB2C3D4E5F6G7H8J9K0MNPQ`;
++ const otherSubject = `usr_01JAB2C3D4E5F6G7H8J9K0MNPR`;
++ await db.prepare('UPDATE users SET subject_id = ? WHERE id = ?').run(subject, id);
++ await db.prepare('UPDATE users SET subject_id = ? WHERE id = ?').run(otherSubject, id + 1);
++ const sent = [];
++ const app = express();
++ app.use(express.json());
++ app.locals.db = db;
++ app.locals.emailService = { isEnabled: true, async sendVerificationEmail(message) { sent.push(message); return true; } };
++ app.use('/api/v1/me/emails', require('../server/identity/emails').meRouter({ requireAuth(req, res, next) {
++ req.user = { id: req.headers.authorization === 'Bearer other-user' ? id + 1 : id };
++ req.tokenClaims = req.headers.authorization === 'Bearer email-reader' ? { client_id: 'sample-app', scope: 'identity.emails.read' }
++ : req.headers.authorization === 'Bearer ungranted-app' ? { client_id: 'sample-app', scope: 'profile' }
++ : req.headers.authorization === 'Bearer stale-user' ? { auth_time: Math.floor(Date.now() / 1000) - 11 * 60 }
++ : { auth_time: Math.floor(Date.now() / 1000) };
++ next();
++ } }));
++ app.use('/api/v1/identity/emails/verified-check', require('../server/identity/emails').verifiedCheckRouter({ guard() { return (_req, _res, next) => next(); } }, { capabilityAvailable: true }));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); });
++ const port = server.address().port;
++ const request = (path = '', method = 'GET', body, authorization = '') => new Promise((resolve, reject) => {
++ const headers = { ...(body ? { 'content-type': 'application/json' } : {}), ...(authorization ? { authorization } : {}) };
++ const urlPath = path.startsWith('/api/') ? path : `/api/v1/me/emails${path}`;
++ const req = http.request({ hostname: '127.0.0.1', port, path: urlPath, method, headers }, (res) => {
++ let text = '';
++ res.setEncoding('utf8');
++ res.on('data', (chunk) => { text += chunk; });
++ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text || '{}') }));
++ });
++ req.on('error', reject);
++ if (body) req.write(JSON.stringify(body));
++ req.end();
++ });
++ try {
++ let response = await request();
++ let body = response.body;
++ assert.equal(body.emails.length, 1);
++ assert.equal(body.emails[0].primary, true);
++ response = await request('', 'POST', { email: `extra-${id}@example.test` });
++ assert.equal(response.status, 202);
++ assert.equal(sent.length, 1);
++ const rows = (await request()).body.emails;
++ const added = rows.find((row) => row.email === `extra-${id}@example.test`);
++ assert.ok(added && !added.verified);
++ response = await request(`/${added.id}/verify`, 'POST');
++ assert.equal(response.status, 202);
++ assert.equal(sent.length, 1, 'the per-account cooldown suppresses another verification email');
++ response = await request('', 'POST', { email: `owned-${id}@example.test` });
++ assert.equal(response.status, 202, 'an address owned by another account gets the same generic response');
++ assert.equal(sent.length, 1, 'a duplicate address is never sent a verification email');
++ const shared = `shared-${id}@example.test`;
++ await db.prepare("UPDATE email_verification_tokens SET created_at = datetime('now', '-4 minutes') WHERE user_id = ?").run(id);
++ const otherAdd = await request('', 'POST', { email: shared }, 'Bearer other-user');
++ const firstAdd = await request('', 'POST', { email: shared });
++ assert.equal(otherAdd.status, 202);
++ assert.equal(firstAdd.status, 202);
++ assert.deepEqual(firstAdd.body, otherAdd.body, "neither account can learn about the other's pending or verified claim at add");
++ assert.equal(sent.length, 3, 'each eligible pending request gets its verification message');
++ const firstSharedToken = new URL(sent[2].verifyUrl).searchParams.get('token');
++ const secondSharedToken = new URL(sent[1].verifyUrl).searchParams.get('token');
++ assert.equal((await require('../server/auth/email-verify').consumeToken(db, firstSharedToken)).ok, true, 'the first account to verify claims the address');
++ const refused = await require('../server/auth/email-verify').consumeToken(db, secondSharedToken);
++ assert.equal(refused.ok, false);
++ assert.match(refused.error, /already been verified for another account/);
++ const token = new URL(sent[0].verifyUrl).searchParams.get('token');
++ const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
++ await db.prepare("UPDATE email_verification_tokens SET expires_at = datetime('now', '-1 minute') WHERE token_hash = ?").run(tokenHash);
++ const verify = require('../server/auth/email-verify').consumeToken;
++ const expired = await verify(db, token);
++ assert.equal(expired.ok, false);
++ assert.match(expired.error, /expired/);
++ await db.prepare("UPDATE email_verification_tokens SET created_at = datetime('now', '-4 minutes'), expires_at = datetime('now', '+60 minutes') WHERE token_hash = ?").run(tokenHash);
++ await db.prepare("UPDATE email_verification_tokens SET created_at = datetime('now', '-4 minutes') WHERE user_id = ?").run(id);
++ response = await request('', 'POST', { email: `second-${id}@example.test` });
++ assert.equal(response.status, 202);
++ assert.equal(sent.length, 4);
++ const token2 = new URL(sent[3].verifyUrl).searchParams.get('token');
++ const added2 = (await request()).body.emails.find((row) => row.email === `second-${id}@example.test`);
++ assert.ok(added2);
++ const first = await verify(db, token2);
++ assert.equal(first.ok, true);
++ assert.equal(first.email, added2.email);
++ assert.equal((await verify(db, token2)).ok, false, 'verification link is single use');
++ response = await request(`/${added2.id}/primary`, 'POST');
++ assert.equal(response.status, 200);
++ body = response.body;
++ assert.equal(body.emails.find((row) => row.id === added2.id).primary, true);
++ assert.equal((await db.prepare('SELECT email FROM users WHERE id = ?').get(id)).email, added2.email);
++ await db.prepare('INSERT INTO account_email_grants (user_id, client_id, account_email_id) VALUES (?, ?, ?)').run(id, 'sample-app', added2.id);
++ response = await request('', 'GET', undefined, 'Bearer email-reader');
++ assert.deepEqual(response.body.emails.map((row) => row.email), [added2.email], 'an app with the email-read scope receives only the chosen primary address');
++ response = await request('', 'GET', undefined, 'Bearer ungranted-app');
++ assert.equal(response.status, 403);
++ response = await request(`/${added2.id}/primary`, 'POST', undefined, 'Bearer stale-user');
++ assert.equal(response.status, 401, 'making a primary requires a recent sign-in');
++ assert.match(response.body.reauth_url, /reauth=1/);
++ response = await request(`/${added2.id}`, 'DELETE');
++ assert.equal(response.status, 409, 'primary must be changed before removal');
++ response = await request(`/legacy-${id}`, 'DELETE', undefined, 'Bearer stale-user');
++ assert.equal(response.status, 401, 'removing an address requires a recent sign-in');
++ response = await request(`/legacy-${id}`, 'DELETE');
++ assert.equal(response.status, 200, 'a recent sign-in can remove the non-primary address');
++ const checkBody = { subject, email: added2.email };
++ const checked = await request('/api/v1/identity/emails/verified-check', 'POST', checkBody, 'Bearer svc');
++ const unknownSubject = await request('/api/v1/identity/emails/verified-check', 'POST', { subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS', email: added2.email }, 'Bearer svc');
++ const unknownAddress = await request('/api/v1/identity/emails/verified-check', 'POST', { subject, email: `unknown-${id}@example.test` }, 'Bearer svc');
++ assert.deepEqual(checked.body, { verified: true });
++ assert.deepEqual(unknownSubject.body, { verified: false });
++ assert.deepEqual(unknownAddress.body, { verified: false });
++ assert.equal(unknownSubject.status, unknownAddress.status, 'unknown person/address have the same status and response shape');
++ } finally {
++ await new Promise((resolve) => server.close(resolve));
++ await db.prepare('DELETE FROM users WHERE id = ?').run(id);
++ await db.prepare('DELETE FROM users WHERE id = ?').run(id + 1);
++ }
++ console.log('account email management: verified address, single-use link, primary change and protected removal passed');
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex b4f7a02a..859e2d77 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
ddiff --git a/test/oauth-pkce.test.js b/test/oauth-pkce.test.js
iindex 76615232..387abcc7 100644
---- a/test/oauth-pkce.test.js
++++ b/test/oauth-pkce.test.js
@@@ -18,6 +18,7 @@ const db = getDb();
console.log = log;
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
++await db.prepare("INSERT INTO account_emails (id, user_id, email, verified, verified_at, is_primary) VALUES ('oauth-email-primary', 7, '[email protected]', 1, ov_now(), 1), ('oauth-email-extra', 7, '[email protected]', 1, ov_now(), 0)").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
@@@ -46,6 +47,21 @@ const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKe
const codeOf = (redirect) => new URL(redirect).searchParams.get('code');
const exchange = (code, extra = {}) => post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT, ...extra });
++ // An app asking for email receives a choice screen, saves one address per grant, and exposes only that choice.
++ let emailChoice = await confirm({ scope: 'openid email profile' });
++ assert.deepStrictEqual(emailChoice.body.email_choice.addresses.map((a) => a.id), ['oauth-email-primary', 'oauth-email-extra']);
++ assert.equal(emailChoice.body.email_choice.selected_id, 'oauth-email-primary');
++ emailChoice = await confirm({ scope: 'openid email profile', email_address_id: 'oauth-email-extra' });
++ assert.ok(emailChoice.body.redirect);
++ const emailTokens = await exchange(codeOf(emailChoice.body.redirect));
++ assert.equal(emailTokens.status, 200, JSON.stringify(emailTokens.body));
++ assert.equal(jwt.decode(emailTokens.body.id_token).email, '[email protected]');
++ const emailInfo = await fetch(`${base}/oauth/userinfo`, { headers: { authorization: `Bearer ${emailTokens.body.access_token}` } }).then((r) => r.json());
++ assert.equal(emailInfo.email, '[email protected]');
++ assert.deepStrictEqual((await db.prepare('SELECT account_email_id FROM account_email_grants WHERE user_id = 7 AND client_id = ?').get('live')).account_email_id, 'oauth-email-extra');
++ emailChoice = await confirm({ scope: 'openid email profile' });
++ assert.equal(emailChoice.body.email_choice.selected_id, 'oauth-email-extra', 'the per-app selected address is preselected next time');
++
// authorize forwards the challenge to the account chooser; plain and malformed challenges are refused
let r = await fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: 'live', redirect_uri: REDIRECT, response_type: 'code', state: 's', code_challenge: challenge, code_challenge_method: 'S256' })}`, { redirect: 'manual' });
assert.strictEqual(r.status, 302);
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 09393cc6..0673ee46 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');