openvibers/openvibe.ai Commits

Memory M5: mem0, Letta and Zep with the person's key, team sharing, the portable file (#337)

OP OpenVibers committed 6 days ago · a65c968690 · 20 files, +1618 −21

* Memory M5: mem0, Letta and Zep with the person's key, team sharing, and the portable file

* Memory M5: the new routes are in the OpenAPI document, and the M2c account guard knows the share tables

* Merge origin/main; guard the memory-service transport and take migrations 0040/0041

* Merge origin/main (Exchange webhooks) with the guarded memory-service transport

Changes

ddiff --git a/docs/memory-m5.md b/docs/memory-m5.md
nnew file mode 100644
iindex 0000000..8e7e2bd
---- /dev/null
++++ b/docs/memory-m5.md
@@@ -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.
ddiff --git a/migrations/0040_memory_services.sql b/migrations/0040_memory_services.sql
nnew file mode 100644
iindex 0000000..22d8dff
---- /dev/null
++++ b/migrations/0040_memory_services.sql
@@@ -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)
++);
ddiff --git a/migrations/0041_memory_shares.sql b/migrations/0041_memory_shares.sql
nnew file mode 100644
iindex 0000000..0d7a77b
---- /dev/null
++++ b/migrations/0041_memory_shares.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex e07b66d..9cabab0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c02b7cd..2cbd6d9 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 3a61ca9..1cb7dc7 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 9bad547..2b28bc8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) }));
ddiff --git a/server/index.js b/server/index.js
iindex cec2bae..44e328e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.now
clientSecret: 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.now
if (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.now
if (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.now
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) {
ddiff --git a/server/memory/index.js b/server/memory/index.js
iindex 33eb425..d02d87c 100644
---- a/server/memory/index.js
++++ b/server/memory/index.js
@@@ -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 = con
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). */
async function exportFor(subject) {
assertConfigured();
@@@ -896,6 +993,8 @@ function createMemory({ db, config, clock = { now: () => Date.now() }, log = con
return {
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 : {};
ddiff --git a/server/memory/services/adapters.js b/server/memory/services/adapters.js
nnew file mode 100644
iindex 0000000..6f4d3ad
---- /dev/null
++++ b/server/memory/services/adapters.js
@@@ -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 };
ddiff --git a/server/memory/services/index.js b/server/memory/services/index.js
nnew file mode 100644
iindex 0000000..7859cec
---- /dev/null
++++ b/server/memory/services/index.js
@@@ -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 };
ddiff --git a/server/memory/services/keys.js b/server/memory/services/keys.js
nnew file mode 100644
iindex 0000000..a7c0489
---- /dev/null
++++ b/server/memory/services/keys.js
@@@ -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 };
ddiff --git a/server/memory/services/transport.js b/server/memory/services/transport.js
nnew file mode 100644
iindex 0000000..bfae897
---- /dev/null
++++ b/server/memory/services/transport.js
@@@ -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,
++};
ddiff --git a/server/memory/sharing.js b/server/memory/sharing.js
nnew file mode 100644
iindex 0000000..b868557
---- /dev/null
++++ b/server/memory/sharing.js
@@@ -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 };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 5d5e0ea..eff67dc 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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.',
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex b2e96eb..bd13cda 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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', a
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' } });
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(); }
});
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 0f518dc..fec6675 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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(); } };
}
ddiff --git a/test/memory-m2c.test.js b/test/memory-m2c.test.js
iindex 559f762..8e6c309 100644
---- a/test/memory-m2c.test.js
++++ b/test/memory-m2c.test.js
@@@ -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 erase
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);
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' }] } });
ddiff --git a/test/memory-m5.test.js b/test/memory-m5.test.js
nnew file mode 100644
iindex 0000000..38afa23
---- /dev/null
++++ b/test/memory-m5.test.js
@@@ -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();
ddiff --git a/test/memory.test.js b/test/memory.test.js
iindex 3efd5dc..86bcbc8 100644
---- a/test/memory.test.js
++++ b/test/memory.test.js
@@@ -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 del
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' } });
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')] } });