openvibers/openvibe.network Commits
openvibe.my: the account hub's own home and session (#646)
Changes
@@@ -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@@@ -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@@@ -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). */@@@ -4,9 +4,15 @@my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and onmy/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) {@@@ -4,7 +4,12 @@my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and onmy/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; }@@@ -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 dataasync 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';@@@ -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; }@@@ -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);@@@ -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);@@@ -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 */ }}@@@ -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 inyour 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 -->@@@ -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',@@@ -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'] },@@@ -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._registryconfig._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();});nnew file mode 100644@@@ -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,++};@@@ -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>nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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 });nnew file mode 100644@@@ -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');@@@ -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');
@@ -101,6 +101,13 @@ OV_TRADE_INTERNAL_URL=http://127.0.0.1:4860
@@ -101,6 +101,13 @@ OV_TRADE_INTERNAL_URL=http://127.0.0.1:4860
#SSH_SERVER_HOST=
#SSH_SERVER_HOST=
#SSH_PROJECTS_ROOT=/opt/openvibe.network /opt/openvibe.live
#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
# 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
# panel's database copy is only a fallback, and the panel cannot change one set here. In
@@ -22,8 +22,9 @@
@@ -22,8 +22,9 @@
# 2026-10-28: the thirteen domains bought that day (open.shoes and twelve openvibe.*) are parked here, each sent to
# 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).
# 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
# 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
# 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
# after removing Sites' vhost for each name. Each name uses its own certificate (a vhost must use its own
@@ -66,23 +67,63 @@ server {
@@ -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.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 {
server {
listen 80;
listen 80;
listen [::]:80;
listen [::]:80;
server_name openvibe.my www.openvibe.my;
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 {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
http2 on;
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 /etc/letsencrypt/live/openvibe.my/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.my/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.my/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
access_log off;
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
# openvibe.link: OpenVibe Link's home since 2026-11-07, served by OpenVibe.Network on 127.0.0.1:4000, which
@@ -242,6 +242,28 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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 .greeting:empty { display: none; }
.page-header .info .badges:empty { display: none; }
.page-header .info .badges:empty { display: none; }
.td-settings-btn { flex: none; align-self: center; text-decoration: 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 { display: grid; gap: 22px; margin: -8px 0 36px; }
.td-sec { display: grid; gap: 10px; min-width: 0; }
.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). */
/* A block with nothing in it stays out (11-07: the empty "Live now" heading showed, and read as if you were live). */
@@ -4,9 +4,15 @@
@@ -4,9 +4,15 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
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());
const _signedIn = !!(getAuthToken() || getAnonToken());
loadUser();
loadUser();
if (_signedIn) {
if (_signedIn) {
@@ -4,7 +4,12 @@
@@ -4,7 +4,12 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
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 currentUser = null;
let availableThemes = [];
let availableThemes = [];
let activeThemeId = 'vibe';
let activeThemeId = 'vibe';
@@ -72,7 +77,7 @@ async function _bootstrapFromCookie() {
@@ -72,7 +77,7 @@ async function _bootstrapFromCookie() {
try {
try {
const res = await fetchWithTimeout(API + '/api/auth/me', {
const res = await fetchWithTimeout(API + '/api/auth/me', {
headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
- credentials: 'include',
+ credentials: API_CREDENTIALS,
});
});
if (res.ok) {
if (res.ok) {
const data = await res.json();
const data = await res.json();
@@ -86,7 +91,7 @@ async function _bootstrapFromCookie() {
@@ -86,7 +91,7 @@ async function _bootstrapFromCookie() {
const refreshRes = await fetchWithTimeout(API + '/api/auth/refresh', {
const refreshRes = await fetchWithTimeout(API + '/api/auth/refresh', {
method: 'POST',
method: 'POST',
headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
- credentials: 'include',
+ credentials: API_CREDENTIALS,
});
});
if (refreshRes.ok) {
if (refreshRes.ok) {
const refreshData = await refreshRes.json();
const refreshData = await refreshRes.json();
@@ -130,7 +135,7 @@ async function apiFetch(path, opts = {}) {
@@ -130,7 +135,7 @@ async function apiFetch(path, opts = {}) {
const headers = { 'Content-Type':'application/json', ...opts.headers };
const headers = { 'Content-Type':'application/json', ...opts.headers };
const authToken = getAuthToken();
const authToken = getAuthToken();
if (authToken) headers.Authorization = 'Bearer ' + authToken;
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.
// 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 { /* */ }
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 = {};
let data = {};
@@ -141,7 +146,7 @@ async function apiFetch(path, opts = {}) {
@@ -141,7 +146,7 @@ async function apiFetch(path, opts = {}) {
async function anonFetch(path, opts = {}) {
async function anonFetch(path, opts = {}) {
const headers = { 'Content-Type':'application/json', ...opts.headers };
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 = {};
let data = {};
try { data = await res.json(); } catch { /* non-JSON */ }
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; }
if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
@@ -7,6 +7,13 @@
@@ -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
// 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.
// 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' }] };
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
// /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.
// section and puts the cursor in the delete confirmation field. Read before core.js rewrites the hash.
@@ -29,6 +36,43 @@ function reauthForDeletion() {
@@ -29,6 +36,43 @@ function reauthForDeletion() {
// The banner's Keep my account has already cancelled it; refresh this page's card too.
// 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(); });
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
// Load user data
async function loadUser() {
async function loadUser() {
// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
@@ -37,7 +81,11 @@ async function loadUser() {
@@ -37,7 +81,11 @@ async function loadUser() {
const anonToken = getAnonToken();
const anonToken = getAnonToken();
// A replace, not a new history entry: Back should not land on a page that bounces again, and a replace navigation
// 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).
// 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 {
try {
if (isAnonSession()) {
if (isAnonSession()) {
const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken));
const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken));
@@ -56,7 +104,7 @@ async function loadUser() {
@@ -56,7 +104,7 @@ async function loadUser() {
applyAnonModeUI(currentUser);
applyAnonModeUI(currentUser);
loadAccounts();
loadAccounts();
loadAnonInfo();
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 {
} else {
const data = await apiFetch('/api/auth/me');
const data = await apiFetch('/api/auth/me');
currentUser = data.user;
currentUser = data.user;
@@ -81,7 +129,7 @@ async function loadUser() {
@@ -81,7 +129,7 @@ async function loadUser() {
loadRecentTools();
loadRecentTools();
// Init navbar
// 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();
const bellMount = OpenVibeNavbar.getBellMount();
if (bellMount && window.OpenVibeNotifications && authToken) {
if (bellMount && window.OpenVibeNotifications && authToken) {
OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
@@ -100,10 +148,11 @@ async function loadUser() {
@@ -100,10 +148,11 @@ async function loadUser() {
if (!window._ovRefreshTried) {
if (!window._ovRefreshTried) {
window._ovRefreshTried = true;
window._ovRefreshTried = true;
try {
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; } }
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 */ }
} catch { /* fall through */ }
}
}
+ if (MY_HOST) { showSignedOutHub(); return; }
window.location.replace('/login?return=' + encodeURIComponent(window.location.pathname));
window.location.replace('/login?return=' + encodeURIComponent(window.location.pathname));
return;
return;
}
}
@@ -364,7 +413,7 @@ async function accountApi(path, opts = {}) {
@@ -364,7 +413,7 @@ async function accountApi(path, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
const t = getAuthToken();
const t = getAuthToken();
if (t) headers.Authorization = 'Bearer ' + t;
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 = {};
let body = {};
try { body = await res.json(); } catch { /* */ }
try { body = await res.json(); } catch { /* */ }
return { status: res.status, ok: res.ok, body };
return { status: res.status, ok: res.ok, body };
@@ -421,7 +470,7 @@ async function downloadExport(id, btn) {
@@ -421,7 +470,7 @@ async function downloadExport(id, btn) {
const headers = {};
const headers = {};
const t = getAuthToken();
const t = getAuthToken();
if (t) headers.Authorization = 'Bearer ' + t;
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; }
if (!res.ok) { toast('That copy is no longer available.', 'error'); renderExport(); return; }
const blob = await res.blob();
const blob = await res.blob();
const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';
const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';
@@ -377,6 +377,11 @@ async function signOutEverywhere() {
@@ -377,6 +377,11 @@ async function signOutEverywhere() {
try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
catch (err) { alert('Error: ' + err.message); return; }
catch (err) { alert('Error: ' + err.message); return; }
try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }
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';
location.href = '/sso/fanout?action=logout&next=%2Flogin';
}
}
@@ -490,7 +495,7 @@ function qrPost(path, body) {
@@ -490,7 +495,7 @@ function qrPost(path, body) {
const headers = { 'Content-Type': 'application/json' };
const headers = { 'Content-Type': 'application/json' };
const token = getAuthToken();
const token = getAuthToken();
if (token) headers.Authorization = 'Bearer ' + token;
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) => {
.then(async (res) => {
let data = {};
let data = {};
try { data = await res.json(); } catch { /* empty answer */ }
try { data = await res.json(); } catch { /* empty answer */ }
@@ -645,7 +650,7 @@ async function qrPoll() {
@@ -645,7 +650,7 @@ async function qrPoll() {
try {
try {
const token = getAuthToken();
const token = getAuthToken();
const res = await fetch(`${API}/auth/qr/${encodeURIComponent(qrTransfer.id)}`, {
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 === 404) { qrExpire(); return; }
if (res.status === 403) { qrSigninClose(); return; }
if (res.status === 403) { qrSigninClose(); return; }
@@ -217,7 +217,7 @@ async function saveCustomColors(btn) {
@@ -217,7 +217,7 @@ async function saveCustomColors(btn) {
async function exportActiveTheme(btn) {
async function exportActiveTheme(btn) {
if (btn) btn.disabled = true;
if (btn) btn.disabled = true;
try {
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');
if (!res.ok) throw new Error('This theme cannot be exported');
const file = await res.json();
const file = await res.json();
if (activeCustomVars && Object.keys(activeCustomVars).length) file.variables = Object.assign({}, file.variables, activeCustomVars);
if (activeCustomVars && Object.keys(activeCustomVars).length) file.variables = Object.assign({}, file.variables, activeCustomVars);
@@ -249,6 +249,107 @@ function renderStuff(live, hist, nodes) {
@@ -249,6 +249,107 @@ function renderStuff(live, hist, nodes) {
if (box) box.hidden = cells.length === 0;
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() {
async function loadToday() {
if (!document.getElementById('today')) return;
if (!document.getElementById('today')) return;
renderGreeting();
renderGreeting();
@@ -256,7 +357,8 @@ async function loadToday() {
@@ -256,7 +357,8 @@ async function loadToday() {
const quiet = (p) => p.catch(() => null);
const quiet = (p) => p.catch(() => null);
renderActions(null);
renderActions(null);
renderStatus();
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/v1/me/live-follows')),
quiet(apiFetch('/api/history?limit=30')),
quiet(apiFetch('/api/history?limit=30')),
quiet(apiFetch('/api/coins/me?with=vibes')),
quiet(apiFetch('/api/coins/me?with=vibes')),
@@ -267,6 +369,7 @@ async function loadToday() {
@@ -267,6 +369,7 @@ async function loadToday() {
quiet(apiFetch('/api/frame?host=openvibe.network')),
quiet(apiFetch('/api/frame?host=openvibe.network')),
]);
]);
globalThis.__tdUnread = unread;
globalThis.__tdUnread = unread;
+ renderNeedsYou(acts);
renderActions(unread);
renderActions(unread);
renderNet(frame);
renderNet(frame);
renderNow(live, unread);
renderNow(live, unread);
@@ -96,7 +96,7 @@
@@ -96,7 +96,7 @@
root.OpenVibeNavbar.init({
root.OpenVibeNavbar.init({
service: 'network', notificationsRealtime: true, token: token(), user,
service: 'network', notificationsRealtime: true, token: token(), user,
menu: WALLET_MENU,
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 */ }
} catch { /* the page works without it */ }
}
}
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
<meta name="keywords" content="account dashboard, openvibe network, user profile, settings">
<meta name="keywords" content="account dashboard, openvibe network, user profile, settings">
<meta name="robots" content="noindex, follow">
<meta name="robots" content="noindex, follow">
<meta property="og:type" content="website">
<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:title" content="My Account - OpenVibe">
<meta property="og:description" content="Personal dashboard for managing your OpenVibe account and preferences.">
<meta property="og:description" content="Personal dashboard for managing your OpenVibe account and preferences.">
<meta property="og:site_name" content="OpenVibe">
<meta property="og:site_name" content="OpenVibe">
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
<meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">
<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:card" content="summary_large_image">
<meta name="twitter:image" content="https://openvibe.network/shared/social/network.jpg">
<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="apple-mobile-web-app-capable" content="yes">
<meta name="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
<!-- The look before first paint (ov-theme-boot): MUST stay ABOVE the stylesheets. server/look-boot.js puts
@@ -52,6 +52,17 @@
@@ -52,6 +52,17 @@
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<div class="page">
<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
<!-- 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. -->
your own colour, your balances, and one tap to what you do most, on every site. -->
<header class="me" id="page-header">
<header class="me" id="page-header">
@@ -420,7 +431,7 @@
@@ -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>
<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">
<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 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>
<div id="linked-list">
<div id="linked-list">
<!-- Populated by JS -->
<!-- Populated by JS -->
@@ -10,6 +10,15 @@ module.exports = {
@@ -10,6 +10,15 @@ module.exports = {
networkUrl: process.env.OV_NETWORK_URL || process.env.BASE_URL || 'https://openvibe.network',
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',
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',
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 || '',
setupToken: process.env.SETUP_TOKEN || '',
bootstrapProfile: process.env.BOOTSTRAP_PROFILE || 'local-dev',
bootstrapProfile: process.env.BOOTSTRAP_PROFILE || 'local-dev',
@@ -91,6 +91,9 @@ async function seedDb(db, { log = console } = {}) {
@@ -91,6 +91,9 @@ async function seedDb(db, { log = console } = {}) {
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const contractClients = [
const contractClients = [
{ client_id: 'live', name: 'OpenVibe.Live', redirect_uris: ['https://openvibe.live/api/auth/callback'] },
{ 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: '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: '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'] },
{ client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },
@@ -230,6 +230,9 @@ function buildAllowedOriginsSet() {
@@ -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.
// name node.openvibe.network stays here too: it 301s to openvibe.link, but a cached page may still ask.
'https://openvibe.link',
'https://openvibe.link',
'https://node.openvibe.network',
'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.live', 'https://www.openvibe.live',
'https://openvibe.tools',
'https://openvibe.tools',
'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
@@ -331,6 +334,12 @@ app.locals.urlRegistry = resolvedRegistry;
@@ -331,6 +334,12 @@ app.locals.urlRegistry = resolvedRegistry;
// Make registry accessible to signToken via config._registry
// Make registry accessible to signToken via config._registry
config._registry = resolvedRegistry;
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) ──────────────────────────────
// ── Analytics Tracking (ADR-021) ──────────────────────────────
// openvibe-shared/analytics on PostgreSQL (server/analytics/network.js; migrations/0002_analytics.sql).
// 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
// 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({
@@ -951,12 +960,13 @@ app.use('/api/admin', require('./admin/service-proxies').createServiceProxies({
app.use('/internal', require('./internal/routes'));
app.use('/internal', require('./internal/routes'));
// ── Host Canonicalization ────────────────────────────────────
// ── 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) => {
app.use((req, res, next) => {
if (getRequestHost(req) === 'my.openvibe.network') {
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('?')) : '';
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();
next();
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -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,
+};
@@ -56,7 +56,7 @@ ${nav}
@@ -56,7 +56,7 @@ ${nav}
<p>There is no page at this address on openvibe.network. The link may be old or mistyped.</p>
<p>There is no page at this address on openvibe.network. The link may be old or mistyped.</p>
<ul>
<ul>
<li><a href="/">OpenVibe.Network home</a></li>
<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="/login">Sign in</a></li>
<li><a href="/status">Service status</a></li>
<li><a href="/status">Service status</a></li>
</ul>
</ul>
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -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); });
@@ -39,7 +39,7 @@ class Node {
@@ -39,7 +39,7 @@ class Node {
function page() {
function page() {
const ids = {};
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 shown = [];
const sandbox = {
const sandbox = {
document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },
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;
@@ -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();
p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
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" ──
// ── Nothing to show: each block stays out; a new account gets "Start here" ──
p = page();
p = page();
p.sandbox.renderNow({ live: [], following: 0, anyone_live: 4 }, { count: 0 });
p.sandbox.renderNow({ live: [], following: 0, anyone_live: 4 }, { count: 0 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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');
@@ -1,12 +1,13 @@
@@ -1,12 +1,13 @@
'use strict';
'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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'profile.js'), 'utf8');
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.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');
assert.ok(!/window\.location\.href = '\/login'/.test(src), 'no signed-out bounce adds a history entry');
console.log('sign-in bounce: all checks passed');
console.log('sign-in bounce: all checks passed');