openvibers/openvibe.ai Commits
Memory M5: mem0, Letta and Zep with the person's key, team sharing, the portable file (#337)
Changes
nnew file mode 100644@@@ -0,0 +1,114 @@++# Memory M5: services, sharing and the portable file++++This is the fifth stage of the Memory plan (`../../OpenVibe_Memory_Plan_2026-10-30.md`). M1–M4 are the Node readers,++the store, the cloud imports, the write-back and the agent API. M5 adds three things to the same store:++++- the memory-service adapters (mem0, Letta, Zep) with the person's own key;++- team sharing of a memory store, through `openvibe-sdk/share`;++- the one portable JSON file, with an export and an import that round-trip.++++Nothing here changes how an item is sealed: `AI_MEMORY_KEY`, AES-256-GCM, the AAD `memory|<owner>|<id>|<scope>`, and++the same redaction on both ends.++++## The memory services++++| id | What it reads | Request |++|---|---|---|++| `mem0` | the user's memories on the mem0 Platform | `GET <base>/v1/memories/?page=&page_size=`, `Authorization: Token <key>` |++| `letta` | each agent's core-memory blocks and archival passages | `GET <base>/v1/agents/`, `/v1/agents/{id}/core-memory/blocks`, `/v1/agents/{id}/archival-memory`, `Authorization: Bearer <key>` |++| `zep` | each user's session memory | `GET <base>/users`, `/users/{id}/sessions`, `/users/{id}/sessions/{id}/memory`, `Authorization: Api-Key <key>`; the default base is `https://api.getzep.com/api/v2` |++++The endpoints are documented by the services: [mem0](https://docs.mem0.ai/api-reference/memory/get-memories),++[Letta](https://docs.letta.com/api-reference/agents) and [Zep](https://help.getzep.com/api-reference). A self-hosted++Letta or Zep names its own `base_url`. It is judged on save and again at every sync: https only, port 443 or 8443,++no credentials in the URL, no IP literal, no internal name, and its DNS answer must be public. The transport uses++`openvibe-shared/egress`'s safe lookup inside the connection (`server/memory/services/transport.js`), follows no++redirect, caps an answer at 1 MB and times out at 15 s. A refused base never sees the person's key.++++### The key++++`PUT /api/v1/memory/services/:provider { key, base_url?, account? }` stores the key in++`memory_service_connections.key_enc`, sealed exactly like a BYO provider key (`server/credentials.js`):++AES-256-GCM with `AI_CREDENTIALS_KEY`, the envelope `v1:<iv>:<tag>:<body>`, the AAD++`memory-service|<subject>|<provider>`, so a row moved to another person or provider does not decrypt.++++- The key is never returned. `GET /api/v1/memory/services` answers the last four characters (`hint`), the service's++ health, the last sync time and its count.++- The key is never put in a URL, a log line, an error, a span or an event. An adapter's refusal is a status and a++ fixed sentence; the tests assert that the key does not appear in the answer, the logs or the ciphertext.++- `PUT` tests the key by reading one page, and keeps the connection with `status: failing` and a short `last_error`++ when the service refuses it, so the person can fix the key or the address without losing the form.++- `DELETE` removes the row. The account deletion removes it too (`server/account-data.js`).++++### Sync++++`POST /api/v1/memory/services/:provider/sync` opens the key for one call, reads at most 1,000 records, drops an item++that is only a secret, and hands the rest to the store's own batch. The batch redacts each item again, seals it under++the person, and dedupes by its text MAC. A sync never overwrites an item: a note that says the same thing as one++already there is merged into that item, with the service added to its sources. The answer is counts only (`found`,++`imported`, `deduped`, `skipped`), never text.++++## Team sharing++++`server/memory/sharing.js` wires `openvibe-sdk/share` with one resource per person: service `ai`, kind++`memory-store`, id the owner's `usr_…`. The store is `private` until its owner changes it; the four audiences are the++library's (`public`, `link`, `people`, `private`), and a team uses `people` with named members. The share settings and++the notes are separate: `GET /api/v1/memory/stores/:owner` calls `canView` first and returns only active notes; a++person who is not a member gets `memory.not_found` and never a count or a title.++++- The uniform routes live at `/api/v1/memory-store/:id/share`; the owner's first GET registers the private row.++- `GET /api/v1/memory/shared` lists the stores shared with the person, with ids and access only.++- A read by a member is not an agent read and is not written to `memory_reads`; the read log is for agents.++- Network supplies the exact-name lookup and the notification push; Events receives the five `share.*` envelopes.++ When the deployment has no Network client secret or Events URL, the sharing routes answer 503++ `memory.sharing_unavailable` and the rest of the store keeps working.++- `openvibe-sdk/share` refuses a locked item that would be public. Memory does not use a content lock in M5.++++Codes renders the sheet and the gate from openvibe-shared `4.123.0`'s markup (`[data-ov-share-sheet]`,++`[data-ov-share-gate]`); there is no inline script. Its `/api/v1/memory-store/...` routes are a thin proxy that++forwards the person's token, the method, the body and `If-Match` to AI, and keeps AI's `ETag` and++`Cache-Control: private, no-store`.++++## The one portable file++++`ai.memory-export@1` (Contracts) is the only file format:++++```json++{++ "format": "openvibe.memory-export@1",++ "version": 1,++ "exported_at": "2026-11-07T09:30:00.000Z",++ "items": [++ {++ "text": "Prefers short answers.",++ "kind": "preference",++ "scope": "user",++ "source": "claude-code",++ "source_ref": { "path": ".claude/CLAUDE.md", "heading": "Style" },++ "status": "active",++ "confidence": 1,++ "seen_at": "2026-11-01T10:00:00.000Z",++ "created_at": "2026-10-30T10:00:00.000Z"++ }++ ]++}++```++++- `GET /api/v1/memory/export[?scope=]` writes it, decrypted for the person's own token, at most 10,000 items.++- `POST /api/v1/memory/import` reads the same document. It reads `format`, `version` and `items`; `exported_at` is++ informational. Each item is redacted again, a secret-only item is skipped, and a `conflict` item is imported as a++ suggestion because its pair did not travel. The store's own dedupe then merges a text that is already there.++- There is deliberately no `id`, `owner`, `merged_into`, `conflict_with` or profile. A file can be imported into++ another account without carrying anyone's identity, and a profile is derived from the notes and rebuilt.++- The round trip is proved in `test/memory-m5.test.js`: export A's store, import it as B, and compare text, kind,++ scope and source; importing the same file again changes nothing.++- The Node writes and reads the same file (`openvibe-node memory export|import`); Codes offers a download and an++ import form. The format is the contract, not either client.++++## Tests++++- `test/memory-m5.test.js`: the three adapters against local stubs of their HTTP APIs (auth headers, pagination and++ mapping), the sealed key (no plaintext in the row, the answers or the logs), the portable round trip, and a shared++ store that only its members can read, with `Cache-Control: no-store` on every answer.++- `test/memory.test.js` and `test/account-data.test.js`: the M1–M4 rules still hold, the account export is unchanged++ (a service key is never exported), and the share tables are erased with the person.nnew file mode 100644@@@ -0,0 +1,26 @@++-- phase: expand++-- OpenVibe Memory M5: the memory services a person connects with their own key (mem0, Letta, Zep).++--++-- The key is sealed exactly the way a BYO provider key is (server/credentials.js): AES-256-GCM with++-- AI_CREDENTIALS_KEY, `v1:<iv>:<tag>:<body>`, and the additional data `memory-service|<subject>|<provider>`, so a row++-- copied to another person or provider does not decrypt. The key is never returned by any route, never logged and++-- never put in an error; only `key_hint` (the last four characters) is shown to the person.++--++-- `account` is an optional user/agent id the service needs (Zep's user, Letta's agent when the person names one);++-- `base_url` is an optional self-hosted endpoint. `last_sync_*` are counts and times only, never text.++CREATE TABLE IF NOT EXISTS memory_service_connections (++ subject text COLLATE "C" NOT NULL, -- usr_…++ provider text COLLATE "C" NOT NULL, -- mem0 | letta | zep++ base_url text COLLATE "C",++ account text COLLATE "C",++ key_enc text COLLATE "C" NOT NULL, -- v1:<iv>:<tag>:<body>, never plaintext++ key_hint text COLLATE "C" NOT NULL, -- the last four characters++ status text COLLATE "C" NOT NULL DEFAULT 'untested', -- untested | ok | failing++ checked_at text COLLATE "C",++ last_error text COLLATE "C", -- redacted, at most 300 characters++ last_sync_at text COLLATE "C",++ last_sync_count integer NOT NULL DEFAULT 0,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (subject, provider)++);nnew file mode 100644@@@ -0,0 +1,71 @@++-- phase: expand++-- The one sharing system (openvibe-sdk/share) for a person's memory store: its audience (public, link, people,++-- private), the people it is shared with, the log and the delivery queue the SDK writes. Kept verbatim from the SDK's++-- own schema (node_modules/openvibe-sdk/src/share/schema.js at the pinned version), so its store finds the tables it++-- expects and the test database has them. The store's resource is one per person, kind 'memory-store', id usr_…;++-- only a member (or the owner) reads it, and the four audiences are the SDK's own.++CREATE TABLE IF NOT EXISTS shares (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),++ revision integer NOT NULL DEFAULT 1,++ lock text NOT NULL DEFAULT 'none',++ reseal_nonce_base text,++ PRIMARY KEY (service, kind, resource_id)++);++CREATE TABLE IF NOT EXISTS share_members (++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,++ key_slot jsonb,++ PRIMARY KEY (service, kind, resource_id, subject),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE TABLE IF NOT EXISTS share_transfers (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),++ key_slot jsonb,++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_access_requests (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)++);++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';++CREATE TABLE IF NOT EXISTS share_log (++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;++DO $$ BEGIN++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();++ END IF;++END $$;++CREATE TABLE IF NOT EXISTS share_deliveries (++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE++);++CREATE TABLE IF NOT EXISTS share_name_limits (++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL++);++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the++-- slot a transfer was offered with. Repeating this migration is a no-op.++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';++DO $$ BEGIN++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.308.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0","pg": "^8.23.0"@@@ -1540,9 +1540,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.289.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",++ "version": "0.308.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.308.0",++ "integrity": "sha512-652wGI95ogZwldAULN2UvhWG/xq/e2ADDDpQ4MoLnvU+bSbluCvZEvQln86Wvmw+0k5IEGuadedUWuu4G7YErw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -29,7 +29,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.308.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0","pg": "^8.23.0"@@@ -43,6 +43,9 @@ const TABLES = [// Memory M3: import previews are drafts, never part of the person's export; they go with the account like the// notes they would become. The payload is sealed, and the row is never read by the account export.{ table: 'memory_imports', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },++ // Memory M5: a connected memory service's sealed key. It goes with the account and is never exported: the key is++ // not the person's data to read back, and its existence is shown on the Memory page instead.++ { table: 'memory_service_connections', subject: 'subject', file: null, erase: 'delete' },{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },// D11 owner settings: the revisions are the service's configuration audit trail, kept like the catalogues.{ table: 'config_snapshots', subject: 'created_by', file: null, erase: { keep: 'the configuration history is the service\'s; created_by is the operator audit trail' } },@@@ -88,6 +91,16 @@ async function extraErase(t, subjects, counts) {counts.add(counts.erased, 'abuse_velocity', await t.exec("DELETE FROM abuse_velocity WHERE kind = 'device' AND key_hash = $1", [hash]));}const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);++ // Memory M5: the store's share rows. The person's own store goes, their memberships and requests go, and a++ // transfer with them on either side goes. The append-only log stays as a tombstone, like every other audit row.++ const forms = subjects.map((s) => JSON.stringify({ type: 'user', id: s }));++ const members = await t.exec('DELETE FROM share_members WHERE subject = ANY($1::jsonb[])', [forms]);++ const requests = await t.exec('DELETE FROM share_access_requests WHERE subject = ANY($1::jsonb[])', [forms]);++ const owned = await t.exec("DELETE FROM shares WHERE service = 'ai' AND kind = 'memory-store' AND owner_subject = ANY($1::jsonb[])", [forms]);++ const transfers = await t.exec("DELETE FROM share_transfers WHERE service = 'ai' AND kind = 'memory-store' AND (from_subject = ANY($1::jsonb[]) OR to_subject = ANY($1::jsonb[]))", [forms]);++ if (Number(members) || Number(requests) || Number(owned) || Number(transfers)) {++ counts.add(counts.erased, 'share_rows', Number(members) + Number(requests) + Number(owned) + Number(transfers));++ }if (!ids.length) return;counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));counts.add(counts.erased, 'citations', await t.exec('DELETE FROM citations WHERE run_id = ANY($1::text[])', [ids]));@@@ -108,4 +121,11 @@ function create({ db, log = console, memory = null } = {}) {return createAccountData({ db, service: 'ai', tables: TABLES, extraExport: exportAll, extraErase, log });}--module.exports = { create, TABLES, TOPICS, extraExport, extraErase };++// The sharing system's own tables carry a person as a jsonb subject (a member, an access request), which the table++// map cannot express; they are erased in extraErase above, with the rest of the person's rows.++const EXEMPT = {++ share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',++ share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',++};++++module.exports = { create, TABLES, TOPICS, EXEMPT, extraExport, extraErase };@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -162,7 +162,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));-- if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, services: memoryServices, sharing: memorySharing, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ // Memory M5: the uniform share routes for kind memory-store (and /api/v1/share/people, /api/v1/shared-with-me).++ if (memorySharing) app.use(memorySharing.router());if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -153,6 +153,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// The store's own maintenance (Memory M2c/M3): owner by owner, with a per-run cap, and the expired import// drafts. It is off in test mode and off while a provider is in a drill (server/memory/jobs.js).const memoryJobs = require('./memory/jobs').createMemoryJobs({ memory, memoryImport, config, clock, log, registry });++ // Memory M5: the memory services with the person's own key (mem0, Letta, Zep), and the store's team sharing.++ // Sharing is null when this deployment has no Network client secret or Events URL; its routes answer 503.++ const memoryServices = require('./memory/services').createMemoryServices({ db, config, clock, log, memory, transport: memoryTransport, lookup: memoryLookup });++ const memorySharing = require('./memory/sharing').createMemorySharing({ db, config, env, clock, log, fetchImpl });// The living price book (plan E1): the weekly job that reads every catalogued provider's own price listing and// keeps the price the router charges in step with it. A change of more than 20% waits for the owner on the// operator console. Off in test mode (the tests replay the recorded listings) and off under a drill.@@@ -164,7 +168,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -205,6 +209,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (listen && config.nodeEnv !== 'test') webhooks.start();if (listen) balance.start();if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();++ // Team sharing's expiry sweep and delivery queue: production only, so a test's database has no background writers.++ if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.if (listen && config.priceBook && config.priceBook.enabled) priceBook.start();// Durable Credit settles: the same jobs rule as Memory (never in test mode, never under a provider drill; the@@@ -242,6 +248,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (accountSubscription) accountSubscription.stop();userModules.stop();memoryJobs.stop();++ if (memorySharing) memorySharing.stop();priceBook.stop();webhooks.stop();if (credits) credits.stopSettlements();@@@ -259,7 +266,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, abuse, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, abuse, close };}if (require.main === module) {@@@ -27,6 +27,15 @@* POST /api/v1/memory/import/preview parse and redact an export into a sealed two-hour draft (M3)* POST /api/v1/memory/import/:id/commit keep the ticked candidates and delete the draft (M3)* DELETE /api/v1/memory/import/:id throw the draft away (M3)++ * GET /api/v1/memory/services the connected memory services, without any key (M5)++ * PUT /api/v1/memory/services/:provider store or replace mem0/Letta/Zep's key, sealed, then test it (M5)++ * DELETE /api/v1/memory/services/:provider forget the key (M5)++ * POST /api/v1/memory/services/:provider/sync read the service and put its notes in the store (M5)++ * GET /api/v1/memory/export the one portable file (ai.memory-export@1), decrypted for its owner (M5)++ * POST /api/v1/memory/import import that same file, redacted and deduped again (M5)++ * GET /api/v1/memory/shared the stores other people shared with this person (M5)++ * GET /api/v1/memory/stores/:owner one shared store's active notes, after canView (M5)++ * * /api/v1/memory-store/:id/share the uniform openvibe-sdk/share routes (M5, server/memory/sharing.js)** Every SQL query that reads or changes a memory table is scoped by `owner = $…` (test/memory.test.js greps for it).* The account export decrypts the person's own items for their own export; deletion removes the rows.@@@ -49,7 +58,7 @@*/const crypto = require('crypto');const express = require('express');--const { ids, http, capabilities } = require('openvibe-contracts');++const { ids, http, capabilities, validate } = require('openvibe-contracts');const { verifyUserToken } = require('openvibe-sdk/auth');const { redact, onlySecret } = require('openvibe-sdk/redact');const { AiError, iso, sha256 } = require('../util');@@@ -878,6 +887,94 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conreturn { reads: list, count: list.length };}++ /* ── Memory M5: the portable file and a shared store ──────────────────────────────────────────────── */++++ const PORTABLE_FORMAT = 'openvibe.memory-export@1';++ const PORTABLE_ITEMS = 10000;++++ /** The one portable file: every item the person has, with no id, owner, merge or conflict link. */++ async function portableExport(req, owner) {++ assertConfigured();++ const scope = req.query.scope ? validateScope(req.query.scope) : null;++ const args = [owner];++ let sql = 'SELECT * FROM memory_items WHERE owner = $1';++ if (scope) { args.push(scope); sql += ` AND scope = $${args.length}`; }++ sql += ' ORDER BY created_at DESC LIMIT ' + PORTABLE_ITEMS;++ const items = [];++ for (const row of await q(sql).all(...args)) {++ const v = itemView(row);++ items.push({++ text: v.text, kind: v.kind, scope: v.scope, source: String(v.source || 'person').split(',')[0],++ ...(v.source_ref ? { source_ref: v.source_ref } : {}),++ status: v.status, confidence: v.confidence,++ ...(v.machine ? { machine: v.machine } : {}),++ ...(v.seen_at ? { seen_at: v.seen_at } : {}),++ ...(v.created_at ? { created_at: v.created_at } : {}),++ });++ }++ return { format: PORTABLE_FORMAT, version: 1, exported_at: iso(clock.now()), items };++ }++++ /**++ * Import the same document. The items go through the store's own batch in chunks, so every one is redacted,++ * deduped by its text MAC and sealed under the importing person. A conflict cannot come alone (its pair did not++ * travel), so it is imported as a suggestion.++ */++ async function importPortable(req, owner) {++ assertConfigured();++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ if (body.format !== PORTABLE_FORMAT || Number(body.version) !== 1) throw new AiError(422, 'input.invalid', `the file must be ${PORTABLE_FORMAT}, version 1`);++ // The contract is the rule, not this function: ai.memory-export@1 (Contracts 0.308.0).++ const check = validate('ai.memory-export@1', body);++ if (!check.valid) throw new AiError(422, 'input.invalid', 'that JSON is not an ai.memory-export@1 file', { errors: check.errors.slice(0, 5) });++ const raw = body.items;++ if (!Array.isArray(raw)) throw new AiError(422, 'input.invalid', 'items must be an array');++ if (raw.length > PORTABLE_ITEMS) throw new AiError(422, 'input.invalid', `a portable file holds at most ${PORTABLE_ITEMS} items`);++ let skipped = 0;++ let created = 0;++ let deduped = 0;++ const prepared = [];++ for (const item of raw) {++ if (!item || typeof item !== 'object') { skipped++; continue; }++ const text = String(item.text == null ? '' : item.text);++ if (!text.trim()) { skipped++; continue; }++ const clean = redact(text).text;++ if (!clean.trim() || onlySecret(clean)) { skipped++; continue; }++ prepared.push({++ text: clean.slice(0, 8000),++ kind: validateKind(item.kind),++ scope: validateScope(item.scope, 'user'),++ source: validateSource(item.source || 'portable'),++ ...(item.source_ref != null ? { source_ref: validateSourceRef(item.source_ref) } : {}),++ status: item.status === 'conflict' ? 'suggested' : validateStatus(item.status, 'active'),++ confidence: item.confidence == null ? 1 : Math.max(0, Math.min(1, Number(item.confidence) || 0)),++ ...(item.machine != null ? { machine: String(item.machine).slice(0, 100) } : {}),++ ...(item.seen_at != null ? { seen_at: validateTime(item.seen_at, null) } : {}),++ });++ }++ for (let i = 0; i < prepared.length; i += MAX_BATCH) {++ const out = await batch({++ principal: { kind: 'user', subject: { type: 'user', id: owner.slice('user:'.length) }, claims: {} },++ body: { items: prepared.slice(i, i + MAX_BATCH) },++ }, owner);++ created += Number(out.created) || 0;++ deduped += Number(out.deduped) || 0;++ }++ return { format: PORTABLE_FORMAT, version: 1, imported: created + deduped, created, deduped, skipped };++ }++++ /** Active items of a store another person shared with the viewer. The caller has already checked canView. */++ async function sharedList(owner, { scope = null } = {}) {++ assertConfigured();++ if (!/^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner))) throw new AiError(404, 'memory.not_found', 'no such shared store');++ const args = [owner];++ let sql = "SELECT * FROM memory_items WHERE owner = $1 AND status = 'active'";++ if (scope) { validateScope(scope); args.push(scope); sql += ` AND scope = $${args.length}`; }++ sql += ' ORDER BY updated_at DESC LIMIT 200';++ const items = (await q(sql).all(...args)).map(itemView);++ return { items, count: items.length };++ }++/** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */async function exportFor(subject) {assertConfigured();@@@ -896,6 +993,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = conreturn {assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor, reads,split, settle, aadFor, macOf, seal, open,++ // M5: the one portable file, its import, and the read of a store somebody shared.++ portableExport, importPortable, sharedList,// The store's own maintenance (server/memory/jobs.js) drives the store through these.ownersForJobs, scopesFor, embedMissing, reconcileScope, refreshProfile, profileFor,};@@@ -906,7 +1005,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con* names the person with `on_behalf_of`, but it may only search and read the profile of a granted scope (both* audited) and import suggestions. Every other route is person-only: a service gets 403 `capability.denied`.*/--function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {++function memoryRouter({ memory, memoryImport = null, services = null, sharing = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {const r = express.Router();// Memory M3: an export zip is raw bytes (or one multipart form) and can be a few MB. The same route also takes// pasted text as JSON, which the app-level JSON parser has already read.@@@ -984,6 +1083,48 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now// M2c: undo a merge, and settle a conflict. Both are the person's own: a service may only search and suggest.r.post('/api/v1/memory/items/:id/split', guard(WRITE_CAP), personOnly, run(async (req) => await memory.split(req, ownerOf(req), req.params.id)));r.post('/api/v1/memory/items/:id/settle', guard(WRITE_CAP), personOnly, run(async (req) => await memory.settle(req, ownerOf(req), req.params.id)));++++ /* ── Memory M5: the memory services, the portable file and team sharing ───────────────────────────── */++++ const unavailable = (what) => { throw new AiError(503, 'memory.services_unavailable', `${what} is not configured on this service`); };++ const subjectOf = (req) => req.principal.subject.id;++++ r.get('/api/v1/memory/services', guard(READ_CAP), personOnly, run(async (req) => {++ if (!services) unavailable('the memory-service connections');++ return await services.list(subjectOf(req));++ }));++ r.put('/api/v1/memory/services/:provider', guard(WRITE_CAP), personOnly, run(async (req) => {++ if (!services) unavailable('the memory-service connections');++ return await services.put(subjectOf(req), String(req.params.provider), req.body);++ }));++ r.delete('/api/v1/memory/services/:provider', guard(WRITE_CAP), personOnly, run(async (req) => {++ if (!services) unavailable('the memory-service connections');++ return await services.remove(subjectOf(req), String(req.params.provider));++ }));++ r.post('/api/v1/memory/services/:provider/sync', guard(WRITE_CAP), personOnly, run(async (req) => {++ if (!services) unavailable('the memory-service connections');++ return await services.sync(subjectOf(req), String(req.params.provider));++ }));++++ // The one portable file. Person-only: it is the person's own plaintext, decrypted for their own export.++ r.get('/api/v1/memory/export', guard(READ_CAP), personOnly, run(async (req) => await memory.portableExport(req, ownerOf(req))));++ r.post('/api/v1/memory/import', guard(WRITE_CAP), personOnly, run(async (req) => await memory.importPortable(req, ownerOf(req))));++++ // Team sharing: what is shared with the person, and one shared store's active notes (never through a search).++ const shareViewer = (req) => (req.principal.kind === 'user' ? { subject: { type: 'user', id: subjectOf(req) }, staff: false } : null);++ r.get('/api/v1/memory/shared', guard(READ_CAP), personOnly, run(async (req) => {++ if (!sharing) unavailable('memory sharing');++ return { stores: await sharing.sharedStores(shareViewer(req)) };++ }));++ r.get('/api/v1/memory/stores/:owner', guard(READ_CAP), personOnly, run(async (req) => {++ if (!sharing) unavailable('memory sharing');++ const owner = String(req.params.owner || '');++ if (!SUBJECT_RE.test(owner)) throw new AiError(404, 'memory.not_found', 'no such shared store');++ if (!await sharing.canView(shareViewer(req), owner)) throw new AiError(404, 'memory.not_found', 'no such shared store');++ const out = await memory.sharedList(`user:${owner}`, { scope: req.query.scope ? String(req.query.scope) : null });++ return { owner, ...out };++ }));++// The owner is resolved after the guard has set the principal.function ownerOf(req) {const body = req.body && typeof req.body === 'object' ? req.body : {};nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++/**++ * The memory-service adapters (Memory M5): mem0, Letta and Zep.++ *++ * Each adapter is data plus one read: where the service keeps a person's notes, how to page through them and how to++ * turn one record into a memory item. They never store anything; server/memory/services/index.js seals the key,++ * calls one adapter with it and hands the items to the store's own batch (which redacts again).++ *++ * The key travels in the request header and nowhere else. It is never put in a URL, a log line, an error message or++ * a response: an upstream refusal becomes a status code and a short, fixed sentence, even when the service echoes++ * the key back. The endpoints and their shapes:++ *++ * mem0 GET <base>/v1/memories/?page=&page_size= Authorization: Token <key>++ * { results: [{ id, memory, created_at }], next } (or a bare array without paging)++ * https://docs.mem0.ai/api-reference/memory/get-memories++ *++ * Letta GET <base>/v1/agents/ Authorization: Bearer <key>++ * GET <base>/v1/agents/{id}/core-memory/blocks (the core memory: { label, value })++ * GET <base>/v1/agents/{id}/archival-memory (the archival passages: { text })++ * https://docs.letta.com/api-reference/agents++ *++ * Zep GET <base>/users Authorization: Api-Key <key>++ * GET <base>/users/{user_id}/sessions++ * GET <base>/users/{user_id}/sessions/{session_id}/memory (facts or messages)++ * <base> defaults to https://api.getzep.com/api/v2++ * https://help.getzep.com/api-reference++ *++ * A service that pages answers at most MAX_ITEMS records per sync; the store dedupes what a later sync finds again.++ */++++const MAX_ITEMS = 1000;++const MAX_PAGES = 10;++const PAGE = 100;++const MAX_CHARS = 2000;++++const { ServiceError } = require('./transport');++++const NAMES = { mem0: 'mem0', letta: 'Letta', zep: 'Zep' };++const DEFAULT_BASE = { mem0: 'https://api.mem0.ai', letta: 'https://api.letta.com', zep: 'https://api.getzep.com/api/v2' };++++function baseOf(id, baseUrl) {++ return String(baseUrl || DEFAULT_BASE[id]).replace(/\/+$/, '');++}++++/** One record → one item. Text is cut to the store's own per-item bound; the store redacts and dedupes. */++function itemOf(text, { kind = 'fact', created_at, path } = {}) {++ const clean = String(text == null ? '' : text).replace(/\r\n/g, '\n').trim();++ if (!clean) return null;++ return {++ text: clean.slice(0, MAX_CHARS),++ kind,++ scope: 'user',++ source_ref: path ? { path: String(path).slice(0, 500) } : null,++ ...(created_at && !Number.isNaN(Date.parse(String(created_at))) ? { seen_at: new Date(created_at).toISOString() } : {}),++ };++}++++const arrayOf = (json, key) => Array.isArray(json) ? json : (json && Array.isArray(json[key]) ? json[key] : []);++++/* ── mem0 ─────────────────────────────────────────────────────────────────── */++async function mem0List({ key, baseUrl, request, limit = MAX_ITEMS }) {++ const base = baseOf('mem0', baseUrl);++ const out = [];++ for (let page = 1; page <= MAX_PAGES && out.length < limit; page++) {++ const url = `${base}/v1/memories/?page=${page}&page_size=${PAGE}`;++ const json = await request(url, { Accept: 'application/json', Authorization: `Token ${key}` });++ const rows = arrayOf(json, 'results');++ for (const row of rows) {++ const it = itemOf(row && row.memory, { created_at: row && row.created_at, path: row && row.id ? `mem0:${row.id}` : 'mem0' });++ if (it) out.push(it);++ if (out.length >= limit) break;++ }++ const next = json && typeof json === 'object' ? json.next : null;++ if (!rows.length || (!next && rows.length < PAGE)) break;++ }++ return out;++}++++/* ── Letta ────────────────────────────────────────────────────────────────── */++async function lettaList({ key, baseUrl, request, limit = MAX_ITEMS }) {++ const base = baseOf('letta', baseUrl);++ const headers = { Accept: 'application/json', Authorization: `Bearer ${key}` };++ const agents = arrayOf(await request(`${base}/v1/agents/`, headers), 'agents');++ const out = [];++ for (const agent of agents) {++ if (!agent || !agent.id || out.length >= limit) continue;++ const id = encodeURIComponent(String(agent.id));++ let blocks = [];++ try { blocks = arrayOf(await request(`${base}/v1/agents/${id}/core-memory/blocks`, headers), 'blocks'); }++ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; /* one agent's blocks */ }++ for (const block of blocks) {++ // `persona` is what the agent is; `human` and the rest are what it knows about the person.++ const kind = block && block.label === 'persona' ? 'fact' : 'preference';++ const it = itemOf(block && block.value, { kind, path: `letta:${agent.id}:${(block && block.label) || 'block'}` });++ if (it) out.push(it);++ if (out.length >= limit) break;++ }++ if (out.length >= limit) break;++ let passages = [];++ try { passages = arrayOf(await request(`${base}/v1/agents/${id}/archival-memory?limit=${PAGE}`, headers), 'passages'); }++ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; /* no archival memory */ }++ for (const passage of passages) {++ const it = itemOf(passage && passage.text, { created_at: passage && passage.created_at, path: passage && passage.id ? `letta:${passage.id}` : `letta:${agent.id}` });++ if (it) out.push(it);++ if (out.length >= limit) break;++ }++ }++ return out;++}++++/* ── Zep ──────────────────────────────────────────────────────────────────── */++/** Zep v2 keeps a session's memory as facts; older responses carry messages. Both are read, never trusted to exist. */++function zepMemories(json) {++ const out = [];++ for (const container of [json && json.memory, json]) {++ if (!container || typeof container !== 'object') continue;++ for (const k of ['facts', 'messages', 'results']) {++ if (Array.isArray(container[k])) out.push(...container[k]);++ }++ }++ return out;++}++++async function zepList({ key, baseUrl, request, limit = MAX_ITEMS }) {++ const base = baseOf('zep', baseUrl);++ const headers = { Accept: 'application/json', Authorization: `Api-Key ${key}` };++ const users = arrayOf(await request(`${base}/users`, headers), 'users');++ const out = [];++ for (const user of users) {++ const uid = user && (user.user_id || user.id);++ if (!uid || out.length >= limit) continue;++ let sessions = [];++ try { sessions = arrayOf(await request(`${base}/users/${encodeURIComponent(String(uid))}/sessions`, headers), 'sessions'); }++ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; continue; }++ for (const session of sessions) {++ const sid = session && (session.session_id || session.id);++ if (!sid || out.length >= limit) continue;++ let json;++ try { json = await request(`${base}/users/${encodeURIComponent(String(uid))}/sessions/${encodeURIComponent(String(sid))}/memory`, headers); }++ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; continue; }++ for (const memory of zepMemories(json)) {++ const text = memory && (memory.fact || memory.content || memory.text);++ const it = itemOf(text, { kind: 'fact', created_at: memory && (memory.created_at || memory.valid_at || memory.createdAt), path: memory && memory.uuid ? `zep:${memory.uuid}` : `zep:${uid}` });++ if (it) out.push(it);++ if (out.length >= limit) break;++ }++ }++ }++ return out;++}++++const ADAPTERS = Object.freeze({++ mem0: { id: 'mem0', name: NAMES.mem0, docs: 'https://docs.mem0.ai/api-reference/memory/get-memories', defaultBaseUrl: DEFAULT_BASE.mem0, list: mem0List },++ letta: { id: 'letta', name: NAMES.letta, docs: 'https://docs.letta.com/api-reference/agents', defaultBaseUrl: DEFAULT_BASE.letta, list: lettaList },++ zep: { id: 'zep', name: NAMES.zep, docs: 'https://help.getzep.com/api-reference', defaultBaseUrl: DEFAULT_BASE.zep, list: zepList },++});++++const PROVIDERS = Object.freeze(Object.keys(ADAPTERS));++++module.exports = { ADAPTERS, PROVIDERS, MAX_ITEMS, MAX_CHARS, ServiceError, itemOf };nnew file mode 100644@@@ -0,0 +1,152 @@++'use strict';++/**++ * The memory-service connections (Memory M5): mem0, Letta and Zep, each with the person's own key.++ *++ * GET /api/v1/memory/services the connections, without any key (the last four characters only)++ * PUT /api/v1/memory/services/:provider store or replace the key, then test it++ * DELETE /api/v1/memory/services/:provider forget the key++ * POST /api/v1/memory/services/:provider/sync read the service and put what it has into the store++ *++ * The key is sealed (server/memory/services/keys.js) and opened only for the duration of one probe or sync. It is++ * never in a URL, a log line, an error or an answer. A sync hands the adapter's items to the store's own batch, so++ * they are redacted again there, deduped by the same text MAC and scoped to the person who connected the service.++ */++++const { redact, onlySecret } = require('openvibe-sdk/redact');++const { AiError } = require('../../util');++const { createServiceKeys } = require('./keys');++const { ADAPTERS, PROVIDERS } = require('./adapters');++const { createServiceTransport, assertPublicBaseUrl } = require('./transport');++++const MAX_SYNC = 1000;++const BATCH = 200;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createMemoryServices({ db, config, clock = { now: () => Date.now() }, log = console, memory, transport = null, lookup = null }) {++ const keys = createServiceKeys({ db, config, clock });++ const guarded = createServiceTransport({ transport, lookup });++ /** The stored base (or the adapter's default), checked again: a legacy row is never sent a key to a private host. */++ async function baseFor(row, adapter) {++ const u = await assertPublicBaseUrl(String((row && row.base_url) || adapter.defaultBaseUrl), { lookup });++ return u.toString().replace(/\/+$/, '');++ }++ const adapterOf = (provider) => {++ const adapter = ADAPTERS[provider];++ if (!adapter) throw new AiError(404, 'memory.service_not_found', 'no such memory service');++ return adapter;++ };++++ /** The person's connections, one row per provider that has a key. Never a key. */++ async function list(subject) {++ const rows = await keys.list(subject);++ return {++ providers: PROVIDERS.map((id) => {++ const adapter = ADAPTERS[id];++ const row = rows.find((r) => r.provider === id);++ return {++ provider: id, name: adapter.name, docs: adapter.docs, default_base_url: adapter.defaultBaseUrl,++ connected: !!row,++ ...(row || { hint: null, base_url: null, account: null, status: 'untested', checked_at: null, last_error: null, last_sync_at: null, last_sync_count: 0 }),++ };++ }),++ };++ }++++ /**++ * Store a key, then test it by reading one page. The URL is judged (and resolved) before anything is stored, so a++ * key is never written for a private or malformed host; a key the service refuses is kept with the connection.++ */++ async function put(subject, provider, body) {++ const adapter = adapterOf(provider);++ const b = body && typeof body === 'object' ? body : {};++ let base = null;++ if (b.base_url != null && b.base_url !== '') {++ // 422 before keys.put: the key must not even be stored for a host that cannot be reached safely.++ base = (await assertPublicBaseUrl(b.base_url, { lookup })).toString().replace(/\/+$/, '');++ }++ if (b.account != null && (typeof b.account !== 'string' || b.account.length > 200)) throw new AiError(422, 'input.invalid', 'account must be a string of at most 200 characters');++ const saved = await keys.put(subject, provider, { key: b.key, base_url: base, account: b.account || null });++ const opened = await keys.openFor(subject, provider);++ let probe;++ try {++ const found = await adapter.list({ key: opened.key, baseUrl: opened.row.base_url, request: guarded.get, limit: 1 });++ probe = { ok: true, count: found.length };++ } catch (err) {++ // A redirect, a blocked answer or an oversized body is the URL's problem, not the key's: forget the row++ // and refuse the save. A refused key stays, with its redacted reason, so the person can replace it.++ if (['memory.service_redirect', 'memory.service_blocked', 'memory.service_too_large'].includes(String(err && err.code))) {++ await keys.remove(subject, provider);++ throw new AiError(422, 'memory.service_url', err.message);++ }++ probe = { ok: false, error: keys.scrub(err && err.message, opened.key) };++ log.warn(`[memory] ${provider} key test failed: ${err && err.code ? err.code : 'error'}`);++ }++ await keys.markProbe(subject, provider, probe);++ const row = (await keys.list(subject)).find((r) => r.provider === provider);++ return { service: row || saved };++ }++++ async function remove(subject, provider) {++ adapterOf(provider);++ const gone = await keys.remove(subject, provider);++ if (!gone) throw new AiError(404, 'memory.service_not_found', 'that memory service is not connected');++ return { removed: 1 };++ }++++ /**++ * Read one connected service and put its notes in the person's store. The adapter sees the decrypted key for this++ * call only; the store's batch does the redaction, the dedupe and the suggestion rule again.++ */++ async function sync(subject, provider) {++ const adapter = adapterOf(provider);++ const owner = `user:${subject}`;++ const view = (await keys.list(subject)).find((r) => r.provider === provider);++ if (!view) throw new AiError(404, 'memory.service_not_found', 'that memory service is not connected');++ // Judge and resolve the base before the key is opened: a refused host never sees the person's key.++ const base = await baseFor(view, adapter);++ const { key } = await keys.openFor(subject, provider);++ let found;++ try {++ found = await adapter.list({ key, baseUrl: base, request: guarded.get, limit: MAX_SYNC });++ } catch (err) {++ await keys.markProbe(subject, provider, { ok: false, error: err && err.message });++ log.warn(`[memory] ${provider} sync failed: ${err && err.code ? err.code : 'error'}`);++ if (String(err && err.code) === 'memory.service_blocked') {++ throw new AiError(422, 'memory.service_url', 'the service resolves to a non-public address');++ }++ if (String(err && err.code) === 'memory.service_redirect') {++ throw new AiError(502, 'memory.service_redirect', 'the service answered with a redirect');++ }++ throw new AiError(err && err.status === 401 ? 401 : 502, err && err.code ? err.code : 'memory.service_failed',++ err && err.status === 401 ? 'the service refused the key' : 'the memory service did not answer');++ }++ // Redact here as well as in the store, so an item that is only a secret is dropped (not a whole batch refused).++ const items = [];++ let skipped = 0;++ for (const raw of found) {++ const text = String((raw && raw.text) || '');++ if (!text.trim() || onlySecret(redact(text).text)) { skipped++; continue; }++ items.push({++ text: text.slice(0, 8000), kind: raw.kind, scope: 'user', source: provider,++ source_ref: raw.source_ref || null, confidence: 0.8, ...(raw.seen_at ? { seen_at: raw.seen_at } : {}),++ });++ }++ let created = 0;++ let deduped = 0;++ for (let i = 0; i < items.length; i += BATCH) {++ const chunk = items.slice(i, i + BATCH);++ const out = await memory.batch({++ principal: { kind: 'user', subject: { type: 'user', id: subject }, claims: {} },++ body: { items: chunk },++ }, owner);++ created += Number(out.created) || 0;++ deduped += Number(out.deduped) || 0;++ }++ await keys.markSync(subject, provider, items.length);++ return { provider, found: found.length, imported: created, deduped, skipped };++ }++++ return { list, put, remove, sync, adapterOf, PROVIDERS, SUBJECT_RE };++}++++module.exports = { createMemoryServices, SUBJECT_RE };nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * The sealed keys of the memory services (Memory M5).++ *++ * A key is stored the way a BYO provider key already is in OpenVibe.AI (server/credentials.js): AES-256-GCM with++ * AI_CREDENTIALS_KEY, the envelope `v1:<iv>:<tag>:<body>`, and the additional data++ * `memory-service|<subject>|<provider>`, so a row moved to another person or provider does not decrypt. The plaintext++ * exists only while one sync runs. It is never returned by a route, written to a log, put in an error or an account++ * export; the person sees the last four characters (`key_hint`) and the service's health.++ */++const crypto = require('crypto');++const { AiError } = require('../../util');++++const KEY_RE = /^[\x21-\x7e]{8,400}$/; // printable, no spaces: an API key, not a passphrase++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++const iso = (ms) => new Date(ms).toISOString();++++/** Replace anything that looks like the key before a reason is stored or answered. */++function scrub(text, key) {++ let out = String(text == null ? '' : text);++ if (key) out = out.split(String(key)).join('[redacted]');++ return out.replace(/\b(sk|key|token|api[-_]?key)[-_][A-Za-z0-9_-]{8,}\b/gi, '[redacted]').slice(0, 300);++}++++function createServiceKeys({ db, config, clock = { now: () => Date.now() } }) {++ function secretKey() {++ const hex = String(config.credentialsKey || '');++ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'memory.services_unavailable', 'memory services are not configured on this service (AI_CREDENTIALS_KEY)');++ return Buffer.from(hex, 'hex');++ }++ function aadFor(subject, provider) { return `memory-service|${subject}|${provider}`; }++ function seal(plain, aad) {++ const iv = crypto.randomBytes(12);++ const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);++ c.setAAD(Buffer.from(aad));++ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);++ return `v1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;++ }++ function open(sealed, aad) {++ const [v, iv, tag, body] = String(sealed).split(':');++ if (v !== 'v1') throw new Error('unknown memory-service key format');++ const d = crypto.createDecipheriv('aes-256-gcm', secretKey(), Buffer.from(iv, 'base64'));++ d.setAAD(Buffer.from(aad));++ d.setAuthTag(Buffer.from(tag, 'base64'));++ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');++ }++ const hintOf = (key) => String(key).slice(-4);++++ function view(row) {++ return {++ provider: row.provider,++ hint: row.key_hint,++ base_url: row.base_url || null,++ account: row.account || null,++ status: row.status || 'untested',++ checked_at: row.checked_at || null,++ last_error: row.last_error || null,++ last_sync_at: row.last_sync_at || null,++ last_sync_count: Number(row.last_sync_count) || 0,++ created_at: row.created_at,++ updated_at: row.updated_at,++ };++ }++++ async function list(subject) {++ if (!SUBJECT_RE.test(String(subject))) return [];++ const rows = await db.many('SELECT * FROM memory_service_connections WHERE subject = $1 ORDER BY provider', [String(subject)]);++ return rows.map(view);++ }++++ async function row(subject, provider) {++ return await db.maybe('SELECT * FROM memory_service_connections WHERE subject = $1 AND provider = $2', [String(subject), String(provider)]);++ }++++ /** Store or replace a key. The key itself never comes back. */++ async function put(subject, provider, { key, base_url, account } = {}) {++ if (!SUBJECT_RE.test(String(subject))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');++ if (key != null && !KEY_RE.test(String(key))) throw new AiError(422, 'input.invalid', 'key must be 8-400 printable characters with no spaces');++ const at = iso(clock.now());++ const prev = await row(subject, provider);++ if (key == null && !prev) throw new AiError(400, 'memory.service_key_required', 'key is required the first time');++ const plain = key != null ? String(key) : open(prev.key_enc, aadFor(subject, provider));++ await db.exec(`INSERT INTO memory_service_connections (subject, provider, base_url, account, key_enc, key_hint, status, checked_at, last_error, last_sync_at, last_sync_count, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, 'untested', NULL, NULL, $7, $8, $9, $10)++ ON CONFLICT (subject, provider) DO UPDATE SET base_url = excluded.base_url, account = excluded.account,++ key_enc = excluded.key_enc, key_hint = excluded.key_hint, status = 'untested', checked_at = NULL,++ last_error = NULL, last_sync_at = excluded.last_sync_at, last_sync_count = excluded.last_sync_count,++ updated_at = excluded.updated_at`,++ [String(subject), String(provider), base_url ? String(base_url).slice(0, 500) : null, account ? String(account).slice(0, 200) : null,++ seal(plain, aadFor(subject, provider)), hintOf(plain), prev ? prev.last_sync_at : null, prev ? prev.last_sync_count : 0,++ prev ? prev.created_at : at, at]);++ return view(await row(subject, provider));++ }++++ async function remove(subject, provider) {++ const gone = await db.exec('DELETE FROM memory_service_connections WHERE subject = $1 AND provider = $2', [String(subject), String(provider)]);++ return Number(gone) > 0;++ }++++ /** The row and its decrypted key, for one sync only. Never returned to a route. */++ async function openFor(subject, provider) {++ const r = await row(subject, provider);++ if (!r) throw new AiError(404, 'memory.service_not_found', 'that memory service is not connected');++ let key;++ try { key = open(r.key_enc, aadFor(subject, provider)); }++ catch { throw new AiError(500, 'memory.service_unreadable', 'the saved key for this service cannot be opened'); }++ return { row: r, key };++ }++++ async function markProbe(subject, provider, { ok, error, count = 0 } = {}) {++ const at = iso(clock.now());++ await db.exec(`UPDATE memory_service_connections SET status = $1, checked_at = $2, last_error = $3, updated_at = $2++ WHERE subject = $4 AND provider = $5`, [ok ? 'ok' : 'failing', at, ok ? null : scrub(error).slice(0, 300), String(subject), String(provider)]);++ if (ok) await db.exec('UPDATE memory_service_connections SET last_sync_count = $1 WHERE subject = $2 AND provider = $3', [Number(count) || 0, String(subject), String(provider)]);++ }++++ async function markSync(subject, provider, count) {++ const at = iso(clock.now());++ await db.exec(`UPDATE memory_service_connections SET last_sync_at = $1, last_sync_count = $2, status = 'ok', checked_at = $1,++ last_error = NULL, updated_at = $1 WHERE subject = $3 AND provider = $4`, [at, Number(count) || 0, String(subject), String(provider)]);++ }++++ return { list, put, remove, openFor, markProbe, markSync, view, scrub, aadFor, seal, open };++}++++module.exports = { createServiceKeys, scrub, KEY_RE, SUBJECT_RE };nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++/**++ * The guarded transport of the memory-service adapters (Memory M5).++ *++ * A person names their own mem0, Letta or Zep address, so every request to it is an untrusted URL. It goes through++ * the same guard OpenVibe.AI already uses for a person's own endpoint (server/community/endpoints.js):++ *++ * - https only, no credentials in the URL, port 443 or 8443;++ * - never an IP literal, never an internal name (localhost, *.local, *.internal, a single label);++ * - the DNS answer is judged inside the connection's own lookup (openvibe-shared/egress), so the socket connects++ * to the checked address and a second answer cannot swap it (no rebinding);++ * - a redirect is never followed: a 3xx is the error "the service answered with a redirect";++ * - a 15 s timeout and a 1 MB response cap on every call.++ *++ * The `transport` and `lookup` seams are for tests only. They are injected by the test helper, never read from config++ * or the environment, so a deployment cannot turn the guard off and a test can script a response without a socket.++ */++const net = require('net');++const https = require('https');++const egress = require('openvibe-shared/egress');++const { AiError } = require('../../util');++++const ALLOWED_PORTS = new Set(['', '443', '8443']);++const TIMEOUT_MS = 15000;++const MAX_BYTES = 1024 * 1024;++const DNS_TIMEOUT_MS = 5000;++++class ServiceError extends Error {++ constructor(status, code, message) {++ super(message);++ this.status = status;++ this.code = code;++ }++}++++/**++ * The one URL rule for a memory service. Returns the URL (its pathname without a trailing slash), or throws a 422++ * whose words never echo the host: the value came from a person and must not come back in an error.++ */++function judgeUrl(raw, { allowQuery = false } = {}) {++ const text = String(raw == null ? '' : raw);++ if (text.length > 500) throw new AiError(422, 'memory.service_url', 'base_url is too long');++ let u;++ try { u = new URL(text); } catch { throw new AiError(422, 'memory.service_url', 'base_url must be an https URL'); }++ if (u.protocol !== 'https:') throw new AiError(422, 'memory.service_url', 'base_url must be https');++ if (u.username || u.password) throw new AiError(422, 'memory.service_url', 'credentials in base_url are not allowed');++ if (u.hash || (!allowQuery && u.search)) throw new AiError(422, 'memory.service_url', 'base_url carries no query or fragment');++ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'memory.service_url', 'base_url must use port 443 or 8443');++ const host = egress.normalizeHost(u.hostname);++ // Node never calls the lookup hook for an IP literal, so the literal itself is the check.++ if (net.isIP(host)) throw new AiError(422, 'memory.service_url', 'base_url must name a public host, not an IP address');++ if (egress.isInternalName(host)) throw new AiError(422, 'memory.service_url', 'base_url must be a public host name');++ u.hostname = host;++ // A stored base URL has no trailing slash; a request URL keeps its own path exactly as the service documents it.++ if (!allowQuery) u.pathname = u.pathname.replace(/\/+$/, '') || '';++ return u;++}++++/** A base URL: the stored form, no query, no trailing slash. */++function judgeBaseUrl(raw) {++ return judgeUrl(raw, { allowQuery: false });++}++++/** A request URL: the base plus its fixed path and (for mem0) paging query, still guarded. */++function judgeRequestUrl(raw) {++ return judgeUrl(raw, { allowQuery: true });++}++++/** Resolve a host through the guard, for the save check. A private answer is a 422, and the key is never used. */++function assertPublicBaseUrl(raw, { lookup = null } = {}) {++ const u = judgeBaseUrl(raw);++ const safe = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });++ return new Promise((resolve, reject) => {++ let done = false;++ const finish = (err) => {++ if (done) return;++ done = true;++ clearTimeout(timer);++ if (!err) return resolve(u);++ if (err && err.code === 'EGRESS_DENIED') return reject(new AiError(422, 'memory.service_url', 'base_url must resolve to a public address'));++ return reject(new AiError(422, 'memory.service_url', 'base_url could not be resolved'));++ };++ const timer = setTimeout(() => finish(new Error('dns timeout')), DNS_TIMEOUT_MS);++ if (timer.unref) timer.unref();++ safe(u.hostname, { all: true }, finish);++ });++}++++/** One response, without following anything. */++function once(target, headers, safeLookup) {++ return new Promise((resolve, reject) => {++ let settled = false;++ let req;++ const fail = (err) => {++ if (settled) return;++ settled = true;++ clearTimeout(timer);++ try { if (req) req.destroy(); } catch { /* already gone */ }++ reject(err);++ };++ const ok = (value) => {++ if (settled) return;++ settled = true;++ clearTimeout(timer);++ resolve(value);++ };++ req = https.request(target, { method: 'GET', headers, lookup: safeLookup, timeout: TIMEOUT_MS, agent: false }, (res) => {++ const status = res.statusCode || 0;++ if (status >= 300 && status < 400) { res.resume(); return ok({ status, headers: res.headers, body: '' }); }++ const declared = Number(res.headers['content-length']);++ if (Number.isFinite(declared) && declared > MAX_BYTES) {++ res.resume();++ return fail(new ServiceError(413, 'memory.service_too_large', 'the service answered more than 1 MB'));++ }++ let bytes = 0;++ const chunks = [];++ res.on('data', (chunk) => {++ bytes += chunk.length;++ if (bytes > MAX_BYTES) {++ res.destroy();++ return fail(new ServiceError(413, 'memory.service_too_large', 'the service answered more than 1 MB'));++ }++ chunks.push(chunk);++ });++ res.on('error', () => fail(new ServiceError(502, 'memory.service_unreachable', 'the service could not be reached')));++ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));++ });++ req.on('error', (err) => fail(new ServiceError(502, err && err.code === 'EGRESS_DENIED' ? 'memory.service_blocked' : 'memory.service_unreachable',++ err && err.code === 'EGRESS_DENIED' ? 'the service resolves to a non-public address' : 'the service could not be reached')));++ req.on('timeout', () => fail(new ServiceError(504, 'memory.service_timeout', 'the service did not answer within 15s')));++ const timer = setTimeout(() => fail(new ServiceError(504, 'memory.service_timeout', 'the service did not answer within 15s')), TIMEOUT_MS);++ req.on('close', () => clearTimeout(timer));++ req.end();++ });++}++++/** What an answer means, as the adapter reads it. The body is JSON; any 3xx is a refusal, never a hop. */++async function readJson(res) {++ const status = Number(res && res.status) || 0;++ if (status >= 300 && status < 400) throw new ServiceError(502, 'memory.service_redirect', 'the service answered with a redirect');++ if (status === 401 || status === 403) throw new ServiceError(401, 'memory.service_key_refused', 'the service refused the key');++ if (status === 429) throw new ServiceError(429, 'memory.service_rate_limited', 'the service is rate-limiting the key; try again later');++ if (status < 200 || status >= 300) throw new ServiceError(502, 'memory.service_failed', 'the service did not answer successfully');++ const body = String((res && res.body) || '');++ if (Buffer.byteLength(body) > MAX_BYTES) throw new ServiceError(413, 'memory.service_too_large', 'the service answered more than 1 MB');++ try { return JSON.parse(body || 'null'); } catch { throw new ServiceError(502, 'memory.service_failed', 'the service answered with a body that is not JSON'); }++}++++function createServiceTransport({ lookup = null, transport = null } = {}) {++ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });++ /** GET one URL with the adapter's headers. The URL is judged here again, on every call. */++ async function get(rawUrl, headers = {}) {++ const target = judgeRequestUrl(rawUrl);++ const res = transport++ ? await transport({ method: 'GET', url: target.toString(), headers, timeoutMs: TIMEOUT_MS, maxBytes: MAX_BYTES })++ : await once(target, headers, safeLookup);++ return await readJson(res);++ }++ return { get };++}++++module.exports = {++ createServiceTransport, assertPublicBaseUrl, judgeBaseUrl, judgeRequestUrl,++ ServiceError, TIMEOUT_MS, MAX_BYTES, ALLOWED_PORTS,++};nnew file mode 100644@@@ -0,0 +1,206 @@++'use strict';++/**++ * Team sharing of a memory store (Memory M5), through openvibe-sdk/share.++ *++ * The store belongs to one person and lives in AI (server/memory). Its share row is one per person: service `ai`,++ * kind `memory-store`, id the owner's `usr_…`. The library owns the model and the rules (the four audiences, the++ * members, requests, transfers, the append-only log and the delivery queue); this file is the wiring AI supplies:++ *++ * network.json Network's identity lookups (`identity.subject.resolve`), so a sheet names @people and resolves++ * an exact name to a subject. It also fetches the projections the sheet shows.++ * events.publish AI's Events producer (`events.event.publish`), so the five share.* envelopes go out. A failure++ * leaves the delivery queued and retried; the ACL change itself is already committed.++ * notify Network's notification push (`network.notifications.push`). A refusal is logged, never fatal.++ *++ * The share sheet asks `/api/v1/memory-store/:id/share` (the uniform route). The first owner read registers the++ * store as private; a member sees the sheet in their role. A person not on a `people` list never reaches the notes:++ * the routes in server/memory check canView before they return one item, and the page on Codes draws the gate.++ *++ * Nothing here reads a note. Titles and notification words are fixed, and no envelope carries text.++ */++const { createShares } = require('openvibe-sdk/share');++const { verifyUserToken } = require('openvibe-sdk/auth');++const { serviceAuth } = require('openvibe-contracts');++const { bearer } = require('../auth');++++const SERVICE = 'ai';++const KIND = 'memory-store';++const AUDIENCES = ['public', 'link', 'people', 'private'];++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++const refOf = (owner) => ({ service: SERVICE, kind: KIND, id: String(owner) });++++/** Everyone a share call may see: a user subject, never a service or a body-supplied subject. */++function viewerOf(subject) {++ if (!SUBJECT_RE.test(String(subject))) return null;++ return { subject: { type: 'user', id: String(subject) }, staff: false };++}++++function createMemorySharing({ db, config, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');++ const eventsUrl = String((config.events && config.events.url) || '').replace(/\/+$/, '');++ const clientId = (config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';++ const clientSecret = env.OV_OAUTH_CLIENT_SECRET || '';++ if (!base || !eventsUrl || !clientSecret) return null;++++ const networkTokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`, clientId, clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve network.notifications.push', fetch: fetchImpl,++ });++ const eventTokens = serviceAuth.createTokenClient({++ tokenUrl: `${base}/oauth/token`, clientId, clientSecret,++ audience: 'openvibe.events', scope: 'events.event.publish', fetch: fetchImpl,++ });++++ async function networkJson({ path, method = 'GET', json, query } = {}) {++ const url = new URL(base + path);++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));++ const headers = { Accept: 'application/json', ...(await networkTokens.authHeaders()) };++ if (json !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });++ const body = await res.json().catch(() => null);++ if (!res.ok) {++ const err = new Error(`Network ${method} ${path} ${res.status}`);++ err.status = res.status;++ throw err;++ }++ return body;++ }++++ /** One notification. It never carries a note, a title or a scope. */++ async function notify(n) {++ const to = n && n.to;++ if (!to || to.type !== 'user') return;++ const words = {++ 'share.added': ['A memory store was shared with you', 'Open it on OpenVibe.Codes, on Memory.'],++ 'share.access_requested': ['Someone asked for access to your memory', 'Review the request on OpenVibe.Codes, on Memory.'],++ 'share.transfer.requested': ['Someone wants to give you a memory store', 'Accept or decline it on OpenVibe.Codes.'],++ 'share.transfer.answered': ['A memory store transfer was answered', 'See what happened on OpenVibe.Codes.'],++ }[n.kind] || ['Sharing on OpenVibe.Codes', 'There is something new for you.'];++ try {++ await networkJson({++ path: '/internal/notifications/push', method: 'POST',++ json: {++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1], service: 'codes',++ url: `${String(config.codesUrl || 'https://codes.openvibe.services').replace(/\/+$/, '')}/memory`, category: 'social',++ },++ });++ } catch (err) {++ log.warn('[memory-sharing] notification not sent:', err && err.message);++ }++ }++++ /** One share.* envelope, with AI's own Events token. A failure leaves the delivery queued for the next flush. */++ async function publish(envelope) {++ const res = await fetchImpl(eventsUrl, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await eventTokens.authHeaders()) },++ body: JSON.stringify(envelope),++ signal: AbortSignal.timeout(8000),++ });++ if (!res.ok) throw new Error(`Events answered ${res.status}`);++ }++++ const shares = createShares({++ db,++ network: { json: networkJson },++ events: { publish },++ notify,++ now: () => clock.now(),++ });++++ /** The person from a verified Network token, or null. A service or an app token is never a viewer here. */++ async function authenticate(req) {++ const token = bearer(req);++ if (!token) return null;++ try {++ const claims = await verifyUserToken(token, {++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),++ issuer: config.issuer,++ audience: ['openvibe.network', 'openvibe.ai'],++ now: clock.now(),++ log,++ });++ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');++ return viewerOf(subject);++ } catch {++ return null;++ }++ }++++ /** The store is private until its owner says otherwise. Repeating it is a no-op, never a change. */++ async function register(owner) {++ if (!SUBJECT_RE.test(String(owner))) return null;++ return await shares.register(refOf(owner), { owner_subject: { type: 'user', id: String(owner) }, audience: 'private' });++ }++++ async function canView(viewer, owner) {++ if (!viewer || !SUBJECT_RE.test(String(owner))) return false;++ try { return await shares.canView(viewer, refOf(owner)); } catch { return false; }++ }++++ /** Every memory store shared with the person, newest service order. Ids and access only. */++ async function sharedStores(viewer) {++ if (!viewer) return [];++ const out = [];++ let cursor = null;++ for (let page = 0; page < 5; page++) {++ const result = await shares.sharedWithMe(viewer, { service: SERVICE, limit: 50, ...(cursor ? { cursor } : {}) });++ for (const item of result.items || []) {++ if (!item.ref || item.ref.kind !== KIND) continue;++ const owner = item.owner_subject && item.owner_subject.id;++ if (!SUBJECT_RE.test(String(owner || ''))) continue;++ out.push({ owner, audience: item.audience, role: item.role, shared_at: item.shared_at, ...(item.transfer ? { transfer: item.transfer } : {}) });++ }++ cursor = result.next_cursor;++ if (!cursor) break;++ }++ return out;++ }++++ const describe = async (ref) => {++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};++ return { title: 'Memory store', link: '/memory', roles: ['viewer'], audiences: AUDIENCES, transferable: false };++ };++++ const inner = shares.router({ service: SERVICE, authenticate, describe });++++ /**++ * The uniform router, plus the lazy registration of an owner's store: the first GET (or PUT) of a person's own++ * `/api/v1/memory-store/<their id>/share` creates the private row. The router still authenticates and decides.++ */++ function router() {++ return async function memoryShareRouter(req, res, next) {++ const m = /^\/api\/v1\/memory-store\/([^/]+)\/share(?:\/|$)/.exec(String(req.url || ''));++ if (m && (req.method === 'GET' || req.method === 'PUT')) {++ try {++ const viewer = await authenticate(req);++ const id = decodeURIComponent(m[1]);++ if (viewer && viewer.subject.id === id) await register(id);++ } catch { /* the router answers the request itself */ }++ }++ return inner(req, res, next);++ };++ }++++ let timer = null;++ /** Expire members/transfers and send queued events, at most once a minute. Tests can call work() directly. */++ function start(intervalMs = 60000) {++ if (timer) return;++ timer = setInterval(() => { work().catch((err) => log.warn('[memory-sharing] sweep:', err && err.message)); }, Math.max(10000, Number(intervalMs) || 60000));++ if (timer.unref) timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++++ async function work({ flush = true } = {}) {++ let expired = 0;++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[memory-sharing] expiry sweep:', err && err.message); }++ let sent = 0;++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[memory-sharing] deliveries:', err && err.message); } }++ return { expired, sent };++ }++++ return { shares, SERVICE, KIND, AUDIENCES, refOf, viewerOf, authenticate, register, canView, sharedStores, router, describe, work, start, stop };++}++++module.exports = { createMemorySharing, refOf, viewerOf, SERVICE, KIND, AUDIENCES, SUBJECT_RE };@@@ -139,6 +139,67 @@ route('post', '/memory/items/{id}/settle', {requestBody: body({ type: 'object', required: ['keep'], properties: { keep: str({ description: 'The id of the item that stays' }) } }),responses: { 200: json('The kept item and the archived one', memoryResult), 404: problem, 409: problem, 422: problem, 503: problem, ...errors([401, 403]) },});++// ── Memory M5: services, the portable file and team sharing ─────────────────────────────────────++const memoryServiceList = {++ type: 'object', required: ['providers'],++ properties: { providers: { type: 'array', items: { type: 'object', additionalProperties: true } } },++};++const memoryServiceView = { type: 'object', required: ['service'], properties: { service: { type: 'object', additionalProperties: true } } };++const memoryServicePut = {++ type: 'object',++ properties: {++ key: str({ minLength: 8, maxLength: 400, description: 'The person’s own key. It is sealed and never returned.' }),++ base_url: str({ maxLength: 500, description: 'A self-hosted endpoint: https, port 443 or 8443, a public host name that resolves to a public address, no credentials or query.' }),++ account: str({ maxLength: 200, description: 'An optional user or agent id the service needs.' }),++ },++};++route('get', '/memory/services', {++ operationId: 'listMemoryServices', summary: 'The memory services the person connected', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read. One entry per mem0, Letta and Zep: whether it is connected, its last four key characters (never the key), its health and the last sync count.',++ responses: { 200: json('The services', memoryServiceList), 503: problem, ...errors([401, 403]) },++});++route('put', '/memory/services/{provider}', {++ operationId: 'putMemoryService', summary: 'Store or replace a memory service key and test it', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.write. The key is sealed with AI_CREDENTIALS_KEY and never returned, logged or put in an error. A refused key keeps the connection with status failing and a short redacted reason.',++ parameters: [path('provider', str({ enum: ['mem0', 'letta', 'zep'] }))],++ requestBody: body(memoryServicePut),++ responses: { 200: json('The connection, without the key', memoryServiceView), 422: problem, 503: problem, ...errors([401, 403]) },++});++route('delete', '/memory/services/{provider}', {++ operationId: 'deleteMemoryService', summary: 'Forget a memory service key', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.write. The sealed row is deleted; the notes it imported stay until the person deletes them.',++ parameters: [path('provider', str({ enum: ['mem0', 'letta', 'zep'] }))],++ responses: { 200: json('What was removed', { type: 'object', properties: { removed: integer() } }), 404: problem, 503: problem, ...errors([401, 403]) },++});++route('post', '/memory/services/{provider}/sync', {++ operationId: 'syncMemoryService', summary: 'Read a connected service and put its notes in the store', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.write. The key is opened for this call only. Up to 1,000 records are read; the store redacts each item again and dedupes by its text MAC. The answer is counts, never text.',++ parameters: [path('provider', str({ enum: ['mem0', 'letta', 'zep'] }))],++ responses: { 200: json('What the sync found and imported', { type: 'object', additionalProperties: true }), 502: problem, 503: problem, ...errors([401, 403]) },++});++route('get', '/memory/export', {++ operationId: 'exportMemory', summary: 'The one portable memory file (ai.memory-export@1)', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read. The person’s own items decrypted for their own export, at most 10,000, with no id, owner, merge link or conflict pair. The body is the person’s plaintext: answer it no-store.',++ parameters: [query('scope', str({ description: 'user or project:<slug>; empty for every scope' }))],++ responses: { 200: json('The portable file', { type: 'object', additionalProperties: true }), 503: problem, ...errors([401, 403]) },++});++route('post', '/memory/import', {++ operationId: 'importMemory', summary: 'Import a portable memory file', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.write. The body must validate ai.memory-export@1. Each item is redacted again; a secret-only item is skipped and a conflict is imported as a suggestion. Dedupe is by text MAC within owner and scope.',++ requestBody: body({ type: 'object', additionalProperties: true }),++ responses: { 200: json('What was imported', { type: 'object', additionalProperties: true }), 422: problem, 503: problem, ...errors([401, 403]) },++});++route('get', '/memory/shared', {++ operationId: 'listSharedMemory', summary: 'The memory stores other people shared with the person', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read. The person’s own token only. Ids and access metadata only, never a note.',++ responses: { 200: json('The shared stores', { type: 'object', properties: { stores: arrayOf({ type: 'object', additionalProperties: true }) } }), 503: problem, ...errors([401, 403]) },++});++route('get', '/memory/stores/{owner}', {++ operationId: 'readSharedMemory', summary: 'One shared store’s active notes', tags: ['memory'], security: memorySecurity,++ description: 'ai.memory.read. canView is checked first: the owner or a member sees active notes, read-only; anybody else gets memory.not_found, never a count or a title.',++ parameters: [path('owner', str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' })), query('scope', str())],++ responses: { 200: json('The shared store’s active notes', { type: 'object', additionalProperties: true }), 404: problem, 503: problem, ...errors([401, 403]) },++});route('get', '/memory/profile', {operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,description: 'ai.memory.read. The person\u2019s own profile, or an agent\u2019s: a service token with on_behalf_of reads only a scope its grant names, and that read is audited in memory_reads with the note ids it was built from, never the text. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',@@@ -10,7 +10,7 @@ const http = require('http');const { uncovered, createNetworkSender } = require('openvibe-sdk/account-data');const { signDeliveryHeaders } = require('openvibe-sdk/events');const { boot, suite } = require('./helpers');--const { TABLES } = require('../server/account-data');++const { TABLES, EXEMPT } = require('../server/account-data');const t = suite('account-data');const SECRET = `whsec_${'fixture'.repeat(6)}`;@@@ -41,7 +41,7 @@ t.test('every table that names a person is covered or exempted with a reason', aconst stub = await startNetworkStub();const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });try {-- const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });++ const missing = await uncovered(h.db, { tables: TABLES, exempt: { ...EXEMPT, account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await h.stop(); await stub.close(); }});@@@ -45,7 +45,7 @@ function tmpDir() {// A boot on a dir used before is a restart on the same database: its database stays open.const restartable = new Map();--async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks } = {}) {++async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup } = {}) {const reuse = dir !== undefined;if (dir === undefined) dir = tmpDir();const fullEnv = {@@@ -62,7 +62,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;-- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore });++ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup });const base = `http://127.0.0.1:${h.server.address().port}`;return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };}@@@ -12,7 +12,7 @@ const { ids } = require('openvibe-contracts');const { uncovered } = require('openvibe-sdk/account-data');const { signDeliveryHeaders } = require('openvibe-sdk/events');const { boot, request, suite, seamServer, token, privateKey, ISSUER } = require('./helpers');--const { TABLES } = require('../server/account-data');++const { TABLES, EXEMPT } = require('../server/account-data');const { sha256 } = require('../server/util');const { PROFILE_INTERVAL_MS, PROFILE_ITEMS } = require('../server/memory');@@@ -327,7 +327,7 @@ t.test('account data: the export shows the sources and the links, and the eraseconst { seam } = await startSeam();const h = await bootWithSeam({ AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' }, seam);try {-- const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });++ const missing = await uncovered(h.db, { tables: TABLES, exempt: { ...EXEMPT, account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });assert.deepStrictEqual(missing, []);const who = userToken(A);await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [{ text: DARK, source: 'cursor' }] } });nnew file mode 100644@@@ -0,0 +1,345 @@++'use strict';++/**++ * Memory M5: the memory-service adapters (mem0, Letta, Zep) against a stub of each HTTP API, the sealed key, the one++ * portable file and its round trip, and a shared store that only its members can read.++ *++ * Nothing here calls a real provider: every adapter reaches a local stub that checks the auth header the service++ * documents. The key is asserted to be sealed in the database, absent from every answer and absent from every log line.++ */++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const { boot, request, suite, privateKey, ISSUER } = require('./helpers');++++const t = suite('memory-m5');++const KEY = 'a'.repeat(64);++const CRED = 'b'.repeat(64);++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const C = 'usr_01JZ0000000000000000000CCC';++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++++/** A person's Network access token: no actor_type, the subject in subject_id. */++function userToken(subject) {++ const now = Math.floor(Date.now() / 1000);++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: subject, subject_id: subject, aud: ['openvibe.network'], iat: now, exp: now + 3600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` })}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++const MEM0 = 'mem0-key-0123456789';++const LETTA = 'letta-key-0123456789';++const ZEP = 'zep-key-0123456789';++// A secret-shaped fixture, built from parts so a source scanner does not read it as a real Stripe key.++const FAKE_STRIPE = ['sk_live', 'abcdef0123456789'].join('_');++++const PUBLIC = '93.184.216.34';++++/**++ * The test seam the guarded transport exposes: a scripted answer instead of a socket. It records the exact request++ * (URL, headers, timeout and cap) so a test can prove a refused host never saw the key.++ */++function fakeServiceTransport(handler) {++ const calls = [];++ const transport = async (send) => {++ const call = { method: send.method, url: send.url, headers: { ...send.headers }, timeoutMs: send.timeoutMs, maxBytes: send.maxBytes };++ calls.push(call);++ const u = new URL(send.url);++ const res = await handler(u, call);++ return res || { status: 404, headers: {}, body: '{"detail":"no route"}' };++ };++ transport.calls = calls;++ return transport;++}++++const jsonRes = (status, obj) => ({ status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(obj) });++++/** The happy answers of the three services: enough for a probe and a sync. */++function happyService(u) {++ if (u.pathname === '/v1/memories/') {++ return jsonRes(200, { results: [++ { id: 'm1', memory: 'Prefers small pull requests.', created_at: '2026-11-01T10:00:00Z' },++ { id: 'm2', memory: 'The API key is ' + FAKE_STRIPE + ' and it must not survive redaction.', created_at: '2026-11-02T10:00:00Z' },++ ], next: null });++ }++ if (u.pathname === '/v1/agents/') return jsonRes(200, [{ id: 'agent_1', name: 'Ada' }]);++ if (u.pathname === '/v1/agents/agent_1/core-memory/blocks') {++ return jsonRes(200, [{ label: 'human', value: 'Ada works in UTC+1.' }, { label: 'persona', value: 'A careful assistant.' }]);++ }++ if (u.pathname === '/v1/agents/agent_1/archival-memory') return jsonRes(200, [{ id: 'passage_1', text: 'Ships on Fridays only with the owner’s Go.', created_at: '2026-10-30T08:00:00Z' }]);++ if (u.pathname === '/users') return jsonRes(200, [{ user_id: 'zep_user' }]);++ if (u.pathname === '/users/zep_user/sessions') return jsonRes(200, [{ session_id: 's1' }]);++ if (u.pathname === '/users/zep_user/sessions/s1/memory') return jsonRes(200, { memory: { facts: [{ uuid: 'f1', fact: 'Prefers TypeScript for new services.', created_at: '2026-11-03T09:00:00Z' }] } });++ return jsonRes(404, { detail: 'no route' });++}++++/** The Network and Events stub the sharing test needs; service traffic never reaches it (it is the injected transport). */++async function startStub() {++ const calls = [];++ const people = {++ [A]: { subject: { type: 'user', id: A }, username: 'ada', display_name: 'Ada' },++ [B]: { subject: { type: 'user', id: B }, username: 'bo', display_name: 'Bo' },++ [C]: { subject: { type: 'user', id: C }, username: 'cy', display_name: 'Cy' },++ };++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString();++ let body = null;++ try { body = raw ? JSON.parse(raw) : null; } catch { body = raw; }++ calls.push({ url: req.url, method: req.method, auth: req.headers.authorization || '', body });++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_service', token_type: 'Bearer', expires_in: 300 });++ if (req.url === '/internal/identity/resolve-batch') {++ const results = {};++ for (const id of (body && body.subject_ids) || []) if (people[id]) results[id] = people[id];++ return json(200, { results });++ }++ if (req.url && req.url.startsWith('/internal/identity/resolve')) {++ const name = new URL(req.url, 'http://x').searchParams.get('username');++ const found = Object.values(people).find((p) => p.username === name) || null;++ return json(200, { person: found });++ }++ if (req.url === '/internal/notifications/push') return json(201, {});++ if (req.url === '/events') return json(202, {});++ return json(404, { detail: 'no route' });++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++t.test('mem0, Letta and Zep: each adapter reads its stub API, and the key never leaves the sealed row', async () => {++ const logs = [];++ const capture = (...args) => logs.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));++ const transport = fakeServiceTransport(async (u) => happyService(u));++ const lookup = (host, opts, cb) => cb(null, [{ address: PUBLIC, family: 4 }]);++ const h = await boot({++ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED },++ log: { log: capture, warn: capture, error: capture },++ memoryTransport: transport,++ memoryLookup: lookup,++ });++ try {++ const put = async (provider, key, base) => {++ const r = await request(h.base, 'PUT', `/api/v1/memory/services/${provider}`, { tok: userToken(A), body: { key, base_url: base } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(key), `the ${provider} answer carries the key`);++ assert.strictEqual(r.body.service.hint, key.slice(-4));++ assert.strictEqual(r.body.service.status, 'ok', r.text);++ const row = await h.db.maybe('SELECT key_enc, key_hint FROM memory_service_connections WHERE subject = $1 AND provider = $2', [A, provider]);++ assert.ok(row && row.key_enc.startsWith('v1:'), `${provider} key is sealed`);++ assert.ok(!row.key_enc.includes(key), `${provider} key is not in the ciphertext`);++ return r;++ };++ await put('mem0', MEM0, 'https://mem0.example');++ await put('letta', LETTA, 'https://letta.example');++ await put('zep', ZEP, 'https://zep.example');++ const call = (host, header) => transport.calls.find((c) => new URL(c.url).hostname === host && c.headers.Authorization === header);++ assert.ok(call('mem0.example', `Token ${MEM0}`), 'mem0 was called with its documented header');++ assert.ok(call('letta.example', `Bearer ${LETTA}`), 'Letta was called with its bearer');++ assert.ok(call('zep.example', `Api-Key ${ZEP}`), 'Zep was called with its Api-Key header');++ assert.ok(transport.calls.every((c) => c.url.startsWith('https://')), 'every adapter call is https');++ assert.ok(transport.calls.every((c) => c.timeoutMs === 15000 && c.maxBytes === 1024 * 1024), 'every call has the 15 s timeout and the 1 MB cap');++++ const list = await request(h.base, 'GET', '/api/v1/memory/services', { tok: userToken(A) });++ assert.strictEqual(list.status, 200);++ assert.ok(!JSON.stringify(list.body).includes(MEM0) && !JSON.stringify(list.body).includes(LETTA) && !JSON.stringify(list.body).includes(ZEP), 'the list carries no key');++++ const sync = async (provider) => {++ const r = await request(h.base, 'POST', `/api/v1/memory/services/${provider}/sync`, { tok: userToken(A) });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.body.found >= 1, `${provider} found something: ${r.text}`);++ return r.body;++ };++ await sync('mem0');++ await sync('letta');++ await sync('zep');++ const items = await request(h.base, 'GET', '/api/v1/memory/items', { tok: userToken(A) });++ assert.strictEqual(items.status, 200);++ const texts = items.body.items.map((i) => i.text).join('\n');++ assert.ok(texts.includes('Prefers small pull requests.'), texts);++ assert.ok(texts.includes('Ada works in UTC+1.'), texts);++ assert.ok(texts.includes('Prefers TypeScript for new services.'), texts);++ assert.ok(!texts.includes(FAKE_STRIPE), 'a secret from a service was redacted before it was stored');++ assert.ok(items.body.items.some((i) => i.source === 'mem0') && items.body.items.some((i) => i.source === 'letta') && items.body.items.some((i) => i.source === 'zep'));++ assert.ok(!logs.join('\n').includes(MEM0) && !logs.join('\n').includes(LETTA) && !logs.join('\n').includes(ZEP), 'a key reached the log');++ assert.ok(!logs.join('\n').includes(FAKE_STRIPE), 'a secret reached the log');++ } finally { await h.stop(); }++});++++t.test('a private or malformed base_url is refused at save and at sync, and the key is never sent there', async () => {++ const { createServiceKeys } = require('../server/memory/services/keys');++ const state = { address: PUBLIC, redirect: false };++ const transport = fakeServiceTransport(async (u) => {++ if (u.hostname === 'redirect.example' || (state.redirect && u.hostname === 'redir.example')) {++ return { status: 302, headers: { location: 'https://10.0.0.5/steal' }, body: '' };++ }++ return happyService(u);++ });++ const lookup = (host, opts, cb) => cb(null, [{ address: state.address, family: 4 }]);++ const h = await boot({++ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED },++ memoryTransport: transport,++ memoryLookup: lookup,++ });++ try {++ const refusedSave = async (url) => {++ const before = transport.calls.length;++ const r = await request(h.base, 'PUT', '/api/v1/memory/services/mem0', { tok: userToken(A), body: { key: MEM0, base_url: url } });++ assert.strictEqual(r.status, 422, `${url}: ${r.text}`);++ assert.strictEqual(transport.calls.length, before, `${url} reached the transport`);++ assert.ok(!JSON.stringify(transport.calls).includes(MEM0), `${url} sent the key`);++ };++ state.address = PUBLIC;++ await refusedSave('https://127.0.0.1');++ await refusedSave('https://10.0.0.5');++ await refusedSave('http://mem0.example');++ await refusedSave('https://localhost');++ await refusedSave('https://example.com:3000');++ await refusedSave('https://user:[email protected]');++ state.address = '192.168.1.2';++ await refusedSave('https://private.example');++ state.address = PUBLIC;++ const rows = await h.db.maybe("SELECT COUNT(*)::int AS n FROM memory_service_connections WHERE subject = $1", [A]);++ assert.strictEqual(Number(rows.n), 0, 'no refused host left a key row');++++ // At sync: a row that exists (a legacy row, or one written before this guard) is refused before its key is opened.++ const keys = createServiceKeys({ db: h.db, config: h.config });++ const refusedSync = async (base) => {++ await keys.put(A, 'zep', { key: ZEP, base_url: base });++ const before = transport.calls.length;++ const r = await request(h.base, 'POST', '/api/v1/memory/services/zep/sync', { tok: userToken(A) });++ assert.strictEqual(r.status, 422, `${base}: ${r.text}`);++ assert.strictEqual(transport.calls.length, before, `${base} reached the transport at sync`);++ assert.ok(!JSON.stringify(transport.calls).includes('zep-key-0123456789'), `${base} sent the key at sync`);++ };++ state.address = PUBLIC;++ await refusedSync('https://127.0.0.1');++ await refusedSync('https://10.0.0.5');++ await refusedSync('http://mem0.example');++ await refusedSync('https://localhost');++ await refusedSync('https://example.com:3000');++ await refusedSync('https://user:[email protected]');++ state.address = '192.168.1.2';++ await refusedSync('https://private.example');++++ // A redirect is never followed. At save it refuses the connection; at sync it refuses the read.++ state.address = PUBLIC;++ const beforeRedirect = transport.calls.length;++ const redirectSave = await request(h.base, 'PUT', '/api/v1/memory/services/letta', { tok: userToken(A), body: { key: LETTA, base_url: 'https://redirect.example' } });++ assert.strictEqual(redirectSave.status, 422, redirectSave.text);++ assert.ok(redirectSave.text.includes('the service answered with a redirect'), redirectSave.text);++ const afterRedirect = transport.calls.slice(beforeRedirect);++ assert.ok(afterRedirect.length >= 1 && afterRedirect.every((c) => new URL(c.url).hostname === 'redirect.example'), 'only the allowed host was tried');++ assert.ok(!afterRedirect.some((c) => c.url.includes('10.0.0.5')), 'the redirect target was never fetched');++++ await keys.put(A, 'mem0', { key: MEM0, base_url: 'https://redir.example' });++ state.redirect = true;++ const beforeSyncRedirect = transport.calls.length;++ const syncRedirect = await request(h.base, 'POST', '/api/v1/memory/services/mem0/sync', { tok: userToken(A) });++ assert.strictEqual(syncRedirect.status, 502, syncRedirect.text);++ assert.strictEqual(syncRedirect.body.code, 'memory.service_redirect');++ assert.ok(String(syncRedirect.body.detail).includes('the service answered with a redirect'), syncRedirect.text);++ const syncCalls = transport.calls.slice(beforeSyncRedirect);++ assert.ok(syncCalls.length >= 1 && syncCalls.every((c) => new URL(c.url).hostname === 'redir.example'), 'the sync tried only the allowed host');++ assert.ok(!syncCalls.some((c) => c.url.includes('10.0.0.5')), 'the sync followed the redirect');++ } finally { await h.stop(); }++});++++t.test('the portable file exports decrypted items, imports into another account, and round-trips', async () => {++ const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED } });++ try {++ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [++ { text: 'Prefers short answers.', kind: 'preference', source: 'claude-code', scope: 'user' },++ { text: 'Never rewrite history on main.', kind: 'project_rule', source: 'agents', scope: 'project:openvibe' },++ ] } });++ assert.strictEqual(made.status, 200, made.text);++ const exp = await request(h.base, 'GET', '/api/v1/memory/export', { tok: userToken(A) });++ assert.strictEqual(exp.status, 200, exp.text);++ assert.strictEqual(exp.headers.get('cache-control'), 'no-store');++ assert.strictEqual(exp.body.format, 'openvibe.memory-export@1');++ assert.strictEqual(exp.body.version, 1);++ assert.strictEqual(exp.body.items.length, 2);++ for (const item of exp.body.items) {++ assert.ok(item.text && item.kind && item.scope && item.source, JSON.stringify(item));++ assert.ok(!('owner' in item) && !('id' in item), 'the portable item carries no identity');++ }++ // Imported into B's store: the same text, kind, scope and source, under a different person.++ const imp = await request(h.base, 'POST', '/api/v1/memory/import', { tok: userToken(B), body: exp.body });++ assert.strictEqual(imp.status, 200, imp.text);++ assert.deepStrictEqual([imp.body.created, imp.body.deduped], [2, 0], imp.text);++ const b = await request(h.base, 'GET', '/api/v1/memory/items', { tok: userToken(B) });++ const got = b.body.items.map((i) => `${i.text}|${i.kind}|${i.scope}|${i.source}`).sort();++ const want = exp.body.items.map((i) => `${i.text}|${i.kind}|${i.scope}|${i.source}`).sort();++ assert.deepStrictEqual(got, want, 'the round trip kept text, kind, scope and source');++ // The same file again is a dedupe, not a second copy.++ const again = await request(h.base, 'POST', '/api/v1/memory/import', { tok: userToken(B), body: exp.body });++ assert.strictEqual(again.status, 200, again.text);++ assert.deepStrictEqual([again.body.created, again.body.deduped], [0, 2], again.text);++ // A file that is not the format is refused.++ const bad = await request(h.base, 'POST', '/api/v1/memory/import', { tok: userToken(B), body: { format: 'openvibe.memory-export@2', version: 2, items: [] } });++ assert.strictEqual(bad.status, 422, bad.text);++ } finally { await h.stop(); }++});++++t.test('a shared store is readable only by its members, and the answer is never shared-cacheable', async () => {++ const stub = await startStub();++ const h = await boot({++ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED, OV_OAUTH_CLIENT_SECRET: 'ai-secret', OV_NETWORK_INTERNAL_URL: stub.url, AI_EVENTS_URL: `${stub.url}/events`, AI_EVENTS_SUBSCRIBE: '0' },++ });++ try {++ assert.ok(h.memorySharing, 'sharing is wired when the Network client and Events are configured');++ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [{ text: 'Team remembers the deploy order.', kind: 'procedure' }] } });++ const notYet = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(B) });++ assert.deepStrictEqual([notYet.status, notYet.body.code], [404, 'memory.not_found']);++ const own = await request(h.base, 'GET', `/api/v1/memory-store/${A}/share`, { tok: userToken(A) });++ assert.strictEqual(own.status, 200, own.text);++ assert.strictEqual(own.body.audience, 'private');++ assert.deepStrictEqual(own.body.audiences, ['public', 'link', 'people', 'private']);++ assert.match(own.headers.get('cache-control') || '', /no-store/);++ // The owner adds B as a viewer, through the same library the routes use.++ await h.memorySharing.shares.addMembers({ subject: { type: 'user', id: A } }, h.memorySharing.refOf(A), [{ subject: { type: 'user', id: B }, role: 'viewer' }]);++ await h.memorySharing.shares.setAudience({ subject: { type: 'user', id: A } }, h.memorySharing.refOf(A), 'people');++ const shared = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(B) });++ assert.strictEqual(shared.status, 200, shared.text);++ assert.deepStrictEqual(shared.body.items.map((i) => i.text), ['Team remembers the deploy order.']);++ assert.strictEqual(shared.headers.get('cache-control'), 'no-store');++ const stranger = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(C) });++ assert.deepStrictEqual([stranger.status, stranger.body.code], [404, 'memory.not_found']);++ const mine = await request(h.base, 'GET', '/api/v1/memory/shared', { tok: userToken(B) });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.body.stores.some((s) => s.owner === A && s.role === 'viewer'), mine.text);++ // Only the owner's token can see the settings for somebody else's store as a member, and the notes never++ // appear in the shared-with-me listing.++ assert.ok(!JSON.stringify(mine.body).includes('Team remembers'), 'the listing carries no note');++ // The settings sheet belongs to the owner; a member reads the store, never its ACL.++ const asB = await request(h.base, 'GET', `/api/v1/memory-store/${A}/share`, { tok: userToken(B) });++ assert.deepStrictEqual([asB.status, asB.body.code], [404, 'share.not_found']);++ // Back to Only me: B loses the store at once.++ await h.memorySharing.shares.setAudience({ subject: { type: 'user', id: A } }, h.memorySharing.refOf(A), 'private');++ const after = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(B) });++ assert.deepStrictEqual([after.status, after.body.code], [404, 'memory.not_found']);++ } finally { await h.stop(); await stub.close(); }++});++++t.test('the transport itself refuses a private DNS answer, and the URL rules are the ones the route uses', async () => {++ const { createServiceTransport, judgeBaseUrl, judgeRequestUrl } = require('../server/memory/services/transport');++ const bad = (url) => assert.throws(() => judgeBaseUrl(url), (e) => e.status === 422, url);++ bad('https://127.0.0.1');++ bad('https://10.0.0.5');++ bad('http://mem0.example');++ bad('https://localhost');++ bad('https://example.com:3000');++ bad('https://user:[email protected]');++ bad('https://example.com/?x=1');++ assert.strictEqual(judgeBaseUrl('https://api.getzep.com/api/v2').toString(), 'https://api.getzep.com/api/v2');++ assert.strictEqual(judgeRequestUrl('https://api.mem0.ai/v1/memories/?page=1').toString(), 'https://api.mem0.ai/v1/memories/?page=1');++ const guarded = createServiceTransport({ lookup: (host, opts, cb) => cb(null, [{ address: '192.168.1.2', family: 4 }]) });++ await assert.rejects(++ guarded.get('https://private.example/v1/memories/', { Authorization: 'Token a-key-that-must-not-leave' }),++ (e) => e.code === 'memory.service_blocked' && e.status === 502,++ );++});++++t.run();@@@ -12,7 +12,7 @@ const path = require('path');const { uncovered } = require('openvibe-sdk/account-data');const { signDeliveryHeaders } = require('openvibe-sdk/events');const { boot, request, suite, token, privateKey, ISSUER } = require('./helpers');--const { TABLES } = require('../server/account-data');++const { TABLES, EXEMPT } = require('../server/account-data');const { createMemory } = require('../server/memory');const t = suite('memory');@@@ -409,7 +409,7 @@ t.test('account data: the table guard passes, the export is decrypted, and a delconst stub = await startNetworkStub();const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });try {-- const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });++ const missing = await uncovered(h.db, { tables: TABLES, exempt: { ...EXEMPT, account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });assert.deepStrictEqual(missing, []);await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('Ada exports this note')] } });await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(B), body: { items: [item('Bo keeps this note')] } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+# Memory M5: services, sharing and the portable file
+
+This is the fifth stage of the Memory plan (`../../OpenVibe_Memory_Plan_2026-10-30.md`). M1–M4 are the Node readers,
+the store, the cloud imports, the write-back and the agent API. M5 adds three things to the same store:
+
+- the memory-service adapters (mem0, Letta, Zep) with the person's own key;
+- team sharing of a memory store, through `openvibe-sdk/share`;
+- the one portable JSON file, with an export and an import that round-trip.
+
+Nothing here changes how an item is sealed: `AI_MEMORY_KEY`, AES-256-GCM, the AAD `memory|<owner>|<id>|<scope>`, and
+the same redaction on both ends.
+
+## The memory services
+
+| id | What it reads | Request |
+|---|---|---|
+| `mem0` | the user's memories on the mem0 Platform | `GET <base>/v1/memories/?page=&page_size=`, `Authorization: Token <key>` |
+| `letta` | each agent's core-memory blocks and archival passages | `GET <base>/v1/agents/`, `/v1/agents/{id}/core-memory/blocks`, `/v1/agents/{id}/archival-memory`, `Authorization: Bearer <key>` |
+| `zep` | each user's session memory | `GET <base>/users`, `/users/{id}/sessions`, `/users/{id}/sessions/{id}/memory`, `Authorization: Api-Key <key>`; the default base is `https://api.getzep.com/api/v2` |
+
+The endpoints are documented by the services: [mem0](https://docs.mem0.ai/api-reference/memory/get-memories),
+[Letta](https://docs.letta.com/api-reference/agents) and [Zep](https://help.getzep.com/api-reference). A self-hosted
+Letta or Zep names its own `base_url`. It is judged on save and again at every sync: https only, port 443 or 8443,
+no credentials in the URL, no IP literal, no internal name, and its DNS answer must be public. The transport uses
+`openvibe-shared/egress`'s safe lookup inside the connection (`server/memory/services/transport.js`), follows no
+redirect, caps an answer at 1 MB and times out at 15 s. A refused base never sees the person's key.
+
+### The key
+
+`PUT /api/v1/memory/services/:provider { key, base_url?, account? }` stores the key in
+`memory_service_connections.key_enc`, sealed exactly like a BYO provider key (`server/credentials.js`):
+AES-256-GCM with `AI_CREDENTIALS_KEY`, the envelope `v1:<iv>:<tag>:<body>`, the AAD
+`memory-service|<subject>|<provider>`, so a row moved to another person or provider does not decrypt.
+
+- The key is never returned. `GET /api/v1/memory/services` answers the last four characters (`hint`), the service's
+ health, the last sync time and its count.
+- The key is never put in a URL, a log line, an error, a span or an event. An adapter's refusal is a status and a
+ fixed sentence; the tests assert that the key does not appear in the answer, the logs or the ciphertext.
+- `PUT` tests the key by reading one page, and keeps the connection with `status: failing` and a short `last_error`
+ when the service refuses it, so the person can fix the key or the address without losing the form.
+- `DELETE` removes the row. The account deletion removes it too (`server/account-data.js`).
+
+### Sync
+
+`POST /api/v1/memory/services/:provider/sync` opens the key for one call, reads at most 1,000 records, drops an item
+that is only a secret, and hands the rest to the store's own batch. The batch redacts each item again, seals it under
+the person, and dedupes by its text MAC. A sync never overwrites an item: a note that says the same thing as one
+already there is merged into that item, with the service added to its sources. The answer is counts only (`found`,
+`imported`, `deduped`, `skipped`), never text.
+
+## Team sharing
+
+`server/memory/sharing.js` wires `openvibe-sdk/share` with one resource per person: service `ai`, kind
+`memory-store`, id the owner's `usr_…`. The store is `private` until its owner changes it; the four audiences are the
+library's (`public`, `link`, `people`, `private`), and a team uses `people` with named members. The share settings and
+the notes are separate: `GET /api/v1/memory/stores/:owner` calls `canView` first and returns only active notes; a
+person who is not a member gets `memory.not_found` and never a count or a title.
+
+- The uniform routes live at `/api/v1/memory-store/:id/share`; the owner's first GET registers the private row.
+- `GET /api/v1/memory/shared` lists the stores shared with the person, with ids and access only.
+- A read by a member is not an agent read and is not written to `memory_reads`; the read log is for agents.
+- Network supplies the exact-name lookup and the notification push; Events receives the five `share.*` envelopes.
+ When the deployment has no Network client secret or Events URL, the sharing routes answer 503
+ `memory.sharing_unavailable` and the rest of the store keeps working.
+- `openvibe-sdk/share` refuses a locked item that would be public. Memory does not use a content lock in M5.
+
+Codes renders the sheet and the gate from openvibe-shared `4.123.0`'s markup (`[data-ov-share-sheet]`,
+`[data-ov-share-gate]`); there is no inline script. Its `/api/v1/memory-store/...` routes are a thin proxy that
+forwards the person's token, the method, the body and `If-Match` to AI, and keeps AI's `ETag` and
+`Cache-Control: private, no-store`.
+
+## The one portable file
+
+`ai.memory-export@1` (Contracts) is the only file format:
+
+```json
+{
+ "format": "openvibe.memory-export@1",
+ "version": 1,
+ "exported_at": "2026-11-07T09:30:00.000Z",
+ "items": [
+ {
+ "text": "Prefers short answers.",
+ "kind": "preference",
+ "scope": "user",
+ "source": "claude-code",
+ "source_ref": { "path": ".claude/CLAUDE.md", "heading": "Style" },
+ "status": "active",
+ "confidence": 1,
+ "seen_at": "2026-11-01T10:00:00.000Z",
+ "created_at": "2026-10-30T10:00:00.000Z"
+ }
+ ]
+}
+```
+
+- `GET /api/v1/memory/export[?scope=]` writes it, decrypted for the person's own token, at most 10,000 items.
+- `POST /api/v1/memory/import` reads the same document. It reads `format`, `version` and `items`; `exported_at` is
+ informational. Each item is redacted again, a secret-only item is skipped, and a `conflict` item is imported as a
+ suggestion because its pair did not travel. The store's own dedupe then merges a text that is already there.
+- There is deliberately no `id`, `owner`, `merged_into`, `conflict_with` or profile. A file can be imported into
+ another account without carrying anyone's identity, and a profile is derived from the notes and rebuilt.
+- The round trip is proved in `test/memory-m5.test.js`: export A's store, import it as B, and compare text, kind,
+ scope and source; importing the same file again changes nothing.
+- The Node writes and reads the same file (`openvibe-node memory export|import`); Codes offers a download and an
+ import form. The format is the contract, not either client.
+
+## Tests
+
+- `test/memory-m5.test.js`: the three adapters against local stubs of their HTTP APIs (auth headers, pagination and
+ mapping), the sealed key (no plaintext in the row, the answers or the logs), the portable round trip, and a shared
+ store that only its members can read, with `Cache-Control: no-store` on every answer.
+- `test/memory.test.js` and `test/account-data.test.js`: the M1–M4 rules still hold, the account export is unchanged
+ (a service key is never exported), and the share tables are erased with the person.
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+-- phase: expand
+-- OpenVibe Memory M5: the memory services a person connects with their own key (mem0, Letta, Zep).
+--
+-- The key is sealed exactly the way a BYO provider key is (server/credentials.js): AES-256-GCM with
+-- AI_CREDENTIALS_KEY, `v1:<iv>:<tag>:<body>`, and the additional data `memory-service|<subject>|<provider>`, so a row
+-- copied to another person or provider does not decrypt. The key is never returned by any route, never logged and
+-- never put in an error; only `key_hint` (the last four characters) is shown to the person.
+--
+-- `account` is an optional user/agent id the service needs (Zep's user, Letta's agent when the person names one);
+-- `base_url` is an optional self-hosted endpoint. `last_sync_*` are counts and times only, never text.
+CREATE TABLE IF NOT EXISTS memory_service_connections (
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ provider text COLLATE "C" NOT NULL, -- mem0 | letta | zep
+ base_url text COLLATE "C",
+ account text COLLATE "C",
+ key_enc text COLLATE "C" NOT NULL, -- v1:<iv>:<tag>:<body>, never plaintext
+ key_hint text COLLATE "C" NOT NULL, -- the last four characters
+ status text COLLATE "C" NOT NULL DEFAULT 'untested', -- untested | ok | failing
+ checked_at text COLLATE "C",
+ last_error text COLLATE "C", -- redacted, at most 300 characters
+ last_sync_at text COLLATE "C",
+ last_sync_count integer NOT NULL DEFAULT 0,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (subject, provider)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+-- phase: expand
+-- The one sharing system (openvibe-sdk/share) for a person's memory store: its audience (public, link, people,
+-- private), the people it is shared with, the log and the delivery queue the SDK writes. Kept verbatim from the SDK's
+-- own schema (node_modules/openvibe-sdk/src/share/schema.js at the pinned version), so its store finds the tables it
+-- expects and the test database has them. The store's resource is one per person, kind 'memory-store', id usr_…;
+-- only a member (or the owner) reads it, and the four audiences are the SDK's own.
+CREATE TABLE IF NOT EXISTS shares (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
+ revision integer NOT NULL DEFAULT 1,
+ lock text NOT NULL DEFAULT 'none',
+ reseal_nonce_base text,
+ PRIMARY KEY (service, kind, resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_members (
+ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
+ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
+ key_slot jsonb,
+ PRIMARY KEY (service, kind, resource_id, subject),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE TABLE IF NOT EXISTS share_transfers (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
+ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
+ key_slot jsonb,
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_access_requests (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
+ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
+ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
+CREATE TABLE IF NOT EXISTS share_log (
+ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
+ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
+BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
+ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
+ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
+ END IF;
+END $$;
+CREATE TABLE IF NOT EXISTS share_deliveries (
+ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
+ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
+);
+CREATE TABLE IF NOT EXISTS share_name_limits (
+ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
+);
+CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
+CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
+CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
+-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
+-- slot a transfer was offered with. Repeating this migration is a no-op.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
+DO $$ BEGIN
+ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.308.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1540,9 +1540,9 @@
@@ -1540,9 +1540,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.308.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.308.0",
+ "integrity": "sha512-652wGI95ogZwldAULN2UvhWG/xq/e2ADDDpQ4MoLnvU+bSbluCvZEvQln86Wvmw+0k5IEGuadedUWuu4G7YErw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.308.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -43,6 +43,9 @@ const TABLES = [
@@ -43,6 +43,9 @@ const TABLES = [
// Memory M3: import previews are drafts, never part of the person's export; they go with the account like the
// Memory M3: import previews are drafts, never part of the person's export; they go with the account like the
// notes they would become. The payload is sealed, and the row is never read by the account export.
// notes they would become. The payload is sealed, and the row is never read by the account export.
{ table: 'memory_imports', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'memory_imports', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
+ // Memory M5: a connected memory service's sealed key. It goes with the account and is never exported: the key is
+ // not the person's data to read back, and its existence is shown on the Memory page instead.
+ { table: 'memory_service_connections', subject: 'subject', file: null, erase: 'delete' },
{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
{ table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
// D11 owner settings: the revisions are the service's configuration audit trail, kept like the catalogues.
// D11 owner settings: the revisions are the service's configuration audit trail, kept like the catalogues.
{ table: 'config_snapshots', subject: 'created_by', file: null, erase: { keep: 'the configuration history is the service\'s; created_by is the operator audit trail' } },
{ table: 'config_snapshots', subject: 'created_by', file: null, erase: { keep: 'the configuration history is the service\'s; created_by is the operator audit trail' } },
@@ -88,6 +91,16 @@ async function extraErase(t, subjects, counts) {
@@ -88,6 +91,16 @@ async function extraErase(t, subjects, counts) {
counts.add(counts.erased, 'abuse_velocity', await t.exec("DELETE FROM abuse_velocity WHERE kind = 'device' AND key_hash = $1", [hash]));
counts.add(counts.erased, 'abuse_velocity', await t.exec("DELETE FROM abuse_velocity WHERE kind = 'device' AND key_hash = $1", [hash]));
}
}
const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);
const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);
+ // Memory M5: the store's share rows. The person's own store goes, their memberships and requests go, and a
+ // transfer with them on either side goes. The append-only log stays as a tombstone, like every other audit row.
+ const forms = subjects.map((s) => JSON.stringify({ type: 'user', id: s }));
+ const members = await t.exec('DELETE FROM share_members WHERE subject = ANY($1::jsonb[])', [forms]);
+ const requests = await t.exec('DELETE FROM share_access_requests WHERE subject = ANY($1::jsonb[])', [forms]);
+ const owned = await t.exec("DELETE FROM shares WHERE service = 'ai' AND kind = 'memory-store' AND owner_subject = ANY($1::jsonb[])", [forms]);
+ const transfers = await t.exec("DELETE FROM share_transfers WHERE service = 'ai' AND kind = 'memory-store' AND (from_subject = ANY($1::jsonb[]) OR to_subject = ANY($1::jsonb[]))", [forms]);
+ if (Number(members) || Number(requests) || Number(owned) || Number(transfers)) {
+ counts.add(counts.erased, 'share_rows', Number(members) + Number(requests) + Number(owned) + Number(transfers));
+ }
if (!ids.length) return;
if (!ids.length) return;
counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));
counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));
counts.add(counts.erased, 'citations', await t.exec('DELETE FROM citations WHERE run_id = ANY($1::text[])', [ids]));
counts.add(counts.erased, 'citations', await t.exec('DELETE FROM citations WHERE run_id = ANY($1::text[])', [ids]));
@@ -108,4 +121,11 @@ function create({ db, log = console, memory = null } = {}) {
@@ -108,4 +121,11 @@ function create({ db, log = console, memory = null } = {}) {
return createAccountData({ db, service: 'ai', tables: TABLES, extraExport: exportAll, extraErase, log });
return createAccountData({ db, service: 'ai', tables: TABLES, extraExport: exportAll, extraErase, log });
}
}
-module.exports = { create, TABLES, TOPICS, extraExport, extraErase };
+// The sharing system's own tables carry a person as a jsonb subject (a member, an access request), which the table
+// map cannot express; they are erased in extraErase above, with the rest of the person's rows.
+const EXEMPT = {
+ share_members: 'the sharing system own member rows: the subject is a jsonb subject, so they are erased in extraErase',
+ share_access_requests: 'the sharing system own access requests: the subject is a jsonb subject, so they are erased in extraErase',
+};
+
+module.exports = { create, TABLES, TOPICS, EXEMPT, extraExport, extraErase };
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, memoryServices = null, memorySharing = null, community = null, endpoints = null, webhooks = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null, priceBook = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -162,7 +162,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -162,7 +162,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
// The Anthropic-compatible endpoint (POST /v1/messages, GET /v1/models): runs of ai.messages (Codes C3).
app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));
app.use(require('./api/messages').messagesRouter({ runs, auth, config, registry, presets, receipts, log }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(require('./api/attribution-quotas').attributionQuotasRouter({ db, quotas, auth, clock, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
- if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ if (memory) app.use(require('./memory').memoryRouter({ memory, memoryImport, services: memoryServices, sharing: memorySharing, auth, config, clock, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ // Memory M5: the uniform share routes for kind memory-store (and /api/v1/share/people, /api/v1/shared-with-me).
+ if (memorySharing) app.use(memorySharing.router());
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
// The budget of a Codes run token (Codes C3): Codes registers it when it mints the token.
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
if (runBudgets) app.use(require('./api/run-budgets').runBudgetsRouter({ runBudgets, auth, log, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -153,6 +153,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -153,6 +153,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// The store's own maintenance (Memory M2c/M3): owner by owner, with a per-run cap, and the expired import
// The store's own maintenance (Memory M2c/M3): owner by owner, with a per-run cap, and the expired import
// drafts. It is off in test mode and off while a provider is in a drill (server/memory/jobs.js).
// drafts. It is off in test mode and off while a provider is in a drill (server/memory/jobs.js).
const memoryJobs = require('./memory/jobs').createMemoryJobs({ memory, memoryImport, config, clock, log, registry });
const memoryJobs = require('./memory/jobs').createMemoryJobs({ memory, memoryImport, config, clock, log, registry });
+ // Memory M5: the memory services with the person's own key (mem0, Letta, Zep), and the store's team sharing.
+ // Sharing is null when this deployment has no Network client secret or Events URL; its routes answer 503.
+ const memoryServices = require('./memory/services').createMemoryServices({ db, config, clock, log, memory, transport: memoryTransport, lookup: memoryLookup });
+ const memorySharing = require('./memory/sharing').createMemorySharing({ db, config, env, clock, log, fetchImpl });
// The living price book (plan E1): the weekly job that reads every catalogued provider's own price listing and
// The living price book (plan E1): the weekly job that reads every catalogued provider's own price listing and
// keeps the price the router charges in step with it. A change of more than 20% waits for the owner on the
// keeps the price the router charges in step with it. A change of more than 20% waits for the owner on the
// operator console. Off in test mode (the tests replay the recorded listings) and off under a drill.
// operator console. Off in test mode (the tests replay the recorded listings) and off under a drill.
@@ -164,7 +168,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -164,7 +168,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, memoryServices, memorySharing, community, endpoints, webhooks, presets, receipts, balance, gpus, abuse, priceBook });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -205,6 +209,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -205,6 +209,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (listen && config.nodeEnv !== 'test') webhooks.start();
if (listen && config.nodeEnv !== 'test') webhooks.start();
if (listen) balance.start();
if (listen) balance.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
if (listen && config.memoryJobs && config.memoryJobs.enabled) memoryJobs.start();
+ // Team sharing's expiry sweep and delivery queue: production only, so a test's database has no background writers.
+ if (listen && config.nodeEnv !== 'test' && memorySharing) memorySharing.start();
// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.
// The price book reads the providers' listings once a week; it never starts in test mode or under a drill.
if (listen && config.priceBook && config.priceBook.enabled) priceBook.start();
if (listen && config.priceBook && config.priceBook.enabled) priceBook.start();
// Durable Credit settles: the same jobs rule as Memory (never in test mode, never under a provider drill; the
// Durable Credit settles: the same jobs rule as Memory (never in test mode, never under a provider drill; the
@@ -242,6 +248,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -242,6 +248,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (accountSubscription) accountSubscription.stop();
if (accountSubscription) accountSubscription.stop();
userModules.stop();
userModules.stop();
memoryJobs.stop();
memoryJobs.stop();
+ if (memorySharing) memorySharing.stop();
priceBook.stop();
priceBook.stop();
webhooks.stop();
webhooks.stop();
if (credits) credits.stopSettlements();
if (credits) credits.stopSettlements();
@@ -259,7 +266,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -259,7 +266,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, priceBook, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, abuse, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, memoryServices, memorySharing, priceBook, credits: creditsRef, settings, vault, community, endpoints, webhooks, gpus, abuse, close };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -27,6 +27,15 @@
@@ -27,6 +27,15 @@
* POST /api/v1/memory/import/preview parse and redact an export into a sealed two-hour draft (M3)
* POST /api/v1/memory/import/preview parse and redact an export into a sealed two-hour draft (M3)
* POST /api/v1/memory/import/:id/commit keep the ticked candidates and delete the draft (M3)
* POST /api/v1/memory/import/:id/commit keep the ticked candidates and delete the draft (M3)
* DELETE /api/v1/memory/import/:id throw the draft away (M3)
* DELETE /api/v1/memory/import/:id throw the draft away (M3)
+ * GET /api/v1/memory/services the connected memory services, without any key (M5)
+ * PUT /api/v1/memory/services/:provider store or replace mem0/Letta/Zep's key, sealed, then test it (M5)
+ * DELETE /api/v1/memory/services/:provider forget the key (M5)
+ * POST /api/v1/memory/services/:provider/sync read the service and put its notes in the store (M5)
+ * GET /api/v1/memory/export the one portable file (ai.memory-export@1), decrypted for its owner (M5)
+ * POST /api/v1/memory/import import that same file, redacted and deduped again (M5)
+ * GET /api/v1/memory/shared the stores other people shared with this person (M5)
+ * GET /api/v1/memory/stores/:owner one shared store's active notes, after canView (M5)
+ * * /api/v1/memory-store/:id/share the uniform openvibe-sdk/share routes (M5, server/memory/sharing.js)
*
*
* Every SQL query that reads or changes a memory table is scoped by `owner = $…` (test/memory.test.js greps for it).
* Every SQL query that reads or changes a memory table is scoped by `owner = $…` (test/memory.test.js greps for it).
* The account export decrypts the person's own items for their own export; deletion removes the rows.
* The account export decrypts the person's own items for their own export; deletion removes the rows.
@@ -49,7 +58,7 @@
@@ -49,7 +58,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { ids, http, capabilities } = require('openvibe-contracts');
+const { ids, http, capabilities, validate } = require('openvibe-contracts');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { redact, onlySecret } = require('openvibe-sdk/redact');
const { redact, onlySecret } = require('openvibe-sdk/redact');
const { AiError, iso, sha256 } = require('../util');
const { AiError, iso, sha256 } = require('../util');
@@ -878,6 +887,94 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -878,6 +887,94 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
return { reads: list, count: list.length };
return { reads: list, count: list.length };
}
}
+ /* ── Memory M5: the portable file and a shared store ──────────────────────────────────────────────── */
+
+ const PORTABLE_FORMAT = 'openvibe.memory-export@1';
+ const PORTABLE_ITEMS = 10000;
+
+ /** The one portable file: every item the person has, with no id, owner, merge or conflict link. */
+ async function portableExport(req, owner) {
+ assertConfigured();
+ const scope = req.query.scope ? validateScope(req.query.scope) : null;
+ const args = [owner];
+ let sql = 'SELECT * FROM memory_items WHERE owner = $1';
+ if (scope) { args.push(scope); sql += ` AND scope = $${args.length}`; }
+ sql += ' ORDER BY created_at DESC LIMIT ' + PORTABLE_ITEMS;
+ const items = [];
+ for (const row of await q(sql).all(...args)) {
+ const v = itemView(row);
+ items.push({
+ text: v.text, kind: v.kind, scope: v.scope, source: String(v.source || 'person').split(',')[0],
+ ...(v.source_ref ? { source_ref: v.source_ref } : {}),
+ status: v.status, confidence: v.confidence,
+ ...(v.machine ? { machine: v.machine } : {}),
+ ...(v.seen_at ? { seen_at: v.seen_at } : {}),
+ ...(v.created_at ? { created_at: v.created_at } : {}),
+ });
+ }
+ return { format: PORTABLE_FORMAT, version: 1, exported_at: iso(clock.now()), items };
+ }
+
+ /**
+ * Import the same document. The items go through the store's own batch in chunks, so every one is redacted,
+ * deduped by its text MAC and sealed under the importing person. A conflict cannot come alone (its pair did not
+ * travel), so it is imported as a suggestion.
+ */
+ async function importPortable(req, owner) {
+ assertConfigured();
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ if (body.format !== PORTABLE_FORMAT || Number(body.version) !== 1) throw new AiError(422, 'input.invalid', `the file must be ${PORTABLE_FORMAT}, version 1`);
+ // The contract is the rule, not this function: ai.memory-export@1 (Contracts 0.308.0).
+ const check = validate('ai.memory-export@1', body);
+ if (!check.valid) throw new AiError(422, 'input.invalid', 'that JSON is not an ai.memory-export@1 file', { errors: check.errors.slice(0, 5) });
+ const raw = body.items;
+ if (!Array.isArray(raw)) throw new AiError(422, 'input.invalid', 'items must be an array');
+ if (raw.length > PORTABLE_ITEMS) throw new AiError(422, 'input.invalid', `a portable file holds at most ${PORTABLE_ITEMS} items`);
+ let skipped = 0;
+ let created = 0;
+ let deduped = 0;
+ const prepared = [];
+ for (const item of raw) {
+ if (!item || typeof item !== 'object') { skipped++; continue; }
+ const text = String(item.text == null ? '' : item.text);
+ if (!text.trim()) { skipped++; continue; }
+ const clean = redact(text).text;
+ if (!clean.trim() || onlySecret(clean)) { skipped++; continue; }
+ prepared.push({
+ text: clean.slice(0, 8000),
+ kind: validateKind(item.kind),
+ scope: validateScope(item.scope, 'user'),
+ source: validateSource(item.source || 'portable'),
+ ...(item.source_ref != null ? { source_ref: validateSourceRef(item.source_ref) } : {}),
+ status: item.status === 'conflict' ? 'suggested' : validateStatus(item.status, 'active'),
+ confidence: item.confidence == null ? 1 : Math.max(0, Math.min(1, Number(item.confidence) || 0)),
+ ...(item.machine != null ? { machine: String(item.machine).slice(0, 100) } : {}),
+ ...(item.seen_at != null ? { seen_at: validateTime(item.seen_at, null) } : {}),
+ });
+ }
+ for (let i = 0; i < prepared.length; i += MAX_BATCH) {
+ const out = await batch({
+ principal: { kind: 'user', subject: { type: 'user', id: owner.slice('user:'.length) }, claims: {} },
+ body: { items: prepared.slice(i, i + MAX_BATCH) },
+ }, owner);
+ created += Number(out.created) || 0;
+ deduped += Number(out.deduped) || 0;
+ }
+ return { format: PORTABLE_FORMAT, version: 1, imported: created + deduped, created, deduped, skipped };
+ }
+
+ /** Active items of a store another person shared with the viewer. The caller has already checked canView. */
+ async function sharedList(owner, { scope = null } = {}) {
+ assertConfigured();
+ if (!/^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(owner))) throw new AiError(404, 'memory.not_found', 'no such shared store');
+ const args = [owner];
+ let sql = "SELECT * FROM memory_items WHERE owner = $1 AND status = 'active'";
+ if (scope) { validateScope(scope); args.push(scope); sql += ` AND scope = $${args.length}`; }
+ sql += ' ORDER BY updated_at DESC LIMIT 200';
+ const items = (await q(sql).all(...args)).map(itemView);
+ return { items, count: items.length };
+ }
+
/** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */
/** The person's own export only: decrypted items, profiles and read ids (never another person's rows). */
async function exportFor(subject) {
async function exportFor(subject) {
assertConfigured();
assertConfigured();
@@ -896,6 +993,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -896,6 +993,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
return {
return {
assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor, reads,
assertConfigured, batch, list, get, patch, remove, removeBySource, profile, search, exportFor, reads,
split, settle, aadFor, macOf, seal, open,
split, settle, aadFor, macOf, seal, open,
+ // M5: the one portable file, its import, and the read of a store somebody shared.
+ portableExport, importPortable, sharedList,
// The store's own maintenance (server/memory/jobs.js) drives the store through these.
// The store's own maintenance (server/memory/jobs.js) drives the store through these.
ownersForJobs, scopesFor, embedMissing, reconcileScope, refreshProfile, profileFor,
ownersForJobs, scopesFor, embedMissing, reconcileScope, refreshProfile, profileFor,
};
};
@@ -906,7 +1005,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
@@ -906,7 +1005,7 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
* names the person with `on_behalf_of`, but it may only search and read the profile of a granted scope (both
* names the person with `on_behalf_of`, but it may only search and read the profile of a granted scope (both
* audited) and import suggestions. Every other route is person-only: a service gets 403 `capability.denied`.
* audited) and import suggestions. Every other route is person-only: a service gets 403 `capability.denied`.
*/
*/
-function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {
+function memoryRouter({ memory, memoryImport = null, services = null, sharing = null, auth, config, clock = { now: () => Date.now() }, log = console, sendError }) {
const r = express.Router();
const r = express.Router();
// Memory M3: an export zip is raw bytes (or one multipart form) and can be a few MB. The same route also takes
// Memory M3: an export zip is raw bytes (or one multipart form) and can be a few MB. The same route also takes
// pasted text as JSON, which the app-level JSON parser has already read.
// pasted text as JSON, which the app-level JSON parser has already read.
@@ -984,6 +1083,48 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now
@@ -984,6 +1083,48 @@ function memoryRouter({ memory, memoryImport = null, auth, config, clock = { now
// M2c: undo a merge, and settle a conflict. Both are the person's own: a service may only search and suggest.
// M2c: undo a merge, and settle a conflict. Both are the person's own: a service may only search and suggest.
r.post('/api/v1/memory/items/:id/split', guard(WRITE_CAP), personOnly, run(async (req) => await memory.split(req, ownerOf(req), req.params.id)));
r.post('/api/v1/memory/items/:id/split', guard(WRITE_CAP), personOnly, run(async (req) => await memory.split(req, ownerOf(req), req.params.id)));
r.post('/api/v1/memory/items/:id/settle', guard(WRITE_CAP), personOnly, run(async (req) => await memory.settle(req, ownerOf(req), req.params.id)));
r.post('/api/v1/memory/items/:id/settle', guard(WRITE_CAP), personOnly, run(async (req) => await memory.settle(req, ownerOf(req), req.params.id)));
+
+ /* ── Memory M5: the memory services, the portable file and team sharing ───────────────────────────── */
+
+ const unavailable = (what) => { throw new AiError(503, 'memory.services_unavailable', `${what} is not configured on this service`); };
+ const subjectOf = (req) => req.principal.subject.id;
+
+ r.get('/api/v1/memory/services', guard(READ_CAP), personOnly, run(async (req) => {
+ if (!services) unavailable('the memory-service connections');
+ return await services.list(subjectOf(req));
+ }));
+ r.put('/api/v1/memory/services/:provider', guard(WRITE_CAP), personOnly, run(async (req) => {
+ if (!services) unavailable('the memory-service connections');
+ return await services.put(subjectOf(req), String(req.params.provider), req.body);
+ }));
+ r.delete('/api/v1/memory/services/:provider', guard(WRITE_CAP), personOnly, run(async (req) => {
+ if (!services) unavailable('the memory-service connections');
+ return await services.remove(subjectOf(req), String(req.params.provider));
+ }));
+ r.post('/api/v1/memory/services/:provider/sync', guard(WRITE_CAP), personOnly, run(async (req) => {
+ if (!services) unavailable('the memory-service connections');
+ return await services.sync(subjectOf(req), String(req.params.provider));
+ }));
+
+ // The one portable file. Person-only: it is the person's own plaintext, decrypted for their own export.
+ r.get('/api/v1/memory/export', guard(READ_CAP), personOnly, run(async (req) => await memory.portableExport(req, ownerOf(req))));
+ r.post('/api/v1/memory/import', guard(WRITE_CAP), personOnly, run(async (req) => await memory.importPortable(req, ownerOf(req))));
+
+ // Team sharing: what is shared with the person, and one shared store's active notes (never through a search).
+ const shareViewer = (req) => (req.principal.kind === 'user' ? { subject: { type: 'user', id: subjectOf(req) }, staff: false } : null);
+ r.get('/api/v1/memory/shared', guard(READ_CAP), personOnly, run(async (req) => {
+ if (!sharing) unavailable('memory sharing');
+ return { stores: await sharing.sharedStores(shareViewer(req)) };
+ }));
+ r.get('/api/v1/memory/stores/:owner', guard(READ_CAP), personOnly, run(async (req) => {
+ if (!sharing) unavailable('memory sharing');
+ const owner = String(req.params.owner || '');
+ if (!SUBJECT_RE.test(owner)) throw new AiError(404, 'memory.not_found', 'no such shared store');
+ if (!await sharing.canView(shareViewer(req), owner)) throw new AiError(404, 'memory.not_found', 'no such shared store');
+ const out = await memory.sharedList(`user:${owner}`, { scope: req.query.scope ? String(req.query.scope) : null });
+ return { owner, ...out };
+ }));
+
// The owner is resolved after the guard has set the principal.
// The owner is resolved after the guard has set the principal.
function ownerOf(req) {
function ownerOf(req) {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const body = req.body && typeof req.body === 'object' ? req.body : {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+/**
+ * The memory-service adapters (Memory M5): mem0, Letta and Zep.
+ *
+ * Each adapter is data plus one read: where the service keeps a person's notes, how to page through them and how to
+ * turn one record into a memory item. They never store anything; server/memory/services/index.js seals the key,
+ * calls one adapter with it and hands the items to the store's own batch (which redacts again).
+ *
+ * The key travels in the request header and nowhere else. It is never put in a URL, a log line, an error message or
+ * a response: an upstream refusal becomes a status code and a short, fixed sentence, even when the service echoes
+ * the key back. The endpoints and their shapes:
+ *
+ * mem0 GET <base>/v1/memories/?page=&page_size= Authorization: Token <key>
+ * { results: [{ id, memory, created_at }], next } (or a bare array without paging)
+ * https://docs.mem0.ai/api-reference/memory/get-memories
+ *
+ * Letta GET <base>/v1/agents/ Authorization: Bearer <key>
+ * GET <base>/v1/agents/{id}/core-memory/blocks (the core memory: { label, value })
+ * GET <base>/v1/agents/{id}/archival-memory (the archival passages: { text })
+ * https://docs.letta.com/api-reference/agents
+ *
+ * Zep GET <base>/users Authorization: Api-Key <key>
+ * GET <base>/users/{user_id}/sessions
+ * GET <base>/users/{user_id}/sessions/{session_id}/memory (facts or messages)
+ * <base> defaults to https://api.getzep.com/api/v2
+ * https://help.getzep.com/api-reference
+ *
+ * A service that pages answers at most MAX_ITEMS records per sync; the store dedupes what a later sync finds again.
+ */
+
+const MAX_ITEMS = 1000;
+const MAX_PAGES = 10;
+const PAGE = 100;
+const MAX_CHARS = 2000;
+
+const { ServiceError } = require('./transport');
+
+const NAMES = { mem0: 'mem0', letta: 'Letta', zep: 'Zep' };
+const DEFAULT_BASE = { mem0: 'https://api.mem0.ai', letta: 'https://api.letta.com', zep: 'https://api.getzep.com/api/v2' };
+
+function baseOf(id, baseUrl) {
+ return String(baseUrl || DEFAULT_BASE[id]).replace(/\/+$/, '');
+}
+
+/** One record → one item. Text is cut to the store's own per-item bound; the store redacts and dedupes. */
+function itemOf(text, { kind = 'fact', created_at, path } = {}) {
+ const clean = String(text == null ? '' : text).replace(/\r\n/g, '\n').trim();
+ if (!clean) return null;
+ return {
+ text: clean.slice(0, MAX_CHARS),
+ kind,
+ scope: 'user',
+ source_ref: path ? { path: String(path).slice(0, 500) } : null,
+ ...(created_at && !Number.isNaN(Date.parse(String(created_at))) ? { seen_at: new Date(created_at).toISOString() } : {}),
+ };
+}
+
+const arrayOf = (json, key) => Array.isArray(json) ? json : (json && Array.isArray(json[key]) ? json[key] : []);
+
+/* ── mem0 ─────────────────────────────────────────────────────────────────── */
+async function mem0List({ key, baseUrl, request, limit = MAX_ITEMS }) {
+ const base = baseOf('mem0', baseUrl);
+ const out = [];
+ for (let page = 1; page <= MAX_PAGES && out.length < limit; page++) {
+ const url = `${base}/v1/memories/?page=${page}&page_size=${PAGE}`;
+ const json = await request(url, { Accept: 'application/json', Authorization: `Token ${key}` });
+ const rows = arrayOf(json, 'results');
+ for (const row of rows) {
+ const it = itemOf(row && row.memory, { created_at: row && row.created_at, path: row && row.id ? `mem0:${row.id}` : 'mem0' });
+ if (it) out.push(it);
+ if (out.length >= limit) break;
+ }
+ const next = json && typeof json === 'object' ? json.next : null;
+ if (!rows.length || (!next && rows.length < PAGE)) break;
+ }
+ return out;
+}
+
+/* ── Letta ────────────────────────────────────────────────────────────────── */
+async function lettaList({ key, baseUrl, request, limit = MAX_ITEMS }) {
+ const base = baseOf('letta', baseUrl);
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${key}` };
+ const agents = arrayOf(await request(`${base}/v1/agents/`, headers), 'agents');
+ const out = [];
+ for (const agent of agents) {
+ if (!agent || !agent.id || out.length >= limit) continue;
+ const id = encodeURIComponent(String(agent.id));
+ let blocks = [];
+ try { blocks = arrayOf(await request(`${base}/v1/agents/${id}/core-memory/blocks`, headers), 'blocks'); }
+ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; /* one agent's blocks */ }
+ for (const block of blocks) {
+ // `persona` is what the agent is; `human` and the rest are what it knows about the person.
+ const kind = block && block.label === 'persona' ? 'fact' : 'preference';
+ const it = itemOf(block && block.value, { kind, path: `letta:${agent.id}:${(block && block.label) || 'block'}` });
+ if (it) out.push(it);
+ if (out.length >= limit) break;
+ }
+ if (out.length >= limit) break;
+ let passages = [];
+ try { passages = arrayOf(await request(`${base}/v1/agents/${id}/archival-memory?limit=${PAGE}`, headers), 'passages'); }
+ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; /* no archival memory */ }
+ for (const passage of passages) {
+ const it = itemOf(passage && passage.text, { created_at: passage && passage.created_at, path: passage && passage.id ? `letta:${passage.id}` : `letta:${agent.id}` });
+ if (it) out.push(it);
+ if (out.length >= limit) break;
+ }
+ }
+ return out;
+}
+
+/* ── Zep ──────────────────────────────────────────────────────────────────── */
+/** Zep v2 keeps a session's memory as facts; older responses carry messages. Both are read, never trusted to exist. */
+function zepMemories(json) {
+ const out = [];
+ for (const container of [json && json.memory, json]) {
+ if (!container || typeof container !== 'object') continue;
+ for (const k of ['facts', 'messages', 'results']) {
+ if (Array.isArray(container[k])) out.push(...container[k]);
+ }
+ }
+ return out;
+}
+
+async function zepList({ key, baseUrl, request, limit = MAX_ITEMS }) {
+ const base = baseOf('zep', baseUrl);
+ const headers = { Accept: 'application/json', Authorization: `Api-Key ${key}` };
+ const users = arrayOf(await request(`${base}/users`, headers), 'users');
+ const out = [];
+ for (const user of users) {
+ const uid = user && (user.user_id || user.id);
+ if (!uid || out.length >= limit) continue;
+ let sessions = [];
+ try { sessions = arrayOf(await request(`${base}/users/${encodeURIComponent(String(uid))}/sessions`, headers), 'sessions'); }
+ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; continue; }
+ for (const session of sessions) {
+ const sid = session && (session.session_id || session.id);
+ if (!sid || out.length >= limit) continue;
+ let json;
+ try { json = await request(`${base}/users/${encodeURIComponent(String(uid))}/sessions/${encodeURIComponent(String(sid))}/memory`, headers); }
+ catch (err) { if (err.status === 401 || err.status === 403 || err.status === 429) throw err; continue; }
+ for (const memory of zepMemories(json)) {
+ const text = memory && (memory.fact || memory.content || memory.text);
+ const it = itemOf(text, { kind: 'fact', created_at: memory && (memory.created_at || memory.valid_at || memory.createdAt), path: memory && memory.uuid ? `zep:${memory.uuid}` : `zep:${uid}` });
+ if (it) out.push(it);
+ if (out.length >= limit) break;
+ }
+ }
+ }
+ return out;
+}
+
+const ADAPTERS = Object.freeze({
+ mem0: { id: 'mem0', name: NAMES.mem0, docs: 'https://docs.mem0.ai/api-reference/memory/get-memories', defaultBaseUrl: DEFAULT_BASE.mem0, list: mem0List },
+ letta: { id: 'letta', name: NAMES.letta, docs: 'https://docs.letta.com/api-reference/agents', defaultBaseUrl: DEFAULT_BASE.letta, list: lettaList },
+ zep: { id: 'zep', name: NAMES.zep, docs: 'https://help.getzep.com/api-reference', defaultBaseUrl: DEFAULT_BASE.zep, list: zepList },
+});
+
+const PROVIDERS = Object.freeze(Object.keys(ADAPTERS));
+
+module.exports = { ADAPTERS, PROVIDERS, MAX_ITEMS, MAX_CHARS, ServiceError, itemOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
+'use strict';
+/**
+ * The memory-service connections (Memory M5): mem0, Letta and Zep, each with the person's own key.
+ *
+ * GET /api/v1/memory/services the connections, without any key (the last four characters only)
+ * PUT /api/v1/memory/services/:provider store or replace the key, then test it
+ * DELETE /api/v1/memory/services/:provider forget the key
+ * POST /api/v1/memory/services/:provider/sync read the service and put what it has into the store
+ *
+ * The key is sealed (server/memory/services/keys.js) and opened only for the duration of one probe or sync. It is
+ * never in a URL, a log line, an error or an answer. A sync hands the adapter's items to the store's own batch, so
+ * they are redacted again there, deduped by the same text MAC and scoped to the person who connected the service.
+ */
+
+const { redact, onlySecret } = require('openvibe-sdk/redact');
+const { AiError } = require('../../util');
+const { createServiceKeys } = require('./keys');
+const { ADAPTERS, PROVIDERS } = require('./adapters');
+const { createServiceTransport, assertPublicBaseUrl } = require('./transport');
+
+const MAX_SYNC = 1000;
+const BATCH = 200;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createMemoryServices({ db, config, clock = { now: () => Date.now() }, log = console, memory, transport = null, lookup = null }) {
+ const keys = createServiceKeys({ db, config, clock });
+ const guarded = createServiceTransport({ transport, lookup });
+ /** The stored base (or the adapter's default), checked again: a legacy row is never sent a key to a private host. */
+ async function baseFor(row, adapter) {
+ const u = await assertPublicBaseUrl(String((row && row.base_url) || adapter.defaultBaseUrl), { lookup });
+ return u.toString().replace(/\/+$/, '');
+ }
+ const adapterOf = (provider) => {
+ const adapter = ADAPTERS[provider];
+ if (!adapter) throw new AiError(404, 'memory.service_not_found', 'no such memory service');
+ return adapter;
+ };
+
+ /** The person's connections, one row per provider that has a key. Never a key. */
+ async function list(subject) {
+ const rows = await keys.list(subject);
+ return {
+ providers: PROVIDERS.map((id) => {
+ const adapter = ADAPTERS[id];
+ const row = rows.find((r) => r.provider === id);
+ return {
+ provider: id, name: adapter.name, docs: adapter.docs, default_base_url: adapter.defaultBaseUrl,
+ connected: !!row,
+ ...(row || { hint: null, base_url: null, account: null, status: 'untested', checked_at: null, last_error: null, last_sync_at: null, last_sync_count: 0 }),
+ };
+ }),
+ };
+ }
+
+ /**
+ * Store a key, then test it by reading one page. The URL is judged (and resolved) before anything is stored, so a
+ * key is never written for a private or malformed host; a key the service refuses is kept with the connection.
+ */
+ async function put(subject, provider, body) {
+ const adapter = adapterOf(provider);
+ const b = body && typeof body === 'object' ? body : {};
+ let base = null;
+ if (b.base_url != null && b.base_url !== '') {
+ // 422 before keys.put: the key must not even be stored for a host that cannot be reached safely.
+ base = (await assertPublicBaseUrl(b.base_url, { lookup })).toString().replace(/\/+$/, '');
+ }
+ if (b.account != null && (typeof b.account !== 'string' || b.account.length > 200)) throw new AiError(422, 'input.invalid', 'account must be a string of at most 200 characters');
+ const saved = await keys.put(subject, provider, { key: b.key, base_url: base, account: b.account || null });
+ const opened = await keys.openFor(subject, provider);
+ let probe;
+ try {
+ const found = await adapter.list({ key: opened.key, baseUrl: opened.row.base_url, request: guarded.get, limit: 1 });
+ probe = { ok: true, count: found.length };
+ } catch (err) {
+ // A redirect, a blocked answer or an oversized body is the URL's problem, not the key's: forget the row
+ // and refuse the save. A refused key stays, with its redacted reason, so the person can replace it.
+ if (['memory.service_redirect', 'memory.service_blocked', 'memory.service_too_large'].includes(String(err && err.code))) {
+ await keys.remove(subject, provider);
+ throw new AiError(422, 'memory.service_url', err.message);
+ }
+ probe = { ok: false, error: keys.scrub(err && err.message, opened.key) };
+ log.warn(`[memory] ${provider} key test failed: ${err && err.code ? err.code : 'error'}`);
+ }
+ await keys.markProbe(subject, provider, probe);
+ const row = (await keys.list(subject)).find((r) => r.provider === provider);
+ return { service: row || saved };
+ }
+
+ async function remove(subject, provider) {
+ adapterOf(provider);
+ const gone = await keys.remove(subject, provider);
+ if (!gone) throw new AiError(404, 'memory.service_not_found', 'that memory service is not connected');
+ return { removed: 1 };
+ }
+
+ /**
+ * Read one connected service and put its notes in the person's store. The adapter sees the decrypted key for this
+ * call only; the store's batch does the redaction, the dedupe and the suggestion rule again.
+ */
+ async function sync(subject, provider) {
+ const adapter = adapterOf(provider);
+ const owner = `user:${subject}`;
+ const view = (await keys.list(subject)).find((r) => r.provider === provider);
+ if (!view) throw new AiError(404, 'memory.service_not_found', 'that memory service is not connected');
+ // Judge and resolve the base before the key is opened: a refused host never sees the person's key.
+ const base = await baseFor(view, adapter);
+ const { key } = await keys.openFor(subject, provider);
+ let found;
+ try {
+ found = await adapter.list({ key, baseUrl: base, request: guarded.get, limit: MAX_SYNC });
+ } catch (err) {
+ await keys.markProbe(subject, provider, { ok: false, error: err && err.message });
+ log.warn(`[memory] ${provider} sync failed: ${err && err.code ? err.code : 'error'}`);
+ if (String(err && err.code) === 'memory.service_blocked') {
+ throw new AiError(422, 'memory.service_url', 'the service resolves to a non-public address');
+ }
+ if (String(err && err.code) === 'memory.service_redirect') {
+ throw new AiError(502, 'memory.service_redirect', 'the service answered with a redirect');
+ }
+ throw new AiError(err && err.status === 401 ? 401 : 502, err && err.code ? err.code : 'memory.service_failed',
+ err && err.status === 401 ? 'the service refused the key' : 'the memory service did not answer');
+ }
+ // Redact here as well as in the store, so an item that is only a secret is dropped (not a whole batch refused).
+ const items = [];
+ let skipped = 0;
+ for (const raw of found) {
+ const text = String((raw && raw.text) || '');
+ if (!text.trim() || onlySecret(redact(text).text)) { skipped++; continue; }
+ items.push({
+ text: text.slice(0, 8000), kind: raw.kind, scope: 'user', source: provider,
+ source_ref: raw.source_ref || null, confidence: 0.8, ...(raw.seen_at ? { seen_at: raw.seen_at } : {}),
+ });
+ }
+ let created = 0;
+ let deduped = 0;
+ for (let i = 0; i < items.length; i += BATCH) {
+ const chunk = items.slice(i, i + BATCH);
+ const out = await memory.batch({
+ principal: { kind: 'user', subject: { type: 'user', id: subject }, claims: {} },
+ body: { items: chunk },
+ }, owner);
+ created += Number(out.created) || 0;
+ deduped += Number(out.deduped) || 0;
+ }
+ await keys.markSync(subject, provider, items.length);
+ return { provider, found: found.length, imported: created, deduped, skipped };
+ }
+
+ return { list, put, remove, sync, adapterOf, PROVIDERS, SUBJECT_RE };
+}
+
+module.exports = { createMemoryServices, SUBJECT_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * The sealed keys of the memory services (Memory M5).
+ *
+ * A key is stored the way a BYO provider key already is in OpenVibe.AI (server/credentials.js): AES-256-GCM with
+ * AI_CREDENTIALS_KEY, the envelope `v1:<iv>:<tag>:<body>`, and the additional data
+ * `memory-service|<subject>|<provider>`, so a row moved to another person or provider does not decrypt. The plaintext
+ * exists only while one sync runs. It is never returned by a route, written to a log, put in an error or an account
+ * export; the person sees the last four characters (`key_hint`) and the service's health.
+ */
+const crypto = require('crypto');
+const { AiError } = require('../../util');
+
+const KEY_RE = /^[\x21-\x7e]{8,400}$/; // printable, no spaces: an API key, not a passphrase
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const iso = (ms) => new Date(ms).toISOString();
+
+/** Replace anything that looks like the key before a reason is stored or answered. */
+function scrub(text, key) {
+ let out = String(text == null ? '' : text);
+ if (key) out = out.split(String(key)).join('[redacted]');
+ return out.replace(/\b(sk|key|token|api[-_]?key)[-_][A-Za-z0-9_-]{8,}\b/gi, '[redacted]').slice(0, 300);
+}
+
+function createServiceKeys({ db, config, clock = { now: () => Date.now() } }) {
+ function secretKey() {
+ const hex = String(config.credentialsKey || '');
+ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'memory.services_unavailable', 'memory services are not configured on this service (AI_CREDENTIALS_KEY)');
+ return Buffer.from(hex, 'hex');
+ }
+ function aadFor(subject, provider) { return `memory-service|${subject}|${provider}`; }
+ function seal(plain, aad) {
+ const iv = crypto.randomBytes(12);
+ const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);
+ c.setAAD(Buffer.from(aad));
+ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
+ return `v1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;
+ }
+ function open(sealed, aad) {
+ const [v, iv, tag, body] = String(sealed).split(':');
+ if (v !== 'v1') throw new Error('unknown memory-service key format');
+ const d = crypto.createDecipheriv('aes-256-gcm', secretKey(), Buffer.from(iv, 'base64'));
+ d.setAAD(Buffer.from(aad));
+ d.setAuthTag(Buffer.from(tag, 'base64'));
+ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');
+ }
+ const hintOf = (key) => String(key).slice(-4);
+
+ function view(row) {
+ return {
+ provider: row.provider,
+ hint: row.key_hint,
+ base_url: row.base_url || null,
+ account: row.account || null,
+ status: row.status || 'untested',
+ checked_at: row.checked_at || null,
+ last_error: row.last_error || null,
+ last_sync_at: row.last_sync_at || null,
+ last_sync_count: Number(row.last_sync_count) || 0,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+ }
+
+ async function list(subject) {
+ if (!SUBJECT_RE.test(String(subject))) return [];
+ const rows = await db.many('SELECT * FROM memory_service_connections WHERE subject = $1 ORDER BY provider', [String(subject)]);
+ return rows.map(view);
+ }
+
+ async function row(subject, provider) {
+ return await db.maybe('SELECT * FROM memory_service_connections WHERE subject = $1 AND provider = $2', [String(subject), String(provider)]);
+ }
+
+ /** Store or replace a key. The key itself never comes back. */
+ async function put(subject, provider, { key, base_url, account } = {}) {
+ if (!SUBJECT_RE.test(String(subject))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');
+ if (key != null && !KEY_RE.test(String(key))) throw new AiError(422, 'input.invalid', 'key must be 8-400 printable characters with no spaces');
+ const at = iso(clock.now());
+ const prev = await row(subject, provider);
+ if (key == null && !prev) throw new AiError(400, 'memory.service_key_required', 'key is required the first time');
+ const plain = key != null ? String(key) : open(prev.key_enc, aadFor(subject, provider));
+ await db.exec(`INSERT INTO memory_service_connections (subject, provider, base_url, account, key_enc, key_hint, status, checked_at, last_error, last_sync_at, last_sync_count, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'untested', NULL, NULL, $7, $8, $9, $10)
+ ON CONFLICT (subject, provider) DO UPDATE SET base_url = excluded.base_url, account = excluded.account,
+ key_enc = excluded.key_enc, key_hint = excluded.key_hint, status = 'untested', checked_at = NULL,
+ last_error = NULL, last_sync_at = excluded.last_sync_at, last_sync_count = excluded.last_sync_count,
+ updated_at = excluded.updated_at`,
+ [String(subject), String(provider), base_url ? String(base_url).slice(0, 500) : null, account ? String(account).slice(0, 200) : null,
+ seal(plain, aadFor(subject, provider)), hintOf(plain), prev ? prev.last_sync_at : null, prev ? prev.last_sync_count : 0,
+ prev ? prev.created_at : at, at]);
+ return view(await row(subject, provider));
+ }
+
+ async function remove(subject, provider) {
+ const gone = await db.exec('DELETE FROM memory_service_connections WHERE subject = $1 AND provider = $2', [String(subject), String(provider)]);
+ return Number(gone) > 0;
+ }
+
+ /** The row and its decrypted key, for one sync only. Never returned to a route. */
+ async function openFor(subject, provider) {
+ const r = await row(subject, provider);
+ if (!r) throw new AiError(404, 'memory.service_not_found', 'that memory service is not connected');
+ let key;
+ try { key = open(r.key_enc, aadFor(subject, provider)); }
+ catch { throw new AiError(500, 'memory.service_unreadable', 'the saved key for this service cannot be opened'); }
+ return { row: r, key };
+ }
+
+ async function markProbe(subject, provider, { ok, error, count = 0 } = {}) {
+ const at = iso(clock.now());
+ await db.exec(`UPDATE memory_service_connections SET status = $1, checked_at = $2, last_error = $3, updated_at = $2
+ WHERE subject = $4 AND provider = $5`, [ok ? 'ok' : 'failing', at, ok ? null : scrub(error).slice(0, 300), String(subject), String(provider)]);
+ if (ok) await db.exec('UPDATE memory_service_connections SET last_sync_count = $1 WHERE subject = $2 AND provider = $3', [Number(count) || 0, String(subject), String(provider)]);
+ }
+
+ async function markSync(subject, provider, count) {
+ const at = iso(clock.now());
+ await db.exec(`UPDATE memory_service_connections SET last_sync_at = $1, last_sync_count = $2, status = 'ok', checked_at = $1,
+ last_error = NULL, updated_at = $1 WHERE subject = $3 AND provider = $4`, [at, Number(count) || 0, String(subject), String(provider)]);
+ }
+
+ return { list, put, remove, openFor, markProbe, markSync, view, scrub, aadFor, seal, open };
+}
+
+module.exports = { createServiceKeys, scrub, KEY_RE, SUBJECT_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+/**
+ * The guarded transport of the memory-service adapters (Memory M5).
+ *
+ * A person names their own mem0, Letta or Zep address, so every request to it is an untrusted URL. It goes through
+ * the same guard OpenVibe.AI already uses for a person's own endpoint (server/community/endpoints.js):
+ *
+ * - https only, no credentials in the URL, port 443 or 8443;
+ * - never an IP literal, never an internal name (localhost, *.local, *.internal, a single label);
+ * - the DNS answer is judged inside the connection's own lookup (openvibe-shared/egress), so the socket connects
+ * to the checked address and a second answer cannot swap it (no rebinding);
+ * - a redirect is never followed: a 3xx is the error "the service answered with a redirect";
+ * - a 15 s timeout and a 1 MB response cap on every call.
+ *
+ * The `transport` and `lookup` seams are for tests only. They are injected by the test helper, never read from config
+ * or the environment, so a deployment cannot turn the guard off and a test can script a response without a socket.
+ */
+const net = require('net');
+const https = require('https');
+const egress = require('openvibe-shared/egress');
+const { AiError } = require('../../util');
+
+const ALLOWED_PORTS = new Set(['', '443', '8443']);
+const TIMEOUT_MS = 15000;
+const MAX_BYTES = 1024 * 1024;
+const DNS_TIMEOUT_MS = 5000;
+
+class ServiceError extends Error {
+ constructor(status, code, message) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+}
+
+/**
+ * The one URL rule for a memory service. Returns the URL (its pathname without a trailing slash), or throws a 422
+ * whose words never echo the host: the value came from a person and must not come back in an error.
+ */
+function judgeUrl(raw, { allowQuery = false } = {}) {
+ const text = String(raw == null ? '' : raw);
+ if (text.length > 500) throw new AiError(422, 'memory.service_url', 'base_url is too long');
+ let u;
+ try { u = new URL(text); } catch { throw new AiError(422, 'memory.service_url', 'base_url must be an https URL'); }
+ if (u.protocol !== 'https:') throw new AiError(422, 'memory.service_url', 'base_url must be https');
+ if (u.username || u.password) throw new AiError(422, 'memory.service_url', 'credentials in base_url are not allowed');
+ if (u.hash || (!allowQuery && u.search)) throw new AiError(422, 'memory.service_url', 'base_url carries no query or fragment');
+ if (!ALLOWED_PORTS.has(u.port)) throw new AiError(422, 'memory.service_url', 'base_url must use port 443 or 8443');
+ const host = egress.normalizeHost(u.hostname);
+ // Node never calls the lookup hook for an IP literal, so the literal itself is the check.
+ if (net.isIP(host)) throw new AiError(422, 'memory.service_url', 'base_url must name a public host, not an IP address');
+ if (egress.isInternalName(host)) throw new AiError(422, 'memory.service_url', 'base_url must be a public host name');
+ u.hostname = host;
+ // A stored base URL has no trailing slash; a request URL keeps its own path exactly as the service documents it.
+ if (!allowQuery) u.pathname = u.pathname.replace(/\/+$/, '') || '';
+ return u;
+}
+
+/** A base URL: the stored form, no query, no trailing slash. */
+function judgeBaseUrl(raw) {
+ return judgeUrl(raw, { allowQuery: false });
+}
+
+/** A request URL: the base plus its fixed path and (for mem0) paging query, still guarded. */
+function judgeRequestUrl(raw) {
+ return judgeUrl(raw, { allowQuery: true });
+}
+
+/** Resolve a host through the guard, for the save check. A private answer is a 422, and the key is never used. */
+function assertPublicBaseUrl(raw, { lookup = null } = {}) {
+ const u = judgeBaseUrl(raw);
+ const safe = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });
+ return new Promise((resolve, reject) => {
+ let done = false;
+ const finish = (err) => {
+ if (done) return;
+ done = true;
+ clearTimeout(timer);
+ if (!err) return resolve(u);
+ if (err && err.code === 'EGRESS_DENIED') return reject(new AiError(422, 'memory.service_url', 'base_url must resolve to a public address'));
+ return reject(new AiError(422, 'memory.service_url', 'base_url could not be resolved'));
+ };
+ const timer = setTimeout(() => finish(new Error('dns timeout')), DNS_TIMEOUT_MS);
+ if (timer.unref) timer.unref();
+ safe(u.hostname, { all: true }, finish);
+ });
+}
+
+/** One response, without following anything. */
+function once(target, headers, safeLookup) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let req;
+ const fail = (err) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ try { if (req) req.destroy(); } catch { /* already gone */ }
+ reject(err);
+ };
+ const ok = (value) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(value);
+ };
+ req = https.request(target, { method: 'GET', headers, lookup: safeLookup, timeout: TIMEOUT_MS, agent: false }, (res) => {
+ const status = res.statusCode || 0;
+ if (status >= 300 && status < 400) { res.resume(); return ok({ status, headers: res.headers, body: '' }); }
+ const declared = Number(res.headers['content-length']);
+ if (Number.isFinite(declared) && declared > MAX_BYTES) {
+ res.resume();
+ return fail(new ServiceError(413, 'memory.service_too_large', 'the service answered more than 1 MB'));
+ }
+ let bytes = 0;
+ const chunks = [];
+ res.on('data', (chunk) => {
+ bytes += chunk.length;
+ if (bytes > MAX_BYTES) {
+ res.destroy();
+ return fail(new ServiceError(413, 'memory.service_too_large', 'the service answered more than 1 MB'));
+ }
+ chunks.push(chunk);
+ });
+ res.on('error', () => fail(new ServiceError(502, 'memory.service_unreachable', 'the service could not be reached')));
+ res.on('end', () => ok({ status, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
+ });
+ req.on('error', (err) => fail(new ServiceError(502, err && err.code === 'EGRESS_DENIED' ? 'memory.service_blocked' : 'memory.service_unreachable',
+ err && err.code === 'EGRESS_DENIED' ? 'the service resolves to a non-public address' : 'the service could not be reached')));
+ req.on('timeout', () => fail(new ServiceError(504, 'memory.service_timeout', 'the service did not answer within 15s')));
+ const timer = setTimeout(() => fail(new ServiceError(504, 'memory.service_timeout', 'the service did not answer within 15s')), TIMEOUT_MS);
+ req.on('close', () => clearTimeout(timer));
+ req.end();
+ });
+}
+
+/** What an answer means, as the adapter reads it. The body is JSON; any 3xx is a refusal, never a hop. */
+async function readJson(res) {
+ const status = Number(res && res.status) || 0;
+ if (status >= 300 && status < 400) throw new ServiceError(502, 'memory.service_redirect', 'the service answered with a redirect');
+ if (status === 401 || status === 403) throw new ServiceError(401, 'memory.service_key_refused', 'the service refused the key');
+ if (status === 429) throw new ServiceError(429, 'memory.service_rate_limited', 'the service is rate-limiting the key; try again later');
+ if (status < 200 || status >= 300) throw new ServiceError(502, 'memory.service_failed', 'the service did not answer successfully');
+ const body = String((res && res.body) || '');
+ if (Buffer.byteLength(body) > MAX_BYTES) throw new ServiceError(413, 'memory.service_too_large', 'the service answered more than 1 MB');
+ try { return JSON.parse(body || 'null'); } catch { throw new ServiceError(502, 'memory.service_failed', 'the service answered with a body that is not JSON'); }
+}
+
+function createServiceTransport({ lookup = null, transport = null } = {}) {
+ const safeLookup = egress.createSafeLookup({ ...(lookup ? { lookup } : {}) });
+ /** GET one URL with the adapter's headers. The URL is judged here again, on every call. */
+ async function get(rawUrl, headers = {}) {
+ const target = judgeRequestUrl(rawUrl);
+ const res = transport
+ ? await transport({ method: 'GET', url: target.toString(), headers, timeoutMs: TIMEOUT_MS, maxBytes: MAX_BYTES })
+ : await once(target, headers, safeLookup);
+ return await readJson(res);
+ }
+ return { get };
+}
+
+module.exports = {
+ createServiceTransport, assertPublicBaseUrl, judgeBaseUrl, judgeRequestUrl,
+ ServiceError, TIMEOUT_MS, MAX_BYTES, ALLOWED_PORTS,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+'use strict';
+/**
+ * Team sharing of a memory store (Memory M5), through openvibe-sdk/share.
+ *
+ * The store belongs to one person and lives in AI (server/memory). Its share row is one per person: service `ai`,
+ * kind `memory-store`, id the owner's `usr_…`. The library owns the model and the rules (the four audiences, the
+ * members, requests, transfers, the append-only log and the delivery queue); this file is the wiring AI supplies:
+ *
+ * network.json Network's identity lookups (`identity.subject.resolve`), so a sheet names @people and resolves
+ * an exact name to a subject. It also fetches the projections the sheet shows.
+ * events.publish AI's Events producer (`events.event.publish`), so the five share.* envelopes go out. A failure
+ * leaves the delivery queued and retried; the ACL change itself is already committed.
+ * notify Network's notification push (`network.notifications.push`). A refusal is logged, never fatal.
+ *
+ * The share sheet asks `/api/v1/memory-store/:id/share` (the uniform route). The first owner read registers the
+ * store as private; a member sees the sheet in their role. A person not on a `people` list never reaches the notes:
+ * the routes in server/memory check canView before they return one item, and the page on Codes draws the gate.
+ *
+ * Nothing here reads a note. Titles and notification words are fixed, and no envelope carries text.
+ */
+const { createShares } = require('openvibe-sdk/share');
+const { verifyUserToken } = require('openvibe-sdk/auth');
+const { serviceAuth } = require('openvibe-contracts');
+const { bearer } = require('../auth');
+
+const SERVICE = 'ai';
+const KIND = 'memory-store';
+const AUDIENCES = ['public', 'link', 'people', 'private'];
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const refOf = (owner) => ({ service: SERVICE, kind: KIND, id: String(owner) });
+
+/** Everyone a share call may see: a user subject, never a service or a body-supplied subject. */
+function viewerOf(subject) {
+ if (!SUBJECT_RE.test(String(subject))) return null;
+ return { subject: { type: 'user', id: String(subject) }, staff: false };
+}
+
+function createMemorySharing({ db, config, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
+ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
+ const eventsUrl = String((config.events && config.events.url) || '').replace(/\/+$/, '');
+ const clientId = (config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';
+ const clientSecret = env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!base || !eventsUrl || !clientSecret) return null;
+
+ const networkTokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`, clientId, clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve network.notifications.push', fetch: fetchImpl,
+ });
+ const eventTokens = serviceAuth.createTokenClient({
+ tokenUrl: `${base}/oauth/token`, clientId, clientSecret,
+ audience: 'openvibe.events', scope: 'events.event.publish', fetch: fetchImpl,
+ });
+
+ async function networkJson({ path, method = 'GET', json, query } = {}) {
+ const url = new URL(base + path);
+ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
+ const headers = { Accept: 'application/json', ...(await networkTokens.authHeaders()) };
+ if (json !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = new Error(`Network ${method} ${path} ${res.status}`);
+ err.status = res.status;
+ throw err;
+ }
+ return body;
+ }
+
+ /** One notification. It never carries a note, a title or a scope. */
+ async function notify(n) {
+ const to = n && n.to;
+ if (!to || to.type !== 'user') return;
+ const words = {
+ 'share.added': ['A memory store was shared with you', 'Open it on OpenVibe.Codes, on Memory.'],
+ 'share.access_requested': ['Someone asked for access to your memory', 'Review the request on OpenVibe.Codes, on Memory.'],
+ 'share.transfer.requested': ['Someone wants to give you a memory store', 'Accept or decline it on OpenVibe.Codes.'],
+ 'share.transfer.answered': ['A memory store transfer was answered', 'See what happened on OpenVibe.Codes.'],
+ }[n.kind] || ['Sharing on OpenVibe.Codes', 'There is something new for you.'];
+ try {
+ await networkJson({
+ path: '/internal/notifications/push', method: 'POST',
+ json: {
+ subject: to.id, type: 'GENERIC', title: words[0], message: words[1], service: 'codes',
+ url: `${String(config.codesUrl || 'https://codes.openvibe.services').replace(/\/+$/, '')}/memory`, category: 'social',
+ },
+ });
+ } catch (err) {
+ log.warn('[memory-sharing] notification not sent:', err && err.message);
+ }
+ }
+
+ /** One share.* envelope, with AI's own Events token. A failure leaves the delivery queued for the next flush. */
+ async function publish(envelope) {
+ const res = await fetchImpl(eventsUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await eventTokens.authHeaders()) },
+ body: JSON.stringify(envelope),
+ signal: AbortSignal.timeout(8000),
+ });
+ if (!res.ok) throw new Error(`Events answered ${res.status}`);
+ }
+
+ const shares = createShares({
+ db,
+ network: { json: networkJson },
+ events: { publish },
+ notify,
+ now: () => clock.now(),
+ });
+
+ /** The person from a verified Network token, or null. A service or an app token is never a viewer here. */
+ async function authenticate(req) {
+ const token = bearer(req);
+ if (!token) return null;
+ try {
+ const claims = await verifyUserToken(token, {
+ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
+ issuer: config.issuer,
+ audience: ['openvibe.network', 'openvibe.ai'],
+ now: clock.now(),
+ log,
+ });
+ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');
+ return viewerOf(subject);
+ } catch {
+ return null;
+ }
+ }
+
+ /** The store is private until its owner says otherwise. Repeating it is a no-op, never a change. */
+ async function register(owner) {
+ if (!SUBJECT_RE.test(String(owner))) return null;
+ return await shares.register(refOf(owner), { owner_subject: { type: 'user', id: String(owner) }, audience: 'private' });
+ }
+
+ async function canView(viewer, owner) {
+ if (!viewer || !SUBJECT_RE.test(String(owner))) return false;
+ try { return await shares.canView(viewer, refOf(owner)); } catch { return false; }
+ }
+
+ /** Every memory store shared with the person, newest service order. Ids and access only. */
+ async function sharedStores(viewer) {
+ if (!viewer) return [];
+ const out = [];
+ let cursor = null;
+ for (let page = 0; page < 5; page++) {
+ const result = await shares.sharedWithMe(viewer, { service: SERVICE, limit: 50, ...(cursor ? { cursor } : {}) });
+ for (const item of result.items || []) {
+ if (!item.ref || item.ref.kind !== KIND) continue;
+ const owner = item.owner_subject && item.owner_subject.id;
+ if (!SUBJECT_RE.test(String(owner || ''))) continue;
+ out.push({ owner, audience: item.audience, role: item.role, shared_at: item.shared_at, ...(item.transfer ? { transfer: item.transfer } : {}) });
+ }
+ cursor = result.next_cursor;
+ if (!cursor) break;
+ }
+ return out;
+ }
+
+ const describe = async (ref) => {
+ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
+ return { title: 'Memory store', link: '/memory', roles: ['viewer'], audiences: AUDIENCES, transferable: false };
+ };
+
+ const inner = shares.router({ service: SERVICE, authenticate, describe });
+
+ /**
+ * The uniform router, plus the lazy registration of an owner's store: the first GET (or PUT) of a person's own
+ * `/api/v1/memory-store/<their id>/share` creates the private row. The router still authenticates and decides.
+ */
+ function router() {
+ return async function memoryShareRouter(req, res, next) {
+ const m = /^\/api\/v1\/memory-store\/([^/]+)\/share(?:\/|$)/.exec(String(req.url || ''));
+ if (m && (req.method === 'GET' || req.method === 'PUT')) {
+ try {
+ const viewer = await authenticate(req);
+ const id = decodeURIComponent(m[1]);
+ if (viewer && viewer.subject.id === id) await register(id);
+ } catch { /* the router answers the request itself */ }
+ }
+ return inner(req, res, next);
+ };
+ }
+
+ let timer = null;
+ /** Expire members/transfers and send queued events, at most once a minute. Tests can call work() directly. */
+ function start(intervalMs = 60000) {
+ if (timer) return;
+ timer = setInterval(() => { work().catch((err) => log.warn('[memory-sharing] sweep:', err && err.message)); }, Math.max(10000, Number(intervalMs) || 60000));
+ if (timer.unref) timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+
+ async function work({ flush = true } = {}) {
+ let expired = 0;
+ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[memory-sharing] expiry sweep:', err && err.message); }
+ let sent = 0;
+ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[memory-sharing] deliveries:', err && err.message); } }
+ return { expired, sent };
+ }
+
+ return { shares, SERVICE, KIND, AUDIENCES, refOf, viewerOf, authenticate, register, canView, sharedStores, router, describe, work, start, stop };
+}
+
+module.exports = { createMemorySharing, refOf, viewerOf, SERVICE, KIND, AUDIENCES, SUBJECT_RE };
@@ -139,6 +139,67 @@ route('post', '/memory/items/{id}/settle', {
@@ -139,6 +139,67 @@ route('post', '/memory/items/{id}/settle', {
requestBody: body({ type: 'object', required: ['keep'], properties: { keep: str({ description: 'The id of the item that stays' }) } }),
requestBody: body({ type: 'object', required: ['keep'], properties: { keep: str({ description: 'The id of the item that stays' }) } }),
responses: { 200: json('The kept item and the archived one', memoryResult), 404: problem, 409: problem, 422: problem, 503: problem, ...errors([401, 403]) },
responses: { 200: json('The kept item and the archived one', memoryResult), 404: problem, 409: problem, 422: problem, 503: problem, ...errors([401, 403]) },
});
});
+// ── Memory M5: services, the portable file and team sharing ─────────────────────────────────────
+const memoryServiceList = {
+ type: 'object', required: ['providers'],
+ properties: { providers: { type: 'array', items: { type: 'object', additionalProperties: true } } },
+};
+const memoryServiceView = { type: 'object', required: ['service'], properties: { service: { type: 'object', additionalProperties: true } } };
+const memoryServicePut = {
+ type: 'object',
+ properties: {
+ key: str({ minLength: 8, maxLength: 400, description: 'The person’s own key. It is sealed and never returned.' }),
+ base_url: str({ maxLength: 500, description: 'A self-hosted endpoint: https, port 443 or 8443, a public host name that resolves to a public address, no credentials or query.' }),
+ account: str({ maxLength: 200, description: 'An optional user or agent id the service needs.' }),
+ },
+};
+route('get', '/memory/services', {
+ operationId: 'listMemoryServices', summary: 'The memory services the person connected', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read. One entry per mem0, Letta and Zep: whether it is connected, its last four key characters (never the key), its health and the last sync count.',
+ responses: { 200: json('The services', memoryServiceList), 503: problem, ...errors([401, 403]) },
+});
+route('put', '/memory/services/{provider}', {
+ operationId: 'putMemoryService', summary: 'Store or replace a memory service key and test it', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.write. The key is sealed with AI_CREDENTIALS_KEY and never returned, logged or put in an error. A refused key keeps the connection with status failing and a short redacted reason.',
+ parameters: [path('provider', str({ enum: ['mem0', 'letta', 'zep'] }))],
+ requestBody: body(memoryServicePut),
+ responses: { 200: json('The connection, without the key', memoryServiceView), 422: problem, 503: problem, ...errors([401, 403]) },
+});
+route('delete', '/memory/services/{provider}', {
+ operationId: 'deleteMemoryService', summary: 'Forget a memory service key', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.write. The sealed row is deleted; the notes it imported stay until the person deletes them.',
+ parameters: [path('provider', str({ enum: ['mem0', 'letta', 'zep'] }))],
+ responses: { 200: json('What was removed', { type: 'object', properties: { removed: integer() } }), 404: problem, 503: problem, ...errors([401, 403]) },
+});
+route('post', '/memory/services/{provider}/sync', {
+ operationId: 'syncMemoryService', summary: 'Read a connected service and put its notes in the store', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.write. The key is opened for this call only. Up to 1,000 records are read; the store redacts each item again and dedupes by its text MAC. The answer is counts, never text.',
+ parameters: [path('provider', str({ enum: ['mem0', 'letta', 'zep'] }))],
+ responses: { 200: json('What the sync found and imported', { type: 'object', additionalProperties: true }), 502: problem, 503: problem, ...errors([401, 403]) },
+});
+route('get', '/memory/export', {
+ operationId: 'exportMemory', summary: 'The one portable memory file (ai.memory-export@1)', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read. The person’s own items decrypted for their own export, at most 10,000, with no id, owner, merge link or conflict pair. The body is the person’s plaintext: answer it no-store.',
+ parameters: [query('scope', str({ description: 'user or project:<slug>; empty for every scope' }))],
+ responses: { 200: json('The portable file', { type: 'object', additionalProperties: true }), 503: problem, ...errors([401, 403]) },
+});
+route('post', '/memory/import', {
+ operationId: 'importMemory', summary: 'Import a portable memory file', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.write. The body must validate ai.memory-export@1. Each item is redacted again; a secret-only item is skipped and a conflict is imported as a suggestion. Dedupe is by text MAC within owner and scope.',
+ requestBody: body({ type: 'object', additionalProperties: true }),
+ responses: { 200: json('What was imported', { type: 'object', additionalProperties: true }), 422: problem, 503: problem, ...errors([401, 403]) },
+});
+route('get', '/memory/shared', {
+ operationId: 'listSharedMemory', summary: 'The memory stores other people shared with the person', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read. The person’s own token only. Ids and access metadata only, never a note.',
+ responses: { 200: json('The shared stores', { type: 'object', properties: { stores: arrayOf({ type: 'object', additionalProperties: true }) } }), 503: problem, ...errors([401, 403]) },
+});
+route('get', '/memory/stores/{owner}', {
+ operationId: 'readSharedMemory', summary: 'One shared store’s active notes', tags: ['memory'], security: memorySecurity,
+ description: 'ai.memory.read. canView is checked first: the owner or a member sees active notes, read-only; anybody else gets memory.not_found, never a count or a title.',
+ parameters: [path('owner', str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' })), query('scope', str())],
+ responses: { 200: json('The shared store’s active notes', { type: 'object', additionalProperties: true }), 404: problem, 503: problem, ...errors([401, 403]) },
+});
route('get', '/memory/profile', {
route('get', '/memory/profile', {
operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
operationId: 'getMemoryProfile', summary: 'The cached profile for one scope', tags: ['memory'], security: memorySecurity,
description: 'ai.memory.read. The person\u2019s own profile, or an agent\u2019s: a service token with on_behalf_of reads only a scope its grant names, and that read is audited in memory_reads with the note ids it was built from, never the text. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',
description: 'ai.memory.read. The person\u2019s own profile, or an agent\u2019s: a service token with on_behalf_of reads only a scope its grant names, and that read is audited in memory_reads with the note ids it was built from, never the text. Rebuilt by the cheap tier only when items_hash changes, at most once an hour per owner and scope; the result is stored as written.',
@@ -10,7 +10,7 @@ const http = require('http');
@@ -10,7 +10,7 @@ const http = require('http');
const { uncovered, createNetworkSender } = require('openvibe-sdk/account-data');
const { uncovered, createNetworkSender } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
-const { TABLES } = require('../server/account-data');
+const { TABLES, EXEMPT } = require('../server/account-data');
const t = suite('account-data');
const t = suite('account-data');
const SECRET = `whsec_${'fixture'.repeat(6)}`;
const SECRET = `whsec_${'fixture'.repeat(6)}`;
@@ -41,7 +41,7 @@ t.test('every table that names a person is covered or exempted with a reason', a
@@ -41,7 +41,7 @@ t.test('every table that names a person is covered or exempted with a reason', a
const stub = await startNetworkStub();
const stub = await startNetworkStub();
const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
try {
try {
- const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
+ const missing = await uncovered(h.db, { tables: TABLES, exempt: { ...EXEMPT, account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await h.stop(); await stub.close(); }
} finally { await h.stop(); await stub.close(); }
});
});
@@ -45,7 +45,7 @@ function tmpDir() {
@@ -45,7 +45,7 @@ function tmpDir() {
// A boot on a dir used before is a restart on the same database: its database stays open.
// A boot on a dir used before is a restart on the same database: its database stays open.
const restartable = new Map();
const restartable = new Map();
-async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks } = {}) {
+async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research, machineModels, log, variantEvidence, community, communityEndpoints, webhooks, memoryTransport, memoryLookup } = {}) {
const reuse = dir !== undefined;
const reuse = dir !== undefined;
if (dir === undefined) dir = tmpDir();
if (dir === undefined) dir = tmpDir();
const fullEnv = {
const fullEnv = {
@@ -62,7 +62,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -62,7 +62,7 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
// An injected endpoint store needs the migrated database, so a factory is called with it before the app boots.
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
const endpointStore = typeof communityEndpoints === 'function' ? communityEndpoints(testdb.db) : communityEndpoints;
const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;
const webhookStore = typeof webhooks === 'function' ? webhooks(testdb.db) : webhooks;
- const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore });
+ const h = await start({ config, db: testdb.db, env: fullEnv, log: log || silent, clock, fetchImpl, credentialFetch, research: researcher, machineModels, variantEvidence, community, communityEndpoints: endpointStore, webhooks: webhookStore, memoryTransport, memoryLookup });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
}
@@ -12,7 +12,7 @@ const { ids } = require('openvibe-contracts');
@@ -12,7 +12,7 @@ const { ids } = require('openvibe-contracts');
const { uncovered } = require('openvibe-sdk/account-data');
const { uncovered } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { boot, request, suite, seamServer, token, privateKey, ISSUER } = require('./helpers');
const { boot, request, suite, seamServer, token, privateKey, ISSUER } = require('./helpers');
-const { TABLES } = require('../server/account-data');
+const { TABLES, EXEMPT } = require('../server/account-data');
const { sha256 } = require('../server/util');
const { sha256 } = require('../server/util');
const { PROFILE_INTERVAL_MS, PROFILE_ITEMS } = require('../server/memory');
const { PROFILE_INTERVAL_MS, PROFILE_ITEMS } = require('../server/memory');
@@ -327,7 +327,7 @@ t.test('account data: the export shows the sources and the links, and the erase
@@ -327,7 +327,7 @@ t.test('account data: the export shows the sources and the links, and the erase
const { seam } = await startSeam();
const { seam } = await startSeam();
const h = await bootWithSeam({ AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' }, seam);
const h = await bootWithSeam({ AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' }, seam);
try {
try {
- const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
+ const missing = await uncovered(h.db, { tables: TABLES, exempt: { ...EXEMPT, account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
assert.deepStrictEqual(missing, []);
assert.deepStrictEqual(missing, []);
const who = userToken(A);
const who = userToken(A);
await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [{ text: DARK, source: 'cursor' }] } });
await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: who, body: { items: [{ text: DARK, source: 'cursor' }] } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,345 @@
@@ -0,0 +1,345 @@
+'use strict';
+/**
+ * Memory M5: the memory-service adapters (mem0, Letta, Zep) against a stub of each HTTP API, the sealed key, the one
+ * portable file and its round trip, and a shared store that only its members can read.
+ *
+ * Nothing here calls a real provider: every adapter reaches a local stub that checks the auth header the service
+ * documents. The key is asserted to be sealed in the database, absent from every answer and absent from every log line.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const { boot, request, suite, privateKey, ISSUER } = require('./helpers');
+
+const t = suite('memory-m5');
+const KEY = 'a'.repeat(64);
+const CRED = 'b'.repeat(64);
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const C = 'usr_01JZ0000000000000000000CCC';
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+
+/** A person's Network access token: no actor_type, the subject in subject_id. */
+function userToken(subject) {
+ const now = Math.floor(Date.now() / 1000);
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: subject, subject_id: subject, aud: ['openvibe.network'], iat: now, exp: now + 3600, jti: `tok_${crypto.randomBytes(8).toString('hex')}` })}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+const MEM0 = 'mem0-key-0123456789';
+const LETTA = 'letta-key-0123456789';
+const ZEP = 'zep-key-0123456789';
+// A secret-shaped fixture, built from parts so a source scanner does not read it as a real Stripe key.
+const FAKE_STRIPE = ['sk_live', 'abcdef0123456789'].join('_');
+
+const PUBLIC = '93.184.216.34';
+
+/**
+ * The test seam the guarded transport exposes: a scripted answer instead of a socket. It records the exact request
+ * (URL, headers, timeout and cap) so a test can prove a refused host never saw the key.
+ */
+function fakeServiceTransport(handler) {
+ const calls = [];
+ const transport = async (send) => {
+ const call = { method: send.method, url: send.url, headers: { ...send.headers }, timeoutMs: send.timeoutMs, maxBytes: send.maxBytes };
+ calls.push(call);
+ const u = new URL(send.url);
+ const res = await handler(u, call);
+ return res || { status: 404, headers: {}, body: '{"detail":"no route"}' };
+ };
+ transport.calls = calls;
+ return transport;
+}
+
+const jsonRes = (status, obj) => ({ status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(obj) });
+
+/** The happy answers of the three services: enough for a probe and a sync. */
+function happyService(u) {
+ if (u.pathname === '/v1/memories/') {
+ return jsonRes(200, { results: [
+ { id: 'm1', memory: 'Prefers small pull requests.', created_at: '2026-11-01T10:00:00Z' },
+ { id: 'm2', memory: 'The API key is ' + FAKE_STRIPE + ' and it must not survive redaction.', created_at: '2026-11-02T10:00:00Z' },
+ ], next: null });
+ }
+ if (u.pathname === '/v1/agents/') return jsonRes(200, [{ id: 'agent_1', name: 'Ada' }]);
+ if (u.pathname === '/v1/agents/agent_1/core-memory/blocks') {
+ return jsonRes(200, [{ label: 'human', value: 'Ada works in UTC+1.' }, { label: 'persona', value: 'A careful assistant.' }]);
+ }
+ if (u.pathname === '/v1/agents/agent_1/archival-memory') return jsonRes(200, [{ id: 'passage_1', text: 'Ships on Fridays only with the owner’s Go.', created_at: '2026-10-30T08:00:00Z' }]);
+ if (u.pathname === '/users') return jsonRes(200, [{ user_id: 'zep_user' }]);
+ if (u.pathname === '/users/zep_user/sessions') return jsonRes(200, [{ session_id: 's1' }]);
+ if (u.pathname === '/users/zep_user/sessions/s1/memory') return jsonRes(200, { memory: { facts: [{ uuid: 'f1', fact: 'Prefers TypeScript for new services.', created_at: '2026-11-03T09:00:00Z' }] } });
+ return jsonRes(404, { detail: 'no route' });
+}
+
+/** The Network and Events stub the sharing test needs; service traffic never reaches it (it is the injected transport). */
+async function startStub() {
+ const calls = [];
+ const people = {
+ [A]: { subject: { type: 'user', id: A }, username: 'ada', display_name: 'Ada' },
+ [B]: { subject: { type: 'user', id: B }, username: 'bo', display_name: 'Bo' },
+ [C]: { subject: { type: 'user', id: C }, username: 'cy', display_name: 'Cy' },
+ };
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString();
+ let body = null;
+ try { body = raw ? JSON.parse(raw) : null; } catch { body = raw; }
+ calls.push({ url: req.url, method: req.method, auth: req.headers.authorization || '', body });
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_service', token_type: 'Bearer', expires_in: 300 });
+ if (req.url === '/internal/identity/resolve-batch') {
+ const results = {};
+ for (const id of (body && body.subject_ids) || []) if (people[id]) results[id] = people[id];
+ return json(200, { results });
+ }
+ if (req.url && req.url.startsWith('/internal/identity/resolve')) {
+ const name = new URL(req.url, 'http://x').searchParams.get('username');
+ const found = Object.values(people).find((p) => p.username === name) || null;
+ return json(200, { person: found });
+ }
+ if (req.url === '/internal/notifications/push') return json(201, {});
+ if (req.url === '/events') return json(202, {});
+ return json(404, { detail: 'no route' });
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+t.test('mem0, Letta and Zep: each adapter reads its stub API, and the key never leaves the sealed row', async () => {
+ const logs = [];
+ const capture = (...args) => logs.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '));
+ const transport = fakeServiceTransport(async (u) => happyService(u));
+ const lookup = (host, opts, cb) => cb(null, [{ address: PUBLIC, family: 4 }]);
+ const h = await boot({
+ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED },
+ log: { log: capture, warn: capture, error: capture },
+ memoryTransport: transport,
+ memoryLookup: lookup,
+ });
+ try {
+ const put = async (provider, key, base) => {
+ const r = await request(h.base, 'PUT', `/api/v1/memory/services/${provider}`, { tok: userToken(A), body: { key, base_url: base } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(key), `the ${provider} answer carries the key`);
+ assert.strictEqual(r.body.service.hint, key.slice(-4));
+ assert.strictEqual(r.body.service.status, 'ok', r.text);
+ const row = await h.db.maybe('SELECT key_enc, key_hint FROM memory_service_connections WHERE subject = $1 AND provider = $2', [A, provider]);
+ assert.ok(row && row.key_enc.startsWith('v1:'), `${provider} key is sealed`);
+ assert.ok(!row.key_enc.includes(key), `${provider} key is not in the ciphertext`);
+ return r;
+ };
+ await put('mem0', MEM0, 'https://mem0.example');
+ await put('letta', LETTA, 'https://letta.example');
+ await put('zep', ZEP, 'https://zep.example');
+ const call = (host, header) => transport.calls.find((c) => new URL(c.url).hostname === host && c.headers.Authorization === header);
+ assert.ok(call('mem0.example', `Token ${MEM0}`), 'mem0 was called with its documented header');
+ assert.ok(call('letta.example', `Bearer ${LETTA}`), 'Letta was called with its bearer');
+ assert.ok(call('zep.example', `Api-Key ${ZEP}`), 'Zep was called with its Api-Key header');
+ assert.ok(transport.calls.every((c) => c.url.startsWith('https://')), 'every adapter call is https');
+ assert.ok(transport.calls.every((c) => c.timeoutMs === 15000 && c.maxBytes === 1024 * 1024), 'every call has the 15 s timeout and the 1 MB cap');
+
+ const list = await request(h.base, 'GET', '/api/v1/memory/services', { tok: userToken(A) });
+ assert.strictEqual(list.status, 200);
+ assert.ok(!JSON.stringify(list.body).includes(MEM0) && !JSON.stringify(list.body).includes(LETTA) && !JSON.stringify(list.body).includes(ZEP), 'the list carries no key');
+
+ const sync = async (provider) => {
+ const r = await request(h.base, 'POST', `/api/v1/memory/services/${provider}/sync`, { tok: userToken(A) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.body.found >= 1, `${provider} found something: ${r.text}`);
+ return r.body;
+ };
+ await sync('mem0');
+ await sync('letta');
+ await sync('zep');
+ const items = await request(h.base, 'GET', '/api/v1/memory/items', { tok: userToken(A) });
+ assert.strictEqual(items.status, 200);
+ const texts = items.body.items.map((i) => i.text).join('\n');
+ assert.ok(texts.includes('Prefers small pull requests.'), texts);
+ assert.ok(texts.includes('Ada works in UTC+1.'), texts);
+ assert.ok(texts.includes('Prefers TypeScript for new services.'), texts);
+ assert.ok(!texts.includes(FAKE_STRIPE), 'a secret from a service was redacted before it was stored');
+ assert.ok(items.body.items.some((i) => i.source === 'mem0') && items.body.items.some((i) => i.source === 'letta') && items.body.items.some((i) => i.source === 'zep'));
+ assert.ok(!logs.join('\n').includes(MEM0) && !logs.join('\n').includes(LETTA) && !logs.join('\n').includes(ZEP), 'a key reached the log');
+ assert.ok(!logs.join('\n').includes(FAKE_STRIPE), 'a secret reached the log');
+ } finally { await h.stop(); }
+});
+
+t.test('a private or malformed base_url is refused at save and at sync, and the key is never sent there', async () => {
+ const { createServiceKeys } = require('../server/memory/services/keys');
+ const state = { address: PUBLIC, redirect: false };
+ const transport = fakeServiceTransport(async (u) => {
+ if (u.hostname === 'redirect.example' || (state.redirect && u.hostname === 'redir.example')) {
+ return { status: 302, headers: { location: 'https://10.0.0.5/steal' }, body: '' };
+ }
+ return happyService(u);
+ });
+ const lookup = (host, opts, cb) => cb(null, [{ address: state.address, family: 4 }]);
+ const h = await boot({
+ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED },
+ memoryTransport: transport,
+ memoryLookup: lookup,
+ });
+ try {
+ const refusedSave = async (url) => {
+ const before = transport.calls.length;
+ const r = await request(h.base, 'PUT', '/api/v1/memory/services/mem0', { tok: userToken(A), body: { key: MEM0, base_url: url } });
+ assert.strictEqual(r.status, 422, `${url}: ${r.text}`);
+ assert.strictEqual(transport.calls.length, before, `${url} reached the transport`);
+ assert.ok(!JSON.stringify(transport.calls).includes(MEM0), `${url} sent the key`);
+ };
+ state.address = PUBLIC;
+ await refusedSave('https://127.0.0.1');
+ await refusedSave('https://10.0.0.5');
+ await refusedSave('http://mem0.example');
+ await refusedSave('https://localhost');
+ await refusedSave('https://example.com:3000');
+ await refusedSave('https://user:[email protected]');
+ state.address = '192.168.1.2';
+ await refusedSave('https://private.example');
+ state.address = PUBLIC;
+ const rows = await h.db.maybe("SELECT COUNT(*)::int AS n FROM memory_service_connections WHERE subject = $1", [A]);
+ assert.strictEqual(Number(rows.n), 0, 'no refused host left a key row');
+
+ // At sync: a row that exists (a legacy row, or one written before this guard) is refused before its key is opened.
+ const keys = createServiceKeys({ db: h.db, config: h.config });
+ const refusedSync = async (base) => {
+ await keys.put(A, 'zep', { key: ZEP, base_url: base });
+ const before = transport.calls.length;
+ const r = await request(h.base, 'POST', '/api/v1/memory/services/zep/sync', { tok: userToken(A) });
+ assert.strictEqual(r.status, 422, `${base}: ${r.text}`);
+ assert.strictEqual(transport.calls.length, before, `${base} reached the transport at sync`);
+ assert.ok(!JSON.stringify(transport.calls).includes('zep-key-0123456789'), `${base} sent the key at sync`);
+ };
+ state.address = PUBLIC;
+ await refusedSync('https://127.0.0.1');
+ await refusedSync('https://10.0.0.5');
+ await refusedSync('http://mem0.example');
+ await refusedSync('https://localhost');
+ await refusedSync('https://example.com:3000');
+ await refusedSync('https://user:[email protected]');
+ state.address = '192.168.1.2';
+ await refusedSync('https://private.example');
+
+ // A redirect is never followed. At save it refuses the connection; at sync it refuses the read.
+ state.address = PUBLIC;
+ const beforeRedirect = transport.calls.length;
+ const redirectSave = await request(h.base, 'PUT', '/api/v1/memory/services/letta', { tok: userToken(A), body: { key: LETTA, base_url: 'https://redirect.example' } });
+ assert.strictEqual(redirectSave.status, 422, redirectSave.text);
+ assert.ok(redirectSave.text.includes('the service answered with a redirect'), redirectSave.text);
+ const afterRedirect = transport.calls.slice(beforeRedirect);
+ assert.ok(afterRedirect.length >= 1 && afterRedirect.every((c) => new URL(c.url).hostname === 'redirect.example'), 'only the allowed host was tried');
+ assert.ok(!afterRedirect.some((c) => c.url.includes('10.0.0.5')), 'the redirect target was never fetched');
+
+ await keys.put(A, 'mem0', { key: MEM0, base_url: 'https://redir.example' });
+ state.redirect = true;
+ const beforeSyncRedirect = transport.calls.length;
+ const syncRedirect = await request(h.base, 'POST', '/api/v1/memory/services/mem0/sync', { tok: userToken(A) });
+ assert.strictEqual(syncRedirect.status, 502, syncRedirect.text);
+ assert.strictEqual(syncRedirect.body.code, 'memory.service_redirect');
+ assert.ok(String(syncRedirect.body.detail).includes('the service answered with a redirect'), syncRedirect.text);
+ const syncCalls = transport.calls.slice(beforeSyncRedirect);
+ assert.ok(syncCalls.length >= 1 && syncCalls.every((c) => new URL(c.url).hostname === 'redir.example'), 'the sync tried only the allowed host');
+ assert.ok(!syncCalls.some((c) => c.url.includes('10.0.0.5')), 'the sync followed the redirect');
+ } finally { await h.stop(); }
+});
+
+t.test('the portable file exports decrypted items, imports into another account, and round-trips', async () => {
+ const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED } });
+ try {
+ const made = await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [
+ { text: 'Prefers short answers.', kind: 'preference', source: 'claude-code', scope: 'user' },
+ { text: 'Never rewrite history on main.', kind: 'project_rule', source: 'agents', scope: 'project:openvibe' },
+ ] } });
+ assert.strictEqual(made.status, 200, made.text);
+ const exp = await request(h.base, 'GET', '/api/v1/memory/export', { tok: userToken(A) });
+ assert.strictEqual(exp.status, 200, exp.text);
+ assert.strictEqual(exp.headers.get('cache-control'), 'no-store');
+ assert.strictEqual(exp.body.format, 'openvibe.memory-export@1');
+ assert.strictEqual(exp.body.version, 1);
+ assert.strictEqual(exp.body.items.length, 2);
+ for (const item of exp.body.items) {
+ assert.ok(item.text && item.kind && item.scope && item.source, JSON.stringify(item));
+ assert.ok(!('owner' in item) && !('id' in item), 'the portable item carries no identity');
+ }
+ // Imported into B's store: the same text, kind, scope and source, under a different person.
+ const imp = await request(h.base, 'POST', '/api/v1/memory/import', { tok: userToken(B), body: exp.body });
+ assert.strictEqual(imp.status, 200, imp.text);
+ assert.deepStrictEqual([imp.body.created, imp.body.deduped], [2, 0], imp.text);
+ const b = await request(h.base, 'GET', '/api/v1/memory/items', { tok: userToken(B) });
+ const got = b.body.items.map((i) => `${i.text}|${i.kind}|${i.scope}|${i.source}`).sort();
+ const want = exp.body.items.map((i) => `${i.text}|${i.kind}|${i.scope}|${i.source}`).sort();
+ assert.deepStrictEqual(got, want, 'the round trip kept text, kind, scope and source');
+ // The same file again is a dedupe, not a second copy.
+ const again = await request(h.base, 'POST', '/api/v1/memory/import', { tok: userToken(B), body: exp.body });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.deepStrictEqual([again.body.created, again.body.deduped], [0, 2], again.text);
+ // A file that is not the format is refused.
+ const bad = await request(h.base, 'POST', '/api/v1/memory/import', { tok: userToken(B), body: { format: 'openvibe.memory-export@2', version: 2, items: [] } });
+ assert.strictEqual(bad.status, 422, bad.text);
+ } finally { await h.stop(); }
+});
+
+t.test('a shared store is readable only by its members, and the answer is never shared-cacheable', async () => {
+ const stub = await startStub();
+ const h = await boot({
+ env: { AI_MEMORY_KEY: KEY, AI_CREDENTIALS_KEY: CRED, OV_OAUTH_CLIENT_SECRET: 'ai-secret', OV_NETWORK_INTERNAL_URL: stub.url, AI_EVENTS_URL: `${stub.url}/events`, AI_EVENTS_SUBSCRIBE: '0' },
+ });
+ try {
+ assert.ok(h.memorySharing, 'sharing is wired when the Network client and Events are configured');
+ await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [{ text: 'Team remembers the deploy order.', kind: 'procedure' }] } });
+ const notYet = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(B) });
+ assert.deepStrictEqual([notYet.status, notYet.body.code], [404, 'memory.not_found']);
+ const own = await request(h.base, 'GET', `/api/v1/memory-store/${A}/share`, { tok: userToken(A) });
+ assert.strictEqual(own.status, 200, own.text);
+ assert.strictEqual(own.body.audience, 'private');
+ assert.deepStrictEqual(own.body.audiences, ['public', 'link', 'people', 'private']);
+ assert.match(own.headers.get('cache-control') || '', /no-store/);
+ // The owner adds B as a viewer, through the same library the routes use.
+ await h.memorySharing.shares.addMembers({ subject: { type: 'user', id: A } }, h.memorySharing.refOf(A), [{ subject: { type: 'user', id: B }, role: 'viewer' }]);
+ await h.memorySharing.shares.setAudience({ subject: { type: 'user', id: A } }, h.memorySharing.refOf(A), 'people');
+ const shared = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(B) });
+ assert.strictEqual(shared.status, 200, shared.text);
+ assert.deepStrictEqual(shared.body.items.map((i) => i.text), ['Team remembers the deploy order.']);
+ assert.strictEqual(shared.headers.get('cache-control'), 'no-store');
+ const stranger = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(C) });
+ assert.deepStrictEqual([stranger.status, stranger.body.code], [404, 'memory.not_found']);
+ const mine = await request(h.base, 'GET', '/api/v1/memory/shared', { tok: userToken(B) });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.body.stores.some((s) => s.owner === A && s.role === 'viewer'), mine.text);
+ // Only the owner's token can see the settings for somebody else's store as a member, and the notes never
+ // appear in the shared-with-me listing.
+ assert.ok(!JSON.stringify(mine.body).includes('Team remembers'), 'the listing carries no note');
+ // The settings sheet belongs to the owner; a member reads the store, never its ACL.
+ const asB = await request(h.base, 'GET', `/api/v1/memory-store/${A}/share`, { tok: userToken(B) });
+ assert.deepStrictEqual([asB.status, asB.body.code], [404, 'share.not_found']);
+ // Back to Only me: B loses the store at once.
+ await h.memorySharing.shares.setAudience({ subject: { type: 'user', id: A } }, h.memorySharing.refOf(A), 'private');
+ const after = await request(h.base, 'GET', `/api/v1/memory/stores/${A}`, { tok: userToken(B) });
+ assert.deepStrictEqual([after.status, after.body.code], [404, 'memory.not_found']);
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('the transport itself refuses a private DNS answer, and the URL rules are the ones the route uses', async () => {
+ const { createServiceTransport, judgeBaseUrl, judgeRequestUrl } = require('../server/memory/services/transport');
+ const bad = (url) => assert.throws(() => judgeBaseUrl(url), (e) => e.status === 422, url);
+ bad('https://127.0.0.1');
+ bad('https://10.0.0.5');
+ bad('http://mem0.example');
+ bad('https://localhost');
+ bad('https://example.com:3000');
+ bad('https://user:[email protected]');
+ bad('https://example.com/?x=1');
+ assert.strictEqual(judgeBaseUrl('https://api.getzep.com/api/v2').toString(), 'https://api.getzep.com/api/v2');
+ assert.strictEqual(judgeRequestUrl('https://api.mem0.ai/v1/memories/?page=1').toString(), 'https://api.mem0.ai/v1/memories/?page=1');
+ const guarded = createServiceTransport({ lookup: (host, opts, cb) => cb(null, [{ address: '192.168.1.2', family: 4 }]) });
+ await assert.rejects(
+ guarded.get('https://private.example/v1/memories/', { Authorization: 'Token a-key-that-must-not-leave' }),
+ (e) => e.code === 'memory.service_blocked' && e.status === 502,
+ );
+});
+
+t.run();
@@ -12,7 +12,7 @@ const path = require('path');
@@ -12,7 +12,7 @@ const path = require('path');
const { uncovered } = require('openvibe-sdk/account-data');
const { uncovered } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { boot, request, suite, token, privateKey, ISSUER } = require('./helpers');
const { boot, request, suite, token, privateKey, ISSUER } = require('./helpers');
-const { TABLES } = require('../server/account-data');
+const { TABLES, EXEMPT } = require('../server/account-data');
const { createMemory } = require('../server/memory');
const { createMemory } = require('../server/memory');
const t = suite('memory');
const t = suite('memory');
@@ -409,7 +409,7 @@ t.test('account data: the table guard passes, the export is decrypted, and a del
@@ -409,7 +409,7 @@ t.test('account data: the table guard passes, the export is decrypted, and a del
const stub = await startNetworkStub();
const stub = await startNetworkStub();
const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
const h = await boot({ env: { AI_MEMORY_KEY: KEY, AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
try {
try {
- const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
+ const missing = await uncovered(h.db, { tables: TABLES, exempt: { ...EXEMPT, account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
assert.deepStrictEqual(missing, []);
assert.deepStrictEqual(missing, []);
await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('Ada exports this note')] } });
await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(A), body: { items: [item('Ada exports this note')] } });
await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(B), body: { items: [item('Bo keeps this note')] } });
await request(h.base, 'POST', '/api/v1/memory/items:batch', { tok: userToken(B), body: { items: [item('Bo keeps this note')] } });