openvibers/openvibe.ai Commits

Your AI: a page for people's own keys and how their AI is paid (#225)

OP OpenVibers committed 13 days ago · 991a37679f · 4 files, +357 −0

* Your AI: a page for people's own keys and how their AI is paid (L36 Y1/Y3/Y8)

GET /you on ai.openvibe.services, on AI's own person session. Saved keys as
rows (working, not working with the provider's reason, the last four
characters, models, when it was checked; Test, Remove, Replace), every other
provider as a short Add list with where to get a key, and how the AI is paid:
your keys first or only your keys, free tiers first, a price ceiling. What it
cost links to openvibe.report/you. Plain forms; each POST needs this site's own
origin signal (none is refused); the key is sealed by the same putSelf the API
uses and never comes back.

* nginx: expose /api/v1/me/ (person-guarded in the app) and the Your AI page with its form posts

Changes

ddiff --git a/deploy/nginx/ai.openvibe.services.conf b/deploy/nginx/ai.openvibe.services.conf
iindex f1d934f..9b7dff4 100644
---- a/deploy/nginx/ai.openvibe.services.conf
++++ b/deploy/nginx/ai.openvibe.services.conf
@@@ -132,6 +132,33 @@ server {
proxy_read_timeout 60s;
}
++ # ── Your AI (plan L36, 2026-10-31): a person's own keys and how their AI is paid. Every route is person-guarded in
++ # the app (server/me.js): the person's own Network token, or AI's own person session with a same-origin signal;
++ # a write needs a token minted for openvibe.ai or that session. A service token is refused. Small bodies.
++ location /api/v1/me/ {
++ proxy_pass http://127.0.0.1:4700;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr; # client address from $remote_addr only (realip),
++ proxy_set_header X-Forwarded-For $remote_addr; # never appended to or passed through
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ client_max_body_size 16k;
++ proxy_read_timeout 30s; # a key test waits up to 10 s for the provider
++ }
++ # The Your AI page and its forms (server/you-page.js): GET the page, POST its forms (AI's person session and a
++ # same-origin signal, checked in the app).
++ location = /you {
++ limit_except GET HEAD { deny all; }
++ proxy_pass http://127.0.0.1:4700; 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;
++ }
++ location /you/ {
++ limit_except POST { deny all; }
++ client_max_body_size 16k;
++ proxy_read_timeout 30s;
++ proxy_pass http://127.0.0.1:4700; 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;
++ }
++
# ── Service API: host-local only (see header) ───────────
location /api/ {
return 403;
ddiff --git a/server/app.js b/server/app.js
iindex 5675953..11ffc54 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -121,6 +121,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
if (credentials && preferences) {
app.use(require('./me').meRouter({ credentials, preferences, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
++ // The same settings as a page for people (GET /you, plain forms on AI's own person session).
++ app.use(require('./you-page').youPageRouter({ credentials, preferences, personSessions, config, log }));
}
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
ddiff --git a/server/you-page.js b/server/you-page.js
nnew file mode 100644
iindex 0000000..ca15b90
---- /dev/null
++++ b/server/you-page.js
@@@ -0,0 +1,227 @@
++'use strict';
++/**
++ * Your AI (plan L36 Y1, Y3, Y8): GET /you on ai.openvibe.services. A person's own provider keys and how their AI is
++ * paid, in one place for every OpenVibe site and agent. Save a key once, test it, replace it or remove it; choose
++ * "only my keys" (never anything paid) or "my keys first, then OpenVibe's allowance", free tiers first, and a price
++ * ceiling. What it all cost lives on openvibe.report/you (L40, L41).
++ *
++ * Server-rendered forms on AI's own person session (the run receipt's sign-in, /auth/person/login), each POST behind
++ * a strict same-origin check: Sec-Fetch-Site same-origin, or an Origin header naming this site; neither is refused.
++ * The page calls the same functions the person's API does (server/credentials.js, server/person-preferences.js), so
++ * a key is sealed the same way and only its last four characters ever come back.
++ *
++ * GET /you the page (signed out: what it is and a sign-in)
++ * POST /you/keys/:provider save or replace a key (key, base_url for "custom")
++ * POST /you/keys/:provider/test test it now
++ * POST /you/keys/:provider/remove forget it
++ * POST /you/preferences only_my_keys, free_first, price_ceiling_usd_per_mtok
++ */
++const express = require('express');
++const shell = require('openvibe-shared/shell');
++const ovServe = require('openvibe-shared/serve');
++const appIcon = require('openvibe-shared/app-icon');
++const { CREDENTIAL_PROVIDERS, CREDENTIAL_PRESET, byId } = require('./console/presets');
++const { HOME_CSP } = require('./home');
++
++const SITE_NAME = 'OpenVibe.AI';
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
++// The order people look for them in: the big labs, then the fast and cheap, then the routers, then their own.
++const ORDER = ['anthropic', 'openai', 'gemini', 'deepseek', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks', 'openrouter', 'minimax', 'custom'];
++const NAMES = { deepseek: 'DeepSeek', minimax: 'MiniMax', custom: 'Another OpenAI-compatible API' };
++const providerName = (id) => NAMES[id] || (byId(CREDENTIAL_PRESET[id]) || {}).name || id;
++const keysUrl = (id) => (byId(CREDENTIAL_PRESET[id]) || {}).keys || null;
++const ago = (iso) => {
++ const t = Date.parse(iso || '');
++ if (!Number.isFinite(t)) return '';
++ const m = Math.round((Date.now() - t) / 60000);
++ return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`;
++};
++
++const STYLE = `<style>
++.you{max-width:900px;margin:0 auto;padding:clamp(18px,4vw,44px) clamp(16px,3vw,28px) 90px;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:var(--text-primary,#e6edf7)}
++.you h1,.you h2{font-family:'OV Brand',Nunito,Inter,-apple-system,sans-serif;font-weight:900}
++.you-eye{margin:0 0 8px;color:var(--accent,#3b82f6);font-size:12px;font-weight:750;letter-spacing:.12em;text-transform:uppercase}
++.you h1{margin:0;font-size:clamp(30px,5vw,48px);line-height:1.06;letter-spacing:-.02em;text-wrap:balance}
++.you-lede{margin:12px 0 0;max-width:62ch;color:var(--text-secondary,#96a7c2);font-size:16.5px;line-height:1.6}
++.you-state{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;padding:0;list-style:none}
++.you-state li{padding:5px 12px;border-radius:999px;border:1px solid var(--border,#1f2d47);font-size:13.5px;color:var(--text-secondary,#96a7c2)}
++.you-state b{color:var(--text-primary,#e6edf7)}
++.you section{margin-top:34px}
++.you h2{margin:0 0 6px;font-size:20px}
++.you-sub{margin:0 0 14px;color:var(--text-secondary,#96a7c2);font-size:14.5px;line-height:1.55;max-width:66ch}
++.you-keys{list-style:none;margin:0;padding:0;border:1px solid var(--border,#1f2d47);border-radius:16px;overflow:hidden}
++.you-key{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 14px;align-items:center;padding:14px 16px;border-top:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
++.you-key:first-child{border-top:0}
++.you-key-t{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-width:0}
++.you-key-t b{font-size:15.5px}
++.you-key-n{grid-column:1/-1;color:var(--text-muted,#7d8ba3);font-size:13px;overflow-wrap:anywhere}
++.you-pill{padding:2px 9px;border-radius:999px;font-size:12px;font-weight:750;background:var(--bg-primary,#0b1220);color:var(--text-secondary,#96a7c2)}
++.you-pill.is-ok{color:#16a34a;background:color-mix(in srgb,#16a34a 15%,transparent)}
++.you-pill.is-bad{color:#ef4444;background:color-mix(in srgb,#ef4444 15%,transparent)}
++.you-acts{display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
++.you-acts form{margin:0}
++.you-btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:0 14px;border-radius:999px;border:1px solid var(--border,#1f2d47);background:transparent;color:var(--text-primary,#e6edf7);font:inherit;font-size:13.5px;font-weight:650;text-decoration:none;cursor:pointer}
++.you-btn:hover{background:var(--bg-primary,#0b1220)}
++.you-btn.is-go{border-color:transparent;background:var(--accent-strong,var(--accent,#3b82f6));color:var(--on-accent-strong,#fff)}
++.you-btn:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
++.you-add{grid-column:1/-1}
++.you-add summary{display:inline-flex;cursor:pointer;list-style:none}
++.you-add summary::-webkit-details-marker{display:none}
++.you-add form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:10px}
++.you-add label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
++.you input[type=password],.you input[type=url],.you input[type=number]{min-height:42px;box-sizing:border-box;width:100%;padding:0 12px;border-radius:10px;border:1px solid var(--border,#1f2d47);background:var(--bg-primary,#0b1220);color:var(--text-primary,#e6edf7);font:inherit;font-size:15px}
++.you-pay{display:grid;gap:10px;padding:16px;border-radius:16px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
++.you-pay fieldset{margin:0;padding:0;border:0;display:grid;gap:8px}
++.you-pay legend{margin-bottom:6px;font-weight:700}
++.you-opt{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:12px;border:1px solid var(--border,#1f2d47);cursor:pointer}
++.you-opt:has(input:checked){border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 10%,transparent)}
++.you-opt input{margin-top:3px;accent-color:var(--accent,#3b82f6)}
++.you-opt span{display:grid;gap:2px}.you-opt small{color:var(--text-secondary,#96a7c2);font-size:13px}
++.you-row{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:flex-end}
++.you-row>label:not(.you-opt){display:grid;gap:4px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
++.you-new{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
++.you-new>li{padding:12px 14px;border-radius:14px;border:1px solid var(--border,#1f2d47);background:var(--bg-secondary,#101828)}
++.you-new>li:has(details[open]){grid-column:1/-1}
++.you-new summary{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;list-style:none;font-weight:700}
++.you-new summary::-webkit-details-marker{display:none}
++.you-new summary span{color:var(--accent,#3b82f6);font-size:13px;font-weight:650}
++.you-new form{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin-top:12px}
++.you-new label{display:grid;gap:4px;flex:1 1 220px;font-size:12.5px;font-weight:700;color:var(--text-secondary,#96a7c2)}
++.you-empty{padding:16px;border-radius:14px;border:1px dashed var(--border,#1f2d47);color:var(--text-secondary,#96a7c2)}
++.you-note{margin:10px 0 0;padding:10px 14px;border-radius:12px;font-size:14px}
++.you-note.is-ok{background:color-mix(in srgb,#16a34a 14%,transparent)}.you-note.is-bad{background:color-mix(in srgb,#ef4444 14%,transparent)}
++.you-fine{color:var(--text-muted,#7d8ba3);font-size:13px;line-height:1.55;max-width:70ch}
++@media (max-width:560px){.you-key{grid-template-columns:minmax(0,1fr)}.you-acts{justify-content:flex-start}}
++</style>`;
++
++function frame({ title, body, siteUrl }) {
++ return shell.page({
++ name: SITE_NAME, service: 'ai', lang: 'en', title, siteName: SITE_NAME,
++ description: 'Your own AI keys for every OpenVibe site and agent: saved once, no fee, and you choose how your AI is paid.',
++ canonical: `${String(siteUrl).replace(/\/+$/, '')}/you`, robots: 'noindex, nofollow',
++ navbar: { service: 'ai', apiBase: 'https://openvibe.network', links: [{ label: 'Your AI', href: '/you', active: true }, { label: 'Prices', href: '/stats' }], history: { type: 'page', title } },
++ footer: { service: 'ai', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' },
++ home: '/', navLinks: [{ label: 'Your AI', href: '/you' }, { label: 'Prices', href: '/stats' }],
++ head: [appIcon.headTags({ site: 'ai' }), STYLE].join('\n'),
++ body: `<div id="navbar-mount"></div>\n<main id="main" class="you">${body}</main>`,
++ });
++}
++
++function signedOut({ siteUrl }) {
++ return frame({ siteUrl, title: 'Your AI', body: `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
++<p class="you-lede">Save a key from Anthropic, OpenAI, Google, DeepSeek or any provider once, and every OpenVibe site and agent can use it, with no fee, ever. You choose whether anything else may pay.</p>
++<p style="margin-top:20px"><a class="you-btn is-go" href="/auth/person/login?next=${encodeURIComponent('/you')}">Sign in to see yours</a></p>` });
++}
++
++function page({ siteUrl, keys = [], prefs = {}, ok = '', error = '' }) {
++ const mine = new Map(keys.map((k) => [k.provider, k]));
++ const working = keys.filter((k) => k.status === 'ok').length;
++ const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;
++ const row = (id) => {
++ const k = mine.get(id);
++ const pill = k.status === 'ok' ? '<span class="you-pill is-ok">Working</span>'
++ : k.status === 'failing' ? '<span class="you-pill is-bad">Not working</span>' : '<span class="you-pill">Not tested yet</span>';
++ const note = [`…${esc(k.hint)}`, k.base_url ? esc(k.base_url) : '', k.models_count ? `${k.models_count} models` : '', k.checked_at ? `checked ${ago(k.checked_at)}` : '', k.status === 'failing' && k.last_error ? esc(String(k.last_error).slice(0, 140)) : ''].filter(Boolean).join(' · ');
++ return `<li class="you-key"><div class="you-key-t"><b>${esc(providerName(id))}</b>${pill}</div>
++<div class="you-acts"><form method="post" action="/you/keys/${esc(id)}/test"><button class="you-btn" type="submit">Test</button></form><form method="post" action="/you/keys/${esc(id)}/remove"><button class="you-btn" type="submit">Remove</button></form></div>
++${note ? `<div class="you-key-n">${note}</div>` : ''}
++<details class="you-add"><summary class="you-btn">Replace the key</summary>${keyForm(id, k)}</details></li>`;
++ };
++ const known = ORDER.filter((id) => CREDENTIAL_PROVIDERS.includes(id));
++ const saved = known.filter((id) => mine.has(id));
++ const toAdd = known.filter((id) => !mine.has(id));
++ const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;
++ const only = prefs.only_my_keys === true;
++ const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
++<p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p>
++<ul class="you-state"><li><b>${keys.length}</b> ${keys.length === 1 ? 'key' : 'keys'}${keys.length ? `, ${working} working` : ''}</li><li>${only ? '<b>Only your keys</b>' : 'Your keys first'}</li><li>Free tiers first: <b>${prefs.free_first === false ? 'off' : 'on'}</b></li></ul>
++${ok ? `<p class="you-note is-ok" role="status">${esc(ok)}</p>` : ''}${error ? `<p class="you-note is-bad" role="alert">${esc(error)}</p>` : ''}
++<section aria-labelledby="you-keys"><h2 id="you-keys">Your keys</h2><p class="you-sub">Each is tested when you save it: one tiny request, and the models it can reach. A key that stops working is marked here and never quietly swapped for a paid one.</p>
++${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p class="you-empty">No keys yet. Add one below: Anthropic, OpenAI, Google or DeepSeek are good first ones.</p>'}
++<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>
++<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
++<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p>
++<form class="you-pay" method="post" action="/you/preferences">
++<fieldset><legend>When none of your keys can do a job</legend>
++<label class="you-opt"><input type="radio" name="only_my_keys" value="0"${only ? '' : ' checked'}><span><b>Use OpenVibe&rsquo;s allowance</b><small>Your keys always go first; a job none of them can do runs on the network&rsquo;s own, within its free allowance.</small></span></label>
++<label class="you-opt"><input type="radio" name="only_my_keys" value="1"${only ? ' checked' : ''}><span><b>Only my keys</b><small>Nothing else ever pays for your AI. A job none of your keys can do stops and says which provider would do it.</small></span></label>
++</fieldset>
++<div class="you-row"><label class="you-opt" style="flex:1 1 260px"><input type="checkbox" name="free_first" value="1"${prefs.free_first === false ? '' : ' checked'}><span><b>Free tiers first</b><small>Use a provider&rsquo;s free tier before anything that costs.</small></span></label>
++<label>Most per million tokens (USD)<input type="number" name="price_ceiling_usd_per_mtok" min="0" max="1000" step="0.01" value="${prefs.price_ceiling_usd_per_mtok == null ? '' : esc(prefs.price_ceiling_usd_per_mtok)}" placeholder="No limit"></label>
++<button class="you-btn is-go" type="submit">Save</button></div>
++</form></section>
++<section aria-labelledby="you-cost"><h2 id="you-cost">What it cost</h2><p class="you-sub">Tokens, how much came from cache, what each call cost and whose key paid, across every site: <a href="https://openvibe.report/you">on openvibe.report</a>.</p></section>
++<p class="you-fine">Removing a key deletes it from OpenVibe.AI. Keys are personal: one serves your own work and nobody else&rsquo;s. Sites and agents that run AI for you use these settings; the ones you have not opened never see your keys.</p>`;
++ return frame({ siteUrl, title: 'Your AI', body });
++}
++
++function youPageRouter({ credentials, preferences, personSessions, config, log = console }) {
++ const r = express.Router();
++ const form = express.urlencoded({ extended: false, limit: '4kb' });
++ const site = new URL(config.baseUrl).origin;
++ // A POST from this site only: Sec-Fetch-Site same-origin, or an Origin naming this site. Neither is refused.
++ const sameSite = (req) => {
++ const sfs = req.headers['sec-fetch-site'];
++ if (sfs) return sfs === 'same-origin';
++ const origin = req.headers.origin;
++ if (!origin) return false;
++ try { return new URL(origin).origin === site; } catch { return false; }
++ };
++ const headers = (res) => res.set({ 'Content-Security-Policy': HOME_CSP, 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', 'Referrer-Policy': 'same-origin' });
++ const back = (res, q) => res.redirect(303, `/you?${new URLSearchParams(q)}`);
++ async function person(req, res) {
++ if (!personSessions) { headers(res).status(503).type('text/plain').send('Sign-in is not configured on this host.\n'); return null; }
++ const viewer = await personSessions.read(req);
++ if (!viewer) { res.redirect(303, `/auth/person/login?next=${encodeURIComponent('/you')}`); return null; }
++ return viewer;
++ }
++ const guard = (fn) => [form, async (req, res) => {
++ if (!sameSite(req)) return headers(res).status(403).type('text/plain').send('Open this form on ai.openvibe.services.\n');
++ const viewer = await person(req, res);
++ if (!viewer) return;
++ try { await fn(req, res, viewer.subject); } catch (err) {
++ const word = err && err.status && err.status < 500 ? err.message : 'That did not work this time; try again in a minute.';
++ if (!err || !err.status || err.status >= 500) log.warn(`[you] ${req.path}: ${err && err.message}`);
++ back(res, { error: String(word).slice(0, 200) });
++ }
++ }];
++
++ r.get('/you', async (req, res) => {
++ headers(res);
++ const viewer = personSessions ? await personSessions.read(req) : null;
++ if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));
++ const [keys, prefs] = await Promise.all([credentials.listSelf(viewer.subject).catch(() => []), preferences.get(viewer.subject).catch(() => ({}))]);
++ res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));
++ });
++ r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {
++ const id = req.params.provider;
++ if (!CREDENTIAL_PROVIDERS.includes(id)) return back(res, { error: 'That provider is not one OpenVibe.AI knows.' });
++ const body = { provider: id, key: String((req.body || {}).key || '').trim(), ...(id === 'custom' && req.body.base_url ? { base_url: String(req.body.base_url).trim() } : {}) };
++ const out = await credentials.putSelf(subject, id, body);
++ const tested = out.status === 'untested' && credentials.testSelf ? await credentials.testSelf(subject, id).catch(() => null) : null;
++ const okNow = (tested ? tested.ok : out.status === 'ok');
++ back(res, okNow ? { ok: `Saved your ${providerName(id)} key, and it works.` } : { error: `Saved your ${providerName(id)} key, but it did not work: ${String((tested && tested.error) || out.last_error || 'the provider refused it').slice(0, 120)}` });
++ }));
++ r.post('/you/keys/:provider/test', ...guard(async (req, res, subject) => {
++ const out = await credentials.testSelf(subject, req.params.provider);
++ back(res, out.ok ? { ok: `Your ${providerName(req.params.provider)} key works${out.models_count ? `: ${out.models_count} models` : ''}.` } : { error: `Your ${providerName(req.params.provider)} key did not work: ${String(out.error || 'the provider refused it').slice(0, 120)}` });
++ }));
++ r.post('/you/keys/:provider/remove', ...guard(async (req, res, subject) => {
++ const gone = await credentials.removeSelf(subject, req.params.provider);
++ back(res, gone ? { ok: `Removed your ${providerName(req.params.provider)} key.` } : { error: 'There was no key to remove.' });
++ }));
++ r.post('/you/preferences', ...guard(async (req, res, subject) => {
++ const b = req.body || {};
++ const cur = await preferences.get(subject).catch(() => ({}));
++ const ceiling = String(b.price_ceiling_usd_per_mtok || '').trim();
++ await preferences.put(subject, {
++ only_my_keys: b.only_my_keys === '1', free_first: b.free_first === '1',
++ price_ceiling_usd_per_mtok: ceiling === '' ? null : Number(ceiling),
++ defaults: cur.defaults || { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' },
++ });
++ back(res, { ok: 'Saved how your AI is paid.' });
++ }));
++ return r;
++}
++
++module.exports = { youPageRouter, page, signedOut };
ddiff --git a/test/you-page.test.js b/test/you-page.test.js
nnew file mode 100644
iindex 0000000..93d255c
---- /dev/null
++++ b/test/you-page.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++// Your AI (plan L36 Y1/Y3/Y8): GET /you and its forms on ai.openvibe.services. Signed out it says what it is; signed in
++// (AI's own person session) it lists every provider with the person's key state, saves, tests and removes keys, and
++// sets how their AI is paid. Every POST needs this site's own origin signal; a key never comes back in the page.
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, suite } = require('./helpers');
++const { SELF_OWNER } = require('../server/credentials');
++
++const t = suite('you-page');
++const KEY = 'ef'.repeat(32);
++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY';
++const ANTHROPIC_KEY = 'sk-ant-test-you-0123456789WXYZ';
++const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++let refuse = false;
++const credentialFetch = async (url) => {
++ if (refuse) return json({ error: { message: 'invalid key' } }, 401);
++ if (String(url).endsWith('/models')) return json({ data: [{ id: 'm-one' }, { id: 'm-two' }] });
++ return json({});
++};
++let h, cookie;
++const page = (path, o = {}) => fetch(h.base + path, { redirect: 'manual', ...o, headers: { ...(o.headers || {}) } }).then(async (r) => ({ status: r.status, headers: r.headers, text: await r.text() }));
++const post = (path, fields, headers = { 'Sec-Fetch-Site': 'same-origin' }) => page(path, { method: 'POST', body: new URLSearchParams(fields).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded', Cookie: cookie, ...headers } });
++const flash = (r) => Object.fromEntries(new URLSearchParams(String(r.headers.get('location') || '').split('?')[1] || ''));
++
++t.test('boot', async () => {
++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: { log() {}, warn() {}, error() {} } });
++ const raw = crypto.randomBytes(32).toString('base64url');
++ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [crypto.createHash('sha256').update(raw).digest('hex'), DANA]);
++ cookie = `ovai_person=${raw}`;
++});
++
++t.test('signed out: what it is and a sign-in, private and not indexed', async () => {
++ const r = await page('/you');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.match(r.headers.get('x-robots-tag'), /noindex/);
++ assert.ok(r.text.includes('Your keys, on every OpenVibe site.') && r.text.includes('href="/auth/person/login?next=%2Fyou"'));
++});
++
++t.test('signed in: every provider, none added yet, and how the AI is paid', async () => {
++ const r = await page('/you', { headers: { Cookie: cookie } });
++ assert.strictEqual(r.status, 200);
++ for (const name of ['Anthropic', 'OpenAI', 'Google Gemini', 'DeepSeek', 'Mistral', 'xAI', 'Groq', 'OpenRouter', 'Another OpenAI-compatible API']) assert.ok(r.text.includes(`<summary>${name}<span>Add</span></summary>`), name);
++ assert.ok(r.text.includes('No keys yet.'), 'an empty list says so');
++ assert.ok(r.text.includes('action="/you/keys/anthropic"') && r.text.includes('href="https://console.anthropic.com/settings/keys"'), 'a form and where to get a key');
++ assert.ok(r.text.includes('name="only_my_keys" value="0" checked'), 'my keys first by default');
++ assert.ok(r.text.includes('href="https://openvibe.report/you"'), 'what it cost is on Report');
++});
++
++t.test('save a key: sealed, tested, never shown again; the page says it works', async () => {
++ const r = await post('/you/keys/anthropic', { key: ANTHROPIC_KEY });
++ assert.strictEqual(r.status, 303);
++ assert.match(flash(r).ok, /Saved your Anthropic key, and it works/);
++ const row = await h.db.prepare('SELECT key_enc, status FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');
++ assert.ok(row && row.key_enc.startsWith('v2:') && !row.key_enc.includes(ANTHROPIC_KEY));
++ const view = await page('/you', { headers: { Cookie: cookie } });
++ assert.ok(view.text.includes('Working') && view.text.includes('…WXYZ') && view.text.includes('2 models'));
++ assert.ok(!view.text.includes(ANTHROPIC_KEY), 'the key never comes back');
++ assert.ok(view.text.includes('action="/you/keys/anthropic/test"') && view.text.includes('Replace the key'));
++ assert.ok(view.text.includes('<b>Anthropic</b>') && !view.text.includes('<summary>Anthropic<span>Add</span></summary>'), 'a saved key moves out of the add list');
++});
++
++t.test('a key the provider refuses is saved and says so; test and remove work', async () => {
++ refuse = true;
++ let r = await post('/you/keys/openai', { key: 'sk-test-you-refused-012345' });
++ assert.match(flash(r).error, /Saved your OpenAI key, but it did not work/);
++ r = await post('/you/keys/openai/test', {});
++ assert.match(flash(r).error, /did not work/);
++ refuse = false;
++ r = await post('/you/keys/openai/test', {});
++ assert.match(flash(r).ok, /Your OpenAI key works/);
++ r = await post('/you/keys/openai/remove', {});
++ assert.match(flash(r).ok, /Removed your OpenAI key/);
++ r = await post('/you/keys/openai/remove', {});
++ assert.match(flash(r).error, /no key to remove/);
++});
++
++t.test('how the AI is paid: only my keys, free tiers, a ceiling', async () => {
++ const r = await post('/you/preferences', { only_my_keys: '1', price_ceiling_usd_per_mtok: '2.5' });
++ assert.match(flash(r).ok, /Saved how your AI is paid/);
++ const view = await page('/you', { headers: { Cookie: cookie } });
++ assert.ok(view.text.includes('name="only_my_keys" value="1" checked') && view.text.includes('<b>Only your keys</b>'));
++ assert.ok(view.text.includes('Free tiers first: <b>off</b>'), 'an unticked box is off');
++ assert.ok(view.text.includes('value="2.5"'));
++});
++
++t.test('another site, a missing origin signal or no session cannot change anything', async () => {
++ let r = await post('/you/keys/anthropic/remove', {}, { Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' });
++ assert.strictEqual(r.status, 403);
++ r = await post('/you/keys/anthropic/remove', {}, {});
++ assert.strictEqual(r.status, 403, 'no origin signal at all is refused');
++ r = await page('/you/preferences', { method: 'POST', body: 'only_my_keys=0', headers: { 'content-type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'same-origin' } });
++ assert.strictEqual(r.status, 303);
++ assert.match(r.headers.get('location'), /^\/auth\/person\/login/);
++ const still = await h.db.prepare('SELECT 1 AS x FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');
++ assert.ok(still, 'the key is still there');
++});
++
++t.test('close', async () => { await h.close(); });
++t.run();