openvibers/openvibe.network Commits

openvibe.my: the account hub's own home and session (#646)

OP OpenVibers committed 6 days ago · 6d02560d0b · 21 files, +996 −36

* openvibe.my: the account hub's own home, session and Needs you

- Host routing: openvibe.my serves the hub at / and /<section>; openvibe.network/my and the hub's old
  paths 301 there (query kept); www.openvibe.my 301s to the apex. The vhost proxies Network.
- The my OAuth client (redirect https://openvibe.my/auth/callback), with PKCE S256 and a host-only
  session; the SDK silent flow (prompt=none) signs in a Network session with no click; sign-out is
  local and offers everywhere.
- openvibe.my is in Network's CORS for /api/v1/me/*; the page calls it with a Bearer token and
  credentials omitted, so the session cookie is never sent to openvibe.network.
- Needs you leads the first screen with the Action Center's waiting items and one button each.
- Tests: host routing and 301s, PKCE/callback/silent/sign-out, CORS and the client seed, no token in
  a URL, Needs you, and the vhost parses with nginx -t.

* openvibe.my review: vhost test runs on CI, no generic env fallback, 302 hops while new, a still dot

- my-vhost.test.js: the scratch http block turns access_log off and nginx -t gets -e, so a runner never opens
  /var/log/nginx (CI failed on access.log).
- config.my reads the seeded oauth_clients secret or OV_MY_OAUTH_CLIENT_SECRET only; Network's env never holds
  the generic OV_OAUTH_CLIENT_* pair, and .env.example no longer says to write it there.
- openvibe.network/my (and my.openvibe.network) send people to openvibe.my with 302 while the new home settles,
  so no browser caches the hop.
- The Needs you dot no longer pulses.

* my-vhost test: the scratch copy listens on high loopback ports (a CI runner cannot bind 80/443)

Changes

ddiff --git a/.env.example b/.env.example
iindex 6b5530d2..292a03bd 100644
---- a/.env.example
++++ b/.env.example
@@@ -101,6 +101,13 @@ OV_TRADE_INTERNAL_URL=http://127.0.0.1:4860
#SSH_SERVER_HOST=
#SSH_PROJECTS_ROOT=/opt/openvibe.network /opt/openvibe.live
++# openvibe.my is Network's own first-party OAuth client 'my' (redirect https://openvibe.my/auth/callback).
++# The client row and its secret are seeded in oauth_clients and read from there at boot; nothing needs setting.
++# To change the secret, rotate the row (setup/service-principal.js rotate my, WITHOUT --write-env) and restart.
++#OV_MY_OAUTH_CLIENT_ID=my
++#OV_MY_OAUTH_CLIENT_SECRET=
++#OV_MY_URL=https://openvibe.my
++
# ═══════════════════════════════════════════════════════════════
# Provider secrets (server/secrets.js). Each one is read from its variable first; the admin
# panel's database copy is only a fallback, and the panel cannot change one set here. In
ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex e579557e..b9863e2c 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -22,8 +22,9 @@
# 2026-10-28: the thirteen domains bought that day (open.shoes and twelve openvibe.*) are parked here, each sent to
# where its capability lives today, until its room's own vhost takes the name (plan: OpenVibe_New_Domains_Plan).
# 2026-11-07: the seven bought that day (openvibe.computer, .email, .link, .locker, .my, .party, .studio) joined this
--# file; six are parked here the same way, each sent to where its capability lives today (plan:
--# OpenVibe_Seven_Domains_Plan §1). openvibe.link is not parked: it is OpenVibe Link's home, in this file below.
++# file; five are parked here the same way, each sent to where its capability lives today (plan:
++# OpenVibe_Seven_Domains_Plan §1). openvibe.link and openvibe.my are not parked: they are OpenVibe Link's and
++# the account hub's homes, in this file below.
#
# Install: sudo ov-vhost-install deploy/nginx/parked-domains.conf parked-domains.conf
# after removing Sites' vhost for each name. Each name uses its own certificate (a vhost must use its own
@@@ -66,23 +67,63 @@ server {
# openvibe.cam is served by OpenVibe.Cam since 2026-10-28 (its own vhost, deploy/nginx/openvibe.cam.conf).
--# openvibe.my: parked, sent to Network's account hub (everything that's yours) until its own vhost takes the name (2026-11-07).
++# openvibe.my: the account hub's home since 2026-11-07, served by OpenVibe.Network on 127.0.0.1:4000, which
++# serves the hub at / (and its sections at /<section>) and its own sign-in at /auth. openvibe.network/my,
++# /my/* and the hub's other old paths 301 there from the app; www.openvibe.my is not a second home and 301s to
++# the apex here, and the app does the same for a request that reaches it on the www host.
++#
++# The account hub's API is not proxied here: its pages call https://openvibe.network/api/v1/me/* with the
++# person's Bearer token (Network's CORS allows this origin), and the app sends a stray /api/ request there too.
++# The TLS certificate is the name's own; the Cloudflare real-IP map is the host-wide
++# /etc/nginx/conf.d/cloudflare-realip.conf, so $remote_addr here is the visitor's address, not Cloudflare's.
server {
listen 80;
listen [::]:80;
server_name openvibe.my www.openvibe.my;
-- return 302 https://openvibe.network/my;
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.my$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
-- server_name openvibe.my www.openvibe.my;
++ server_name www.openvibe.my;
ssl_certificate /etc/letsencrypt/live/openvibe.my/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.my/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
access_log off;
-- return 302 https://openvibe.network/my;
++ return 301 https://openvibe.my$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.my;
++ include snippets/security-txt.conf;
++ ssl_certificate /etc/letsencrypt/live/openvibe.my/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.my/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++ access_log /var/log/nginx/openvibe.my.access.log;
++ error_log /var/log/nginx/openvibe.my.error.log;
++ add_header X-Content-Type-Options nosniff always;
++ add_header Referrer-Policy strict-origin-when-cross-origin always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ location ~* ^/(metrics|internal)(/|$) { return 404; }
++
++ # The hub's page, its sections, its /auth sign-in and every shared asset the page loads (css, js, shared,
++ # img, fonts, icons, manifest, service worker) are Network's own responses for this Host.
++ location / {
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
}
# openvibe.link: OpenVibe Link's home since 2026-11-07, served by OpenVibe.Network on 127.0.0.1:4000, which
ddiff --git a/public/css/my.css b/public/css/my.css
iindex efee9458..174da66a 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -242,6 +242,28 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
.page-header .info .greeting:empty { display: none; }
.page-header .info .badges:empty { display: none; }
.td-settings-btn { flex: none; align-self: center; text-decoration: none; }
++
++/* Needs you (plan OpenVibe_Seven_Domains_Plan §4): the Action Center's waiting items, above the hero. Same kit:
++ card surface, one line of plain text, one button. */
++.ny { display: grid; gap: 10px; margin: 0 0 20px; padding: 18px 20px; border-radius: 16px; background: var(--bg-card); border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); box-shadow: 0 8px 30px rgba(0,0,0,.18); }
++.ny[hidden] { display: none !important; }
++.ny-h { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
++.ny-h h2 { display: flex; align-items: center; gap: 9px; font-size: 1.05rem; font-weight: 850; letter-spacing: -.01em; }
++.ny-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); }
++.ny-sub { font-size: 12.5px; color: var(--text-muted); }
++.ny-list { display: grid; gap: 8px; }
++.ny-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 12px; background: var(--bg-secondary); border: 1px solid var(--border); }
++.ny-ic { flex: none; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: color-mix(in srgb, var(--accent) 14%, var(--bg-input)); color: var(--accent-light); font-size: 14px; }
++.ny-body { flex: 1; min-width: 0; display: grid; gap: 2px; }
++.ny-body h3 { font-size: 14px; font-weight: 750; overflow-wrap: anywhere; }
++.ny-body p { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; overflow-wrap: anywhere; }
++.ny-meta { font-size: 11.5px; color: var(--text-muted); }
++.ny-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; min-height: 38px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent, #fff); font: inherit; font-size: 13px; font-weight: 700; text-decoration: none; cursor: pointer; }
++.ny-btn:hover, .ny-btn:focus-visible { filter: brightness(1.08); outline: none; }
++.ny-btn:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent); }
++.ny-btn[disabled] { opacity: .65; cursor: progress; }
++@media (max-width: 600px) { .ny { padding: 16px; } .ny-item { align-items: flex-start; flex-wrap: wrap; } .ny-btn { margin-left: 46px; } }
++
.td { display: grid; gap: 22px; margin: -8px 0 36px; }
.td-sec { display: grid; gap: 10px; min-width: 0; }
/* A block with nothing in it stays out (11-07: the empty "Live now" heading showed, and read as if you were live). */
ddiff --git a/public/js/my/boot.js b/public/js/my/boot.js
iindex d4912a85..007c4774 100644
---- a/public/js/my/boot.js
++++ b/public/js/my/boot.js
@@@ -4,9 +4,15 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
═══════════════════════════════════════════════════════════════ */
--// Init. Signed out, loadUser() sends the visitor to /login: the section and the badge stay unasked, so the page never
--// calls an API that answers 401 on the way there (/history, /themes and /notifications logged two each).
--OpenVibeAccountSwitcher.init({ apiBase: API });
++// Init. Signed out, loadUser() shows openvibe.my's own sign-in card (or sends a visitor to /login on Network's old
++// host): the section and the badge stay unasked, so the page never calls an API that answers 401 on the way there
++// (/history, /themes and /notifications logged two each).
++OpenVibeAccountSwitcher.init({ apiBase: API, onSwitch: (account) => {
++ // Signing out of the switcher (account === null) must clear the host-only SDK session too, or the
++ // silent sign-in would bring the person straight back.
++ if (!account && MY_HOST) { window.location.replace('/auth/logout?next=%2F'); return; }
++ window.location.reload();
++} });
const _signedIn = !!(getAuthToken() || getAnonToken());
loadUser();
if (_signedIn) {
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex 4b5c7836..dba41a4e 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -4,7 +4,12 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
═══════════════════════════════════════════════════════════════ */
--const API = window.location.origin;
++// The account hub has its own origin (openvibe.my) since 2026-11-07: its session is host-only there, while
++// every /my API call stays on Network and carries the person's Bearer token. Cookies are never sent to
++// openvibe.network, so credentials are omitted on the cross-origin calls (server/index.js CORS allows the origin).
++const MY_HOST = window.location.hostname === 'openvibe.my' || window.location.hostname === 'www.openvibe.my';
++const API = MY_HOST ? 'https://openvibe.network' : window.location.origin;
++const API_CREDENTIALS = MY_HOST ? 'omit' : 'include';
let currentUser = null;
let availableThemes = [];
let activeThemeId = 'vibe';
@@@ -72,7 +77,7 @@ async function _bootstrapFromCookie() {
try {
const res = await fetchWithTimeout(API + '/api/auth/me', {
headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
-- credentials: 'include',
++ credentials: API_CREDENTIALS,
});
if (res.ok) {
const data = await res.json();
@@@ -86,7 +91,7 @@ async function _bootstrapFromCookie() {
const refreshRes = await fetchWithTimeout(API + '/api/auth/refresh', {
method: 'POST',
headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
-- credentials: 'include',
++ credentials: API_CREDENTIALS,
});
if (refreshRes.ok) {
const refreshData = await refreshRes.json();
@@@ -130,7 +135,7 @@ async function apiFetch(path, opts = {}) {
const headers = { 'Content-Type':'application/json', ...opts.headers };
const authToken = getAuthToken();
if (authToken) headers.Authorization = 'Bearer ' + authToken;
-- const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: API_CREDENTIALS });
// Sliding sessions: the server hands back a renewed token when the old one is due.
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 = {};
@@@ -141,7 +146,7 @@ async function apiFetch(path, opts = {}) {
async function anonFetch(path, opts = {}) {
const headers = { 'Content-Type':'application/json', ...opts.headers };
-- const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: API_CREDENTIALS });
let data = {};
try { data = await res.json(); } catch { /* non-JSON */ }
if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex d4ea6323..a93c60f6 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -7,6 +7,13 @@
// The wallet (MONEY.md D12) in the account menu on Network's own pages: Shared's navbar needs the same item on every
// site (the report says so); here Network can pass it in.
const OV_WALLET_MENU = { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] };
++// On openvibe.my the shared navbar's sign-in/out is the hub's own SDK session, not Network's cookie page.
++const OV_NAV_AUTH = MY_HOST ? {
++ silentLogin: '/auth/login?silent=1&next={url}',
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++} : {};
// /my#delete is the return target of the re-authentication path (and a deep link): it opens the accounts
// section and puts the cursor in the delete confirmation field. Read before core.js rewrites the hash.
@@@ -29,6 +36,43 @@ function reauthForDeletion() {
// The banner's Keep my account has already cancelled it; refresh this page's card too.
window.addEventListener('ov:deletion-cancelled', () => { if (document.getElementById('delete-body')) renderDeletion(); });
++/**
++ * Signed out on openvibe.my: stay on the hub and offer the way in, plus "sign out everywhere" for the
++ * browser that still has a Network session (the SDK cleared only this origin). Never a bounce loop.
++ */
++function showSignedOutHub() {
++ const page = document.querySelector('.page');
++ if (!page) { window.location.replace('/login'); return; }
++ const here = new URL(window.location.href);
++ here.searchParams.delete('sso');
++ here.searchParams.delete('signed_out');
++ const next = `${here.pathname}${here.search}${here.hash}`;
++ const card = document.createElement('section');
++ card.className = 'card';
++ const heading = document.createElement('h2');
++ heading.textContent = 'Sign in to OpenVibe.My';
++ const text = document.createElement('p');
++ text.textContent = 'Your account hub lives on its own address. Sign in with OpenVibe; your account and its API stay on openvibe.network.';
++ const row = document.createElement('div');
++ row.className = 'btn-row';
++ const signIn = document.createElement('a');
++ signIn.className = 'btn btn-primary';
++ signIn.href = `/auth/login?next=${encodeURIComponent(next)}`;
++ signIn.setAttribute('href', signIn.href);
++ signIn.textContent = 'Sign in with OpenVibe';
++ const everywhere = document.createElement('a');
++ everywhere.className = 'btn';
++ everywhere.href = 'https://openvibe.network/sso/fanout?action=logout&next=' + encodeURIComponent('https://openvibe.my/?sso=none');
++ everywhere.setAttribute('href', everywhere.href);
++ everywhere.textContent = 'Sign out everywhere';
++ row.append(signIn, everywhere);
++ card.append(heading, text, row);
++ page.replaceChildren(card);
++ try {
++ if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'network', token: null, user: null, menu: OV_WALLET_MENU, ...OV_NAV_AUTH });
++ } catch { /* the page still offers the buttons */ }
++}
++
// Load user data
async function loadUser() {
// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
@@@ -37,7 +81,11 @@ async function loadUser() {
const anonToken = getAnonToken();
// A replace, not a new history entry: Back should not land on a page that bounces again, and a replace navigation
// skips the cross-document view transition the sign-in page cannot finish (openvibe-shared 4.88.1).
-- if (!authToken && !anonToken) { window.location.replace('/login'); return; }
++ if (!authToken && !anonToken) {
++ if (MY_HOST) { showSignedOutHub(); return; }
++ window.location.replace('/login');
++ return;
++ }
try {
if (isAnonSession()) {
const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken));
@@@ -56,7 +104,7 @@ async function loadUser() {
applyAnonModeUI(currentUser);
loadAccounts();
loadAnonInfo();
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser, menu: OV_WALLET_MENU });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser, menu: OV_WALLET_MENU, ...OV_NAV_AUTH });
} else {
const data = await apiFetch('/api/auth/me');
currentUser = data.user;
@@@ -81,7 +129,7 @@ async function loadUser() {
loadRecentTools();
// Init navbar
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser, menu: OV_WALLET_MENU });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser, menu: OV_WALLET_MENU, ...OV_NAV_AUTH });
const bellMount = OpenVibeNavbar.getBellMount();
if (bellMount && window.OpenVibeNotifications && authToken) {
OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
@@@ -100,10 +148,11 @@ async function loadUser() {
if (!window._ovRefreshTried) {
window._ovRefreshTried = true;
try {
-- const r = await fetchWithTimeout(API + '/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { Authorization: 'Bearer ' + getAuthToken() } : {}) }, credentials: 'include' });
++ const r = await fetchWithTimeout(API + '/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { Authorization: 'Bearer ' + getAuthToken() } : {}) }, credentials: API_CREDENTIALS });
if (r.ok) { const d = await r.json(); if (d.token) { localStorage.setItem('ov_token', d.token); document.cookie = `ov_token=${d.token};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; location.reload(); return; } }
} catch { /* fall through */ }
}
++ if (MY_HOST) { showSignedOutHub(); return; }
window.location.replace('/login?return=' + encodeURIComponent(window.location.pathname));
return;
}
@@@ -364,7 +413,7 @@ async function accountApi(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
const t = getAuthToken();
if (t) headers.Authorization = 'Bearer ' + t;
-- const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: API_CREDENTIALS });
let body = {};
try { body = await res.json(); } catch { /* */ }
return { status: res.status, ok: res.ok, body };
@@@ -421,7 +470,7 @@ async function downloadExport(id, btn) {
const headers = {};
const t = getAuthToken();
if (t) headers.Authorization = 'Bearer ' + t;
-- const res = await fetch(API + `/api/v1/account/export/${encodeURIComponent(id)}/download`, { headers, credentials: 'include' });
++ const res = await fetch(API + `/api/v1/account/export/${encodeURIComponent(id)}/download`, { headers, credentials: API_CREDENTIALS });
if (!res.ok) { toast('That copy is no longer available.', 'error'); renderExport(); return; }
const blob = await res.blob();
const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
iindex 8e8b7916..12fc8535 100644
---- a/public/js/my/security.js
++++ b/public/js/my/security.js
@@@ -377,6 +377,11 @@ async function signOutEverywhere() {
try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
catch (err) { alert('Error: ' + err.message); return; }
try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }
++ if (MY_HOST) {
++ const fanout = 'https://openvibe.network/sso/fanout?action=logout&next=' + encodeURIComponent('https://openvibe.my/?sso=none');
++ location.href = '/auth/logout?next=' + encodeURIComponent(fanout);
++ return;
++ }
location.href = '/sso/fanout?action=logout&next=%2Flogin';
}
@@@ -490,7 +495,7 @@ function qrPost(path, body) {
const headers = { 'Content-Type': 'application/json' };
const token = getAuthToken();
if (token) headers.Authorization = 'Bearer ' + token;
-- return fetch(API + path, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(body || {}) })
++ return fetch(API + path, { method: 'POST', credentials: API_CREDENTIALS, headers, body: JSON.stringify(body || {}) })
.then(async (res) => {
let data = {};
try { data = await res.json(); } catch { /* empty answer */ }
@@@ -645,7 +650,7 @@ async function qrPoll() {
try {
const token = getAuthToken();
const res = await fetch(`${API}/auth/qr/${encodeURIComponent(qrTransfer.id)}`, {
-- credentials: 'include', cache: 'no-store', headers: token ? { Authorization: 'Bearer ' + token } : {},
++ credentials: API_CREDENTIALS, cache: 'no-store', headers: token ? { Authorization: 'Bearer ' + token } : {},
});
if (res.status === 404) { qrExpire(); return; }
if (res.status === 403) { qrSigninClose(); return; }
ddiff --git a/public/js/my/themes.js b/public/js/my/themes.js
iindex efbb031d..efe9486f 100644
---- a/public/js/my/themes.js
++++ b/public/js/my/themes.js
@@@ -217,7 +217,7 @@ async function saveCustomColors(btn) {
async function exportActiveTheme(btn) {
if (btn) btn.disabled = true;
try {
-- const res = await fetch('/api/themes/' + encodeURIComponent(activeThemeId) + '/export', { credentials: 'include' });
++ const res = await fetch(API + '/api/themes/' + encodeURIComponent(activeThemeId) + '/export', { credentials: API_CREDENTIALS });
if (!res.ok) throw new Error('This theme cannot be exported');
const file = await res.json();
if (activeCustomVars && Object.keys(activeCustomVars).length) file.variables = Object.assign({}, file.variables, activeCustomVars);
ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex c553755e..9fc85a75 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -249,6 +249,107 @@ function renderStuff(live, hist, nodes) {
if (box) box.hidden = cells.length === 0;
}
++
++/* ── Needs you (plan OpenVibe_Seven_Domains_Plan §4) ─────────────────────────────────────────────
++ The Action Center's waiting bucket, above the hero. One button per item, its primary action:
++ approve/retry go through POST /api/v1/me/actions/:service/:id/:action; open is the item's page.
++ A waiting row is plain text (title, summary) from the authority, so it is built with DOM nodes. */
++const NY_ICONS = { actor: 'fa-robot', codes: 'fa-code', ai: 'fa-wand-magic-sparkles', domains: 'fa-globe', network: 'fa-shield-halved', run: 'fa-terminal' };
++
++function nyHref(item) {
++ if (item && item.service === 'network') return '#approvals';
++ const href = String((item && item.href) || '');
++ return /^https:\/\//.test(href) ? href : '#approvals';
++}
++
++function nyButton(item, action) {
++ const label = action === 'approve' ? 'Approve' : action === 'retry' ? 'Try again' : 'Open';
++ if (action !== 'approve' && action !== 'retry') {
++ const href = nyHref(item);
++ const a = document.createElement('a');
++ a.className = 'ny-btn';
++ a.setAttribute('href', href);
++ a.href = href;
++ a.textContent = label;
++ if (a.getAttribute('href').startsWith('#')) {
++ a.addEventListener('click', (e) => { e.preventDefault(); showSection(a.getAttribute('href').slice(1)); });
++ } else {
++ a.target = '_blank';
++ a.rel = 'noopener';
++ }
++ return a;
++ }
++ const b = document.createElement('button');
++ b.type = 'button';
++ b.className = 'ny-btn';
++ b.textContent = label;
++ b.addEventListener('click', () => nyDo(item, action, b));
++ return b;
++}
++
++async function nyDo(item, action, button) {
++ if (!item || !item.service || !item.id) return;
++ const label = button.textContent;
++ button.disabled = true;
++ button.textContent = action === 'retry' ? 'Trying…' : 'Approving…';
++ try {
++ await apiFetch(`/api/v1/me/actions/${encodeURIComponent(item.service)}/${encodeURIComponent(item.id)}/${action}`, { method: 'POST' });
++ const row = button.closest('.ny-item');
++ if (row) row.remove();
++ const left = document.querySelectorAll('#needs-you-list .ny-item').length;
++ const box = document.getElementById('needs-you');
++ const sub = document.getElementById('needs-you-sub');
++ if (sub) sub.textContent = left ? `${left} more waiting` : '';
++ if (!left && box) box.hidden = true;
++ if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : 'Approved', 'success');
++ } catch (err) {
++ button.disabled = false;
++ button.textContent = label;
++ if (typeof toast === 'function') toast(err && err.message ? err.message : 'That did not go through', 'error');
++ }
++}
++
++function renderNeedsYou(doc) {
++ const box = document.getElementById('needs-you');
++ const list = document.getElementById('needs-you-list');
++ if (!box || !list) return;
++ const items = doc && doc.buckets && Array.isArray(doc.buckets.waiting) ? doc.buckets.waiting : [];
++ if (!items.length) { box.hidden = true; list.replaceChildren(); return; }
++ const sub = document.getElementById('needs-you-sub');
++ if (sub) sub.textContent = `${items.length} thing${items.length === 1 ? '' : 's'} waiting`;
++ list.replaceChildren(...items.map((item) => {
++ const row = document.createElement('article');
++ row.className = 'ny-item';
++ const icon = document.createElement('span');
++ icon.className = 'ny-ic';
++ const i = document.createElement('i');
++ i.className = `fa-solid ${NY_ICONS[item.service] || 'fa-circle-exclamation'}`;
++ i.setAttribute('aria-hidden', 'true');
++ icon.append(i);
++ const body = document.createElement('div');
++ body.className = 'ny-body';
++ const title = document.createElement('h3');
++ title.textContent = item.title || 'Something needs you';
++ body.append(title);
++ if (item.summary) {
++ const summary = document.createElement('p');
++ summary.textContent = item.summary;
++ body.append(summary);
++ }
++ if (item.agent) {
++ const meta = document.createElement('span');
++ meta.className = 'ny-meta';
++ meta.textContent = item.agent;
++ body.append(meta);
++ }
++ const actions = Array.isArray(item.actions) ? item.actions : [];
++ const primary = actions.includes('approve') ? 'approve' : actions.includes('retry') ? 'retry' : 'open';
++ row.append(icon, body, nyButton(item, primary));
++ return row;
++ }));
++ box.hidden = false;
++}
++
async function loadToday() {
if (!document.getElementById('today')) return;
renderGreeting();
@@@ -256,7 +357,8 @@ async function loadToday() {
const quiet = (p) => p.catch(() => null);
renderActions(null);
renderStatus();
-- const [live, hist, coins, last, unread, sites, nodes, frame] = await Promise.all([
++ const [acts, live, hist, coins, last, unread, sites, nodes, frame] = await Promise.all([
++ quiet(apiFetch('/api/v1/me/actions')),
quiet(apiFetch('/api/v1/me/live-follows')),
quiet(apiFetch('/api/history?limit=30')),
quiet(apiFetch('/api/coins/me?with=vibes')),
@@@ -267,6 +369,7 @@ async function loadToday() {
quiet(apiFetch('/api/frame?host=openvibe.network')),
]);
globalThis.__tdUnread = unread;
++ renderNeedsYou(acts);
renderActions(unread);
renderNet(frame);
renderNow(live, unread);
ddiff --git a/public/js/wallet/common.js b/public/js/wallet/common.js
iindex 02bda4ec..a64df2fc 100644
---- a/public/js/wallet/common.js
++++ b/public/js/wallet/common.js
@@@ -96,7 +96,7 @@
root.OpenVibeNavbar.init({
service: 'network', notificationsRealtime: true, token: token(), user,
menu: WALLET_MENU,
-- links: [{ label: 'Link', href: 'https://openvibe.link', icon: 'fa-desktop', featured: true }, { label: 'Wallet', href: '/wallet' }, { label: 'My Account', href: '/my' }],
++ links: [{ label: 'Link', href: 'https://openvibe.link', icon: 'fa-desktop', featured: true }, { label: 'Wallet', href: '/wallet' }, { label: 'My Account', href: 'https://openvibe.my/' }],
});
} catch { /* the page works without it */ }
}
ddiff --git a/public/my.html b/public/my.html
iindex 9ddd005b..b04b1b54 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -14,7 +14,7 @@
<meta name="keywords" content="account dashboard, openvibe network, user profile, settings">
<meta name="robots" content="noindex, follow">
<meta property="og:type" content="website">
--<meta property="og:url" content="https://openvibe.network/">
++<meta property="og:url" content="https://openvibe.my/">
<meta property="og:title" content="My Account - OpenVibe">
<meta property="og:description" content="Personal dashboard for managing your OpenVibe account and preferences.">
<meta property="og:site_name" content="OpenVibe">
@@@ -22,7 +22,7 @@
<meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://openvibe.network/shared/social/network.jpg">
--<link rel="canonical" href="https://openvibe.network/my">
++<link rel="canonical" href="https://openvibe.my/">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
@@@ -52,6 +52,17 @@
<div id="navbar-mount"></div>
<div class="page">
++ <!-- Needs you (plan OpenVibe_Seven_Domains_Plan §4): the Action Center's waiting items, first on the screen.
++ my/today.js fills it from GET /api/v1/me/actions; a waiting item has one button (Approve, Try again, or
++ Open), and an empty queue leaves the section out. The hero and the sections follow unchanged. -->
++ <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">
++ <div class="ny-h">
++ <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>
++ <span class="ny-sub" id="needs-you-sub"></span>
++ </div>
++ <div class="ny-list" id="needs-you-list"></div>
++ </section>
++
<!-- You (11-07 redesign; the owner: "front-face the best stuff we have to offer … awesome design"): who you are in
your own colour, your balances, and one tap to what you do most, on every site. -->
<header class="me" id="page-header">
@@@ -420,7 +431,7 @@
<p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Every OpenVibe site you have signed in to with this account. Sign in once here and the network signs you in everywhere; sign out everywhere with one click.</p>
<div class="btn-row" style="margin-bottom:14px">
<a class="btn btn-primary" href="/sso/fanout?next=%2Flinked"><i class="fa-solid fa-wand-magic-sparkles"></i> Sign in everywhere</a>
-- <a class="btn" href="/sso/fanout?action=logout&next=%2Flogin"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere</a>
++ <a class="btn" href="/sso/fanout?action=logout&next=%2F"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere</a>
</div>
<div id="linked-list">
<!-- Populated by JS -->
ddiff --git a/server/config.js b/server/config.js
iindex 473dd5e0..089a5ded 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -10,6 +10,15 @@ module.exports = {
networkUrl: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
loginUrl: process.env.LOGIN_URL || process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
internalUrl: process.env.INTERNAL_URL || process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
++ // The account hub's own home (plan OpenVibe_Seven_Domains_Plan §4): openvibe.my is Network's OAuth client 'my',
++ // with PKCE and a host-only session on that origin. The secret is the client's row in oauth_clients (Network is
++ // the issuer and keeps every client's secret there); OV_MY_OAUTH_CLIENT_SECRET overrides it on a host. Network's
++ // own env never holds the generic OV_OAUTH_CLIENT_* pair: that pair names a product's client, not Network's.
++ my: {
++ clientId: process.env.OV_MY_OAUTH_CLIENT_ID || 'my',
++ clientSecret: process.env.OV_MY_OAUTH_CLIENT_SECRET || '',
++ baseUrl: process.env.OV_MY_URL || 'https://openvibe.my',
++ },
setupToken: process.env.SETUP_TOKEN || '',
bootstrapProfile: process.env.BOOTSTRAP_PROFILE || 'local-dev',
ddiff --git a/server/db/database.js b/server/db/database.js
iindex aab43858..45a7b3a3 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -91,6 +91,9 @@ async function seedDb(db, { log = console } = {}) {
const { v4: uuidv4 } = require('uuid');
const contractClients = [
{ client_id: 'live', name: 'OpenVibe.Live', redirect_uris: ['https://openvibe.live/api/auth/callback'] },
++ // The account hub's own origin (plan OpenVibe_Seven_Domains_Plan §4): a first-party confidential client
++ // with PKCE, so openvibe.my has its own host-only session instead of reading a .openvibe.network cookie.
++ { client_id: 'my', name: 'OpenVibe.My', redirect_uris: ['https://openvibe.my/auth/callback'] },
{ client_id: 'tools', name: 'OpenVibe.Tools', redirect_uris: ['https://openvibe.tools/auth/callback'] },
{ client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },
{ client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },
ddiff --git a/server/index.js b/server/index.js
iindex 3f151e90..a66a667a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -230,6 +230,9 @@ function buildAllowedOriginsSet() {
// name node.openvibe.network stays here too: it 301s to openvibe.link, but a cached page may still ask.
'https://openvibe.link',
'https://node.openvibe.network',
++ // The account hub's own origin (openvibe.my) asks /api/v1/me/* with the person's Bearer token and no
++ // credentials: the session cookie is host-only on openvibe.my, never sent to openvibe.network.
++ 'https://openvibe.my', 'https://www.openvibe.my',
'https://openvibe.live', 'https://www.openvibe.live',
'https://openvibe.tools',
'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
@@@ -331,6 +334,12 @@ app.locals.urlRegistry = resolvedRegistry;
// Make registry accessible to signToken via config._registry
config._registry = resolvedRegistry;
++// openvibe.my: the account hub's own home (plan OpenVibe_Seven_Domains_Plan §4). The Host header decides:
++// openvibe.my serves the hub at / and its sections at /<section>; openvibe.network/my and the hub's old
++// paths 301 there; www.openvibe.my 301s to the apex. The route also mounts the hub's own OAuth client
++// (PKCE, host-only cookies) at /auth, before Network's sign-in pages, so /my never renders off-origin.
++app.use(await require('./my/routes').createMyRoutes({ db, config }));
++
// ── Analytics Tracking (ADR-021) ──────────────────────────────
// openvibe-shared/analytics on PostgreSQL (server/analytics/network.js; migrations/0002_analytics.sql).
// Raw rows: route template, rotating session id, user-agent class, referer origin; never an IP or a
@@@ -951,12 +960,13 @@ app.use('/api/admin', require('./admin/service-proxies').createServiceProxies({
app.use('/internal', require('./internal/routes'));
// ── Host Canonicalization ────────────────────────────────────
--// my.openvibe.network is legacy — 301 to the apex (nginx will also do this).
++// my.openvibe.network is legacy — 301 to the account hub's own home, openvibe.my (nginx will also do this).
app.use((req, res, next) => {
if (getRequestHost(req) === 'my.openvibe.network') {
-- const targetPath = (req.path === '/' || req.path === '/my' || req.path === '/my.html') ? '/my' : req.path;
++ const { pathFromNetwork, isHubPath } = require('./my/routes');
++ const targetPath = isHubPath(req.path) ? pathFromNetwork(req.path) : '/';
const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : '';
-- return res.redirect(301, `https://openvibe.network${targetPath}${query}`);
++ return res.redirect(302, `https://openvibe.my${targetPath}${query}`); // 302 while openvibe.my is new (my/routes.js)
}
next();
});
ddiff --git a/server/my/routes.js b/server/my/routes.js
nnew file mode 100644
iindex 00000000..1877cfc5
---- /dev/null
++++ b/server/my/routes.js
@@@ -0,0 +1,223 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe.my — the account hub's own front door (plan OpenVibe_Seven_Domains_Plan §4).
++//
++// Network stays the authority: the API is https://openvibe.network/api/v1/me/*, and every call from
++// openvibe.my is a Bearer call (no cookie is ever sent to openvibe.network). openvibe.my has its own
++// first-party OAuth client (`my`, PKCE S256, confidential) and a host-only ov_token/ov_refresh on its
++// own origin; the shared navbar's silent flow (prompt=none) signs a person already signed in to
++// Network in with no click. Signing in here calls OpenVibe.Network's own /oauth/authorize and
++// /oauth/token, exactly as every product site does.
++//
++// The Host header decides:
++// openvibe.my the account hub at /, and /<section> (the old /my/<section> paths)
++// www.openvibe.my 301 to the apex
++// openvibe.network/my 302 to https://openvibe.my/… (query kept); the account hub's other old paths
++// (/themes, /security, /machines, …) 302 there too. 302 while openvibe.my is new
++// (2026-11-07): a browser never caches it, so the hub can move back without a stuck hop.
++// Make it 301 once openvibe.my has run a few weeks.
++// /my, /my.html, /my/* on openvibe.my are 301 to the canonical /<section>
++// ═══════════════════════════════════════════════════════════════
++
++const path = require('path');
++const express = require('express');
++const { createSsoClient, COOKIES } = require('openvibe-sdk/sso');
++
++const MY_HOST = 'openvibe.my';
++const WWW_HOST = 'www.openvibe.my';
++const NETWORK_HOSTS = new Set(['openvibe.network', 'www.openvibe.network']);
++const HUB_PATHS = new Set(require('../not-found').ACCOUNT_HUB_PATHS.filter((p) => !p.startsWith('/my')));
++const BOT_RE = /bot|crawl|spider|slurp|headless/i;
++const PAGE = path.join(__dirname, '..', '..', 'public', 'my.html');
++
++/** The request's host, lowercased, without the port. */
++function hostOf(req) {
++ return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
++}
++
++/** The request's query, exactly as it came (empty when there is none). */
++function queryOf(req) {
++ const url = String(req.originalUrl || '');
++ const i = url.indexOf('?');
++ return i >= 0 ? url.slice(i) : '';
++}
++
++function isMyPath(p) {
++ return p === '/my' || p === '/my.html' || p === '/my/' || p.startsWith('/my/');
++}
++
++function isHubPath(p) {
++ return isMyPath(p) || HUB_PATHS.has(p);
++}
++
++/** The canonical openvibe.my path for an old Network path: /my/security → /security, /my → /. */
++function pathFromNetwork(p) {
++ if (p === '/my' || p === '/my.html' || p === '/my/') return '/';
++ if (p.startsWith('/my/')) return p.slice(3) || '/';
++ return p;
++}
++
++/** The canonical openvibe.my path for a /my… path requested on openvibe.my itself. */
++function pathFromMy(p) {
++ if (p === '/my' || p === '/my.html' || p === '/my/') return '/';
++ return p.slice(3) || '/';
++}
++
++/** A same-site post-sign-in target: /my… becomes the canonical path, and anything not ours goes home. */
++function canonicalNext(raw) {
++ const s = String(raw == null ? '' : raw);
++ if (!s || /[\\\u0000-\u001f\u007f]/.test(s)) return '/';
++ if (s.startsWith('/')) {
++ if (s.startsWith('//')) return '/';
++ if (s === '/my' || s === '/my.html' || s === '/my/') return '/';
++ if (s.startsWith('/my/')) return s.slice(3) || '/';
++ if (s.startsWith('/my?') || s.startsWith('/my#')) return `/${s.slice(3)}`;
++ if (s.startsWith('/my.html?') || s.startsWith('/my.html#')) return `/${s.slice(8)}`;
++ return s;
++ }
++ try {
++ const u = new URL(s);
++ if (u.username || u.password) return '/';
++ if (u.origin === `https://${MY_HOST}`) return canonicalNext(`${u.pathname}${u.search}${u.hash}`);
++ if (NETWORK_HOSTS.has(u.hostname) && u.protocol === 'https:') {
++ const p = pathFromNetwork(u.pathname);
++ return `${p}${u.search}${u.hash}`;
++ }
++ } catch { /* not a URL */ }
++ return '/';
++}
++
++function readCookie(req, name) {
++ if (req.cookies && Object.prototype.hasOwnProperty.call(req.cookies, name)) return req.cookies[name];
++ const m = String((req.headers && req.headers.cookie) || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
++ return m ? decodeURIComponent(m[1]) : null;
++}
++
++/** Build the sign-in client: the `my` row's secret, or the host's OV_MY_OAUTH_CLIENT_SECRET. */
++async function buildSso({ db, config = require('../config'), log = console } = {}) {
++ const opts = config.my || {};
++ const clientId = String(opts.clientId || 'my');
++ let secret = String(opts.clientSecret || '');
++ if (!secret && db && typeof db.prepare === 'function') {
++ try {
++ const row = await db.prepare('SELECT client_secret FROM oauth_clients WHERE client_id = ?').get(clientId);
++ secret = row && row.client_secret ? String(row.client_secret) : '';
++ } catch (err) {
++ log.warn(`[my] could not read the OAuth client secret: ${err.message}`);
++ }
++ }
++ if (!secret) log.warn(`[my] OAuth client '${clientId}' has no secret yet; sign-in on ${opts.baseUrl || MY_HOST} will fail until the client is provisioned`);
++ const baseUrl = String(opts.baseUrl || `https://${MY_HOST}`).replace(/\/+$/, '');
++ return createSsoClient({
++ site: 'my',
++ siteName: 'OpenVibe.My',
++ baseUrl,
++ clientId,
++ clientSecret: secret,
++ redirectUri: `${baseUrl}/auth/callback`,
++ scope: 'profile theme',
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.internalUrl,
++ issuer: config.jwt.issuer,
++ audience: ['openvibe.network', 'openvibe.my'],
++ secureCookies: config.nodeEnv === 'production',
++ log,
++ });
++}
++
++/** The /login, /register… URLs the hub already uses, translated to the SDK's /auth/login (+prompt). */
++function loginRedirect(req, res) {
++ const q = new URLSearchParams();
++ const next = canonicalNext(req.query.return || req.query.next || '/');
++ if (next !== '/') q.set('next', next);
++ const add = req.query.add_account === '1' || req.query.add_account === '' || req.query.prompt === 'select_account';
++ const reauth = req.query.reauth === '1' || req.query.prompt === 'login';
++ if (add) q.set('prompt', 'select_account');
++ else if (reauth) q.set('prompt', 'login');
++ const hint = String(req.query.login_hint || '').trim();
++ if (hint.length <= 64 && /^@?[A-Za-z0-9_.-]+$/.test(hint)) q.set('login_hint', hint);
++ return res.redirect(302, `/auth/login${q.toString() ? `?${q.toString()}` : ''}`);
++}
++
++/** The hub's "sign in/out everywhere" links: clear this origin first, then run Network's fanout, then come back. */
++function fanoutRedirect(req, res) {
++ const action = req.query.action === 'logout' ? 'logout' : 'login';
++ let back = String(req.query.next || '/');
++ if (back.startsWith('/') && !back.startsWith('//')) back = `https://${MY_HOST}${back}`;
++ try {
++ const u = new URL(back);
++ if (u.protocol !== 'https:' || !['openvibe.my', 'www.openvibe.my', 'openvibe.network', 'www.openvibe.network'].includes(u.hostname)) back = `https://${MY_HOST}/`;
++ } catch { back = `https://${MY_HOST}/`; }
++ // After signing out everywhere, land quietly on the hub instead of starting a silent sign-in with the
++ // Network session that the fanout is about to clear.
++ if (action === 'logout') {
++ try { const u = new URL(back); u.searchParams.set('sso', 'none'); back = u.toString(); } catch { /* keep back */ }
++ }
++ const fanout = new URL('https://openvibe.network/sso/fanout');
++ fanout.searchParams.set('action', action);
++ fanout.searchParams.set('next', back);
++ for (const k of ['step', 'done', 'skipped', 'skip']) {
++ if (req.query[k] != null) fanout.searchParams.set(k, String(req.query[k]));
++ }
++ const base = action === 'logout' ? '/auth/logout' : '/auth/login?silent=1';
++ return res.redirect(302, `${base}${base.includes('?') ? '&' : '?'}next=${encodeURIComponent(fanout.toString())}`);
++}
++
++/**
++ * The routes. Calls next() for a request that is not for one of these hosts, so every other host falls
++ * through to Network's own pages.
++ */
++async function createMyRoutes({ db, config = require('../config'), sso, pagePath = PAGE, log = console } = {}) {
++ const client = sso || await buildSso({ db, config, log });
++ const auth = client.router(express);
++ const page = path.resolve(pagePath);
++
++ function serveApp(req, res) {
++ const quiet = String(req.query.sso || '') === 'none' || readCookie(req, COOKIES.hint) === 'guest';
++ if (!readCookie(req, COOKIES.access) && !quiet && ['GET', 'HEAD'].includes(req.method) && !BOT_RE.test(String(req.headers['user-agent'] || ''))) {
++ return res.redirect(302, `/auth/login?silent=1&next=${encodeURIComponent(req.originalUrl || '/')}`);
++ }
++ res.set('Cache-Control', 'no-store');
++ return res.sendFile(page);
++ }
++
++ function onMyHost(req, res, next) {
++ const p = req.path;
++ // The API stays on its canonical host: the page calls it with a Bearer token from openvibe.my,
++ // and a cookie from this host must never be what authenticates a call there.
++ if (/^\/(api|internal|oauth)(\/|$)/.test(p)) return res.redirect(308, `https://openvibe.network${req.originalUrl}`);
++ if (!['GET', 'HEAD'].includes(req.method)) return next();
++ if (isMyPath(p)) return res.redirect(301, `${pathFromMy(p)}${queryOf(req)}`);
++ if (p === '/index.html') return res.redirect(301, `/${queryOf(req)}`);
++ if (['/login', '/register', '/signup', '/sign-up', '/forgot-password', '/reset-password'].includes(p)) return loginRedirect(req, res);
++ if (p === '/sso/fanout' || p === '/sso/fanout.html') return fanoutRedirect(req, res);
++ if (p === '/sso/check') return res.redirect(302, `https://openvibe.network${req.originalUrl}`);
++ // Pages that live on Network, not in the account hub: keep their old relative links working.
++ if (['/wallet', '/sites', '/apps', '/admin'].includes(p) || p.startsWith('/admin/')) {
++ return res.redirect(302, `https://openvibe.network${req.originalUrl}`);
++ }
++ if (p === '/' || HUB_PATHS.has(p)) return serveApp(req, res);
++ return next();
++ }
++
++ const router = express.Router();
++ // Host routing first: the hub's SSO and pages answer only on openvibe.my.
++ router.use((req, res, next) => {
++ const host = hostOf(req);
++ if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
++ if (NETWORK_HOSTS.has(host)) {
++ if (!isHubPath(req.path) || !['GET', 'HEAD'].includes(req.method)) return next();
++ return res.redirect(302, `https://${MY_HOST}${pathFromNetwork(req.path)}${queryOf(req)}`);
++ }
++ if (host === MY_HOST) req.ovMyHost = true;
++ next();
++ });
++ // Mounted at /auth, as the SDK expects; the host guard keeps it off openvibe.network.
++ router.use('/auth', (req, res, next) => (req.ovMyHost ? auth(req, res, next) : next()));
++ router.use((req, res, next) => (req.ovMyHost ? onMyHost(req, res, next) : next()));
++ return router;
++}
++
++module.exports = {
++ MY_HOST, WWW_HOST, HUB_PATHS, isMyPath, isHubPath, createMyRoutes, buildSso, pathFromNetwork, pathFromMy, canonicalNext, loginRedirect, fanoutRedirect,
++};
ddiff --git a/server/not-found.js b/server/not-found.js
iindex 34c41fa4..ea66e28c 100644
---- a/server/not-found.js
++++ b/server/not-found.js
@@@ -56,7 +56,7 @@ ${nav}
<p>There is no page at this address on openvibe.network. The link may be old or mistyped.</p>
<ul>
<li><a href="/">OpenVibe.Network home</a></li>
--<li><a href="/my">Your account</a></li>
++<li><a href="https://openvibe.my/">Your account</a></li>
<li><a href="/login">Sign in</a></li>
<li><a href="/status">Service status</a></li>
</ul>
ddiff --git a/test/my-cors.test.js b/test/my-cors.test.js
nnew file mode 100644
iindex 00000000..03fac421
---- /dev/null
++++ b/test/my-cors.test.js
@@@ -0,0 +1,134 @@
++'use strict';
++// openvibe.my is in Network's CORS allow-list for the account hub's Bearer calls to /api/v1/me/*, and the
++// `my` OAuth client is seeded with the exact redirect https://openvibe.my/auth/callback. The session cookie is
++// host-only on openvibe.my: the cross-origin calls need no credentials, only the Authorization header.
++// node --import ./test/helpers/pg-preload.mjs test/my-cors.test.js
++const assert = require('assert');
++const http = require('http');
++const { bootServer } = require('./helpers/boot-server');
++const { signToken } = require('../server/auth/routes');
++
++const MY = 'https://openvibe.my';
++
++(async () => {
++ const srv = await bootServer();
++ try {
++ const db = srv.app.locals.db;
++ const config = srv.app.locals.config;
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (91, 'mydomain', '[email protected]', 'x', 1)").run();
++ const user = await db.prepare('SELECT * FROM users WHERE id = 91').get();
++ const token = signToken(user, srv.app.locals.privateKey, config);
++
++ // The seed (server/db/database.js) made a confidential first-party client with the exact callback.
++ const row = await db.prepare('SELECT client_id, client_secret, name, redirect_uris, is_first_party FROM oauth_clients WHERE client_id = ?').get('my');
++ assert.ok(row, 'the my client is seeded');
++ assert.strictEqual(row.name, 'OpenVibe.My');
++ assert.deepStrictEqual(JSON.parse(row.redirect_uris), ['https://openvibe.my/auth/callback']);
++ assert.strictEqual(Number(row.is_first_party), 1, 'a first-party client');
++ assert.ok(row.client_secret && String(row.client_secret).length >= 16, 'it has a secret (never logged)');
++
++ // The real app mounted the host routing: openvibe.network/my 302s, openvibe.my serves the hub (and its
++ // static assets) itself, and a signed-out visit tries the silent sign-in first.
++ const getHost = (p, host, cookies = []) => new Promise((resolve) => {
++ const headers = { host };
++ if (cookies.length) headers.cookie = cookies.join('; ');
++ http.get({ host: '127.0.0.1', port: srv.port, path: p, headers }, (res) => {
++ let body = '';
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, setCookies: res.headers['set-cookie'] || [] }));
++ });
++ });
++ const cookieValue = (setCookies, name) => {
++ for (const c of setCookies) {
++ const m = String(c).match(new RegExp(`^${name}=([^;]*)`));
++ if (m) return decodeURIComponent(m[1]);
++ }
++ return null;
++ };
++ let r0 = await getHost('/my?x=1', 'openvibe.network');
++ assert.strictEqual(r0.status, 302, '302 while openvibe.my is new: no browser caches the hop');
++ assert.strictEqual(r0.headers.location, 'https://openvibe.my/?x=1', 'the app, not only nginx, moves /my');
++ r0 = await getHost('/?sso=none', 'openvibe.my');
++ assert.strictEqual(r0.status, 200, 'openvibe.my serves the hub');
++ assert.ok(r0.body.includes('id="needs-you"'), 'with Needs you above the hero');
++ r0 = await getHost('/css/my.css', 'openvibe.my');
++ assert.strictEqual(r0.status, 200, 'the hub\'s stylesheet is served by the same host');
++ assert.match(String(r0.headers['content-type'] || ''), /text\/css/);
++ r0 = await getHost('/', 'openvibe.my');
++ assert.strictEqual(r0.status, 302);
++ assert.strictEqual(r0.headers.location, '/auth/login?silent=1&next=%2F', 'the app starts the silent flow');
++
++ // ── The whole sign-in on the real app: the 'my' client row's secret, PKCE, Network's own authorize and
++ // token endpoints, and a host-only session on openvibe.my ──
++ const login = await getHost('/auth/login?silent=1&next=%2Fsecurity', 'openvibe.my');
++ assert.strictEqual(login.status, 302);
++ const authUrl = new URL(login.headers.location);
++ assert.strictEqual(authUrl.origin, 'https://openvibe.network');
++ assert.strictEqual(authUrl.pathname, '/oauth/authorize');
++ const state = cookieValue(login.setCookies, 'ov_oauth_state');
++ const verifier = cookieValue(login.setCookies, 'ov_oauth_verifier');
++ const next = cookieValue(login.setCookies, 'ov_oauth_next');
++ assert.ok(state && verifier && next, 'the SDK started a stateful PKCE flow');
++ const authorizePath = `${authUrl.pathname}${authUrl.search}`;
++ // The browser is signed in on Network: prompt=none continues as that account and redirects to the hub.
++ const authorized = await getHost(authorizePath, 'openvibe.network', [`ov_token=${encodeURIComponent(token)}`]);
++ assert.strictEqual(authorized.status, 302, JSON.stringify(authorized.body));
++ const back = new URL(authorized.headers.location);
++ assert.strictEqual(back.origin, 'https://openvibe.my');
++ assert.strictEqual(back.pathname, '/auth/callback');
++ assert.ok(back.searchParams.get('code'), 'an authorization code');
++ assert.strictEqual(back.searchParams.get('state'), state);
++ // The callback exchanges it server-side, then sets the hub's own session.
++ const callback = await getHost(`${back.pathname}${back.search}`, 'openvibe.my', [
++ `ov_oauth_state=${state}`, `ov_oauth_verifier=${verifier}`, `ov_oauth_next=${encodeURIComponent(next)}`,
++ ]);
++ assert.strictEqual(callback.status, 302, JSON.stringify(callback.body));
++ assert.strictEqual(callback.headers.location, '/security', 'back where the person was going');
++ const session = cookieValue(callback.setCookies, 'ov_token');
++ assert.ok(session, 'the hub session cookie is set');
++ const claims = JSON.parse(Buffer.from(session.split('.')[1], 'base64url').toString('utf8'));
++ assert.strictEqual(claims.iss, 'https://openvibe.network');
++ assert.ok(Array.isArray(claims.aud) && claims.aud.includes('openvibe.network'), 'a real Network user token');
++ assert.ok(!/access_token|refresh_token|[?&]token=/.test(callback.headers.location), 'the session is never in a URL');
++
++ const preflight = await fetch(`${srv.base}/api/v1/me/actions`, {
++ method: 'OPTIONS',
++ headers: {
++ origin: MY,
++ 'access-control-request-method': 'GET',
++ 'access-control-request-headers': 'authorization, content-type',
++ },
++ });
++ assert.strictEqual(preflight.status, 204, 'the preflight is answered');
++ assert.strictEqual(preflight.headers.get('access-control-allow-origin'), MY, 'openvibe.my is allowed, exactly');
++ assert.notStrictEqual(preflight.headers.get('access-control-allow-origin'), '*', 'never a wildcard');
++ assert.match(String(preflight.headers.get('access-control-allow-headers') || '').toLowerCase(), /authorization/, 'Authorization is allowed');
++ assert.ok(Number(preflight.headers.get('access-control-max-age')) > 0);
++
++ // A Bearer call (no cookies) from openvibe.my reads the person's own route.
++ let r = await fetch(`${srv.base}/api/v1/me/nodes`, { headers: { origin: MY, authorization: `Bearer ${token}` } });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), MY);
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), 'true', 'the allow-list keeps the platform\'s credentialed contract');
++ await r.arrayBuffer();
++
++ // The Action Center route too: unauthenticated still gets a readable 401 on the hub's origin.
++ r = await fetch(`${srv.base}/api/v1/me/actions`, { headers: { origin: MY } });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), MY, 'the 401 is readable from openvibe.my');
++ await r.arrayBuffer();
++
++ // A foreign origin keeps the old refusal, so this change is scoped to first parties.
++ r = await fetch(`${srv.base}/api/v1/me/nodes`, { headers: { origin: 'https://my.example.evil' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null);
++ await r.arrayBuffer();
++
++ console.log('my-cors: my client seed, openvibe.my preflight and Bearer /api/v1/me/* CORS ok');
++ } catch (err) {
++ console.error(srv.logs());
++ throw err;
++ } finally {
++ await srv.stop();
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/my-home.test.js b/test/my-home.test.js
nnew file mode 100644
iindex 00000000..3cddef3d
---- /dev/null
++++ b/test/my-home.test.js
@@@ -0,0 +1,248 @@
++'use strict';
++// openvibe.my: the account hub's own home (plan OpenVibe_Seven_Domains_Plan §4), served by Network. The Host
++// header decides: openvibe.my serves the hub at / and its sections at /<section>; openvibe.network/my and the
++// hub's old paths 302 there with the query (301 once openvibe.my has settled); www.openvibe.my 301s to the apex. Sign-in is the SDK's first-party
++// OAuth client 'my' (PKCE S256, a host-only session on openvibe.my), with the silent path (prompt=none) and
++// sign-out; no token is ever in a URL.
++// node test/my-home.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { createSsoClient } = require('openvibe-sdk/sso');
++const { createMyRoutes, canonicalNext } = require('../server/my/routes');
++
++const ROOT = path.join(__dirname, '..');
++const MY = 'openvibe.my';
++
++(async () => {
++ // ── A Network that issues the 'my' client's tokens ──
++ const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ });
++ const grants = [];
++ const fake = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); };
++ let data = {};
++ try { data = JSON.parse(body || '{}'); } catch { /* */ }
++ grants.push({ path: req.url, data });
++ if (req.url === '/oauth/revoke') return json(200, { ok: true });
++ if (req.url !== '/oauth/token') return json(404, { error: 'not_found' });
++ if (data.client_id !== 'my' || data.client_secret !== 'test-secret') return json(401, { error: 'invalid_client' });
++ if (data.grant_type !== 'authorization_code') return json(400, { error: 'invalid_grant' });
++ if (data.code !== 'good-code' || !/^[A-Za-z0-9_-]{43,128}$/.test(String(data.code_verifier || ''))) {
++ return json(400, { error: 'invalid_grant' });
++ }
++ const access = jwt.sign({
++ sub: 7, id: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', username: 'alice', display_name: 'Alice', role: 'user',
++ }, keys.privateKey, { algorithm: 'RS256', issuer: 'https://openvibe.network', audience: ['openvibe.network'], expiresIn: '1h' });
++ return json(200, { access_token: access, refresh_token: 'refresh-1', token_type: 'Bearer', expires_in: 3600 });
++ });
++ });
++ await new Promise((r) => fake.listen(0, '127.0.0.1', r));
++ const fakeUrl = `http://127.0.0.1:${fake.address().port}`;
++
++ const sso = createSsoClient({
++ site: 'my', siteName: 'OpenVibe.My', baseUrl: 'https://openvibe.my',
++ clientId: 'my', clientSecret: 'test-secret',
++ redirectUri: 'https://openvibe.my/auth/callback',
++ networkUrl: 'https://openvibe.network', networkInternalUrl: fakeUrl,
++ issuer: 'https://openvibe.network', publicKey: keys.publicKey,
++ audience: ['openvibe.network', 'openvibe.my'], secureCookies: false,
++ log: { warn() {}, error() {}, log() {} },
++ });
++ const app = express();
++ app.use(await createMyRoutes({ sso, config: { my: { baseUrl: 'https://openvibe.my' } }, pagePath: path.join(ROOT, 'public', 'my.html') }));
++ app.get('/', (_req, res) => res.type('html').send('<p>network home</p>'));
++ app.get('/themes', (_req, res) => res.type('html').send('<p>network themes</p>'));
++ app.use(express.static(path.join(ROOT, 'public')));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const port = server.address().port;
++ const get = (p, host, cookies = []) => new Promise((resolve) => {
++ const headers = { host };
++ if (cookies.length) headers.cookie = cookies.join('; ');
++ const req = http.request({ host: '127.0.0.1', port, path: p, method: 'GET', headers }, (res) => {
++ let body = '';
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, setCookies: res.headers['set-cookie'] || [] }));
++ });
++ req.end();
++ });
++ const cookieValue = (setCookies, name) => {
++ for (const c of setCookies) {
++ const m = String(c).match(new RegExp(`^${name}=([^;]*)`));
++ if (m) return decodeURIComponent(m[1]);
++ }
++ return null;
++ };
++ const locations = [];
++
++ try {
++ // ── Host routing ──
++ let r = await get('/my?x=1', 'openvibe.network');
++ assert.strictEqual(r.status, 302, '302 while openvibe.my is new: no browser caches the hop');
++ assert.strictEqual(r.headers.location, 'https://openvibe.my/?x=1', 'the old /my keeps the query');
++ locations.push(r.headers.location);
++
++ r = await get('/my/security?from=notes', 'openvibe.network');
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, 'https://openvibe.my/security?from=notes', '/my/* strips /my');
++
++ r = await get('/my.html', 'openvibe.network');
++ assert.strictEqual(r.headers.location, 'https://openvibe.my/', 'my.html lands on the hub home');
++
++ r = await get('/machines', 'openvibe.network');
++ assert.strictEqual(r.headers.location, 'https://openvibe.my/machines', 'every old account-hub path moves');
++
++ r = await get('/', 'openvibe.network');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.includes('network home'), 'the Network apex is untouched');
++
++ r = await get('/security?x=1', 'www.openvibe.my');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.location, 'https://openvibe.my/security?x=1', 'www 301s to the apex, path and query kept');
++
++ r = await get('/my/security?x=1', MY);
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.location, '/security?x=1', 'on openvibe.my /my/* is canonicalized too');
++
++ // ── The first screen ──
++ r = await get('/', MY);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, '/auth/login?silent=1&next=%2F', 'a signed-out visit tries the silent sign-in first');
++
++ r = await get('/security', MY);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, '/auth/login?silent=1&next=%2Fsecurity', 'the section is where the silent sign-in returns');
++
++ r = await get('/?sso=none', MY);
++ assert.strictEqual(r.status, 200, 'a Network guest gets the page quietly, with sso=none');
++ assert.ok(r.body.includes('id="needs-you"'), 'the hub leads with Needs you');
++ assert.ok(r.body.includes('id="needs-you-list"'), 'and has its list');
++ assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="page-header"'), 'Needs you is above the hero');
++ assert.ok(r.body.includes('id="page-header"') && r.body.includes('class="section-tabs"'), 'the hero and sections stay');
++ assert.ok(r.body.includes('<link rel="canonical" href="https://openvibe.my/">'), 'the canonical is openvibe.my');
++ assert.strictEqual(r.headers['cache-control'], 'no-store', 'the hub is not cached');
++
++ r = await get('/', MY, ['ov_token=already']);
++ assert.strictEqual(r.status, 200, 'a session skips the silent redirect');
++
++ // ── The sign-in: PKCE S256, state and verifier in host-only cookies ──
++ r = await get('/auth/login?next=%2Fsecurity', MY);
++ assert.strictEqual(r.status, 302);
++ const authorize = new URL(r.headers.location);
++ assert.strictEqual(authorize.origin, 'https://openvibe.network');
++ assert.strictEqual(authorize.pathname, '/oauth/authorize');
++ assert.strictEqual(authorize.searchParams.get('client_id'), 'my');
++ assert.strictEqual(authorize.searchParams.get('redirect_uri'), 'https://openvibe.my/auth/callback');
++ assert.strictEqual(authorize.searchParams.get('response_type'), 'code');
++ assert.strictEqual(authorize.searchParams.get('code_challenge_method'), 'S256');
++ assert.ok(/^[A-Za-z0-9_-]{43,128}$/.test(authorize.searchParams.get('code_challenge')), 'S256 challenge');
++ assert.ok(authorize.searchParams.get('state'), 'state');
++ assert.ok(!/access_token|refresh_token|id_token|[?&]token=/.test(r.headers.location), 'no token in the sign-in URL');
++ locations.push(r.headers.location);
++
++ const state = cookieValue(r.setCookies, 'ov_oauth_state');
++ const verifier = cookieValue(r.setCookies, 'ov_oauth_verifier');
++ const next = cookieValue(r.setCookies, 'ov_oauth_next');
++ assert.ok(state && verifier && next, 'state, verifier and next cookies are set');
++ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), authorize.searchParams.get('code_challenge'), 'the challenge is the verifier\'s SHA-256');
++ for (const name of ['ov_oauth_state', 'ov_oauth_verifier']) {
++ const c = r.setCookies.find((x) => String(x).startsWith(`${name}=`));
++ assert.ok(c, `${name} cookie`);
++ assert.match(c, /Path=\/auth/i, `${name} is scoped to /auth`);
++ assert.match(c, /HttpOnly/i, `${name} is HttpOnly`);
++ assert.match(c, /SameSite=Lax/i, `${name} is SameSite=Lax`);
++ assert.ok(!/Domain=/i.test(c), `${name} is host-only`);
++ }
++
++ // ── The callback: the code is exchanged server-side and the session is host-only ──
++ r = await get(`/auth/callback?code=good-code&state=${encodeURIComponent(state)}`, MY, [`ov_oauth_state=${state}`, `ov_oauth_verifier=${verifier}`, `ov_oauth_next=${encodeURIComponent(next)}`]);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, '/security', 'back where the person was going');
++ assert.ok(!/access_token|refresh_token|[?&]token=/.test(r.headers.location), 'the token is never in the callback URL');
++ const grant = grants.find((g) => g.data.grant_type === 'authorization_code');
++ assert.strictEqual(grant.data.client_id, 'my');
++ assert.strictEqual(grant.data.client_secret, 'test-secret', 'the code is exchanged server-side');
++ assert.strictEqual(grant.data.code_verifier, verifier, 'the PKCE verifier is the one the cookie kept');
++ const accessCookie = r.setCookies.find((x) => String(x).startsWith('ov_token='));
++ const refreshCookie = r.setCookies.find((x) => String(x).startsWith('ov_refresh='));
++ assert.ok(accessCookie && refreshCookie, 'the session is set');
++ assert.ok(!/Domain=/i.test(accessCookie) && !/Domain=/i.test(refreshCookie), 'the session cookies are host-only');
++ assert.match(refreshCookie, /Path=\/auth/i, 'the refresh token is scoped to /auth');
++ assert.match(refreshCookie, /HttpOnly/i, 'the refresh token is HttpOnly');
++ assert.match(accessCookie, /SameSite=Lax/i, 'the access cookie is SameSite=Lax');
++
++ // ── The silent path: prompt=none, and a Network guest comes back quietly ──
++ r = await get('/auth/login?silent=1&next=%2F', MY);
++ assert.strictEqual(r.status, 302);
++ const silentAuthorize = new URL(r.headers.location);
++ assert.strictEqual(silentAuthorize.searchParams.get('prompt'), 'none');
++ const silentState = cookieValue(r.setCookies, 'ov_oauth_state');
++ r = await get(`/auth/callback?error=login_required&state=${encodeURIComponent(silentState)}`, MY, [`ov_oauth_state=${silentState}`]);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, '/?sso=none', 'a guest stays quiet, marked sso=none');
++ assert.ok(!r.setCookies.some((c) => String(c).startsWith('ov_token=')), 'a failed silent sign-in sets no session');
++ assert.ok(!/access_token|refresh_token|[?&]token=/.test(r.headers.location), 'no token in the silent URL');
++ locations.push(r.headers.location);
++
++ // ── Sign-out: this origin now, with the way everywhere left open ──
++ r = await get('/auth/logout?next=%2F', MY, ['ov_token=old', 'ov_refresh=old-refresh']);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, '/');
++ assert.ok(grants.some((g) => g.path === '/oauth/revoke' && g.data.token === 'old-refresh'), 'the refresh token is revoked');
++ assert.ok(r.setCookies.some((c) => String(c).startsWith('ov_token=;')), 'the access cookie is cleared');
++ assert.ok(r.setCookies.some((c) => String(c).startsWith('ov_refresh=;')), 'the refresh cookie is cleared');
++ assert.ok(r.setCookies.some((c) => String(c).startsWith('ov_sso_hint=guest')), 'the browser is remembered as a guest');
++ assert.ok(!/access_token|refresh_token|[?&]token=/.test(r.headers.location), 'no token in the sign-out URL');
++
++ // The hub's "sign out everywhere" goes through Network's fanout after clearing this origin.
++ r = await get('/sso/fanout?action=logout&next=%2Flinked', MY);
++ assert.strictEqual(r.status, 302);
++ const logoutFanout = new URL(r.headers.location, 'https://openvibe.my');
++ assert.strictEqual(logoutFanout.pathname, '/auth/logout');
++ const fanout = new URL(logoutFanout.searchParams.get('next'));
++ assert.strictEqual(fanout.origin, 'https://openvibe.network');
++ assert.strictEqual(fanout.pathname, '/sso/fanout');
++ assert.strictEqual(fanout.searchParams.get('action'), 'logout');
++ assert.strictEqual(fanout.searchParams.get('next'), 'https://openvibe.my/linked?sso=none', 'it comes back to the hub quietly, not into a silent sign-in');
++
++ // The old /login links are the SDK's sign-in, with prompt for reauth / another account.
++ r = await get('/login?reauth=1&return=%2Fmy%23delete', MY);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, '/auth/login?next=%2F%23delete&prompt=login');
++ r = await get('/login?add_account=1&return=%2Faccounts', MY);
++ assert.strictEqual(r.headers.location, '/auth/login?next=%2Faccounts&prompt=select_account');
++
++ // ── No token in a URL the hub produces ──
++ for (const loc of locations) assert.ok(!/access_token|refresh_token|id_token|[?&]token=/.test(String(loc)), `no token in ${loc}`);
++ assert.strictEqual(canonicalNext('/my#delete'), '/#delete');
++ assert.strictEqual(canonicalNext('https://openvibe.network/my/security?x=1'), '/security?x=1');
++ assert.strictEqual(canonicalNext('https://evil.example/my'), '/', 'a foreign next goes home');
++
++ // The page asks Network with the cookie anyway: it must not carry a token in a query.
++ const html = fs.readFileSync(path.join(ROOT, 'public', 'my.html'), 'utf8');
++ assert.ok(!/[?&](access_token|refresh_token|id_token|token)=/.test(html), 'the page never puts a token in a URL');
++ const boot = fs.readFileSync(path.join(ROOT, 'public', 'js', 'my', 'boot.js'), 'utf8');
++ assert.ok(boot.includes("window.location.replace('/auth/logout?next=%2F')"), 'the account switcher signs out of openvibe.my itself');
++ const profile = fs.readFileSync(path.join(ROOT, 'public', 'js', 'my', 'profile.js'), 'utf8');
++ assert.ok(profile.includes("sessionUrl: '/auth/me'") && profile.includes("logoutUrl: '/auth/logout?next={path}'"), 'the shared navbar uses the hub\'s session');
++ assert.ok(profile.includes('function showSignedOutHub') && profile.includes("'Sign out everywhere'"), 'a signed-out hub stays put and offers everywhere');
++ const security = fs.readFileSync(path.join(ROOT, 'public', 'js', 'my', 'security.js'), 'utf8');
++ assert.ok(security.includes("'https://openvibe.network/sso/fanout?action=logout&next='"), 'signing out everywhere still offers Network\'s fanout');
++ } finally {
++ server.close();
++ fake.close();
++ }
++
++ console.log('my-home: host routing, 301s, PKCE sign-in, host-only session, silent path, sign-out and Needs you ok');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/my-today.test.js b/test/my-today.test.js
iindex f3ffb1f5..d211e8b6 100644
---- a/test/my-today.test.js
++++ b/test/my-today.test.js
@@@ -39,7 +39,7 @@ class Node {
function page() {
const ids = {};
-- for (const [, id] of HTML.matchAll(/id="((?:td-|me-|page-header|user-greeting|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
++ for (const [, id] of HTML.matchAll(/id="((?:td-|me-|page-header|user-greeting|needs-you|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
const shown = [];
const sandbox = {
document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },
@@@ -144,6 +144,24 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
++ // ── Needs you (plan §4): the Action Center's waiting items, one button each, above the hero ──
++ p = page();
++ assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="page-header"'), 'Needs you is the first section');
++ p.sandbox.renderNeedsYou({ buckets: { waiting: [
++ { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Send one email to the venue', summary: 'Trip planner asks to use mail.send', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },
++ { service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open'] },
++ ] } });
++ assert.strictEqual(p.ids['needs-you'].hidden, false);
++ assert.strictEqual(p.ids['needs-you-sub'].textContent, '2 things waiting');
++ const rows = p.ids['needs-you-list'].children;
++ assert.strictEqual(rows.length, 2);
++ assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON')[0].textContent, 'Approve', 'an approval has its one button');
++ assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Send one email to the venue');
++ assert.strictEqual(rows[0].all((c) => c.tagName === 'P')[0].textContent, 'Trip planner asks to use mail.send');
++ assert.strictEqual(rows[1].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.actor/tasks/tsk_1', 'an open item links its page');
++ p.sandbox.renderNeedsYou({ buckets: { waiting: [] } });
++ assert.strictEqual(p.ids['needs-you'].hidden, true, 'an empty queue leaves the section out');
++
// ── Nothing to show: each block stays out; a new account gets "Start here" ──
p = page();
p.sandbox.renderNow({ live: [], following: 0, anyone_live: 4 }, { count: 0 });
ddiff --git a/test/my-vhost.test.js b/test/my-vhost.test.js
nnew file mode 100644
iindex 00000000..c8200d97
---- /dev/null
++++ b/test/my-vhost.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++// openvibe.my's vhost (deploy/nginx/parked-domains.conf): the apex proxies the hub and its /auth sign-in to
++// Network on 127.0.0.1:4000, www 301s to the apex, and the old parked redirect to openvibe.network/my is gone.
++// The file must parse: nginx -t runs in a scratch prefix with a self-signed certificate (the real certificate
++// lives on the host). When this machine has no runnable nginx (the docker wrapper), the parse is skipped and
++// the structural checks still run.
++// node test/my-vhost.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync, spawnSync } = require('child_process');
++
++const ROOT = path.join(__dirname, '..');
++const nginx = fs.readFileSync(path.join(ROOT, 'deploy', 'nginx', 'parked-domains.conf'), 'utf8');
++
++// ── The structure ──
++assert.ok(/server_name openvibe\.my www\.openvibe\.my;/.test(nginx), 'the HTTP block claims both names');
++assert.ok(/server_name openvibe\.my;/.test(nginx), 'the HTTPS block claims openvibe.my');
++assert.ok(/server_name www\.openvibe\.my;/.test(nginx), 'a separate block 301s www');
++assert.ok(/location \/ \{ return 301 https:\/\/openvibe\.my\$request_uri; \}/.test(nginx), 'HTTP goes to HTTPS, preserving the path and query');
++assert.ok(/return 301 https:\/\/openvibe\.my\$request_uri;/.test(nginx), 'www goes to the apex');
++assert.ok(/proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(nginx), 'the hub proxies to Network');
++assert.ok(nginx.includes('/etc/letsencrypt/live/openvibe.my/fullchain.pem'), 'the name\'s own certificate');
++assert.ok(nginx.includes('proxy_set_header Host $host;'), 'the Host header is the hub\'s, so the app routes it');
++assert.ok(nginx.includes('location ~* ^/(metrics|internal)(/|$)'), 'metrics and internal stay closed');
++assert.ok(!nginx.includes('return 302 https://openvibe.network/my;'), 'the old parked redirect is gone');
++
++// ── It parses ──
++let tmp = null;
++try {
++ tmp = fs.mkdtempSync(path.join(ROOT, '.tmp-nginx-'));
++ const key = path.join(tmp, 'key.pem');
++ const cert = path.join(tmp, 'cert.pem');
++ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=openvibe.my'], { stdio: 'ignore' });
++ let conf = nginx
++ .replace(/^\s*include snippets\/security-txt\.conf;\s*$/gm, '')
++ .replace(/^\s*ssl_certificate\s+[^;]+;/gm, ` ssl_certificate ${cert};`)
++ .replace(/^\s*ssl_certificate_key\s+[^;]+;/gm, ` ssl_certificate_key ${key};`)
++ .replace(/^\s*access_log\s+\/var\/log\/[^;]+;/gm, ' access_log off;')
++ .replace(/^\s*error_log\s+\/var\/log\/[^;]+;/gm, ' error_log stderr;')
++ // nginx -t binds the listen sockets too: a runner is not root, so the scratch copy listens on high loopback ports.
++ .replace(/^\s*listen \[::\]:\d+[^;]*;\s*$/gm, '')
++ .replace(/^(\s*)listen 80;/gm, '$1listen 127.0.0.1:18080;')
++ .replace(/^(\s*)listen 443 ssl;/gm, '$1listen 127.0.0.1:18443 ssl;');
++ const main = path.join(tmp, 'nginx.conf');
++ // A server block with no access_log of its own inherits nginx's compiled-in /var/log/nginx/access.log, which a CI
++ // runner cannot open: the scratch http block turns it off, and -e keeps the startup error log in the prefix too.
++ fs.writeFileSync(main, `pid ${path.join(tmp, 'nginx.pid')};\nerror_log ${path.join(tmp, 'error.log')};\nevents {}\nhttp {\naccess_log off;\n${conf}\n}\n`);
++ const result = spawnSync('nginx', ['-t', '-p', tmp, '-e', path.join(tmp, 'error.log'), '-c', main], { cwd: ROOT, encoding: 'utf8', timeout: 60000 });
++ const out = `${result.stdout || ''}${result.stderr || ''}`;
++ if (result.status === 0 && /test is successful/.test(out)) {
++ assert.ok(out.includes('syntax is ok'), 'nginx parses the vhost');
++ } else if (result.error && result.error.code === 'ENOENT') {
++ console.log('my-vhost: skipped the nginx -t parse (no nginx on this machine); structural checks passed');
++ } else if (/docker|Cannot connect to the Docker daemon|command not found/i.test(out)) {
++ console.log('my-vhost: skipped the nginx -t parse (no runnable nginx/docker here); structural checks passed');
++ } else {
++ assert.fail(`nginx -t failed:\n${out}`);
++ }
++} finally {
++ if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
++}
++
++console.log('my-vhost: openvibe.my proxy, www 301 and the vhost parse ok');
ddiff --git a/test/signin-bounce-replace.test.js b/test/signin-bounce-replace.test.js
iindex c6a1983e..a7a9d21f 100644
---- a/test/signin-bounce-replace.test.js
++++ b/test/signin-bounce-replace.test.js
@@@ -1,12 +1,13 @@
'use strict';
--// A signed-out visitor on the account hub is sent to sign-in with location.replace: Back does not land on a page that
--// bounces again, and a replace navigation skips the cross-document view transition the sign-in page cannot finish.
++// A signed-out visitor is never bounced into a history entry: openvibe.my stays on the hub with a sign-in card (and
++// the way to sign out everywhere), while Network's own signed-out path uses location.replace, so Back does not land
++// on a page that bounces again and the cross-document view transition the sign-in page cannot finish is skipped.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'profile.js'), 'utf8');
--assert.match(src, /if \(!authToken && !anonToken\) \{ window\.location\.replace\('\/login'\); return; \}/);
++assert.match(src, /if \(!authToken && !anonToken\) \{\s*if \(MY_HOST\) \{ showSignedOutHub\(\); return; \}\s*window\.location\.replace\('\/login'\);\s*return;\s*\}/);
assert.match(src, /window\.location\.replace\('\/login\?return=' \+ encodeURIComponent\(window\.location\.pathname\)\);/);
assert.ok(!/window\.location\.href = '\/login'/.test(src), 'no signed-out bounce adds a history entry');
console.log('sign-in bounce: all checks passed');