openvibers/openvibe.codes Commits

Memory M5: memory services, shared stores and the portable file on the Memory page (#296)

OP OpenVibers committed 6 days ago · cf1118644f · 10 files, +612 −11

* Memory M5: memory services, shared stores and the portable file on the Memory page

* Memory M5: the share proxy is on the SSRF review list

* Merge main: memory routes beside the skills hub's; pins from main

Changes

ddiff --git a/public/js/memory-import.js b/public/js/memory-import.js
nnew file mode 100644
iindex 0000000..4b69403
---- /dev/null
++++ b/public/js/memory-import.js
@@@ -0,0 +1,25 @@
++'use strict';
++/*
++ * Memory M5: choosing a portable file fills the import textarea, so the page works with the browser's own file
++ * picker and no inline script. It only copies the file's text; the import itself is a normal form post to Codes,
++ * which forwards the document to the store.
++ */
++(function () {
++ var input = document.getElementById('memory-import-file');
++ var text = document.getElementById('memory-import-text');
++ if (!input || !text) return;
++ input.addEventListener('change', function () {
++ var file = input.files && input.files[0];
++ if (!file) return;
++ if (file.size > 25 * 1024 * 1024) {
++ text.value = '';
++ input.setCustomValidity('That file is larger than 25 MB.');
++ input.reportValidity();
++ return;
++ }
++ input.setCustomValidity('');
++ var reader = new FileReader();
++ reader.onload = function () { text.value = String(reader.result || ''); };
++ reader.readAsText(file);
++ });
++}());
ddiff --git a/server/app.js b/server/app.js
iindex 9c6ba79..e96d276 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -282,6 +282,9 @@ async function createApp(opts = {}) {
// GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
// and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
// its own paths.
++ // Memory M5: a memory store's share row lives in OpenVibe.AI, so its sheet is proxied there before the local
++ // share router sees the path. AI authenticates the same person token and decides; Codes only forwards.
++ app.use(require('./http/memory-share').createMemoryShareProxy({ config, sso: ctx.sso, fetchImpl: fetchImpl || globalThis.fetch, log }));
{
const shareApi = express.Router();
shareApi.use(sharing.shares.router({
ddiff --git a/server/domain/memory.js b/server/domain/memory.js
iindex 594cddb..4e0a9ef 100644
---- a/server/domain/memory.js
++++ b/server/domain/memory.js
@@@ -171,7 +171,64 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
return { discarded: Number(doc.discarded) || 0 };
}
-- return { items, patch, remove, removeBySource, profile, reads, split, settle, providers, preview, commit, discard };
++ /* ── Memory M5: the memory services, the portable file and team sharing ─────────────────────────────── */
++
++ /** The three memory services, each with its health and its four-character hint, never a key. */
++ async function services(token) {
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/services`);
++ return { providers: Array.isArray(doc.providers) ? doc.providers : [] };
++ }
++
++ /** Store or replace one service's key. The store seals it before it answers. */
++ async function connect(token, provider, body) {
++ const doc = await call(token, 'PUT', `${MEMORY_PATH}/services/${encodeURIComponent(provider)}`, body || {});
++ return { service: doc.service || null };
++ }
++
++ async function disconnect(token, provider) {
++ const doc = await call(token, 'DELETE', `${MEMORY_PATH}/services/${encodeURIComponent(provider)}`);
++ return { removed: Number(doc.removed) || 0 };
++ }
++
++ /** Read the service and put what it has into the store; the answer is counts only. */
++ async function syncService(token, provider) {
++ const doc = await call(token, 'POST', `${MEMORY_PATH}/services/${encodeURIComponent(provider)}/sync`, {});
++ return {
++ provider: doc.provider || provider, found: Number(doc.found) || 0, imported: Number(doc.imported) || 0,
++ deduped: Number(doc.deduped) || 0, skipped: Number(doc.skipped) || 0,
++ };
++ }
++
++ /** The one portable file, decrypted for the person's own export. */
++ async function exportFile(token, scope) {
++ const query = scope ? `?scope=${encodeURIComponent(scope)}` : '';
++ return await call(token, 'GET', `${MEMORY_PATH}/export${query}`);
++ }
++
++ /** Import the same document, under the person's own token. */
++ async function importFile(token, document) {
++ const doc = await call(token, 'POST', `${MEMORY_PATH}/import`, document);
++ return {
++ imported: Number(doc.imported) || 0, created: Number(doc.created) || 0,
++ deduped: Number(doc.deduped) || 0, skipped: Number(doc.skipped) || 0,
++ };
++ }
++
++ /** The stores other people shared with this person: owner ids and access only, never a note. */
++ async function shared(token) {
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/shared`);
++ return { stores: Array.isArray(doc.stores) ? doc.stores : [] };
++ }
++
++ /** One shared store's active notes. A non-member gets memory.not_found from the store, never the notes. */
++ async function sharedStore(token, owner, scope) {
++ const query = scope ? `?scope=${encodeURIComponent(scope)}` : '';
++ const doc = await call(token, 'GET', `${MEMORY_PATH}/stores/${encodeURIComponent(owner)}${query}`);
++ return { owner: doc.owner || owner, items: Array.isArray(doc.items) ? doc.items : [], count: Number(doc.count) || 0 };
++ }
++
++ return { items, patch, remove, removeBySource, profile, reads, split, settle, providers, preview, commit, discard,
++ services, connect, disconnect, syncService, exportFile, importFile, shared, sharedStore };
}
module.exports = { createMemory, MEMORY_PATH };
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex dc09101..a321742 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -96,7 +96,7 @@ function createC4Routes(ctx) {
const own = Array.isArray(extra.scripts) ? extra.scripts : [];
if (viewer(req) && ctx.chatShell && !extra.index) {
const here = '/' + (String(req.path || '/').split('/')[1] || '');
-- return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js', ...own], here });
++ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js', ...own], styles: extra.styles, sharedScripts: extra.sharedScripts, here });
}
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), body: raw(body), ...extra, css, scripts: [...(wantsScript ? ['js/c4.js'] : []), ...own] });
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 18bc96e..eb9b592 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -393,7 +393,7 @@ function createChatRoutes(ctx) {
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
/** The frame every signed-in page shares: the rail, the conversation (or a page) and, for a task, the panel. */
-- async function frame(req, res, { currentId = null, title, heading = null, state = '', turns = [], empty = '', composerHtml = '', panel = '', streamUrl = '', start = false, page = null, here = '', css = [], scripts = [] }) {
++ async function frame(req, res, { currentId = null, title, heading = null, state = '', turns = [], empty = '', composerHtml = '', panel = '', streamUrl = '', start = false, page = null, here = '', css = [], scripts = [], styles = [], sharedScripts = [] }) {
const rows = await conversations(req.viewer.subject);
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waitingIds = new Set(waiting.map((a) => a.task_id));
@@@ -404,7 +404,7 @@ function createChatRoutes(ctx) {
cls: page != null ? 'is-page' : '',
});
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js', 'ov-thread.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
}
/** The panel: who worked on it, and where the work is. */
@@@ -446,8 +446,8 @@ function createChatRoutes(ctx) {
] });
}
-- ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {
-- title, page: body, here, css, scripts,
++ ctx.chatShell = (req, res, { title, body, css = [], scripts = [], styles = [], sharedScripts = [], here = '' }) => frame(req, res, {
++ title, page: body, here, css, scripts, styles, sharedScripts,
});
/** The person's machines, for the line that says where the work runs: never more than 1.5 s, never fatal. */
ddiff --git a/server/http/memory-share.js b/server/http/memory-share.js
nnew file mode 100644
iindex 0000000..0583253
---- /dev/null
++++ b/server/http/memory-share.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * The Share sheet's API for a memory store (Memory M5), proxied to OpenVibe.AI.
++ *
++ * The store lives in AI and so does its share row (service `ai`, kind `memory-store`, id `usr_…`). The sheet on
++ * Codes talks same-origin to `/api/v1/memory-store/:id/share...`; this proxy reads the person's session token with
++ * sso.asViewer and forwards the exact method, path, query and JSON body to the store's uniform share routes. AI
++ * authenticates the same person token again and decides; Codes never mints, reads or widens an ACL.
++ *
++ * The response keeps AI's Cache-Control (`private, no-store` on every share body) and its ETag, so the sheet's
++ * conditional saves keep working. A mutation without a same-site marker is refused here as well as in AI.
++ */
++const MAX_BODY = 8 * 1024 * 1024; // a re-sealed envelope may be up to 8 MiB
++const PATH_RE = /^\/api\/v1\/memory-store\/[^/]+\/share(?:\/|$)/;
++
++function rawBody(req, limit = MAX_BODY) {
++ return new Promise((resolve, reject) => {
++ const chunks = [];
++ let size = 0;
++ req.on('data', (chunk) => {
++ size += chunk.length;
++ if (size > limit) {
++ const err = new Error('That save is too large');
++ err.status = 413;
++ err.code = 'share.too_large';
++ reject(err);
++ req.destroy();
++ return;
++ }
++ chunks.push(chunk);
++ });
++ req.on('end', () => resolve(Buffer.concat(chunks)));
++ req.on('error', reject);
++ });
++}
++
++/** The CSRF marker the shared sheet already sends; a cookie-authenticated write without one is refused. */
++function sameSite(req) {
++ if (req.get('x-requested-with') !== undefined) return true;
++ const site = String(req.get('sec-fetch-site') || '').toLowerCase();
++ return site === 'same-origin' || site === 'same-site';
++}
++
++function createMemoryShareProxy({ config, sso, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String((config.ai && (config.ai.internalUrl || config.ai.url)) || 'https://ai.openvibe.services').replace(/\/+$/, '');
++
++ return async function memoryShareProxy(req, res, next) {
++ if (!PATH_RE.test(String(req.path || ''))) return next();
++ if (!['GET', 'PUT', 'POST'].includes(req.method)) return res.status(405).json({ code: 'share.method', error: 'Method not allowed' });
++ if (req.method !== 'GET' && !sameSite(req)) return res.status(403).json({ code: 'share.csrf', error: 'Cross-site request refused' });
++ let token;
++ try {
++ token = await sso.asViewer(req, res, async (t) => t);
++ } catch {
++ return res.status(401).json({ code: 'token.invalid', error: 'Sign in again to share your memory' });
++ }
++ let body;
++ if (req.method !== 'GET') {
++ try { body = await rawBody(req); } catch (err) {
++ return res.status(err.status || 400).json({ code: err.code || 'input.invalid', error: err.message || 'That save could not be read' });
++ }
++ if (!body.length) body = undefined;
++ }
++ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
++ if (body !== undefined) headers['Content-Type'] = req.get('content-type') || 'application/json';
++ if (req.get('if-match')) headers['If-Match'] = req.get('if-match');
++ let upstream;
++ try {
++ upstream = await fetchImpl(`${base}${req.originalUrl}`, { method: req.method, headers, body, signal: AbortSignal.timeout(15000) });
++ } catch (err) {
++ log.warn(`[Codes] the memory share route did not answer: ${String(err && err.message).slice(0, 120)}`);
++ return res.status(502).json({ code: 'memory.unavailable', error: 'The memory store did not answer' });
++ }
++ const text = await upstream.text().catch(() => '');
++ res.status(upstream.status);
++ res.set('Cache-Control', upstream.headers.get('cache-control') || 'private, no-store');
++ const etag = upstream.headers.get('etag');
++ if (etag) res.set('ETag', etag);
++ res.type(upstream.headers.get('content-type') || 'application/json');
++ return res.send(text);
++ };
++}
++
++module.exports = { createMemoryShareProxy, PATH_RE, MAX_BODY };
ddiff --git a/server/http/memory.js b/server/http/memory.js
iindex e82dfc5..ce9915e 100644
---- a/server/http/memory.js
++++ b/server/http/memory.js
@@@ -75,9 +75,14 @@ const agoOf = (value) => {
const MEMORY_TABS = [
['notes', 'Notes'], ['disagree', 'Disagree'], ['suggested', 'Suggested'],
-- ['profile', 'Profile'], ['reads', 'Who read it'], ['import', 'Import'],
++ ['profile', 'Profile'], ['reads', 'Who read it'], ['services', 'Services'],
++ ['shared', 'Shared'], ['import', 'Import'], ['export', 'Export'],
];
++/** The share sheet and the gate are drawn by openvibe-shared (openvibe-shared 4.123.0); no inline script. */
++const SHARE_ASSETS = { styles: ['ov-share.css'], sharedScripts: ['ov-share.js'], scripts: ['js/memory-import.js'] };
++const SERVICES = ['mem0', 'letta', 'zep'];
++
/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */
const PROBLEMS = {
'memory.not_found': 'That note is gone.',
@@@ -88,6 +93,18 @@ const PROBLEMS = {
'memory.unreadable': 'That note could not be read.',
'memory.unavailable': 'Your memory is not available right now.',
'memory.signed_out': 'Sign in again to see your memory.',
++ 'memory.services_unavailable': 'Memory services are not configured on OpenVibe yet.',
++ 'memory.service_not_found': 'That memory service is not connected.',
++ 'memory.service_key_required': 'Enter the key the first time.',
++ 'memory.service_key_refused': 'The service refused that key. Check it and try again.',
++ 'memory.service_unreachable': 'That service could not be reached. Check the address.',
++ 'memory.service_rate_limited': 'The service is rate-limiting the key. Try again later.',
++ 'memory.service_failed': 'The service did not answer in a way we could read.',
++ 'memory.sharing_unavailable': 'Sharing is not configured on OpenVibe yet.',
++ 'share.not_found': 'That sharing setting is not there.',
++ 'share.exists': 'That store is already shared.',
++ 'share.invalid': 'That sharing change was refused.',
++ 'share.rate_limited': 'Too many name checks. Try again in a minute.',
'import.bad_zip': 'That file is not a zip archive we can read.',
'import.invalid': 'That file is not an export this page reads.',
'import.expired': 'That preview has expired. Drop the export again.',
@@@ -135,12 +152,19 @@ function createMemoryRoutes(ctx, kit) {
/** The page. One call to the store holds every tab's data; only the open tab is built. */
async function memoryPage(req, res) {
const title = 'Memory';
++ const me = viewer(req);
const head = header(title, 'What your assistants know about you, in one place.');
-- if (!viewer(req)) return page(req, res, title, wrap(head + signIn(req)));
++ if (!me) return page(req, res, title, wrap(head + signIn(req)));
++ // The owner's own store is shared from here. The sheet is openvibe-shared's markup and script; the API is
++ // Codes' proxy to AI (/api/v1/memory-store/:id/share), so the ACL stays where the notes are.
++ const shareButton = /^usr_/.test(String(me || ''))
++ ? `<div class="c4-actions"><button type="button" class="c4-button c4-button-quiet" data-ov-share-sheet data-kind="memory-store" data-id="${esc(me)}" data-title="Your memory">Share</button></div>`
++ : '';
const asked = String(req.query.tab || '');
const tab = MEMORY_TABS.some(([id]) => id === asked) ? asked : 'notes';
const q = String(req.query.q || '').slice(0, 200);
const scope = String(req.query.scope || '').slice(0, 80);
++ const owner = String(req.query.owner || '').slice(0, 80);
let items = [];
let problem = null;
@@@ -179,6 +203,9 @@ function createMemoryRoutes(ctx, kit) {
else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
else if (tab === 'reads') panel = await readsPanel(req, res);
++ else if (tab === 'services') panel = await servicesPanel(req, res);
++ else if (tab === 'shared') panel = await sharedPanel(req, res, owner);
++ else if (tab === 'export') panel = exportPanel();
else if (tab === 'import') {
let walkthroughs = null;
let prompt = '';
@@@ -192,10 +219,10 @@ function createMemoryRoutes(ctx, kit) {
panel = importPanel(walkthroughs, prompt);
}
-- const content = head + flash(req)
++ const content = head + shareButton + flash(req)
+ (problem ? note('Your memory could not be read right now. ' + problemWord(problem), 'bad') : '')
+ sourcesPanel(sources) + tabs + panel;
-- return page(req, res, title, wrap(content));
++ return page(req, res, title, wrap(content), SHARE_ASSETS);
}
/** The Import tab: one walkthrough per cloud assistant, then one paste card that works anywhere. */
@@@ -449,6 +476,111 @@ function createMemoryRoutes(ctx, kit) {
+ '<ul class="c4-rows">' + rows + '</ul></section>';
}
++ /* ── Memory M5: services, the shared stores and the portable file ─────────────────────────────────── */
++
++ /** mem0, Letta and Zep: connect (with the person's own key), sync, forget. The key is never shown back. */
++ async function servicesPanel(req, res) {
++ let providers = [];
++ let problem = null;
++ try {
++ const out = await withToken(req, res, (token) => memory.services(token));
++ providers = Array.isArray(out.providers) ? out.providers : [];
++ } catch (e) { problem = e; }
++ if (problem) return '<section class="c4-card c4-empty"><h2>Services</h2><p>' + esc(problemWord(problem)) + '</p></section>';
++ const intro = '<p class="c4-hint">Connect a memory service with your own key. OpenVibe keeps the key sealed on the server, '
++ + 'shows only its last four characters, and reads the service only when you press Sync. The key is never written into a page or a log.</p>';
++ const cards = providers.map((p) => {
++ const connected = !!p.connected;
++ const state = !connected ? 'Not connected' : (p.status === 'failing' ? 'Key needs attention' : p.status === 'ok' ? 'Connected' : 'Saved, not tested');
++ const hint = connected && p.hint ? ' · key ending ' + esc(String(p.hint)) : '';
++ const when = p.last_sync_at ? ' · last sync ' + esc(agoOf(p.last_sync_at)) + ' (' + (Number(p.last_sync_count) || 0) + ' found)' : '';
++ const error = p.last_error ? '<p class="c4-note-meta">' + esc(String(p.last_error).slice(0, 200)) + '</p>' : '';
++ const fields = '<label class="c4-field"><span>Key</span><input type="password" name="key" autocomplete="off" spellcheck="false" '
++ + (connected ? 'placeholder="Leave empty to keep the saved key"' : 'required') + '></label>'
++ + '<label class="c4-field"><span>Address (optional)</span><input type="url" name="base_url" value="' + esc(p.base_url || '') + '" placeholder="' + esc(p.default_base_url || '') + '"></label>'
++ + '<label class="c4-field"><span>Account or agent (optional)</span><input type="text" name="account" value="' + esc(p.account || '') + '"></label>';
++ const connect = '<form method="post" action="/memory/services/' + esc(p.provider) + '">'
++ + '<input type="hidden" name="tab" value="services">' + fields
++ + '<button class="c4-button" type="submit">' + (connected ? 'Save and test again' : 'Save and test') + '</button></form>';
++ const actions = connected
++ ? '<form method="post" action="/memory/services/' + esc(p.provider) + '/sync"><input type="hidden" name="tab" value="services">'
++ + '<button class="c4-button c4-button-quiet" type="submit">Sync now</button></form>'
++ + '<form method="post" action="/memory/services/' + esc(p.provider) + '/disconnect"><input type="hidden" name="tab" value="services">'
++ + '<button class="c4-button c4-button-danger" type="submit">Forget the key</button></form>'
++ : '';
++ return '<section class="c4-card"><div class="c4-card-head"><h2>' + esc(p.name || p.provider) + '</h2>'
++ + '<span class="c4-chip">' + esc(state + hint + when) + '</span></div>'
++ + '<p class="c4-sub">' + esc(p.docs || '') + '</p>' + error + connect + actions + '</section>';
++ }).join('');
++ return '<section class="c4-card c4-empty"><h2>Memory services</h2>' + intro + '</section>' + cards;
++ }
++
++ /** The store another person shared, read-only, and the gate when this reader is not on its list. */
++ async function sharedPanel(req, res, owner) {
++ const me = viewer(req);
++ if (owner) {
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(owner)) {
++ return '<section class="c4-card c4-empty"><h2>Shared</h2><p>That store could not be found.</p></section>';
++ }
++ if (me && owner === me) return '<section class="c4-card c4-empty"><h2>That is your own memory</h2><p>Open Notes to see it. Share it with the Share button at the top.</p></section>';
++ let out = null;
++ let problem = null;
++ try { out = await withToken(req, res, (token) => memory.sharedStore(token, owner)); }
++ catch (e) { problem = e; }
++ if (problem) {
++ if (String(problem.code || '') !== 'memory.not_found') {
++ return '<section class="c4-card c4-empty"><h2>Shared</h2><p>' + esc(problemWord(problem)) + '</p></section>';
++ }
++ // Specific people, and this reader is not on the list: the audience, never a note. The gate draws
++ // itself from this markup and asks for access through the same proxied share routes.
++ const signInUrl = me ? '' : '/auth/login?next=' + encodeURIComponent('/memory?tab=shared&owner=' + owner);
++ return '<section class="c4-card c4-empty"><h2>This memory is shared with specific people</h2>'
++ + '<div id="memory-gate" data-ov-share-gate data-kind="memory-store" data-id="' + esc(owner) + '" data-signed-in-as="' + esc(me ? (req.viewer.username || me) : '') + '" data-sign-in-url="' + esc(signInUrl) + '">'
++ + 'This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div></section>';
++ }
++ const rows = (out.items || []).map((item) => '<li class="c4-row c4-row-flat">'
++ + '<span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span>'
++ + '<span class="c4-row-label">' + esc(item.text) + '</span>'
++ + '<span class="c4-row-note">' + esc(scopeName(item.scope)) + ' · ' + esc(kindName(item.kind)) + ' · from ' + esc(sourceName(item.source)) + '</span></li>').join('');
++ return '<section class="c4-card"><div class="c4-card-head"><h2>A shared memory</h2><span class="c4-chip">read-only</span></div>'
++ + '<p class="c4-sub">Its owner shared it with specific people. You can read it; only the owner can change or share it.</p>'
++ + (rows ? '<ul class="c4-rows">' + rows + '</ul>' : '<p class="c4-hint">This store has no active notes yet.</p>') + '</section>';
++ }
++ let stores = [];
++ let problem = null;
++ try {
++ const out = await withToken(req, res, (token) => memory.shared(token));
++ stores = Array.isArray(out.stores) ? out.stores : [];
++ } catch (e) { problem = e; }
++ if (problem) return '<section class="c4-card c4-empty"><h2>Shared</h2><p>' + esc(problemWord(problem)) + '</p></section>';
++ if (!stores.length) {
++ return '<section class="c4-card c4-empty"><h2>Nothing has been shared with you</h2>'
++ + '<p>When someone shares their memory with you, it appears here as a read-only store.</p></section>';
++ }
++ const rows = stores.map((s) => '<li class="c4-row"><span class="c4-row-dot c4-dot-ok" aria-hidden="true"></span>'
++ + '<span class="c4-row-label"><a href="/memory?tab=shared&amp;owner=' + esc(s.owner) + '">A memory store</a></span>'
++ + '<span class="c4-row-note">' + esc(s.role || 'viewer') + ' · shared ' + esc(agoOf(s.shared_at)) + '</span></li>').join('');
++ return '<section class="c4-card"><div class="c4-card-head"><h2>Shared with me</h2></div>'
++ + '<p class="c4-sub">Read-only stores other people shared with you. They never appear in your own notes.</p>'
++ + '<ul class="c4-rows">' + rows + '</ul></section>';
++ }
++
++ /** The portable file: download it, or paste/choose one to import. One documented JSON format. */
++ function exportPanel() {
++ return '<section class="c4-card"><div class="c4-card-head"><h2>Your portable file</h2></div>'
++ + '<p class="c4-sub">One JSON file with every note, its kind, its scope, its source and its confidence. '
++ + 'It carries no account id, no note id and no conflict link, so you can import it into another OpenVibe account. '
++ + 'The JSON is redacted again when it is imported, and a summary is rebuilt from the notes, never imported.</p>'
++ + '<p><a class="c4-button" href="/memory/export" download>Download memory.json</a></p></section>'
++ + '<section class="c4-card"><div class="c4-card-head"><h2>Import a portable file</h2></div>'
++ + '<p class="c4-sub">Choose the file or paste its JSON. Notes that say the same thing as one you already have are merged, not copied.</p>'
++ + '<form method="post" action="/memory/import"><input type="hidden" name="tab" value="export">'
++ + '<label class="c4-field"><span>File</span><input type="file" id="memory-import-file" accept="application/json,.json"></label>'
++ + '<label class="c4-field"><span>Or paste the JSON</span><textarea id="memory-import-text" name="document" rows="8" spellcheck="false"></textarea></label>'
++ + '<button class="c4-button" type="submit">Import</button></form>'
++ + '<p class="c4-hint">A file over 25 MB is refused. Nothing is kept until you press Import.</p></section>';
++ }
++
/** The confirmation page: what one source's removal would take, and the one form that does it. */
async function removePage(req, res) {
const source = String(req.query.source || '');
@@@ -484,6 +616,87 @@ function createMemoryRoutes(ctx, kit) {
router.get('/memory', async (req, res) => memoryPage(req, res));
router.get('/memory/remove', async (req, res) => removePage(req, res));
++ /* ── Memory M5: the memory services, the portable file and team sharing ─────────────────────────── */
++
++ // The portable file is decrypted by the store for the person's own token and streamed as a download. It is never
++ // cached: the body is the person's own notes.
++ router.get('/memory/export', async (req, res) => {
++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=' + encodeURIComponent('/memory?tab=export'));
++ try {
++ const doc = await withToken(req, res, (token) => memory.exportFile(token, String(req.query.scope || '').slice(0, 80) || undefined));
++ res.set('Cache-Control', 'private, no-store');
++ res.set('Content-Disposition', `attachment; filename="openvibe-memory-${new Date().toISOString().slice(0, 10)}.json"`);
++ return res.type('application/json').send(JSON.stringify(doc));
++ } catch (e) {
++ return back(req, res, 'export', '', problemWord(e));
++ }
++ });
++
++ // The same document back in. A form body (pasted, or a file the page's own script copied into the textarea) is
++ // bounded at 25 MB; the store validates the format, redacts every item again and dedupes by its text MAC.
++ router.post('/memory/import', express.urlencoded({ extended: false, limit: '25mb' }), async (req, res) => {
++ if (refuse(req, res)) return;
++ const raw = String((req.body && req.body.document) || '');
++ if (!raw.trim()) return back(req, res, 'export', '', 'Choose a file or paste the JSON first.');
++ let doc;
++ try { doc = JSON.parse(raw); } catch { return back(req, res, 'export', '', 'That is not JSON. Choose the file the Export tab downloaded.'); }
++ try {
++ const out = await withToken(req, res, (token) => memory.importFile(token, doc));
++ return back(req, res, 'export', 'Imported ' + out.imported + (out.imported === 1 ? ' note' : ' notes')
++ + ' (' + out.created + ' new, ' + out.deduped + ' already there' + (out.skipped ? ', ' + out.skipped + ' skipped' : '') + ').');
++ } catch (e) {
++ return back(req, res, 'export', '', problemWord(e));
++ }
++ });
++
++ // Connect (or re-test) one service with the person's own key. The store seals it; the page never sees it again.
++ router.post('/memory/services/:provider', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const provider = String(req.params.provider || '');
++ if (!SERVICES.includes(provider)) return back(req, res, 'services', '', 'No such memory service.');
++ const body = req.body || {};
++ try {
++ const out = await withToken(req, res, (token) => memory.connect(token, provider, {
++ ...(String(body.key || '') ? { key: String(body.key) } : {}),
++ ...(String(body.base_url || '').trim() ? { base_url: String(body.base_url).trim() } : {}),
++ ...(String(body.account || '').trim() ? { account: String(body.account).trim() } : {}),
++ }));
++ const service = out.service || {};
++ if (service.status === 'failing') return back(req, res, 'services', '', 'Saved, but the key test failed: ' + String(service.last_error || 'the service refused it').slice(0, 140));
++ return back(req, res, 'services', (service.name || provider) + ' is connected.');
++ } catch (e) {
++ return back(req, res, 'services', '', problemWord(e));
++ }
++ });
++
++ // Read the service and put what it has into the store. The answer is counts, never text.
++ router.post('/memory/services/:provider/sync', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const provider = String(req.params.provider || '');
++ if (!SERVICES.includes(provider)) return back(req, res, 'services', '', 'No such memory service.');
++ try {
++ const out = await withToken(req, res, (token) => memory.syncService(token, provider));
++ return back(req, res, 'services', 'Synced ' + out.imported + (out.imported === 1 ? ' note' : ' notes') + ' from ' + provider
++ + (out.deduped ? ' (' + out.deduped + ' already there)' : '') + '.');
++ } catch (e) {
++ return back(req, res, 'services', '', problemWord(e));
++ }
++ });
++
++ // Forget the key: the row, and with it the evidence of the key, is deleted.
++ router.post('/memory/services/:provider/disconnect', form, async (req, res) => {
++ if (refuse(req, res)) return res;
++ const provider = String(req.params.provider || '');
++ if (!SERVICES.includes(provider)) return back(req, res, 'services', '', 'No such memory service.');
++ try {
++ await withToken(req, res, (token) => memory.disconnect(token, provider));
++ return back(req, res, 'services', provider + ' was forgotten.');
++ } catch (e) {
++ return back(req, res, 'services', '', problemWord(e));
++ }
++ });
++
++
// Memory M3: the export is forwarded untouched to the store, which parses, redacts and holds a sealed draft.
// The answer is shown once; nothing is kept until the person ticks and submits the preview form.
router.post('/memory/import/preview', importUpload, async (req, res) => {
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex ae3bec8..8af00c6 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// their own agents read and add (Know K5).
--const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
ddiff --git a/test/memory-m5.test.js b/test/memory-m5.test.js
nnew file mode 100644
iindex 0000000..2bde75c
---- /dev/null
++++ b/test/memory-m5.test.js
@@@ -0,0 +1,218 @@
++'use strict';
++/**
++ * Memory M5 on Codes: the memory-service cards, the portable file, the shared stores and the gate, and the Share
++ * sheet's proxy to AI. The store is a local stand-in that records every call, so the test can see the route, the
++ * person's token and the body; nothing reaches a real provider.
++ */
++const http = require('http');
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const WHEN = new Date(Date.now() - 36e5).toISOString();
++const A = 'usr_01JZ0000000000000000000AAA';
++// Built from parts so a source scanner does not read the fixture as a real key.
++const MEM0_KEY = ['mem0', 'key', '0123456789'].join('-');
++
++async function startStore() {
++ const calls = [];
++ const items = [
++ { id: 'mem_01JZ0000000000000000000001', text: 'A shared note about deploys.', kind: 'procedure', scope: 'user', source: 'claude-code', status: 'active', confidence: 1, seen_at: WHEN, created_at: WHEN, updated_at: WHEN },
++ { id: 'mem_01JZ0000000000000000000002', text: 'Another shared note.', kind: 'fact', scope: 'project:demo', source: 'cursor', status: 'active', confidence: 0.8, seen_at: WHEN, created_at: WHEN, updated_at: WHEN },
++ ];
++ const providers = ['mem0', 'letta', 'zep'].map((id, i) => ({
++ provider: id, name: { mem0: 'mem0', letta: 'Letta', zep: 'Zep' }[id], docs: `https://example.test/${id}`,
++ default_base_url: `https://api.${id}.example`, connected: i === 0, hint: i === 0 ? '6789' : null,
++ base_url: null, account: null, status: i === 0 ? 'ok' : 'untested', checked_at: i === 0 ? WHEN : null,
++ last_error: null, last_sync_at: i === 0 ? WHEN : null, last_sync_count: i === 0 ? 4 : 0,
++ }));
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
++ const url = new URL(req.url, 'http://store');
++ let parsed = null;
++ try { parsed = raw ? JSON.parse(raw) : null; } catch { parsed = null; }
++ const call = { method: req.method, path: url.pathname, query: url.search, auth: req.headers.authorization || '', headers: req.headers, body: parsed, raw };
++ calls.push(call);
++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
++ if (url.pathname.startsWith('/api/v1/memory-store/')) {
++ if (!/^Bearer /.test(call.auth)) return json(401, { code: 'token.invalid', error: 'no token' });
++ if (req.method === 'GET') return json(200, { audience: 'people', revision: 3, members: [], audiences: ['public', 'link', 'people', 'private'], roles: ['viewer'] }, { ETag: '"rev-3"', 'Cache-Control': 'private, no-store' });
++ return json(200, { audience: 'people', revision: 4, members: [{ subject: { type: 'user', id: 'usr_01JZ0000000000000000000BBB' }, role: 'viewer', username: 'bo' }] }, { ETag: '"rev-4"', 'Cache-Control': 'private, no-store' });
++ }
++ if (!/^Bearer /.test(call.auth)) return json(401, { code: 'token.invalid', error: 'no token' });
++ if (req.method === 'GET' && url.pathname === '/api/v1/memory/items') return json(200, { items: [items[0]], count: 1 });
++ if (req.method === 'GET' && url.pathname === '/api/v1/memory/services') return json(200, { providers });
++ if (req.method === 'PUT' && url.pathname.startsWith('/api/v1/memory/services/')) {
++ assert.ok(call.body && call.body.key, 'the key reached the store');
++ return json(200, { service: { provider: 'mem0', name: 'mem0', hint: String(call.body.key).slice(-4), status: 'ok', checked_at: WHEN, last_sync_at: null, last_sync_count: 0 } });
++ }
++ if (req.method === 'POST' && url.pathname === '/api/v1/memory/services/mem0/sync') return json(200, { provider: 'mem0', found: 4, imported: 3, deduped: 1, skipped: 0 });
++ if (req.method === 'DELETE' && url.pathname === '/api/v1/memory/services/mem0') return json(200, { removed: 1 });
++ if (req.method === 'GET' && url.pathname === '/api/v1/memory/shared') return json(200, { stores: [{ owner: A, audience: 'people', role: 'viewer', shared_at: WHEN }] });
++ if (req.method === 'GET' && url.pathname === `/api/v1/memory/stores/${A}`) {
++ if (server.gate) return json(404, { code: 'memory.not_found', detail: 'no such shared store' });
++ return json(200, { owner: A, items, count: 2 });
++ }
++ if (req.method === 'GET' && url.pathname === '/api/v1/memory/export') {
++ return json(200, { format: 'openvibe.memory-export@1', version: 1, exported_at: WHEN, items: [{ text: 'A portable note.', kind: 'fact', scope: 'user', source: 'person' }] }, { 'Cache-Control': 'no-store' });
++ }
++ if (req.method === 'POST' && url.pathname === '/api/v1/memory/import') {
++ assert.strictEqual(call.body && call.body.format, 'openvibe.memory-export@1', 'the document reached the store');
++ return json(200, { imported: 2, created: 1, deduped: 1, skipped: 0 });
++ }
++ return json(404, { code: 'memory.not_found', detail: 'no such route' });
++ });
++ });
++ server.gate = false;
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return {
++ url: 'http://127.0.0.1:' + server.address().port, calls, items, providers,
++ set gate(v) { server.gate = v; },
++ close: () => new Promise((r) => server.close(r)),
++ last: (method, part) => [...calls].reverse().find((c) => c.method === method && c.path.includes(part)),
++ };
++}
++
++(async () => {
++await check('the Services tab lists mem0, Letta and Zep, and connect/sync/forget go to the store with the person token', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const page = await h.get('/memory?tab=services', { as: user });
++ assert.strictEqual(page.status, 200);
++ for (const name of ['mem0', 'Letta', 'Zep']) assert.ok(page.text.includes(name), name + ' is listed');
++ assert.ok(page.text.includes('key ending 6789'), 'the saved key is shown by its last four characters only');
++ assert.ok(!page.text.includes(MEM0_KEY), 'no key is on the page');
++ assert.ok(/name="key"[^>]*type="password"|type="password"[^>]*name="key"/.test(page.text), 'the key field is a password field');
++ assert.match(String(page.headers.get('cache-control')), /private/);
++
++ const saved = await h.get('/memory/services/mem0', { as: user, method: 'POST', form: { tab: 'services', key: MEM0_KEY, base_url: 'https://api.mem0.example' } });
++ assert.strictEqual(saved.status, 303, saved.text);
++ const put = store.last('PUT', '/api/v1/memory/services/mem0');
++ assert.ok(put && put.body.key === MEM0_KEY && put.body.base_url === 'https://api.mem0.example', 'the key went to the store');
++ assert.match(put.auth, /^Bearer /, 'with the person token');
++ assert.ok(!String(saved.headers.get('location')).includes(MEM0_KEY), 'the key is not in the redirect');
++
++ const synced = await h.get('/memory/services/mem0/sync', { as: user, method: 'POST', form: { tab: 'services' } });
++ assert.strictEqual(synced.status, 303);
++ assert.match(String(synced.headers.get('location')), /ok=Synced%203%20notes/, String(synced.headers.get('location')));
++ assert.ok(store.last('POST', '/api/v1/memory/services/mem0/sync'), 'the sync reached the store');
++
++ const forgot = await h.get('/memory/services/mem0/disconnect', { as: user, method: 'POST', form: { tab: 'services' } });
++ assert.strictEqual(forgot.status, 303);
++ assert.ok(store.last('DELETE', '/api/v1/memory/services/mem0'), 'the key was forgotten');
++
++ // A cross-site post never reaches the store.
++ const before = store.calls.length;
++ const evil = await h.get('/memory/services/mem0', { as: user, method: 'POST', form: { tab: 'services', key: MEM0_KEY }, headers: { origin: 'https://evil.example' } });
++ assert.strictEqual(evil.status, 403);
++ assert.strictEqual(store.calls.length, before, 'the refused post did not reach the store');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the Shared tab lists stores, a member reads one read-only, and a stranger sees the gate, never a note', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const list = await h.get('/memory?tab=shared', { as: user });
++ assert.strictEqual(list.status, 200);
++ assert.ok(list.text.includes('Shared with me'), 'the list heading');
++ assert.ok(list.text.includes('/memory?tab=shared&amp;owner=' + A), 'a link to the shared store');
++ assert.ok(!list.text.includes('A shared note'), 'the listing carries no note');
++
++ store.gate = false;
++ const member = await h.get('/memory?tab=shared&owner=' + A, { as: user });
++ assert.strictEqual(member.status, 200);
++ assert.ok(member.text.includes('A shared note about deploys.'), 'a member reads the notes');
++ assert.ok(member.text.includes('read-only'), 'read-only');
++ assert.ok(!member.text.includes('name="text"'), 'a member gets no edit form');
++
++ store.gate = true;
++ const stranger = await h.get('/memory?tab=shared&owner=' + A, { as: user });
++ assert.strictEqual(stranger.status, 200);
++ assert.ok(stranger.text.includes('data-ov-share-gate'), 'the gate markup');
++ assert.ok(stranger.text.includes('data-kind="memory-store"'), 'the gate names the kind');
++ assert.ok(stranger.text.includes('data-id="' + A + '"'), 'the gate names the store');
++ assert.ok(stranger.text.includes('data-signed-in-as="maria"'), 'the gate names the account');
++ assert.ok(!stranger.text.includes('A shared note'), 'the gate carries no note');
++ assert.ok(stranger.text.includes('ov-share.js'), 'the gate is drawn by the shared script, never an inline one');
++ const gate = /<div id="memory-gate"[\s\S]*?<\/div>/.exec(stranger.text);
++ assert.ok(gate && !gate[0].includes('<script'), 'the gate has no inline script');
++
++ const own = await h.get('/memory?tab=notes', { as: user });
++ assert.ok(own.text.includes('data-ov-share-sheet'), 'the owner has the Share button');
++ assert.ok(own.text.includes('data-kind="memory-store"'), 'the sheet shares a memory store');
++ assert.ok(own.text.includes('ov-share.css'), 'the sheet stylesheet is loaded');
++ assert.ok(own.text.includes('data-id="' + user.subject + '"'), 'the sheet names the owner store');
++ assert.match(String(own.headers.get('cache-control')), /private/);
++ assert.ok(!String(own.headers.get('cache-control')).includes('public'), 'a signed-in answer is never public-cacheable');
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the portable file downloads as an attachment, and an import goes back to the store', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const page = await h.get('/memory?tab=export', { as: user });
++ assert.ok(page.text.includes('Download memory.json'), 'the export link');
++ assert.ok(page.text.includes('Import a portable file'), 'the import form');
++ assert.ok(page.text.includes('memory-import.js'), 'the file picker script');
++
++ const file = await h.get('/memory/export', { as: user });
++ assert.strictEqual(file.status, 200);
++ assert.match(String(file.headers.get('content-disposition')), /^attachment; filename="openvibe-memory-\d{4}-\d{2}-\d{2}\.json"$/);
++ assert.match(String(file.headers.get('cache-control')), /no-store/);
++ const doc = file.json();
++ assert.strictEqual(doc.format, 'openvibe.memory-export@1');
++ assert.strictEqual(doc.items[0].text, 'A portable note.');
++
++ const imported = await h.get('/memory/import', { as: user, method: 'POST', form: { tab: 'export', document: JSON.stringify(doc) } });
++ assert.strictEqual(imported.status, 303);
++ assert.match(String(imported.headers.get('location')), /ok=Imported%202%20notes/, String(imported.headers.get('location')));
++ const post = store.last('POST', '/api/v1/memory/import');
++ assert.ok(post && post.body.format === 'openvibe.memory-export@1', 'the document reached the store');
++
++ const bad = await h.get('/memory/import', { as: user, method: 'POST', form: { tab: 'export', document: '{not json' } });
++ assert.strictEqual(bad.status, 303);
++ assert.match(String(bad.headers.get('location')), /error=That%20is%20not%20JSON/, String(bad.headers.get('location')));
++ } finally { await h.close(); await store.close(); }
++});
++
++await check('the Share sheet API is proxied to AI with the person token, its ETag and a private no-store answer', async () => {
++ const store = await startStore();
++ const h = await boot({ env: { CODES_AI_INTERNAL_URL: store.url } });
++ try {
++ const user = h.network.addUser('maria');
++ const get = await h.get(`/api/v1/memory-store/${A}/share`, { as: user });
++ assert.strictEqual(get.status, 200, get.text);
++ assert.strictEqual(get.json().audience, 'people');
++ assert.strictEqual(get.headers.get('etag'), '"rev-3"');
++ assert.match(String(get.headers.get('cache-control')), /private, no-store/);
++ const read = store.last('GET', `/api/v1/memory-store/${A}/share`);
++ assert.ok(read && /^Bearer /.test(read.auth), 'the person token reached the store');
++
++ // A cookie-authenticated write without the same-site marker is refused here, before AI.
++ const before = store.calls.length;
++ const noMarker = await h.get(`/api/v1/memory-store/${A}/share`, { as: user, method: 'PUT', json: { audience: 'people' } });
++ assert.deepStrictEqual([noMarker.status, noMarker.json().code], [403, 'share.csrf']);
++ assert.strictEqual(store.calls.length, before, 'the refused write did not reach AI');
++
++ const put = await h.get(`/api/v1/memory-store/${A}/share`, {
++ as: user, method: 'PUT', json: { audience: 'people' }, headers: { 'x-requested-with': 'fetch', 'if-match': '"rev-3"' },
++ });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().revision, 4);
++ const wrote = store.last('PUT', `/api/v1/memory-store/${A}/share`);
++ assert.ok(wrote && wrote.body.audience === 'people', 'the body reached AI');
++ assert.strictEqual(wrote.headers['if-match'], '"rev-3"', 'the conditional header reached AI');
++ assert.strictEqual(put.headers.get('etag'), '"rev-4"');
++ } finally { await h.close(); await store.close(); }
++});
++
++done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex dbab6ba..ffdca59 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -62,6 +62,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/memory.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory with the person\'s own session token: their own notes, their profile and their read log. The base is the configured address, the path is fixed, and a note id is checked against a mem_ shape before it goes in a path; no URL from a person',
++ 'server/http/memory-share.js': 'OpenVibe.AI (CODES_AI_INTERNAL_URL, configured) /api/v1/memory-store/{id}/share with the person\'s own session token: the configured base, the fixed share path and the id from the API route; a JSON body is forwarded as-is, never a URL from a person',
'server/domain/knowledge.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, to read the teams a knowledge row may be shared with; the path is fixed and no URL from a person is fetched',
'server/sharing.js': 'the configured Network internal base (OV_NETWORK_INTERNAL_URL) for the token endpoint, identity lookups and one notification push, and the configured Events URL (CODES_EVENTS_URL); share refs are the sharing system\'s own kind/id strings, never a URL from a person (test/sharing.test.js)',
'server/domain/github.js': 'api.github.com only, at fixed paths: the public repository search and a pull request\'s state. The owner/name/number come from a Codes-stored task result and are checked against GitHub\'s id shapes before they are put in a path',