openvibers/openvibe.codes Commits
Memory M5: memory services, shared stores and the portable file on the Memory page (#296)
Changes
nnew file mode 100644@@@ -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);++ });++}());@@@ -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({@@@ -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 };@@@ -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] });@@@ -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. */nnew file mode 100644@@@ -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 };@@@ -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&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) => {@@@ -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}/`);nnew file mode 100644@@@ -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&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); });@@@ -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',
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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);
+ });
+}());
@@ -282,6 +282,9 @@ async function createApp(opts = {}) {
@@ -282,6 +282,9 @@ async function createApp(opts = {}) {
// GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
// 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
// and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
// its own paths.
// 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();
const shareApi = express.Router();
shareApi.use(sharing.shares.router({
shareApi.use(sharing.shares.router({
@@ -171,7 +171,64 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
@@ -171,7 +171,64 @@ function createMemory({ config, fetchImpl = globalThis.fetch, log = console } =
return { discarded: Number(doc.discarded) || 0 };
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 };
module.exports = { createMemory, MEMORY_PATH };
@@ -96,7 +96,7 @@ function createC4Routes(ctx) {
@@ -96,7 +96,7 @@ function createC4Routes(ctx) {
const own = Array.isArray(extra.scripts) ? extra.scripts : [];
const own = Array.isArray(extra.scripts) ? extra.scripts : [];
if (viewer(req) && ctx.chatShell && !extra.index) {
if (viewer(req) && ctx.chatShell && !extra.index) {
const here = '/' + (String(req.path || '/').split('/')[1] || '');
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);
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] });
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] });
@@ -393,7 +393,7 @@ function createChatRoutes(ctx) {
@@ -393,7 +393,7 @@ function createChatRoutes(ctx) {
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
/** The frame every signed-in page shares: the rail, the conversation (or a page) and, for a task, the panel. */
/** 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 rows = await conversations(req.viewer.subject);
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waitingIds = new Set(waiting.map((a) => a.task_id));
const waitingIds = new Set(waiting.map((a) => a.task_id));
@@ -404,7 +404,7 @@ function createChatRoutes(ctx) {
@@ -404,7 +404,7 @@ function createChatRoutes(ctx) {
cls: page != null ? 'is-page' : '',
cls: page != null ? 'is-page' : '',
});
});
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
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. */
/** The panel: who worked on it, and where the work is. */
@@ -446,8 +446,8 @@ function createChatRoutes(ctx) {
@@ -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. */
/** The person's machines, for the line that says where the work runs: never more than 1.5 s, never fatal. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 };
@@ -75,9 +75,14 @@ const agoOf = (value) => {
@@ -75,9 +75,14 @@ const agoOf = (value) => {
const MEMORY_TABS = [
const MEMORY_TABS = [
['notes', 'Notes'], ['disagree', 'Disagree'], ['suggested', 'Suggested'],
['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. */
/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */
const PROBLEMS = {
const PROBLEMS = {
'memory.not_found': 'That note is gone.',
'memory.not_found': 'That note is gone.',
@@ -88,6 +93,18 @@ const PROBLEMS = {
@@ -88,6 +93,18 @@ const PROBLEMS = {
'memory.unreadable': 'That note could not be read.',
'memory.unreadable': 'That note could not be read.',
'memory.unavailable': 'Your memory is not available right now.',
'memory.unavailable': 'Your memory is not available right now.',
'memory.signed_out': 'Sign in again to see your memory.',
'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.bad_zip': 'That file is not a zip archive we can read.',
'import.invalid': 'That file is not an export this page reads.',
'import.invalid': 'That file is not an export this page reads.',
'import.expired': 'That preview has expired. Drop the export again.',
'import.expired': 'That preview has expired. Drop the export again.',
@@ -135,12 +152,19 @@ function createMemoryRoutes(ctx, kit) {
@@ -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. */
/** The page. One call to the store holds every tab's data; only the open tab is built. */
async function memoryPage(req, res) {
async function memoryPage(req, res) {
const title = 'Memory';
const title = 'Memory';
+ const me = viewer(req);
const head = header(title, 'What your assistants know about you, in one place.');
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 asked = String(req.query.tab || '');
const tab = MEMORY_TABS.some(([id]) => id === asked) ? asked : 'notes';
const tab = MEMORY_TABS.some(([id]) => id === asked) ? asked : 'notes';
const q = String(req.query.q || '').slice(0, 200);
const q = String(req.query.q || '').slice(0, 200);
const scope = String(req.query.scope || '').slice(0, 80);
const scope = String(req.query.scope || '').slice(0, 80);
+ const owner = String(req.query.owner || '').slice(0, 80);
let items = [];
let items = [];
let problem = null;
let problem = null;
@@ -179,6 +203,9 @@ function createMemoryRoutes(ctx, kit) {
@@ -179,6 +203,9 @@ function createMemoryRoutes(ctx, kit) {
else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
else if (tab === 'suggested') panel = suggestedPanel(byStatus.suggested);
else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
else if (tab === 'profile') panel = await profilePanel(req, res, scopes, byStatus.active);
else if (tab === 'reads') panel = await readsPanel(req, res);
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') {
else if (tab === 'import') {
let walkthroughs = null;
let walkthroughs = null;
let prompt = '';
let prompt = '';
@@ -192,10 +219,10 @@ function createMemoryRoutes(ctx, kit) {
@@ -192,10 +219,10 @@ function createMemoryRoutes(ctx, kit) {
panel = importPanel(walkthroughs, prompt);
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') : '')
+ (problem ? note('Your memory could not be read right now. ' + problemWord(problem), 'bad') : '')
+ sourcesPanel(sources) + tabs + panel;
+ 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. */
/** The Import tab: one walkthrough per cloud assistant, then one paste card that works anywhere. */
@@ -449,6 +476,111 @@ function createMemoryRoutes(ctx, kit) {
@@ -449,6 +476,111 @@ function createMemoryRoutes(ctx, kit) {
+ '<ul class="c4-rows">' + rows + '</ul></section>';
+ '<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&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. */
/** The confirmation page: what one source's removal would take, and the one form that does it. */
async function removePage(req, res) {
async function removePage(req, res) {
const source = String(req.query.source || '');
const source = String(req.query.source || '');
@@ -484,6 +616,87 @@ function createMemoryRoutes(ctx, kit) {
@@ -484,6 +616,87 @@ function createMemoryRoutes(ctx, kit) {
router.get('/memory', async (req, res) => memoryPage(req, res));
router.get('/memory', async (req, res) => memoryPage(req, res));
router.get('/memory/remove', async (req, res) => removePage(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.
// 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.
// 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) => {
router.post('/memory/import/preview', importUpload, async (req, res) => {
@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
@@ -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
// 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
// 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).
// 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}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,218 @@
@@ -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&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); });
@@ -62,6 +62,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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/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/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/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/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/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',
'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',