openvibers/openvibe.network Commits

Connect apps, phase 1 (E7): the vault, the catalogue and the gateway (#600)

OP OpenVibers committed 7 days ago · d6aa263e20 · 45 files, +4593 −6

* Connect apps, phase 1 (Compute plan E7): the vault, the catalogue and the gateway

A person's agents act in the person's other apps through one gateway, with the
person's own credentials, their approval rules and a log.

The vault (server/connect/vault.js): a credential is sealed with AES-256-GCM
under Network's KEK (NETWORK_SECRETS_KEY, the key D11's sealed config uses),
with the person's subject and the app as the authenticated data, so a ciphertext
copied onto another row does not open. The plaintext is decrypted only inside
the gateway, for the one request that carries it, and is never shown back. A
credential the walkthrough's test call refuses is never stored. The export
lists the apps, never the secrets; the deletion erases them.

The catalogue (server/connect/apps): ten definitions, one file each (GitHub,
Linear, Notion, Slack, Google, Steam, Discord, OpenAI & Anthropic, Todoist,
Trello). Each says the auth kind, the walkthrough with its links, the test call
and the actions, each with a name, a JSON Schema for its input, a risk level
(read, write, send, delete, pay) and the fixed request it makes. A definition
that is broken is refused when the catalogue loads.

The gateway (server/connect/gateway.js): every call checks the grant, validates
the input against the action's schema, makes the fixed request to the app's
fixed API host only through openvibe-shared/egress (an IP literal is refused
before the socket opens, because Node does not call a custom lookup for a
literal; a redirect is followed by hand and every hop is checked again, so a
redirect off the app's own host is refused), redacts the answer of anything that
looks like a secret, and writes the log (who, which action, when, ok or error;
never the input of a write action). The limits are 120 calls a minute for one
person in one app, and a daily cap on top.

The ask first: a send, a delete and a pay action waits for the person through
the approvals inbox (server/connect/approvals.js), unless the person set a
rule for that one recipe.

The MCP server (server/connect/mcp.js): streamable HTTP at /mcp, served by
Network for connect.openvibe.network. It answers with the person's token, a key
the person made on the Apps page, or a service token acting for them, and it
lists only the actions of the apps this person connected and this caller was
granted.

The Apps page (server/connect/page.js, public/js/connect.js): the catalogue, the
walkthrough with the paste box and the test result, the connected list with the
grants per caller, the log and Revoke. The kit, phone first. The account hub
has an Apps section beside it.

The migration is expand-only (0043). The nginx vhost for
connect.openvibe.network is not touched.

* Connect: named services only, per-person service callers, keys that always parse, no dot segments

From Opus's security review: any valid service token with on_behalf_of could act for any person with their app
credentials; a service's caller row was shared across people, so one person's grant opened every person's apps to that
service; base64url key secrets could hold the "_" the key parser splits on (about 40% of keys would never work); and
"." or ".." as a path value walked the request to another endpoint on the app's API host.

* Connect apps: the account hub's Apps module no longer overwrites Security's signed-in apps

apps.js declared loadApps(), which security.js already declares (shared global scope: the later file wins), and its list
used id="apps-list", which Security's signed-in apps list already uses. Renamed to loadConnectApps and
connect-apps-list. The migration rehearsals list 0043, and the gateway's one outbound call site is reviewed.

Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex 077bba5d..4bc17905 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -10,6 +10,12 @@ condition = "AND"
paths = ['''^test/security-ssrf\.test\.js$''']
regexes = ['''^tBHItJI5svbpez7KI4CCXg$''']
++[[allowlists]]
++description = "Plan E7 test fixture: the TLS key of the stand-in app server the Connect tests call on loopback (test/helpers/connect-app.js); it protects nothing outside the test process"
++condition = "AND"
++paths = ['''^test/fixtures/connect-key\.pem$''']
++regexes = ['''(?s)^-----BEGIN PRIVATE KEY-----.*$''']
++
[[allowlists]]
description = "Plan E5 test fixture: a fake public subject id (usr_…), not a key; the generic-api-key rule matched a constant named USER_PASSKEY in an earlier commit of this branch"
condition = "AND"
ddiff --git a/migrations/0043_connect_apps.sql b/migrations/0043_connect_apps.sql
nnew file mode 100644
iindex 00000000..83caf57c
---- /dev/null
++++ b/migrations/0043_connect_apps.sql
@@@ -0,0 +1,98 @@
++-- phase: expand
++-- OpenVibe Connect, phase 1 (Compute plan E7): the vault of a person's own credentials for the apps their
++-- agents act in, the callers those agents come from, the grants they hold, the approvals a send/delete/pay
++-- action waits for, the log of what was done and the counters the limits use.
++--
++-- Additive only: an older release never reads these tables, so this ships on its own and needs no
++-- backfill. A credential is stored only sealed (AES-256-GCM under a key derived from NETWORK_SECRETS_KEY,
++-- the person's subject and the app as the authenticated data); the plaintext is never written anywhere
++-- else, and a row whose ciphertext was shredded keeps its metadata for the log.
++CREATE TABLE IF NOT EXISTS connect_credentials (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ app text COLLATE "C" NOT NULL,
++ label text COLLATE "C" NOT NULL,
++ scopes text COLLATE "C" NOT NULL DEFAULT '[]',
++ key_id text COLLATE "C" NOT NULL,
++ iv text COLLATE "C" NOT NULL,
++ tag text COLLATE "C" NOT NULL,
++ ciphertext text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ last_used_at text COLLATE "C",
++ revoked_at text COLLATE "C",
++ UNIQUE (subject, app, label)
++);
++
++-- A caller: the person themselves, a key the person made on the Apps page (their own Claude Code), or a
++-- service acting for them (Codes, Actor). Only the hash of a key is kept.
++CREATE TABLE IF NOT EXISTS connect_callers (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('person', 'key', 'service')),
++ name text COLLATE "C" NOT NULL,
++ token_hash text COLLATE "C",
++ token_prefix text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL,
++ last_used_at text COLLATE "C",
++ revoked_at text COLLATE "C"
++);
++
++-- What one caller may do in one app: none (no row), read, or read and write.
++CREATE TABLE IF NOT EXISTS connect_grants (
++ caller_id text COLLATE "C" NOT NULL REFERENCES connect_callers(id) ON DELETE CASCADE,
++ app text COLLATE "C" NOT NULL,
++ level text COLLATE "C" NOT NULL CHECK (level IN ('read', 'write')),
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (caller_id, app)
++);
++
++-- A send, delete or pay action waits here for the person. The rule is one recipe: the app and the action.
++CREATE TABLE IF NOT EXISTS connect_approvals (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ caller_id text COLLATE "C" NOT NULL,
++ app text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ summary text COLLATE "C" NOT NULL,
++ input_digest text COLLATE "C" NOT NULL,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'denied', 'expired')),
++ created_at text COLLATE "C" NOT NULL,
++ expires_at text COLLATE "C" NOT NULL,
++ decided_at text COLLATE "C"
++);
++
++-- The log: who, which action, when, and whether it worked. The input of a write action is never written.
++CREATE TABLE IF NOT EXISTS connect_log (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ caller_id text COLLATE "C" NOT NULL,
++ caller_name text COLLATE "C" NOT NULL,
++ app text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ risk text COLLATE "C" NOT NULL,
++ ok integer NOT NULL,
++ status integer,
++ error_code text COLLATE "C",
++ input text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL
++);
++CREATE INDEX IF NOT EXISTS connect_log_subject_created ON connect_log (subject, created_at DESC);
++
++-- A standing rule: the person says that one recipe (an app and an action) does not have to ask again.
++CREATE TABLE IF NOT EXISTS connect_rules (
++ subject text COLLATE "C" NOT NULL,
++ app text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ PRIMARY KEY (subject, app, action)
++);
++
++-- The counters the daily cap counts: one row per person, app and UTC day.
++CREATE TABLE IF NOT EXISTS connect_usage_daily (
++ subject text COLLATE "C" NOT NULL,
++ app text COLLATE "C" NOT NULL,
++ day text COLLATE "C" NOT NULL,
++ calls bigint NOT NULL DEFAULT 0,
++ PRIMARY KEY (subject, app, day)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 54da5f5a..135014c6 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -29,7 +29,8 @@
"web-push": "^3.6.7"
},
"devDependencies": {
-- "@electric-sql/pglite": "^0.5.8"
++ "@electric-sql/pglite": "^0.5.8",
++ "@modelcontextprotocol/sdk": "^1.32.2"
}
},
"node_modules/@acemir/cssom": {
@@@ -175,12 +176,493 @@
"devOptional": true,
"license": "Apache-2.0"
},
++ "node_modules/@hono/node-server": {
++ "version": "2.1.6",
++ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.6.tgz",
++ "integrity": "sha512-p/KH9MnfNcsm3sR6x4lNgm5j+QN4Et8srKredl9DZfXiyZ1UQ3aQ2YVpFLbqktYN5JI+DB+VZFKM2PqE5hLKjQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "hono": "^4 || ^5.0.0-0"
++ }
++ },
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
},
++ "node_modules/@modelcontextprotocol/sdk": {
++ "version": "1.32.2",
++ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
++ "integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "@hono/node-server": "^1.19.9 || ^2.0.5",
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1",
++ "content-type": "^1.0.5",
++ "cors": "^2.8.5",
++ "cross-spawn": "^7.0.5",
++ "eventsource": "^3.0.2",
++ "eventsource-parser": "^3.0.0",
++ "express": "^5.2.1",
++ "express-rate-limit": "^8.2.1",
++ "hono": "^4.11.4",
++ "jose": "^6.1.3",
++ "json-schema-typed": "^8.0.2",
++ "pkce-challenge": "^5.0.0",
++ "raw-body": "^3.0.0",
++ "zod": "^3.25 || ^4.0",
++ "zod-to-json-schema": "^3.25.1"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "peerDependencies": {
++ "@cfworker/json-schema": "^4.1.1",
++ "zod": "^3.25 || ^4.0"
++ },
++ "peerDependenciesMeta": {
++ "@cfworker/json-schema": {
++ "optional": true
++ },
++ "zod": {
++ "optional": false
++ }
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
++ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "^3.0.0",
++ "negotiator": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
++ "version": "2.4.0",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
++ "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "^3.1.2",
++ "content-type": "^2.1.0",
++ "debug": "^4.4.3",
++ "http-errors": "^2.0.1",
++ "iconv-lite": "^0.7.3",
++ "on-finished": "^2.4.1",
++ "qs": "^6.16.1",
++ "raw-body": "^3.0.2",
++ "type-is": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
++ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
++ "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
++ "version": "1.2.2",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
++ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=6.6.0"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/express": {
++ "version": "5.3.0",
++ "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
++ "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "^2.0.0",
++ "body-parser": "^2.3.0",
++ "content-disposition": "^2.0.1",
++ "content-type": "^2.0.0",
++ "cookie": "^0.7.1",
++ "cookie-signature": "^1.2.1",
++ "debug": "^4.4.0",
++ "depd": "^2.0.0",
++ "encodeurl": "^2.0.0",
++ "escape-html": "^1.0.3",
++ "etag": "^1.8.1",
++ "finalhandler": "^2.1.0",
++ "fresh": "^2.0.0",
++ "http-errors": "^2.0.0",
++ "merge-descriptors": "^2.0.0",
++ "mime-types": "^3.0.0",
++ "on-finished": "^2.4.1",
++ "once": "^1.4.0",
++ "parseurl": "^1.3.3",
++ "proxy-addr": "^2.0.8",
++ "qs": "^6.16.0",
++ "range-parser": "^1.2.1",
++ "router": "^2.2.0",
++ "send": "^1.1.0",
++ "serve-static": "^2.2.0",
++ "statuses": "^2.0.1",
++ "type-is": "^2.1.0",
++ "vary": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": {
++ "version": "8.7.3",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.3.tgz",
++ "integrity": "sha512-zPLqDI2f0gV2WmGnacLf9XSSqn4VldRyP5LCzOwx0BKH40mO6+2PCFkoSsRO2jePx87/Kf6NUF6P2QOTKXHbQA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "debug": "^4.4.3",
++ "ip-address": "^10.2.0"
++ },
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/express/node_modules/content-type": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
++ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
++ "version": "2.1.1",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
++ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "debug": "^4.4.0",
++ "encodeurl": "^2.0.0",
++ "escape-html": "^1.0.3",
++ "on-finished": "^2.4.1",
++ "parseurl": "^1.3.3",
++ "statuses": "^2.0.1"
++ },
++ "engines": {
++ "node": ">= 18.0.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
++ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
++ "version": "0.7.3",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
++ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
++ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
++ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
++ "version": "1.54.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
++ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
++ "version": "3.0.2",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
++ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "^1.54.0"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
++ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "content-type": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator/node_modules/content-type": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
++ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
++ "version": "6.17.1",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz",
++ "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==",
++ "dev": true,
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
++ "version": "3.0.2",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
++ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.7.0",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/send": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
++ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "debug": "^4.4.3",
++ "encodeurl": "^2.0.0",
++ "escape-html": "^1.0.3",
++ "etag": "^1.8.1",
++ "fresh": "^2.0.0",
++ "http-errors": "^2.0.1",
++ "mime-types": "^3.0.2",
++ "ms": "^2.1.3",
++ "on-finished": "^2.4.1",
++ "range-parser": "^1.2.1",
++ "statuses": "^2.0.2"
++ },
++ "engines": {
++ "node": ">= 18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
++ "version": "2.2.1",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
++ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "^2.0.0",
++ "escape-html": "^1.0.3",
++ "parseurl": "^1.3.3",
++ "send": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
++ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "content-type": "^2.0.0",
++ "media-typer": "^1.1.0",
++ "mime-types": "^3.0.0"
++ },
++ "engines": {
++ "node": ">= 18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
++ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@@ -589,6 +1071,21 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/cross-spawn": {
++ "version": "7.0.6",
++ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
++ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "path-key": "^3.1.0",
++ "shebang-command": "^2.0.0",
++ "which": "^2.0.1"
++ },
++ "engines": {
++ "node": ">= 8"
++ }
++ },
"node_modules/css-select": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
@@@ -890,6 +1387,29 @@
"node": ">= 0.6"
}
},
++ "node_modules/eventsource": {
++ "version": "3.0.7",
++ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
++ "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "eventsource-parser": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
++ "node_modules/eventsource-parser": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
++ "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ }
++ },
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@@ -1125,6 +1645,16 @@
"url": "https://github.com/sponsors/EvanHahn"
}
},
++ "node_modules/hono": {
++ "version": "4.13.16",
++ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.16.tgz",
++ "integrity": "sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=16.9.0"
++ }
++ },
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@@ -1355,6 +1885,16 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
++ "node_modules/ip-address": {
++ "version": "10.7.6",
++ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.6.tgz",
++ "integrity": "sha512-00LYkM6byILa9eQIx6qoaXCM40xTLd56AZ4vyaPW3YTyb49VZYPo5xsiAq4tuQ54GH4R7gVdYScJ1B7B77PuTg==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">= 12"
++ }
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -1373,18 +1913,49 @@
"node": ">=8"
}
},
++ "node_modules/is-promise": {
++ "version": "4.0.0",
++ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
++ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
++ "dev": true,
++ "license": "MIT"
++ },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
++ "node_modules/isexe": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
++ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/jose": {
++ "version": "6.2.15",
++ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.15.tgz",
++ "integrity": "sha512-qdF6ewcZARtef63fJ9AKKZPgxw+t7USH6HLXYcD4CrdmSvDp79rpJSPFEKc90QBeGS63u1KQtN2MZQHC0yDIzA==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/panva"
++ }
++ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
++ "node_modules/json-schema-typed": {
++ "version": "8.0.2",
++ "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
++ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
++ "dev": true,
++ "license": "BSD-2-Clause"
++ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@@ -1721,6 +2292,16 @@
"node": ">= 0.8"
}
},
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "dev": true,
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
"node_modules/openvibe-contracts": {
"version": "0.298.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
@@@ -1832,6 +2413,16 @@
"node": ">=8"
}
},
++ "node_modules/path-key": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
++ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@@ -1927,6 +2518,16 @@
"split2": "^4.1.0"
}
},
++ "node_modules/pkce-challenge": {
++ "version": "5.0.1",
++ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
++ "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=16.20.0"
++ }
++ },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@@ -2121,6 +2722,59 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
++ "node_modules/router": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
++ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "debug": "^4.4.0",
++ "depd": "^2.0.0",
++ "is-promise": "^4.0.0",
++ "parseurl": "^1.3.3",
++ "path-to-regexp": "^8.0.0"
++ },
++ "engines": {
++ "node": ">= 18"
++ }
++ },
++ "node_modules/router/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/router/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/router/node_modules/path-to-regexp": {
++ "version": "8.4.2",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
++ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@@ -2216,6 +2870,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
++ "node_modules/shebang-command": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
++ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "shebang-regex": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/shebang-regex": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
++ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@@ -2487,6 +3164,22 @@
"node": ">= 16"
}
},
++ "node_modules/which": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
++ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
++ "dev": true,
++ "license": "ISC",
++ "dependencies": {
++ "isexe": "^2.0.0"
++ },
++ "bin": {
++ "node-which": "bin/node-which"
++ },
++ "engines": {
++ "node": ">= 8"
++ }
++ },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@@ -2507,6 +3200,13 @@
"node": ">=8"
}
},
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "dev": true,
++ "license": "ISC"
++ },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@@@ -2577,6 +3277,26 @@
"engines": {
"node": ">=6"
}
++ },
++ "node_modules/zod": {
++ "version": "4.6.7",
++ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
++ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/colinhacks"
++ }
++ },
++ "node_modules/zod-to-json-schema": {
++ "version": "3.25.2",
++ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
++ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
++ "dev": true,
++ "license": "ISC",
++ "peerDependencies": {
++ "zod": "^3.25.28 || ^4"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 5f92538a..383a6c22 100644
---- a/package.json
++++ b/package.json
@@@ -33,6 +33,7 @@
"web-push": "^3.6.7"
},
"devDependencies": {
-- "@electric-sql/pglite": "^0.5.8"
++ "@electric-sql/pglite": "^0.5.8",
++ "@modelcontextprotocol/sdk": "^1.32.2"
}
}
ddiff --git a/public/js/connect.js b/public/js/connect.js
nnew file mode 100644
iindex 00000000..4570cf47
---- /dev/null
++++ b/public/js/connect.js
@@@ -0,0 +1,256 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — Connect (Compute plan E7): the Apps page.
++
++ The catalogue, the walkthrough, the paste box, the test result, the connected
++ list with the grants per caller, the log and the approvals. Everything here
++ is a small, quiet piece of the page: the page itself is the kit's own markup.
++
++ Classic script, no modules. The token is the person's own (the same one the
++ account hub uses): the cookie first, then localStorage.
++ ═══════════════════════════════════════════════════════════════ */
++(function () {
++ 'use strict';
++
++ var API = '/api/v1/connect';
++
++ function token() {
++ try {
++ var m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ if (m) return decodeURIComponent(m[1]);
++ } catch (e) { /* no cookie */ }
++ try { return localStorage.getItem('ov_token') || null; } catch (e) { return null; }
++ }
++
++ function api(path, opts) {
++ opts = opts || {};
++ var headers = { 'Content-Type': 'application/json' };
++ var t = token();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ return fetch(API + path, {
++ method: opts.method || 'GET',
++ headers: headers,
++ credentials: 'include',
++ body: opts.body ? JSON.stringify(opts.body) : undefined,
++ }).then(function (res) {
++ return res.text().then(function (text) {
++ var data = null;
++ try { data = text ? JSON.parse(text) : null; } catch (e) { data = { error: 'connect.failed', detail: text.slice(0, 200) }; }
++ if (!res.ok) {
++ var err = new Error((data && (data.detail || data.error)) || ('The call failed (' + res.status + ')'));
++ err.status = res.status;
++ err.code = data && data.error;
++ throw err;
++ }
++ return data;
++ });
++ });
++ }
++
++ function el(tag, attrs, kids) {
++ var n = document.createElement(tag);
++ attrs = attrs || {};
++ Object.keys(attrs).forEach(function (k) {
++ var v = attrs[k];
++ if (v === null || v === undefined) return;
++ if (k === 'text') n.textContent = v;
++ else if (k === 'class') n.className = v;
++ else if (k.slice(0, 2) === 'on') n.addEventListener(k.slice(2), v);
++ else n.setAttribute(k, v);
++ });
++ (kids || []).forEach(function (kid) { if (kid) n.append(kid); });
++ return n;
++ }
++
++ function say(box, text, kind) {
++ if (!box) return;
++ box.hidden = false;
++ box.textContent = text;
++ box.className = 'ovk-result' + (kind ? ' ovk-result--' + kind : '');
++ }
++
++ // ── The catalogue: search over the folds ────────────────────
++ function wireSearch() {
++ var form = document.querySelector('.ovk-find');
++ if (!form) return;
++ var input = form.querySelector('input');
++ form.addEventListener('submit', function (e) {
++ e.preventDefault();
++ filter(input ? input.value : '');
++ });
++ if (input) input.addEventListener('input', function () { filter(input.value); });
++ }
++
++ function filter(q) {
++ var needle = String(q || '').trim().toLowerCase();
++ document.querySelectorAll('.ovk-connect-grid > details').forEach(function (d) {
++ var text = (d.textContent || '').toLowerCase();
++ var title = (d.querySelector('summary') || {}).textContent || '';
++ d.hidden = !!needle && text.indexOf(needle) === -1 && title.toLowerCase().indexOf(needle) === -1;
++ });
++ }
++
++ // ── The paste box, the test call and the connect ───────────
++ function wireConnect() {
++ document.querySelectorAll('[data-ovk-connect]').forEach(function (form) {
++ var app = form.getAttribute('data-ovk-connect');
++ var box = form.querySelector('[data-ovk-result]');
++ var values = function () {
++ var out = {};
++ form.querySelectorAll('[data-ovk-field]').forEach(function (input) {
++ out[input.getAttribute('data-ovk-field')] = input.value;
++ });
++ return out;
++ };
++ var test = form.querySelector('[data-ovk-test]');
++ var save = form.querySelector('[data-ovk-save]');
++ if (test) test.addEventListener('click', function () {
++ say(box, 'Testing…');
++ api('/apps/' + encodeURIComponent(app) + '/test', { method: 'POST', body: { credential: values() } })
++ .then(function (out) {
++ say(box, 'It works. ' + (out.summary || '') + ' Nothing is stored until you press Connect.', 'ok');
++ })
++ .catch(function (e) { say(box, 'The credential was refused: ' + e.message, 'bad'); });
++ });
++ if (save) save.addEventListener('click', function () {
++ say(box, 'Testing…');
++ api('/apps/' + encodeURIComponent(app) + '/connect', { method: 'POST', body: { credential: values() } })
++ .then(function () { window.location.reload(); })
++ .catch(function (e) { say(box, 'The credential was refused: ' + e.message, 'bad'); });
++ });
++ });
++ }
++
++ // ── Revoke a credential or a caller ─────────────────────────
++ function wireRevoke() {
++ document.querySelectorAll('[data-ovk-revoke]').forEach(function (btn) {
++ btn.addEventListener('click', function () {
++ var app = btn.getAttribute('data-ovk-revoke');
++ var label = btn.getAttribute('data-ovk-label') || 'default';
++ if (!window.confirm('Revoke the credential for ' + app + '? The key is gone from OpenVibe.')) return;
++ api('/credentials/' + encodeURIComponent(app) + '/' + encodeURIComponent(label), { method: 'DELETE' })
++ .then(function () { window.location.reload(); })
++ .catch(function (e) { window.alert(e.message); });
++ });
++ });
++ document.querySelectorAll('[data-ovk-revoke-caller]').forEach(function (btn) {
++ btn.addEventListener('click', function () {
++ var id = btn.getAttribute('data-ovk-revoke-caller');
++ if (!window.confirm('Revoke this caller? Its grants go with it.')) return;
++ api('/callers/' + encodeURIComponent(id), { method: 'DELETE' })
++ .then(function () { window.location.reload(); })
++ .catch(function (e) { window.alert(e.message); });
++ });
++ });
++ }
++
++ // ── A key for one caller of the person's own ───────────────
++ function wireMakeKey() {
++ var form = document.querySelector('[data-ovk-make-key]');
++ if (!form) return;
++ var box = form.querySelector('[data-ovk-key]');
++ form.addEventListener('submit', function (e) {
++ e.preventDefault();
++ var name = (form.querySelector('input[name=name]') || {}).value || '';
++ if (!name.trim()) { say(box, 'Give the caller a name.', 'bad'); return; }
++ api('/callers', { method: 'POST', body: { name: name.trim() } })
++ .then(function (out) {
++ say(box, 'Here is your key — it is shown once: ' + out.key, 'ok');
++ form.querySelector('input[name=name]').value = '';
++ if (window.OpenVibeKit && OpenVibeKit.enhance) OpenVibeKit.enhance(document.body);
++ })
++ .catch(function (e) { say(box, e.message, 'bad'); });
++ });
++ }
++
++ // ── The grants: one caller, one app ─────────────────────────
++ function loadGrants() {
++ var panel = document.querySelector('[data-ovk-grants-panel]');
++ if (!panel) return;
++ Promise.all([api('/callers'), api('/credentials')]).then(function (res) {
++ var callers = (res[0] && res[0].callers) || [];
++ var creds = (res[1] && res[1].credentials) || [];
++ var apps = [];
++ creds.forEach(function (c) { if (apps.indexOf(c.app) === -1) apps.push(c.app); });
++ panel.replaceChildren();
++ if (!apps.length) { panel.append(el('p', { class: 'ovk-note', text: 'Connect an app first, then give your callers a grant in it.' })); return; }
++ var rows = [];
++ callers.forEach(function (call) {
++ apps.forEach(function (app) {
++ var level = (call.grants || {})[app] || '';
++ var select = el('select', { class: 'ovk-grant-select', 'data-ovk-grant': call.id, 'data-ovk-app': app });
++ [['', 'No access'], ['read', 'Read'], ['write', 'Read and write']].forEach(function (opt) {
++ var o = el('option', { value: opt[0], text: opt[1] });
++ if (opt[0] === level) o.selected = true;
++ select.append(o);
++ });
++ select.addEventListener('change', function () {
++ api('/callers/' + encodeURIComponent(call.id) + '/grants', { method: 'PUT', body: { app: app, level: select.value || null } })
++ .catch(function (e) { window.alert(e.message); });
++ });
++ rows.push(el('div', { class: 'ovk-grant-row' },
++ el('span', { class: 'ovk-grant-who', text: call.name }),
++ el('span', { class: 'ovk-grant-app', text: app }),
++ select));
++ });
++ });
++ if (!rows.length) { panel.append(el('p', { class: 'ovk-note', text: 'No callers yet.' })); return; }
++ panel.append(el('div', { class: 'ovk-grants-head' },
++ el('span', { text: 'Caller' }), el('span', { text: 'App' }), el('span', { text: 'Grant' })));
++ rows.forEach(function (r) { panel.append(r); });
++ }).catch(function () { /* the page still works without the panel */ });
++ }
++
++ // ── The approvals that wait for the person ─────────────────
++ function wireApprovals() {
++ document.querySelectorAll('[data-ovk-approval]').forEach(function (btn) {
++ btn.addEventListener('click', function () {
++ var id = btn.getAttribute('data-ovk-approval');
++ var decision = btn.getAttribute('data-ovk-decision');
++ api('/approvals/' + encodeURIComponent(id), { method: 'POST', body: { decision: decision } })
++ .then(function () { window.location.reload(); })
++ .catch(function (e) { window.alert(e.message); });
++ });
++ });
++ }
++
++ /** New approvals can arrive while the page is open: look now and then. */
++ function watchApprovals() {
++ var section = document.getElementById('ovk-needs-you');
++ if (!section) return;
++ setInterval(function () {
++ api('/approvals?state=pending').then(function (out) {
++ var pending = (out && out.approvals) || [];
++ var count = section.querySelector('.ovk-count');
++ if (count) count.textContent = String(pending.length);
++ var list = section.querySelector('.ovk-list');
++ if (list && pending.length) {
++ list.replaceChildren.apply(list, pending.map(function (a) {
++ return el('li', { class: 'ovk-row ovk-row--static' },
++ el('span', { class: 'ovk-row-main' },
++ el('span', { class: 'ovk-row-top' }, el('strong', { text: a.summary || a.action })),
++ el('span', { class: 'ovk-row-sub' }, el('span', { text: a.action }))),
++ el('span', { class: 'ovk-row-trail' },
++ el('button', { class: 'ovk-btn ovk-btn--primary ovk-btn--small', type: 'button', text: 'Allow', onclick: function () { decide(a.id, 'approve'); } }),
++ el('button', { class: 'ovk-btn ovk-btn--quiet ovk-btn--small', type: 'button', text: 'Deny', onclick: function () { decide(a.id, 'deny'); } })));
++ }));
++ }
++ }).catch(function () { /* offline: the page keeps what it has */ });
++ }, 5000);
++ }
++
++ function decide(id, decision) {
++ api('/approvals/' + encodeURIComponent(id), { method: 'POST', body: { decision: decision } })
++ .then(function () { window.location.reload(); })
++ .catch(function (e) { window.alert(e.message); });
++ }
++
++ document.addEventListener('DOMContentLoaded', function () {
++ wireSearch();
++ wireConnect();
++ wireRevoke();
++ wireMakeKey();
++ wireApprovals();
++ loadGrants();
++ watchApprovals();
++ });
++})();
ddiff --git a/public/js/my/apps.js b/public/js/my/apps.js
nnew file mode 100644
iindex 00000000..b803f6d9
---- /dev/null
++++ b/public/js/my/apps.js
@@@ -0,0 +1,52 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): Apps (Compute plan E7).
++
++ The apps this person's agents may act in, and the callers they may act
++ as. It is a summary: the walkthrough, the paste box, the grants and the
++ log live on the Apps page (/apps), which this section links to.
++
++ Classic script, global scope; relies on my/core.js (apiFetch, toast).
++ ═══════════════════════════════════════════════════════════════ */
++
++function appsEl(tag, attrs, ...kids) {
++ const n = document.createElement(tag);
++ for (const [k, v] of Object.entries(attrs || {})) {
++ if (v == null) continue;
++ if (k === 'class') n.className = v;
++ else if (k === 'text') n.textContent = v;
++ else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
++ else n.setAttribute(k, v);
++ }
++ for (const kid of kids) if (kid != null) n.append(kid);
++ return n;
++}
++
++async function loadConnectApps() {
++ const list = document.getElementById('connect-apps-list');
++ if (!list) return;
++ if (typeof isAnonSession === 'function' && isAnonSession()) {
++ list.replaceChildren(appsEl('p', { class: 'muted', style: 'font-size:13px', text: 'Apps are only available once you sign in with a full OpenVibe account.' }));
++ return;
++ }
++ try {
++ const [cat, creds] = await Promise.all([
++ apiFetch('/api/v1/connect/catalogue'),
++ apiFetch('/api/v1/connect/credentials'),
++ ]);
++ const apps = (cat && cat.apps) || [];
++ const connected = (creds && creds.credentials) || [];
++ if (!connected.length) {
++ list.replaceChildren(appsEl('p', { class: 'muted', style: 'font-size:13px', text: 'No app is connected yet. Pick one from the catalogue and paste your own key.' }));
++ return;
++ }
++ list.replaceChildren(...connected.map((c) => {
++ const app = apps.find((a) => a.key === c.app);
++ const when = c.last_used_at ? new Date(c.last_used_at).toLocaleDateString() : 'not used yet';
++ return appsEl('div', { class: 'approval', style: 'padding:10px 12px' },
++ appsEl('div', { class: 'approval-summary', text: (app && app.name) || c.app }),
++ appsEl('div', { class: 'approval-meta', text: [c.label !== 'default' ? c.label : '', c.scopes && c.scopes.length ? c.scopes.join(', ') : '', when].filter(Boolean).join(' \u00b7 ') }));
++ }));
++ } catch (e) {
++ list.replaceChildren(appsEl('p', { class: 'muted', style: 'font-size:13px', text: 'Could not load your apps: ' + e.message }));
++ }
++}
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex 6669716e..4b5c7836 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -161,6 +161,7 @@ function showSection(id, btn) {
if (id === 'content' && typeof loadAdultSection === 'function') loadAdultSection();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
++ if (id === 'apps' && typeof loadConnectApps === 'function') loadConnectApps();
}
function openInitialSection() {
@@@ -169,7 +170,7 @@ function openInitialSection() {
const hash = window.location.hash.replace(/^#/, '');
const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
-- if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines'].includes(routeSection)) {
++ if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
return showSection('anon');
}
if (document.getElementById('sec-' + routeSection)) {
ddiff --git a/public/my.html b/public/my.html
iindex 5997ce97..26a71290 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -113,6 +113,7 @@
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI &amp; Data</button>
<button onclick="showSection('content',this)"><i class="fa-solid fa-shield-halved"></i> Content</button>
<button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button>
++ <button onclick="showSection('apps',this)"><i class="fa-solid fa-plug"></i> Apps</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('machines',this)"><i class="fa-solid fa-laptop"></i> Machines</button>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
@@@ -384,6 +385,18 @@
</div>
</div>
++ <!-- Apps Section (Compute plan E7): the apps a person's agents act in, with their own keys. -->
++ <div class="section" id="sec-apps">
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-plug"></i></span> Apps</h3>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Your agents can work in your other apps with your own keys. The keys are sealed, never shown again, and every call is written down.</p>
++ <div id="connect-apps-list" aria-live="polite">
++ <!-- Populated by JS (my/apps.js) -->
++ </div>
++ <p style="margin-top:12px"><a class="btn btn-primary" href="/apps"><i class="fa-solid fa-plug" aria-hidden="true"></i> Open the Apps page</a></p>
++ </div>
++ </div>
++
<div class="section" id="sec-linked">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
@@@ -555,6 +568,7 @@
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
++<script src="/js/my/apps.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
ddiff --git a/server/connect/approvals.js b/server/connect/approvals.js
nnew file mode 100644
iindex 00000000..4586448f
---- /dev/null
++++ b/server/connect/approvals.js
@@@ -0,0 +1,123 @@
++'use strict';
++/**
++ * The ask first (Compute plan E7): a send, a delete and a pay action always wait for the person.
++ *
++ * The request is written down, the caller waits, and the person decides on the Apps page (and in the
++ * account hub). Nothing happens before the answer: the action runs only after an approval, and a
++ * denial or a timeout is a refusal, not a partial run.
++ *
++ * A standing rule changes that for one recipe: when the person says that one app and action does not
++ * have to ask again, it does not ask again. The rule is per app and per action, never per person
++ * across apps.
++ *
++ * The summary is what the person reads: the app, the action and what it touches. The input itself is
++ * not in the summary; only its digest is kept, so the same input can be recognised later.
++ */
++const crypto = require('crypto');
++const { newId } = require('./ids');
++
++const STATES = ['pending', 'approved', 'denied', 'expired'];
++const DEFAULT_TTL_MS = 15 * 60 * 1000;
++const DEFAULT_WAIT_MS = 120000;
++const DEFAULT_POLL_MS = 500;
++
++class ApprovalError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; }
++}
++const fail = (status, code, detail) => { throw new ApprovalError(status, code, detail); };
++
++const nowIso = () => new Date().toISOString();
++const digestOf = (input) => crypto.createHash('sha256').update(JSON.stringify(input ?? null)).digest('hex');
++
++function view(row) {
++ if (!row) return null;
++ return {
++ id: row.id, app: row.app, action: row.action, summary: row.summary, state: row.state,
++ created_at: row.created_at, expires_at: row.expires_at, decided_at: row.decided_at || null,
++ caller_id: row.caller_id,
++ };
++}
++
++/** The person's standing rules, as a map of app.action. */
++async function rulesOf(db, subject) {
++ const rows = await db.prepare('SELECT app, action FROM connect_rules WHERE subject = ?').all(subject);
++ return new Set(rows.map((r) => `${r.app}.${r.action}`));
++}
++
++/** Let one recipe ask no more. */
++async function setRule(db, subject, app, action, on = true) {
++ if (!on) {
++ await db.prepare('DELETE FROM connect_rules WHERE subject = ? AND app = ? AND action = ?').run(subject, app, action);
++ return { rules: [...(await rulesOf(db, subject))] };
++ }
++ await db.prepare(`INSERT INTO connect_rules (subject, app, action, created_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT (subject, app, action) DO NOTHING`).run(subject, app, action, nowIso());
++ return { rules: [...(await rulesOf(db, subject))] };
++}
++
++/** The approvals waiting for this person. */
++async function list(db, subject, { state = null } = {}) {
++ const rows = state
++ ? await db.prepare('SELECT * FROM connect_approvals WHERE subject = ? AND state = ? ORDER BY created_at DESC LIMIT 100').all(subject, state)
++ : await db.prepare('SELECT * FROM connect_approvals WHERE subject = ? ORDER BY created_at DESC LIMIT 100').all(subject);
++ return rows.map(view);
++}
++
++/** Write the request down and let the caller wait. */
++async function request(db, { subject, callerId, app, action, summary, input, ttlMs = DEFAULT_TTL_MS }) {
++ const id = newId('cap');
++ const at = nowIso();
++ const expires = new Date(Date.parse(at) + ttlMs).toISOString();
++ await db.prepare(`INSERT INTO connect_approvals (id, subject, caller_id, app, action, summary, input_digest, state, created_at, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
++ .run(id, subject, callerId, app, action, String(summary || `${app}.${action}`).slice(0, 500), digestOf(input), at, expires);
++ return view(await db.prepare('SELECT * FROM connect_approvals WHERE id = ?').get(id));
++}
++
++/** The person decides. Only a pending request can be decided, and only by its owner. */
++async function decide(db, subject, id, decision) {
++ if (!['approve', 'deny'].includes(decision)) fail(422, 'connect.invalid_decision', 'the decision is approve or deny');
++ const row = await db.prepare('SELECT * FROM connect_approvals WHERE id = ? AND subject = ?').get(String(id), subject);
++ if (!row) fail(404, 'connect.approval_not_found', 'no such request');
++ if (row.state !== 'pending') fail(409, 'connect.approval_decided', 'that request was already decided');
++ const state = decision === 'approve' ? 'approved' : 'denied';
++ const at = nowIso();
++ const r = await db.prepare(`UPDATE connect_approvals SET state = ?, decided_at = ? WHERE id = ? AND state = 'pending'`)
++ .run(state, at, row.id);
++ if (!r.changes) fail(409, 'connect.approval_decided', 'that request was already decided');
++ return view(await db.prepare('SELECT * FROM connect_approvals WHERE id = ?').get(row.id));
++}
++
++/**
++ * Wait for the answer. → { state: 'approved' | 'denied' | 'expired' }
++ * A request past its expiry is expired, whether or not anyone noticed.
++ */
++async function wait(db, id, { timeoutMs = DEFAULT_WAIT_MS, pollMs = DEFAULT_POLL_MS, now = () => Date.now() } = {}) {
++ const start = now();
++ for (;;) {
++ const row = await db.prepare('SELECT * FROM connect_approvals WHERE id = ?').get(String(id));
++ if (!row) return { state: 'expired' };
++ if (row.state !== 'pending') return { state: row.state };
++ if (row.expires_at <= nowIso()) {
++ await db.prepare("UPDATE connect_approvals SET state = 'expired' WHERE id = ? AND state = 'pending'").run(row.id);
++ return { state: 'expired' };
++ }
++ if (now() - start >= timeoutMs) return { state: 'pending' };
++ await new Promise((resolve) => setTimeout(resolve, pollMs));
++ }
++}
++
++/** The summary the person reads: what the action would do. */
++function summaryOf(app, action, input) {
++ const parts = [app.name || app.key, action.title || action.name];
++ if (input && typeof input === 'object') {
++ const shown = Object.entries(input)
++ .filter(([, v]) => typeof v !== 'object' || v === null)
++ .slice(0, 4)
++ .map(([k, v]) => `${k}: ${String(v).slice(0, 120)}`);
++ if (shown.length) parts.push(shown.join(', '));
++ }
++ return parts.join(' — ');
++}
++
++module.exports = { request, decide, wait, list, setRule, rulesOf, summaryOf, view, ApprovalError, STATES, DEFAULT_TTL_MS, DEFAULT_WAIT_MS };
ddiff --git a/server/connect/apps/ai.js b/server/connect/apps/ai.js
nnew file mode 100644
iindex 00000000..ffcd7af1
---- /dev/null
++++ b/server/connect/apps/ai.js
@@@ -0,0 +1,42 @@
++'use strict';
++/**
++ * OpenAI and Anthropic (phase 1): the keys go to OpenVibe.AI's own-key (BYOK) page, and this entry is
++ * the link to it.
++ *
++ * These two are one catalogue entry, because they are one thing: a provider key that OpenVibe.AI
++ * already holds the way it holds every person's own key, and runs with. Connect does not seal them and
++ * does not proxy them; there is no credential here and no action to grant, and the gateway lists no
++ * tool for them. What this page offers is the way through to AI, and a note of where the key lives.
++ *
++ * Phase 2 gives each provider its own OAuth app, and then these become ordinary Connect apps.
++ */
++module.exports = {
++ key: 'ai',
++ name: 'OpenAI & Anthropic',
++ vendor: 'OpenVibe.AI',
++ blurb: 'Provider keys live in OpenVibe.AI’s own-key page; Connect links there.',
++ apiHost: null,
++ docsUrl: 'https://ai.openvibe.services/',
++ byok: {
++ service: 'ai',
++ note: 'A provider key is stored by OpenVibe.AI, not by Connect. It is used by AI runs, not by Connect actions.',
++ providers: [
++ { key: 'openai', name: 'OpenAI', hint: 'An OpenAI API key, for the models AI runs on your own key.', link: 'https://platform.openai.com/api-keys', linkLabel: 'Open the OpenAI keys page' },
++ { key: 'anthropic', name: 'Anthropic', hint: 'An Anthropic API key, for the models AI runs on your own key.', link: 'https://console.anthropic.com/settings/keys', linkLabel: 'Open the Anthropic keys page' },
++ ],
++ },
++ auth: {
++ kind: 'byok_link',
++ label: 'Your own key, in OpenVibe.AI',
++ hint: 'This key is never stored in Connect. OpenVibe.AI holds it, under your account.',
++ fields: [],
++ scopes: [],
++ },
++ walkthrough: [
++ { title: 'Open OpenVibe.AI', text: 'AI is where your own provider keys live, and where they are used for the runs you start.', link: 'https://ai.openvibe.services/', linkLabel: 'Open OpenVibe.AI' },
++ { title: 'Add your key there', text: 'The key is sealed by AI, under your own account, and AI runs on it alone.', linkLabel: 'The next step' },
++ { title: 'Come back here', text: 'Connect has nothing to keep for these two: no credential, no grant, no tool.', linkLabel: 'The next step' },
++ ],
++ test: null,
++ actions: [],
++};
ddiff --git a/server/connect/apps/discord.js b/server/connect/apps/discord.js
nnew file mode 100644
iindex 00000000..6163a225
---- /dev/null
++++ b/server/connect/apps/discord.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * Discord (phase 1): a bot token for the person's own bot.
++ *
++ * The person creates a bot in the Discord developer portal, invites it to their server and pastes its
++ * token. The bot posts as the bot, never as the person. The API host is fixed here: discord.com.
++ */
++module.exports = {
++ key: 'discord',
++ name: 'Discord',
++ vendor: 'Discord',
++ blurb: 'Channels and messages in the servers your bot is in.',
++ apiHost: 'discord.com',
++ docsUrl: 'https://discord.com/developers/docs/reference',
++ auth: {
++ kind: 'bot_token',
++ label: 'Bot token',
++ hint: 'The token of your own Discord bot. It posts as the bot, never as you.',
++ fields: [
++ { name: 'default', label: 'Bot token', placeholder: 'MTIz…', where: 'header', header: 'Authorization', scheme: 'Bot' },
++ ],
++ scopes: ['Read messages in the channels the bot can see', 'Send messages', 'Manage its own messages'],
++ },
++ walkthrough: [
++ { title: 'Open the Discord developer portal', text: 'Sign in to Discord, then the Developer Portal → My Applications.', link: 'https://discord.com/developers/applications', linkLabel: 'Open the developer portal' },
++ { title: 'Create a new application', text: 'Give it a name and a bot. The bot is a separate user in your server.', linkLabel: 'The next step' },
++ { title: 'Copy the bot token', text: 'Bot → Token → Reset/Copy. It is shown once.', linkLabel: 'The next step' },
++ { title: 'Invite the bot to your server', text: 'OAuth2 → URL Generator: bot scope, and the permissions it needs (View Channels, Read Message History, Send Messages, Manage Messages).', linkLabel: 'The next step' },
++ { title: 'Paste the token below', text: 'Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/api/v10/users/@me', summary: 'checks that the token works and which bot it is' },
++ actions: [
++ {
++ name: 'discord.channel.messages', title: 'Read a channel', risk: 'read',
++ description: 'The recent messages of one channel.',
++ input: {
++ type: 'object',
++ properties: {
++ channel_id: { type: 'string', minLength: 1, maxLength: 100 },
++ limit: { type: 'integer', minimum: 1, maximum: 100 },
++ },
++ required: ['channel_id'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/api/v10/channels/{channel_id}/messages', query: { limit: '{limit}' } },
++ },
++ {
++ name: 'discord.message.create', title: 'Post a message', risk: 'send',
++ description: 'Send a message to a channel. It reaches people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ channel_id: { type: 'string', minLength: 1, maxLength: 100 },
++ content: { type: 'string', minLength: 1, maxLength: 2000 },
++ },
++ required: ['channel_id', 'content'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/api/v10/channels/{channel_id}/messages', body: { content: '{content}' } },
++ },
++ {
++ name: 'discord.message.delete', title: 'Delete a message', risk: 'delete',
++ description: 'Remove a message from a channel. It cannot be undone, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ channel_id: { type: 'string', minLength: 1, maxLength: 100 },
++ message_id: { type: 'string', minLength: 1, maxLength: 100 },
++ },
++ required: ['channel_id', 'message_id'], additionalProperties: false,
++ },
++ request: { method: 'DELETE', path: '/api/v10/channels/{channel_id}/messages/{message_id}' },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/github.js b/server/connect/apps/github.js
nnew file mode 100644
iindex 00000000..0b437f08
---- /dev/null
++++ b/server/connect/apps/github.js
@@@ -0,0 +1,126 @@
++'use strict';
++/**
++ * GitHub (phase 1): a fine-grained personal access token.
++ *
++ * The token is the person's own; OpenVibe Connect never asks GitHub for an OAuth app. The walkthrough
++ * sends them to GitHub's own settings page, where they choose the repositories and the permissions
++ * this file asks for, and paste the token here. The token is sealed in the vault and never shown back.
++ *
++ * The API host is fixed here: api.github.com. A request may only go there, and a redirect off this host
++ * is refused (server/connect/egress.js).
++ */
++module.exports = {
++ key: 'github',
++ name: 'GitHub',
++ vendor: 'GitHub',
++ blurb: 'Repositories, issues and pull requests.',
++ apiHost: 'api.github.com',
++ docsUrl: 'https://docs.github.com/en/rest',
++ auth: {
++ kind: 'token',
++ label: 'Fine-grained personal access token',
++ hint: 'A token for your own account. Give it only the repositories and the permissions below.',
++ fields: [
++ { name: 'default', label: 'Fine-grained token', placeholder: 'github_pat_…', where: 'header', header: 'Authorization', scheme: 'Bearer' },
++ ],
++ scopes: ['Metadata: read', 'Contents: read', 'Issues: read and write', 'Pull requests: read and write'],
++ },
++ walkthrough: [
++ { title: 'Open the fine-grained token page', text: 'Sign in to GitHub as yourself. GitHub calls these "personal access tokens (fine-grained)".', link: 'https://github.com/settings/personal-access-tokens/new', linkLabel: 'Open GitHub settings' },
++ { title: 'Name the token', text: 'Something like "OpenVibe Connect", so you can recognise it later and revoke it here.', linkLabel: 'The next step' },
++ { title: 'Choose the repositories', text: 'Pick only the repositories your agent should touch. "All repositories" gives it every one of them.', linkLabel: 'The next step' },
++ { title: 'Set the permissions', text: 'Metadata: read. Contents: read. Issues: read and write. Pull requests: read and write. Nothing else.', linkLabel: 'The next step' },
++ { title: 'Generate and copy the token', text: 'GitHub shows the token once. Copy it and paste it below — Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/user', summary: 'checks that the token works and says whose it is' },
++ actions: [
++ {
++ name: 'github.repo.read', title: 'Read a repository', risk: 'read',
++ description: 'One repository: its name, description, default branch and stars.',
++ input: {
++ type: 'object',
++ properties: { owner: { type: 'string', minLength: 1, maxLength: 100 }, repo: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: ['owner', 'repo'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/repos/{owner}/{repo}' },
++ },
++ {
++ name: 'github.issue.list', title: 'List issues', risk: 'read',
++ description: 'The open issues of a repository.',
++ input: {
++ type: 'object',
++ properties: {
++ owner: { type: 'string', minLength: 1, maxLength: 100 },
++ repo: { type: 'string', minLength: 1, maxLength: 100 },
++ state: { type: 'string', enum: ['open', 'closed', 'all'] },
++ },
++ required: ['owner', 'repo'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/repos/{owner}/{repo}/issues', query: { state: '{state}' } },
++ },
++ {
++ name: 'github.issue.create', title: 'Create an issue', risk: 'write',
++ description: 'Open a new issue in a repository.',
++ input: {
++ type: 'object',
++ properties: {
++ owner: { type: 'string', minLength: 1, maxLength: 100 },
++ repo: { type: 'string', minLength: 1, maxLength: 100 },
++ title: { type: 'string', minLength: 1, maxLength: 256 },
++ body: { type: 'string', maxLength: 20000 },
++ },
++ required: ['owner', 'repo', 'title'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/repos/{owner}/{repo}/issues', body: { title: '{title}', body: '{body}' } },
++ },
++ {
++ name: 'github.issue.comment', title: 'Comment on an issue', risk: 'send',
++ description: 'Post a comment on an issue. This sends something to people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ owner: { type: 'string', minLength: 1, maxLength: 100 },
++ repo: { type: 'string', minLength: 1, maxLength: 100 },
++ number: { type: 'integer', minimum: 1 },
++ body: { type: 'string', minLength: 1, maxLength: 20000 },
++ },
++ required: ['owner', 'repo', 'number', 'body'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/repos/{owner}/{repo}/issues/{number}/comments', body: { body: '{body}' } },
++ },
++ {
++ name: 'github.issue.comment.delete', title: 'Delete a comment', risk: 'delete',
++ description: 'Remove a comment from an issue. This cannot be undone, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ owner: { type: 'string', minLength: 1, maxLength: 100 },
++ repo: { type: 'string', minLength: 1, maxLength: 100 },
++ comment_id: { type: 'integer', minimum: 1 },
++ },
++ required: ['owner', 'repo', 'comment_id'], additionalProperties: false,
++ },
++ request: { method: 'DELETE', path: '/repos/{owner}/{repo}/issues/comments/{comment_id}' },
++ },
++ {
++ name: 'github.pull.create', title: 'Open a pull request', risk: 'write',
++ description: 'Open a pull request from a branch into another.',
++ input: {
++ type: 'object',
++ properties: {
++ owner: { type: 'string', minLength: 1, maxLength: 100 },
++ repo: { type: 'string', minLength: 1, maxLength: 100 },
++ title: { type: 'string', minLength: 1, maxLength: 256 },
++ head: { type: 'string', minLength: 1, maxLength: 200 },
++ base: { type: 'string', minLength: 1, maxLength: 200 },
++ body: { type: 'string', maxLength: 20000 },
++ },
++ required: ['owner', 'repo', 'title', 'head', 'base'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/repos/{owner}/{repo}/pulls',
++ body: { title: '{title}', head: '{head}', base: '{base}', body: '{body}' },
++ },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/google.js b/server/connect/apps/google.js
nnew file mode 100644
iindex 00000000..554f15bc
---- /dev/null
++++ b/server/connect/apps/google.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * Google (phase 1): an API key, and only the APIs that take one.
++ *
++ * Most of Google's personal APIs (Gmail, Drive, Calendar) want an OAuth sign-in as you, and that is
++ * phase 2. What works with a plain API key today is the Cloud Translation API, so that is what this
++ * app does. The API host is fixed here: translation.googleapis.com.
++ */
++module.exports = {
++ key: 'google',
++ name: 'Google',
++ vendor: 'Google',
++ blurb: 'Translation through a Google API key (the personal APIs come with OAuth in phase 2).',
++ apiHost: 'translation.googleapis.com',
++ docsUrl: 'https://cloud.google.com/translate/docs',
++ auth: {
++ kind: 'api_key',
++ label: 'Google Cloud API key',
++ hint: 'An API key of your own Google Cloud project, restricted to the Translation API.',
++ fields: [
++ { name: 'default', label: 'API key', placeholder: 'AIza…', where: 'query', param: 'key' },
++ ],
++ scopes: ['Cloud Translation API only'],
++ },
++ walkthrough: [
++ { title: 'Open Google Cloud console', text: 'Sign in with the Google account whose project you want to use.', link: 'https://console.cloud.google.com/apis/credentials', linkLabel: 'Open the credentials page' },
++ { title: 'Create an API key', text: 'Credentials → Create credentials → API key.', linkLabel: 'The next step' },
++ { title: 'Restrict the key', text: 'Under API restrictions, allow only the Cloud Translation API. An unrestricted key is a key anyone can spend.', linkLabel: 'The next step' },
++ { title: 'Paste the key below', text: 'Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/language/translate/v2', query: { q: 'hello', target: 'es' }, summary: 'checks that the key works' },
++ actions: [
++ {
++ name: 'google.translation.translate', title: 'Translate text', risk: 'read',
++ description: 'Translate a piece of text into another language.',
++ input: {
++ type: 'object',
++ properties: {
++ q: { type: 'string', minLength: 1, maxLength: 5000 },
++ target: { type: 'string', pattern: '^[a-z]{2,10}$' },
++ format: { type: 'string', enum: ['text', 'html'] },
++ },
++ required: ['q', 'target'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/language/translate/v2', query: { q: '{q}', target: '{target}', format: '{format}' } },
++ },
++ {
++ name: 'google.translation.detect', title: 'Detect the language', risk: 'read',
++ description: 'Say which language a piece of text is in.',
++ input: {
++ type: 'object',
++ properties: { q: { type: 'string', minLength: 1, maxLength: 5000 } },
++ required: ['q'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/language/translate/v2', query: { q: '{q}', dt: 't' } },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/index.js b/server/connect/apps/index.js
nnew file mode 100644
iindex 00000000..7b92e575
---- /dev/null
++++ b/server/connect/apps/index.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * The catalogue (phase 1): ten app definitions, one file each, loaded here.
++ *
++ * A definition says: the auth kind (what the person has to paste), the walkthrough with its links, the
++ * test call, and the actions — each with a name, a JSON Schema for its input, a risk level and the
++ * fixed request it makes. The API host is part of the definition: a request may only go to that host,
++ * never to a host that came out of the input.
++ *
++ * New apps are new files and reviews (a changed file is a changed capability).
++ */
++const fs = require('fs');
++const path = require('path');
++
++const DIR = __dirname;
++const RISKS = ['read', 'write', 'send', 'delete', 'pay'];
++const AUTH_KINDS = ['token', 'api_key', 'bot_token', 'byok_link'];
++
++function loadDir(dir = DIR) {
++ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js') && f !== 'index.js').sort();
++ const out = [];
++ for (const file of files) {
++ const def = require(path.join(dir, file));
++ out.push(def);
++ }
++ return out;
++}
++
++/** The definition is checked when it is loaded: a mistake here is a mistake in a capability. */
++function validateDefinition(def) {
++ const problems = [];
++ if (!def || typeof def !== 'object') return ['the definition is not an object'];
++ if (!/^[a-z][a-z0-9_]*$/.test(String(def.key || ''))) problems.push('key must be a lowercase name');
++ if (!def.name) problems.push('name is missing');
++ if (!def.auth || !AUTH_KINDS.includes(def.auth.kind)) problems.push(`auth.kind must be one of ${AUTH_KINDS.join(', ')}`);
++ if (!Array.isArray(def.walkthrough) || !def.walkthrough.length) problems.push('the walkthrough has no steps');
++ for (const [i, step] of (def.walkthrough || []).entries()) {
++ if (!step.title || !step.text) problems.push(`walkthrough step ${i + 1} has no title or text`);
++ if (step.link && !/^https:\/\//.test(step.link)) problems.push(`walkthrough step ${i + 1} has a link that is not https`);
++ }
++ const byok = def.auth && def.auth.kind === 'byok_link';
++ if (!byok) {
++ if (!def.apiHost || !/^[a-z0-9.-]+$/.test(String(def.apiHost || ''))) problems.push('apiHost must be a host name');
++ if (!def.test || !def.test.method || !def.test.path) problems.push('the test call is missing');
++ if (!Array.isArray(def.auth.fields) || !def.auth.fields.length) problems.push('auth has no fields');
++ }
++ const names = new Set();
++ for (const action of def.actions || []) {
++ if (!/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/.test(String(action.name || ''))) problems.push(`the action name "${action.name}" is not app.action`);
++ if (names.has(action.name)) problems.push(`the action name "${action.name}" is used twice`);
++ names.add(action.name);
++ if (!RISKS.includes(action.risk)) problems.push(`the action "${action.name}" has an unknown risk "${action.risk}"`);
++ if (!action.input || action.input.type !== 'object') problems.push(`the action "${action.name}" has no object schema for its input`);
++ if (!action.request || !action.request.method || !action.request.path) problems.push(`the action "${action.name}" has no request`);
++ if (action.request && String(action.request.path).includes('://')) problems.push(`the action "${action.name}" names a URL; the host is fixed by the app`);
++ }
++ return problems;
++}
++
++let cached = null;
++
++/** The catalogue: the ten definitions, by key, checked once. */
++function all() {
++ if (cached) return cached;
++ const defs = loadDir();
++ const problems = [];
++ for (const def of defs) {
++ for (const p of validateDefinition(def)) problems.push(`${def.key || '?'}: ${p}`);
++ }
++ if (problems.length) throw new Error(`the Connect catalogue is broken: ${problems.join(' | ')}`);
++ cached = defs;
++ return defs;
++}
++
++/** One app by its key, or null. */
++function get(key) {
++ return all().find((d) => d.key === key) || null;
++}
++
++/** The apps as the catalogue shows them: the key, the name, the blurb, the auth kind and the links. */
++function summaries() {
++ return all().map((d) => ({
++ key: d.key, name: d.name, vendor: d.vendor, blurb: d.blurb, docsUrl: d.docsUrl || null,
++ auth_kind: d.auth.kind, auth_label: d.auth.label, auth_hint: d.auth.hint || null,
++ scopes: d.auth.scopes || [], byok: d.byok || null,
++ actions: (d.actions || []).map((a) => ({ name: a.name, title: a.title, risk: a.risk, description: a.description || '' })),
++ }));
++}
++
++module.exports = { all, get, summaries, validateDefinition, RISKS, AUTH_KINDS };
ddiff --git a/server/connect/apps/linear.js b/server/connect/apps/linear.js
nnew file mode 100644
iindex 00000000..ac3a9051
---- /dev/null
++++ b/server/connect/apps/linear.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * Linear (phase 1): a personal API key.
++ *
++ * Linear's personal API keys are made under Settings → Security & access → API. The key goes in the
++ * Authorization header as it is (Linear does not use a "Bearer" prefix). The API host is fixed here:
++ * api.linear.app.
++ */
++module.exports = {
++ key: 'linear',
++ name: 'Linear',
++ vendor: 'Linear',
++ blurb: 'Issues and projects in your Linear workspace.',
++ apiHost: 'api.linear.app',
++ docsUrl: 'https://developers.linear.app/docs',
++ auth: {
++ kind: 'api_key',
++ label: 'Personal API key',
++ hint: 'A key for your own Linear account. It acts as you, with the teams you can see.',
++ fields: [
++ { name: 'default', label: 'API key', placeholder: 'lin_api_…', where: 'header', header: 'Authorization', scheme: null },
++ ],
++ scopes: ['Your Linear workspace, as you'],
++ },
++ walkthrough: [
++ { title: 'Open Linear', text: 'Sign in to Linear as yourself, on the web.', link: 'https://linear.app/settings/api', linkLabel: 'Open Linear settings' },
++ { title: 'Go to Security & access', text: 'In the left menu, "Security & access" holds the API keys.', linkLabel: 'The next step' },
++ { title: 'Create a personal API key', text: 'Give it a name you will recognise, such as "OpenVibe Connect".', linkLabel: 'The next step' },
++ { title: 'Copy the key', text: 'Linear shows the key once. Copy it and paste it below — Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'POST', path: '/graphql', body: { query: '{ viewer { id name email } }' }, summary: 'checks that the key works and says whose it is' },
++ actions: [
++ {
++ name: 'linear.issue.list', title: 'List issues', risk: 'read',
++ description: 'The issues of a Linear team.',
++ input: {
++ type: 'object',
++ properties: {
++ team: { type: 'string', minLength: 1, maxLength: 100 },
++ first: { type: 'integer', minimum: 1, maximum: 100 },
++ },
++ required: ['team'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/graphql',
++ body: {
++ query: 'query($team:String!,$first:Int){ issues(filter:{team:{name:{eq:$team}}},first:$first){ nodes{ id identifier title state{ name } url } } }',
++ variables: { team: '{team}', first: '{first}' },
++ },
++ },
++ },
++ {
++ name: 'linear.issue.create', title: 'Create an issue', risk: 'write',
++ description: 'Open a new issue in a Linear team.',
++ input: {
++ type: 'object',
++ properties: {
++ team: { type: 'string', minLength: 1, maxLength: 100 },
++ title: { type: 'string', minLength: 1, maxLength: 256 },
++ description: { type: 'string', maxLength: 20000 },
++ },
++ required: ['team', 'title'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/graphql',
++ body: {
++ query: 'mutation($team:String!,$title:String!,$description:String){ issueCreate(input:{teamId:$team,title:$title,description:$description}){ success issue{ id identifier url } } }',
++ variables: { team: '{team}', title: '{title}', description: '{description}' },
++ },
++ },
++ },
++ {
++ name: 'linear.issue.comment.create', title: 'Comment on an issue', risk: 'send',
++ description: 'Write a comment on a Linear issue. It reaches people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ issue_id: { type: 'string', minLength: 1, maxLength: 100 },
++ body: { type: 'string', minLength: 1, maxLength: 20000 },
++ },
++ required: ['issue_id', 'body'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/graphql',
++ body: {
++ query: 'mutation($issue:String!,$body:String!){ commentCreate(input:{issueId:$issue,body:$body}){ success comment{ id url } } }',
++ variables: { issue: '{issue_id}', body: '{body}' },
++ },
++ },
++ },
++ {
++ name: 'linear.issue.delete', title: 'Delete an issue', risk: 'delete',
++ description: 'Remove a Linear issue. It cannot be undone, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: { issue_id: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: ['issue_id'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/graphql',
++ body: {
++ query: 'mutation($issue:String!){ issueDelete(id:$issue){ success } }',
++ variables: { issue: '{issue_id}' },
++ },
++ },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/notion.js b/server/connect/apps/notion.js
nnew file mode 100644
iindex 00000000..4ea05d2e
---- /dev/null
++++ b/server/connect/apps/notion.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * Notion (phase 1): an internal integration token.
++ *
++ * A Notion integration is created in the Notion app (Settings → Connections → your integration), and
++ * its secret is copied once. The token goes in the Authorization header, with Notion's version header.
++ * The API host is fixed here: api.notion.com.
++ */
++module.exports = {
++ key: 'notion',
++ name: 'Notion',
++ vendor: 'Notion',
++ blurb: 'Pages and databases in the Notion workspace you share with the integration.',
++ apiHost: 'api.notion.com',
++ docsUrl: 'https://developers.notion.com/reference/intro',
++ headers: { 'Notion-Version': '2022-06-28' },
++ auth: {
++ kind: 'token',
++ label: 'Internal integration token',
++ hint: 'The secret of your own integration. Share the pages you want with the integration too.',
++ fields: [
++ { name: 'default', label: 'Integration token', placeholder: 'ntn_… (or secret_…)', where: 'header', header: 'Authorization', scheme: 'Bearer' },
++ ],
++ scopes: ['The pages and databases the integration was invited to'],
++ },
++ walkthrough: [
++ { title: 'Open the Notion integrations page', text: 'In the Notion app: Settings → Connections.', link: 'https://www.notion.so/my-integrations', linkLabel: 'Open Notion integrations' },
++ { title: 'Create an internal integration', text: 'Name it "OpenVibe Connect" and pick the workspace.', linkLabel: 'The next step' },
++ { title: 'Copy the secret', text: 'Notion shows the internal integration secret once, as "ntn_…" or "secret_…".', linkLabel: 'The next step' },
++ { title: 'Share the pages with it', text: 'Open a page, then "…" → Connections, and add your integration. It sees only what you share with it.', linkLabel: 'The next step' },
++ { title: 'Paste the secret below', text: 'Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/v1/users/me', summary: 'checks that the token works and says whose it is' },
++ actions: [
++ {
++ name: 'notion.search', title: 'Search the workspace', risk: 'read',
++ description: 'Find the pages and databases the integration can see.',
++ input: {
++ type: 'object',
++ properties: { query: { type: 'string', maxLength: 200 } },
++ required: [], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/v1/search', body: { query: '{query}' } },
++ },
++ {
++ name: 'notion.page.read', title: 'Read a page', risk: 'read',
++ description: 'One page by its id.',
++ input: {
++ type: 'object',
++ properties: { page_id: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: ['page_id'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/v1/pages/{page_id}' },
++ },
++ {
++ name: 'notion.page.create', title: 'Create a page', risk: 'write',
++ description: 'Add a page under another page or in a database.',
++ input: {
++ type: 'object',
++ properties: {
++ parent_id: { type: 'string', minLength: 1, maxLength: 100 },
++ title: { type: 'string', minLength: 1, maxLength: 200 },
++ body: { type: 'string', maxLength: 10000 },
++ },
++ required: ['parent_id', 'title'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/v1/pages',
++ body: {
++ parent: { page_id: '{parent_id}' },
++ properties: { title: { title: [{ text: { content: '{title}' } }] } },
++ children: [{ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: '{body}' } }] } }],
++ },
++ },
++ },
++ {
++ name: 'notion.comment.create', title: 'Comment on a page', risk: 'send',
++ description: 'Write a comment on a page. It reaches people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ page_id: { type: 'string', minLength: 1, maxLength: 100 },
++ body: { type: 'string', minLength: 1, maxLength: 10000 },
++ },
++ required: ['page_id', 'body'], additionalProperties: false,
++ },
++ request: {
++ method: 'POST', path: '/v1/comments',
++ body: {
++ parent: { page_id: '{page_id}' },
++ rich_text: [{ type: 'text', text: { content: '{body}' } }],
++ },
++ },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/slack.js b/server/connect/apps/slack.js
nnew file mode 100644
iindex 00000000..40f2f418
---- /dev/null
++++ b/server/connect/apps/slack.js
@@@ -0,0 +1,83 @@
++'use strict';
++/**
++ * Slack (phase 1): a bot token for the person's own Slack app.
++ *
++ * The person creates a Slack app of their own, gives it the bot scopes below, installs it in their
++ * workspace and pastes the bot token (xoxb-…). The bot posts as the app, not as the person. The API
++ * host is fixed here: slack.com.
++ */
++module.exports = {
++ key: 'slack',
++ name: 'Slack',
++ vendor: 'Slack',
++ blurb: 'Channels and messages in the workspace your bot is in.',
++ apiHost: 'slack.com',
++ docsUrl: 'https://api.slack.com/methods',
++ auth: {
++ kind: 'bot_token',
++ label: 'Bot user OAuth token',
++ hint: 'The xoxb-… token of your own Slack app. The bot posts as the app.',
++ fields: [
++ { name: 'default', label: 'Bot token', placeholder: 'xoxb-…', where: 'header', header: 'Authorization', scheme: 'Bearer' },
++ ],
++ scopes: ['channels:read', 'groups:read', 'channels:history', 'chat:write'],
++ },
++ walkthrough: [
++ { title: 'Create a Slack app', text: 'Sign in to Slack and create an app from scratch, under your own workspace.', link: 'https://api.slack.com/apps', linkLabel: 'Open the Slack apps page' },
++ { title: 'Give it the bot scopes', text: 'OAuth & Permissions → Scopes → Bot Token Scopes: channels:read, groups:read, channels:history, chat:write.', linkLabel: 'The next step' },
++ { title: 'Install it in your workspace', text: 'The bot posts as your app, never as you.', linkLabel: 'The next step' },
++ { title: 'Copy the bot token', text: 'It starts with xoxb-. Paste it below — Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/api/auth.test', summary: 'checks that the token works and which workspace it is in' },
++ actions: [
++ {
++ name: 'slack.channel.list', title: 'List channels', risk: 'read',
++ description: 'The conversations the bot is in.',
++ input: {
++ type: 'object',
++ properties: { limit: { type: 'integer', minimum: 1, maximum: 200 } },
++ required: [], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/api/conversations.list', query: { limit: '{limit}' } },
++ },
++ {
++ name: 'slack.message.list', title: 'Read a channel', risk: 'read',
++ description: 'The recent messages of one channel.',
++ input: {
++ type: 'object',
++ properties: {
++ channel: { type: 'string', minLength: 1, maxLength: 100 },
++ limit: { type: 'integer', minimum: 1, maximum: 200 },
++ },
++ required: ['channel'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/api/conversations.history', query: { channel: '{channel}', limit: '{limit}' } },
++ },
++ {
++ name: 'slack.message.post', title: 'Post a message', risk: 'send',
++ description: 'Send a message to a channel. It reaches people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ channel: { type: 'string', minLength: 1, maxLength: 100 },
++ text: { type: 'string', minLength: 1, maxLength: 4000 },
++ },
++ required: ['channel', 'text'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/api/chat.postMessage', body: { channel: '{channel}', text: '{text}' } },
++ },
++ {
++ name: 'slack.message.delete', title: 'Delete a message', risk: 'delete',
++ description: 'Remove a message from a channel. It cannot be undone, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ channel: { type: 'string', minLength: 1, maxLength: 100 },
++ ts: { type: 'string', minLength: 1, maxLength: 100 },
++ },
++ required: ['channel', 'ts'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/api/chat.delete', body: { channel: '{channel}', ts: '{ts}' } },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/steam.js b/server/connect/apps/steam.js
nnew file mode 100644
iindex 00000000..5a64bbd6
---- /dev/null
++++ b/server/connect/apps/steam.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * Steam (phase 1): a Steam Web API key.
++ *
++ * The key is created under Steam's developer site (My Account → Web API). It travels as a query
++ * parameter, which is how Steam's own API takes it. The API host is fixed here:
++ * api.steampowered.com.
++ */
++module.exports = {
++ key: 'steam',
++ name: 'Steam',
++ vendor: 'Steam',
++ blurb: 'Player profiles, owned games and news.',
++ apiHost: 'api.steampowered.com',
++ docsUrl: 'https://steamcommunity.com/dev',
++ auth: {
++ kind: 'api_key',
++ label: 'Steam Web API key',
++ hint: 'The Web API key of your Steam account.',
++ fields: [
++ { name: 'default', label: 'Web API key', placeholder: '32 hex characters', where: 'query', param: 'key' },
++ ],
++ scopes: ['Public Steam data'],
++ },
++ walkthrough: [
++ { title: 'Open your Steam account page', text: 'Sign in to Steam, then My Account → Web API.', link: 'https://steamcommunity.com/dev/apikey', linkLabel: 'Open the Steam Web API page' },
++ { title: 'Register the key', text: 'Steam shows the key right away, as 32 hexadecimal characters.', linkLabel: 'The next step' },
++ { title: 'Paste the key below', text: 'Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/ISteamUser/GetPlayerSummaries/v0002/', query: { steamids: '76561197960287930' }, summary: 'checks that the key works' },
++ actions: [
++ {
++ name: 'steam.player.summary', title: 'Read a player profile', risk: 'read',
++ description: 'The public profile of one Steam player.',
++ input: {
++ type: 'object',
++ properties: { steamid: { type: 'string', pattern: '^\\d{17}$' } },
++ required: ['steamid'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/ISteamUser/GetPlayerSummaries/v0002/', query: { steamids: '{steamid}' } },
++ },
++ {
++ name: 'steam.player.games', title: 'List the games of a player', risk: 'read',
++ description: 'The games a Steam player owns.',
++ input: {
++ type: 'object',
++ properties: { steamid: { type: 'string', pattern: '^\\d{17}$' } },
++ required: ['steamid'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/IPlayerService/GetOwnedGames/v0001/', query: { steamid: '{steamid}', include_appinfo: '1' } },
++ },
++ {
++ name: 'steam.news.list', title: 'News about an app', risk: 'read',
++ description: 'The news feed of one Steam application.',
++ input: {
++ type: 'object',
++ properties: {
++ appid: { type: 'integer', minimum: 1 },
++ count: { type: 'integer', minimum: 1, maximum: 100 },
++ },
++ required: ['appid'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/ISteamNews/GetNewsForApp/v0002/', query: { appid: '{appid}', count: '{count}' } },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/todoist.js b/server/connect/apps/todoist.js
nnew file mode 100644
iindex 00000000..793c8988
---- /dev/null
++++ b/server/connect/apps/todoist.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * Todoist (phase 1): an API token.
++ *
++ * The token is made in Todoist's own settings (Settings → Integrations → API token). It travels as a
++ * bearer token. The API host is fixed here: api.todoist.com.
++ */
++module.exports = {
++ key: 'todoist',
++ name: 'Todoist',
++ vendor: 'Todoist',
++ blurb: 'Tasks, projects and comments.',
++ apiHost: 'api.todoist.com',
++ docsUrl: 'https://developer.todoist.com/rest/v2/',
++ auth: {
++ kind: 'api_key',
++ label: 'API token',
++ hint: 'The API token of your own Todoist account.',
++ fields: [
++ { name: 'default', label: 'API token', placeholder: '40 hex characters', where: 'header', header: 'Authorization', scheme: 'Bearer' },
++ ],
++ scopes: ['Your Todoist account, as you'],
++ },
++ walkthrough: [
++ { title: 'Open Todoist settings', text: 'Sign in to Todoist, then Settings → Integrations.', link: 'https://app.todoist.com/app/settings/integrations', linkLabel: 'Open Todoist settings' },
++ { title: 'Copy the API token', text: 'The "API token" line holds 40 hexadecimal characters.', linkLabel: 'The next step' },
++ { title: 'Paste the token below', text: 'Connect seals it and never shows it again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/api/v1/tasks', summary: 'checks that the token works' },
++ actions: [
++ {
++ name: 'todoist.task.list', title: 'List tasks', risk: 'read',
++ description: 'The tasks of a project, or of every project when none is named.',
++ input: {
++ type: 'object',
++ properties: { project_id: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: [], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/api/v1/tasks', query: { project_id: '{project_id}' } },
++ },
++ {
++ name: 'todoist.task.create', title: 'Create a task', risk: 'write',
++ description: 'Add a task to a project.',
++ input: {
++ type: 'object',
++ properties: {
++ content: { type: 'string', minLength: 1, maxLength: 500 },
++ project_id: { type: 'string', minLength: 1, maxLength: 100 },
++ description: { type: 'string', maxLength: 10000 },
++ },
++ required: ['content'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/api/v1/tasks', body: { content: '{content}', project_id: '{project_id}', description: '{description}' } },
++ },
++ {
++ name: 'todoist.task.close', title: 'Complete a task', risk: 'write',
++ description: 'Close a task.',
++ input: {
++ type: 'object',
++ properties: { task_id: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: ['task_id'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/api/v1/tasks/{task_id}/close', body: {} },
++ },
++ {
++ name: 'todoist.comment.create', title: 'Comment on a task', risk: 'send',
++ description: 'Write a comment on a task. It reaches people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ task_id: { type: 'string', minLength: 1, maxLength: 100 },
++ content: { type: 'string', minLength: 1, maxLength: 10000 },
++ },
++ required: ['task_id', 'content'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/api/v1/comments', body: { task_id: '{task_id}', content: '{content}' } },
++ },
++ ],
++};
ddiff --git a/server/connect/apps/trello.js b/server/connect/apps/trello.js
nnew file mode 100644
iindex 00000000..8f88b0cf
---- /dev/null
++++ b/server/connect/apps/trello.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * Trello (phase 1): an API key and a token.
++ *
++ * Trello splits the credential in two: the key identifies the app, the token identifies the person who
++ * granted it. Both are needed for every call, so this app asks for both. The API host is fixed here:
++ * api.trello.com.
++ */
++module.exports = {
++ key: 'trello',
++ name: 'Trello',
++ vendor: 'Trello',
++ blurb: 'Boards, lists and cards.',
++ apiHost: 'api.trello.com',
++ docsUrl: 'https://developer.atlassian.com/cloud/trello/rest/api-group-introduction/',
++ auth: {
++ kind: 'api_key',
++ label: 'API key and token',
++ hint: 'Both parts of the credential: the key identifies your Trello app, the token identifies you.',
++ fields: [
++ { name: 'key', label: 'API key', placeholder: '32 hex characters', where: 'query', param: 'key' },
++ { name: 'token', label: 'Token', placeholder: '64 hex characters', where: 'query', param: 'token' },
++ ],
++ scopes: ['Your Trello boards, as you'],
++ },
++ walkthrough: [
++ { title: 'Open the Trello API key page', text: 'Sign in to Trello, then the power-up admin page.', link: 'https://trello.com/power-ups/admin', linkLabel: 'Open the Trello admin page' },
++ { title: 'Copy the key and the token', text: 'The page shows a "Key" and a "Token". The key identifies your app, the token identifies you.', linkLabel: 'The next step' },
++ { title: 'Paste both below', text: 'Connect seals each one and never shows either again.', linkLabel: 'The next step' },
++ ],
++ test: { method: 'GET', path: '/1/members/me', summary: 'checks that the key and token work and whose they are' },
++ actions: [
++ {
++ name: 'trello.board.list', title: 'List boards', risk: 'read',
++ description: 'The boards you are a member of.',
++ input: {
++ type: 'object',
++ properties: { filter: { type: 'string', enum: ['open', 'closed', 'all'] } },
++ required: [], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/1/boards', query: { filter: '{filter}' } },
++ },
++ {
++ name: 'trello.card.list', title: 'List the cards of a board', risk: 'read',
++ description: 'The cards of one board.',
++ input: {
++ type: 'object',
++ properties: { board_id: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: ['board_id'], additionalProperties: false,
++ },
++ request: { method: 'GET', path: '/1/boards/{board_id}/cards' },
++ },
++ {
++ name: 'trello.card.create', title: 'Create a card', risk: 'write',
++ description: 'Add a card to a list.',
++ input: {
++ type: 'object',
++ properties: {
++ list_id: { type: 'string', minLength: 1, maxLength: 100 },
++ name: { type: 'string', minLength: 1, maxLength: 500 },
++ desc: { type: 'string', maxLength: 10000 },
++ },
++ required: ['list_id', 'name'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/1/cards', body: { idList: '{list_id}', name: '{name}', desc: '{desc}' } },
++ },
++ {
++ name: 'trello.comment.create', title: 'Comment on a card', risk: 'send',
++ description: 'Write a comment on a card. It reaches people, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: {
++ card_id: { type: 'string', minLength: 1, maxLength: 100 },
++ text: { type: 'string', minLength: 1, maxLength: 10000 },
++ },
++ required: ['card_id', 'text'], additionalProperties: false,
++ },
++ request: { method: 'POST', path: '/1/cards/{card_id}/actions/comments', body: { text: '{text}' } },
++ },
++ {
++ name: 'trello.card.delete', title: 'Delete a card', risk: 'delete',
++ description: 'Remove a card from a board. It cannot be undone, so it always asks first.',
++ input: {
++ type: 'object',
++ properties: { card_id: { type: 'string', minLength: 1, maxLength: 100 } },
++ required: ['card_id'], additionalProperties: false,
++ },
++ request: { method: 'DELETE', path: '/1/cards/{card_id}' },
++ },
++ ],
++};
ddiff --git a/server/connect/auth.js b/server/connect/auth.js
nnew file mode 100644
iindex 00000000..ad4a19aa
---- /dev/null
++++ b/server/connect/auth.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * Who is calling the gateway (the MCP server and the pages behind it).
++ *
++ * Three kinds of caller, one person behind each:
++ *
++ * the person's own session token the person is signed in (the Apps page, their own scripts)
++ * a key the person made one caller of their own (their Claude Code, a script on their
++ * laptop). The key is ovc_<id>_<secret>; only its hash is kept.
++ * a service token that names a person a service acting for them (Codes, Actor): the token carries
++ * on_behalf_of, and the caller is the service.
++ *
++ * -> { ok: true, subject, caller, kind } | { ok: false, status, code, detail }
++ */
++const { serviceAuth } = require('openvibe-contracts');
++const { verifySession } = require('../auth/session');
++const callers = require('./callers');
++
++const bearer = (req) => {
++ const h = String(req.headers.authorization || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++};
++
++async function resolveCaller(db, req, { config, publicKey, issuer } = {}) {
++ const token = bearer(req);
++ if (!token) return { ok: false, status: 401, code: 'connect.unauthenticated', detail: 'a token is needed' };
++
++ // A key the person made on the Apps page.
++ if (token.startsWith(callers.KEY_PREFIX)) {
++ const row = await callers.findByKey(db, token);
++ if (!row) return { ok: false, status: 401, code: 'connect.unauthenticated', detail: 'that key is not known' };
++ return { ok: true, subject: row.subject, caller: row, kind: 'key' };
++ }
++
++ // A service token that names a person.
++ if (token.split('.').length === 3 && publicKey) {
++ const v = serviceAuth.verifyServiceToken(token, { publicKey, issuer: issuer || (config && config.jwt && config.jwt.issuer), audience: 'openvibe.network' });
++ if (v.ok && v.claims.on_behalf_of) {
++ const person = String(v.claims.on_behalf_of);
++ const service = String(v.claims.sub || '').replace(/^svc:/, '') || 'service';
++ // A valid service token is not enough: a person's app credentials are reachable only by the services
++ // Connect names (CONNECT_SERVICE_CALLERS, default codes and actor), and then only through the grants
++ // that person gave that service.
++ const allowed = String((config && config.connect && config.connect.serviceCallers) || process.env.CONNECT_SERVICE_CALLERS || 'codes,actor')
++ .split(',').map((x) => x.trim().toLowerCase()).filter(Boolean);
++ if (!allowed.includes(service.toLowerCase())) {
++ return { ok: false, status: 403, code: 'connect.service_not_allowed', detail: 'this service may not act for people in Connect' };
++ }
++ const row = await callers.ensureServiceCaller(db, person, service, service);
++ return { ok: true, subject: person, caller: row, kind: 'service', service };
++ }
++ }
++
++ // The person's own session.
++ if (publicKey && config) {
++ const out = await verifySession(token, { db, publicKey, config });
++ if (!out.error) {
++ const subject = out.user.subject_id || out.user.subject || String(out.user.id);
++ const row = await callers.ensurePersonCaller(db, subject, out.user.display_name || out.user.username || 'You');
++ return { ok: true, subject, caller: row, kind: 'person', user: out.user };
++ }
++ }
++
++ return { ok: false, status: 401, code: 'connect.unauthenticated', detail: 'that token is not accepted here' };
++}
++
++module.exports = { resolveCaller, bearer };
ddiff --git a/server/connect/callers.js b/server/connect/callers.js
nnew file mode 100644
iindex 00000000..ef5f3aff
---- /dev/null
++++ b/server/connect/callers.js
@@@ -0,0 +1,154 @@
++'use strict';
++/**
++ * The callers: who may act for a person, and what they may do in which app.
++ *
++ * person the person themselves (their session token, on the Apps page)
++ * key a key the person made on the Apps page for one caller of their own (their Claude Code,
++ * a script on their laptop). Only the hash of the key is kept; the key itself is shown
++ * once, at the moment it is made.
++ * service a service acting for the person (Codes, Actor): its token names the person in
++ * on_behalf_of, and the caller is the service.
++ *
++ * A grant is per caller and per app: none, read, or read and write. A send, delete or pay action
++ * asks again whatever the grant says (server/connect/approvals.js).
++ */
++const crypto = require('crypto');
++const { newId } = require('./ids');
++
++const LEVELS = ['read', 'write'];
++const KEY_PREFIX = 'ovc_';
++
++const nowIso = () => new Date().toISOString();
++const hash = (text) => crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
++
++class ConnectError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; }
++}
++const fail = (status, code, detail) => { throw new ConnectError(status, code, detail); };
++
++/** The caller's row as the Apps page shows it: never a key, only its hash and its prefix. */
++function view(row) {
++ if (!row) return null;
++ return {
++ id: row.id, kind: row.kind, name: row.name, created_at: row.created_at,
++ last_used_at: row.last_used_at || null, revoked_at: row.revoked_at || null,
++ token_prefix: row.token_prefix || null,
++ };
++}
++
++/** The person themselves: one row per person, made on first use. */
++async function ensurePersonCaller(db, subject, name = 'You') {
++ const id = `per_${hash(subject).slice(0, 32)}`;
++ const row = await db.prepare('SELECT * FROM connect_callers WHERE id = ?').get(id);
++ if (row) return row;
++ await db.prepare(`INSERT INTO connect_callers (id, subject, kind, name, created_at) VALUES (?, ?, 'person', ?, ?)
++ ON CONFLICT (id) DO NOTHING`).run(id, subject, String(name || 'You').slice(0, 100), nowIso());
++ return await db.prepare('SELECT * FROM connect_callers WHERE id = ?').get(id);
++}
++
++/** A service acting for the person: one row per service, made on first use. */
++async function ensureServiceCaller(db, subject, service, name) {
++ // One row per service AND person: grants hang off the caller id, so a shared row would let one person's grant
++ // open another person's apps to the same service.
++ const id = `svc_${String(service || '').replace(/[^a-z0-9-]/gi, '').slice(0, 40).toLowerCase()}_${hash(subject).slice(0, 24)}`;
++ const row = await db.prepare('SELECT * FROM connect_callers WHERE id = ?').get(id);
++ if (row) return row;
++ await db.prepare(`INSERT INTO connect_callers (id, subject, kind, name, created_at) VALUES (?, ?, 'service', ?, ?)
++ ON CONFLICT (id) DO NOTHING`).run(id, subject, String(name || service || 'A service').slice(0, 100), nowIso());
++ return await db.prepare('SELECT * FROM connect_callers WHERE id = ?').get(id);
++}
++
++/**
++ * Make a key for one caller of the person's own. The key is returned once; only its hash is kept.
++ * → { caller, key }
++ */
++async function createKeyCaller(db, subject, name) {
++ const label = String(name || '').trim();
++ if (!label || label.length > 100) fail(422, 'connect.invalid_caller', 'the caller needs a name of 1-100 characters');
++ const id = newId('cal');
++ const secret = crypto.randomBytes(24).toString('hex'); // hex: no "_" for findByKey to split on
++ const key = `${KEY_PREFIX}${id}_${secret}`;
++ await db.prepare(`INSERT INTO connect_callers (id, subject, kind, name, token_hash, token_prefix, created_at)
++ VALUES (?, ?, 'key', ?, ?, ?, ?)`)
++ .run(id, subject, label, hash(secret), secret.slice(0, 6), nowIso());
++ return { caller: view(await db.prepare('SELECT * FROM connect_callers WHERE id = ?').get(id)), key };
++}
++
++/** The caller a personal key belongs to, or null. The key is compared by hash, in constant time. */
++async function findByKey(db, token) {
++ const raw = String(token || '');
++ if (!raw.startsWith(KEY_PREFIX)) return null;
++ // The key is ovc_<caller id>_<secret>; the secret may hold an underscore, so the split is the last one.
++ const rest = raw.slice(KEY_PREFIX.length);
++ const cut = rest.lastIndexOf('_');
++ if (cut <= 0) return null;
++ const id = rest.slice(0, cut);
++ const secret = rest.slice(cut + 1);
++ const row = await db.prepare('SELECT * FROM connect_callers WHERE id = ? AND kind = \'key\' AND revoked_at IS NULL').get(id);
++ if (!row || !row.token_hash) return null;
++ const given = Buffer.from(hash(secret), 'hex');
++ const known = Buffer.from(String(row.token_hash), 'hex');
++ if (given.length !== known.length || !crypto.timingSafeEqual(given, known)) return null;
++ return row;
++}
++
++/** Note that a caller was used. */
++async function touch(db, id) {
++ await db.prepare('UPDATE connect_callers SET last_used_at = ? WHERE id = ?').run(nowIso(), id);
++}
++
++/** Every caller of this person, with its grants. */
++async function listCallers(db, subject) {
++ const rows = await db.prepare('SELECT * FROM connect_callers WHERE subject = ? ORDER BY kind, name').all(subject);
++ const out = [];
++ for (const row of rows) {
++ const grants = await db.prepare('SELECT app, level FROM connect_grants WHERE caller_id = ? ORDER BY app').all(row.id);
++ out.push({ ...view(row), grants: Object.fromEntries(grants.map((g) => [g.app, g.level])) });
++ }
++ return out;
++}
++
++/** What one caller may do in one app: 'read', 'write', or null (nothing). */
++async function grantOf(db, callerId, app) {
++ const row = await db.prepare('SELECT level FROM connect_grants WHERE caller_id = ? AND app = ?').get(callerId, app);
++ return row ? row.level : null;
++}
++
++/** Every grant of one caller, as a map. */
++async function grantsOf(db, callerId) {
++ const rows = await db.prepare('SELECT app, level FROM connect_grants WHERE caller_id = ?').all(callerId);
++ return Object.fromEntries(rows.map((r) => [r.app, r.level]));
++}
++
++/** Give a caller a grant (read or write), or take it away (null). */
++async function setGrant(db, callerId, app, level, subject) {
++ const appKey = String(app || '');
++ if (!/^[a-z][a-z0-9_]*$/.test(appKey)) fail(422, 'connect.invalid_app', 'that is not an app in the catalogue');
++ if (level === null || level === undefined || level === '') {
++ await db.prepare('DELETE FROM connect_grants WHERE caller_id = ? AND app = ?').run(callerId, appKey);
++ return {};
++ }
++ if (!LEVELS.includes(level)) fail(422, 'connect.invalid_grant', 'a grant is read or write');
++ // A grant is only for an app this person has connected.
++ const has = await db.prepare('SELECT 1 AS ok FROM connect_credentials WHERE subject = ? AND app = ? AND revoked_at IS NULL LIMIT 1')
++ .get(String(subject), appKey);
++ if (!has) fail(409, 'connect.not_connected', 'connect that app first');
++ const at = nowIso();
++ await db.prepare(`INSERT INTO connect_grants (caller_id, app, level, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT (caller_id, app) DO UPDATE SET level = excluded.level, updated_at = excluded.updated_at`)
++ .run(callerId, appKey, level, at, at);
++ return grantsOf(db, callerId);
++}
++
++/** Take a caller away: its grants go with it (the table cascades). */
++async function revokeCaller(db, callerId) {
++ await db.prepare('DELETE FROM connect_grants WHERE caller_id = ?').run(callerId);
++ const r = await db.prepare('DELETE FROM connect_callers WHERE id = ?').run(callerId);
++ if (!r.changes) fail(404, 'connect.not_found', 'no such caller');
++ return { revoked: true };
++}
++
++module.exports = {
++ ensurePersonCaller, ensureServiceCaller, createKeyCaller, findByKey, touch, listCallers,
++ grantOf, grantsOf, setGrant, revokeCaller, view, ConnectError, LEVELS, KEY_PREFIX,
++};
ddiff --git a/server/connect/egress.js b/server/connect/egress.js
nnew file mode 100644
iindex 00000000..1bc655c7
---- /dev/null
++++ b/server/connect/egress.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * The one way out of the gateway: a request to the app's own API host, through openvibe-shared/egress.
++ *
++ * The host is fixed by the app definition, never taken from the input. Three rules hold here:
++ *
++ * 1. An IP literal is refused before the socket is opened. Node does not call a custom lookup for
++ * a literal, so the guard would never see it: isPublicAddress(normalizeHost(host)) decides.
++ * (The same check is false for a name, which is not an address, so the literal case is the one
++ * that applies.)
++ * 2. A name resolves only to public addresses: the lookup is openvibe-shared/egress's safeLookup,
++ * which fails unless every answer is public, so the socket connects to the checked address and
++ * a rebinding cannot slip a private one in.
++ * 3. A redirect is followed by hand and every hop is checked again: the new host must still be the
++ * app's own host, and the new URL must pass the guard again. A redirect off the app's host is
++ * refused.
++ *
++ * The answer is at most MAX_BYTES, and the request has a timeout.
++ */
++const https = require('https');
++const net = require('net');
++const { isPublicAddress, normalizeHost, isInternalName, createSafeLookup, safeLookup } = require('openvibe-shared/egress');
++
++const MAX_BYTES = 256 * 1024;
++const MAX_REDIRECTS = 3;
++const DEFAULT_TIMEOUT_MS = 15000;
++
++class ConnectError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; }
++}
++const denied = (detail) => { throw new ConnectError(403, 'connect.egress_denied', detail); };
++
++/** The host of an app definition, checked. */
++function hostOf(def) {
++ const host = normalizeHost(def && def.apiHost);
++ if (!host) denied('this app has no API host');
++ if (net.isIP(host)) {
++ // A literal: Node does not call a custom lookup for it, so it is checked here.
++ if (!isPublicAddress(normalizeHost(host))) denied(`${host} is not a public address`);
++ return host;
++ }
++ if (isInternalName(host)) denied(`${host} is not a public host name`);
++ return host;
++}
++
++/** The URL of one request: the fixed host, the fixed path, the query the definition asks for. */
++function urlFor(def, { path: requestPath, query }) {
++ const host = hostOf(def);
++ const url = new URL(`https://${host}${requestPath}`);
++ for (const [name, value] of Object.entries(query || {})) {
++ if (value === undefined || value === null || value === '') continue;
++ url.searchParams.set(name, String(value));
++ }
++ return url;
++}
++
++/** One https request with the guard's lookup, a timeout and a size cap. */
++function send(url, { method = 'GET', headers = {}, body = null, timeoutMs = DEFAULT_TIMEOUT_MS, lookup, isAllowed, tls = null } = {}) {
++ const look = lookup ? createSafeLookup({ lookup, isAllowed }) : undefined;
++ return new Promise((resolve, reject) => {
++ const req = https.request({
++ hostname: url.hostname, port: url.port || 443, path: url.pathname + url.search,
++ method, headers, ...(look ? { lookup: look } : {}), servername: url.hostname, ...(tls || {}),
++ }, (res) => {
++ const chunks = [];
++ let size = 0;
++ res.on('data', (c) => {
++ size += c.length;
++ if (size > MAX_BYTES) { req.destroy(); reject(new ConnectError(502, 'connect.answer_too_large', 'the app answered more than 256 KB')); return; }
++ chunks.push(c);
++ });
++ res.on('end', () => resolve({ status: res.statusCode || 0, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }));
++ res.on('error', (e) => reject(e));
++ });
++ req.setTimeout(timeoutMs, () => { req.destroy(new ConnectError(504, 'connect.timeout', 'the app did not answer in time')); });
++ req.on('error', (e) => reject(e));
++ if (body !== null && body !== undefined) req.write(body);
++ req.end();
++ });
++}
++
++/**
++ * The URL passes the guard: https, no credentials in it, and a name that resolves only to public
++ * addresses. The lookup is openvibe-shared/egress's own, so the socket connects to the checked
++ * address and a rebinding cannot slip a private one in. A test may inject its own resolver.
++ */
++async function checkUrl(url, { lookup, isAllowed } = {}) {
++ if (url.protocol !== 'https:') denied('only https is called');
++ if (url.username || url.password) denied('credentials in the URL are not allowed');
++ const look = lookup ? createSafeLookup({ lookup, isAllowed }) : safeLookup;
++ await new Promise((resolve, reject) => look(url.hostname, { all: true }, (e) => (e ? reject(e) : resolve())));
++}
++
++/**
++ * The request the gateway makes. -> { status, headers, body, url }.
++ * The redirect is not followed by the http client: every hop is checked here.
++ */
++async function request(def, { method = 'GET', path: requestPath, query = {}, headers = {}, body = null, timeoutMs = DEFAULT_TIMEOUT_MS, lookup, isAllowed, tls = null } = {}) {
++ let url = urlFor(def, { path: requestPath, query });
++ let hops = 0;
++ for (;;) {
++ // The guard's own check: the scheme, the name and every address it resolves to.
++ await checkUrl(url, { lookup, isAllowed });
++ const res = await send(url, { method, headers, body, timeoutMs, lookup, isAllowed, tls });
++ const location = res.headers && res.headers.location;
++ if (![301, 302, 303, 307, 308].includes(res.status) || !location) {
++ return { ...res, url: url.toString(), redirects: hops };
++ }
++ if (hops >= MAX_REDIRECTS) throw new ConnectError(502, 'connect.too_many_redirects', 'the app redirected too many times');
++ let next;
++ try { next = new URL(location, url); } catch { throw new ConnectError(502, 'connect.bad_redirect', 'the app redirected somewhere unreadable'); }
++ // A redirect off the app's own host is refused: the credential never travels there.
++ if (normalizeHost(next.hostname) !== normalizeHost(url.hostname)) {
++ denied(`the app redirected to ${next.hostname}, which is not its own host`);
++ }
++ url = next;
++ hops += 1;
++ }
++}
++
++module.exports = { request, urlFor, hostOf, checkUrl, ConnectError, MAX_BYTES, MAX_REDIRECTS, DEFAULT_TIMEOUT_MS };
ddiff --git a/server/connect/gateway.js b/server/connect/gateway.js
nnew file mode 100644
iindex 00000000..b0fac53b
---- /dev/null
++++ b/server/connect/gateway.js
@@@ -0,0 +1,304 @@
++'use strict';
++/**
++ * The gateway (Compute plan E7): one person's agents act in the person's other apps through here.
++ *
++ * Every call goes through the same five steps, in this order:
++ *
++ * 1. the grant: what this caller may do in this app (read, or read and write);
++ * 2. the schema: the input is checked against the action's JSON Schema;
++ * 3. the request: the fixed request of the definition, to the app's fixed API host only, through
++ * the egress guard, with the credential from the vault and nothing from the input;
++ * 4. the answer: redacted, so nothing that looks like a secret leaves;
++ * 5. the log: who, which action, when, and whether it worked. The input of a write action is never
++ * written.
++ *
++ * A send, a delete and a pay action asks the person first, whatever the grant says (server/connect/
++ * approvals.js). The limits (120 calls a minute for one person in one app, and a daily cap) are
++ * checked before anything is sent.
++ */
++const vault = require('./vault');
++const callers = require('./callers');
++const approvals = require('./approvals');
++const egress = require('./egress');
++const redact = require('./redact');
++const schema = require('./schema');
++const registry = require('./apps');
++const { newId } = require('./ids');
++
++const ASK_FIRST = ['send', 'delete', 'pay'];
++const USER_AGENT = 'OpenVibe-Connect/1.0';
++
++class GatewayError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; }
++}
++const fail = (status, code, detail) => { throw new GatewayError(status, code, detail); };
++
++const nowIso = () => new Date().toISOString();
++
++/** The app and the action behind one action name ('github.issue.create'). */
++function findAction(defs, name) {
++ for (const def of defs) {
++ if (!name.startsWith(def.key + '.')) continue;
++ const action = (def.actions || []).find((a) => a.name === name);
++ if (action) return { def, action };
++ }
++ return null;
++}
++
++/** The names of the input fields the action knows about. */
++const fieldNames = (action) => Object.keys((action.input && action.input.properties) || {});
++
++/** A path: '{owner}' becomes the value of owner, encoded for a path. */
++function interpolatePath(template, input, names) {
++ const path = String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (whole, name) => {
++ if (!names.includes(name)) return whole;
++ const value = input[name];
++ if (value === undefined || value === null || value === '') fail(422, 'connect.invalid_input', `${name} is missing`);
++ if (String(value) === '.' || String(value) === '..') fail(422, 'connect.invalid_input', `${name} may not be "." or ".."`);
++ return encodeURIComponent(String(value));
++ });
++ if (/\{[a-zA-Z0-9_]+\}/.test(path)) {
++ const missing = /\{([a-zA-Z0-9_]+)\}/.exec(path);
++ if (missing && names.includes(missing[1])) fail(422, 'connect.invalid_input', `${missing[1]} is missing`);
++ }
++ return path;
++}
++
++/** A value from the input: '{name}' alone keeps its type, inside a string it becomes text. */
++function interpolate(value, input, names, depth = 0) {
++ if (depth > 8) return value;
++ if (typeof value === 'string') {
++ const whole = /^\{([a-zA-Z0-9_]+)\}$/.exec(value);
++ if (whole && names.includes(whole[1])) {
++ const v = input[whole[1]];
++ return v === undefined ? undefined : v;
++ }
++ return value.replace(/\{([a-zA-Z0-9_]+)\}/g, (whole2, name) => {
++ if (!names.includes(name)) return whole2;
++ const v = input[name];
++ return v === undefined || v === null ? '' : String(v);
++ });
++ }
++ if (Array.isArray(value)) return value.map((v) => interpolate(v, input, names, depth + 1)).filter((v) => v !== undefined);
++ if (value && typeof value === 'object') {
++ const out = {};
++ for (const [k, v] of Object.entries(value)) {
++ const next = interpolate(v, input, names, depth + 1);
++ if (next !== undefined) out[k] = next;
++ }
++ return out;
++ }
++ return value;
++}
++
++/** The request of one action: the definition's fixed request, with the input put in its place. */
++function buildRequest(def, action, input, credentials) {
++ const names = fieldNames(action);
++ const req = action.request;
++ const path = interpolatePath(req.path, input, names);
++ const query = interpolate(req.query || {}, input, names) || {};
++ const bodyValue = interpolate(req.body, input, names);
++ const headers = Object.assign({}, def.headers || {}, {
++ 'User-Agent': USER_AGENT,
++ Accept: 'application/json',
++ });
++ if (bodyValue !== undefined && bodyValue !== null && !(typeof bodyValue === 'object' && !Object.keys(bodyValue).length)) {
++ headers['Content-Type'] = 'application/json';
++ }
++ for (const field of def.auth.fields || []) {
++ const value = credentials[field.name];
++ if (!value) fail(409, 'connect.no_credential', `the credential for ${def.name} is missing`);
++ if (field.where === 'header') headers[field.header] = field.scheme ? `${field.scheme} ${value}` : value;
++ else if (field.where === 'query') query[field.param] = value;
++ else fail(500, 'connect.bad_definition', 'a credential has no place to go');
++ }
++ const body = bodyValue === undefined || bodyValue === null ? null : JSON.stringify(bodyValue);
++ return { method: req.method, path, query, headers, body };
++}
++
++/** The credential of every field an app needs, opened from the vault. */
++async function credentialsFor(db, def, subject, config) {
++ const out = {};
++ for (const field of def.auth.fields || []) {
++ const value = await vault.open(db, subject, def.key, field.name, { config });
++ if (!value) return null; // the app is not connected (or a field is missing)
++ out[field.name] = value;
++ }
++ return out;
++}
++
++/** The apps this person has connected: every field of the app is there. */
++async function connectedApps(db, subject, defs, config) {
++ const out = [];
++ for (const def of defs) {
++ if (!def.auth.fields || !def.auth.fields.length) continue; // a BYOK link holds nothing here
++ const creds = await credentialsFor(db, def, subject, config);
++ if (creds) out.push(def);
++ }
++ return out;
++}
++
++function createGateway({ db, config, defs = null, limiter = null, approvalWaitMs = approvals.DEFAULT_WAIT_MS, defaultRequestOptions = null } = {}) {
++ const catalogue = defs || registry.all();
++
++ /** The actions this caller may use: the apps this person connected and this caller was granted. */
++ async function listActions({ subject, callerId }) {
++ const grants = await callers.grantsOf(db, callerId);
++ const connected = await connectedApps(db, subject, catalogue, config);
++ const tools = [];
++ for (const def of connected) {
++ const level = grants[def.key];
++ if (!level) continue; // no grant: this caller sees nothing of this app
++ for (const action of def.actions || []) {
++ if (action.risk !== 'read' && level !== 'write') continue;
++ tools.push({
++ name: action.name,
++ description: action.description || action.title || action.name,
++ inputSchema: action.input,
++ risk: action.risk,
++ app: def.key,
++ });
++ }
++ }
++ return tools;
++ }
++
++ /** The log: who, which action, when, and whether it worked. */
++ async function writeLog({ subject, callerId, callerName, app, action, risk, ok, status = null, errorCode = null, input = null, at = nowIso() }) {
++ // The input of a write action is never written.
++ const kept = risk === 'read' && input ? JSON.stringify(input).slice(0, 2000) : null;
++ await db.prepare(`INSERT INTO connect_log (id, subject, caller_id, caller_name, app, action, risk, ok, status, error_code, input, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(newId('clg'), subject, callerId, String(callerName || '').slice(0, 100), app, action, risk, ok ? 1 : 0, status, errorCode, kept, at);
++ }
++
++ /** The log of this person, newest first. */
++ async function listLog({ subject, limit = 100 }) {
++ const rows = await db.prepare('SELECT * FROM connect_log WHERE subject = ? ORDER BY created_at DESC LIMIT ?').all(subject, Math.min(Number(limit) || 100, 500));
++ return rows.map((r) => ({
++ id: r.id, caller_id: r.caller_id, caller_name: r.caller_name, app: r.app, action: r.action,
++ risk: r.risk, ok: Number(r.ok) === 1, status: r.status, error_code: r.error_code, created_at: r.created_at,
++ }));
++ }
++
++ /**
++ * One call. → { app, action, status, data } or throws a GatewayError.
++ */
++ async function call({ subject, callerId, callerName, actionName, input = {}, requestOptions: perCall = {} }) {
++ const requestOptions = Object.assign({}, defaultRequestOptions || {}, perCall || {});
++ const found = findAction(catalogue, String(actionName || ''));
++ if (!found) fail(404, 'connect.unknown_action', 'no such action');
++ const { def, action } = found;
++
++ // 1. the grant
++ const level = await callers.grantOf(db, callerId, def.key);
++ const needed = action.risk === 'read' ? 'read' : 'write';
++ if (!level || (needed === 'write' && level !== 'write')) {
++ fail(403, 'connect.not_granted', `${callerName || 'this caller'} may not ${action.name}`);
++ }
++
++ // 2. the schema
++ const check = schema.validate(action.input, input);
++ if (!check.valid) fail(422, 'connect.invalid_input', schema.describe(check));
++
++ // the limits
++ if (limiter) {
++ const limit = await limiter.check(db, subject, def.key);
++ if (!limit.ok) {
++ await writeLog({ subject, callerId, callerName, app: def.key, action: action.name, risk: action.risk, ok: false, status: 429, errorCode: limit.code });
++ const err = new GatewayError(429, limit.code, limit.code === 'connect.rate_limited' ? 'too many calls for this app in this minute' : 'the daily cap for this app is reached');
++ err.retryAfter = limit.retryAfter;
++ throw err;
++ }
++ }
++
++ // send, delete and pay ask first
++ let approval = null;
++ if (ASK_FIRST.includes(action.risk)) {
++ const rules = await approvals.rulesOf(db, subject);
++ if (!rules.has(`${def.key}.${action.name}`)) {
++ approval = await approvals.request(db, {
++ subject, callerId, app: def.key, action: action.name,
++ summary: approvals.summaryOf(def, action, input), input,
++ });
++ const decided = await approvals.wait(db, approval.id, { timeoutMs: approvalWaitMs });
++ if (decided.state !== 'approved') {
++ await writeLog({ subject, callerId, callerName, app: def.key, action: action.name, risk: action.risk, ok: false, status: 403, errorCode: decided.state === 'denied' ? 'connect.approval_denied' : 'connect.approval_required' });
++ fail(403, decided.state === 'denied' ? 'connect.approval_denied' : 'connect.approval_required',
++ decided.state === 'denied' ? 'the person said no' : 'the person has not approved this one');
++ }
++ }
++ }
++
++ // 3. the request
++ const credentials = await credentialsFor(db, def, subject, config);
++ if (!credentials) fail(409, 'connect.not_connected', `${def.name} is not connected`);
++ const built = buildRequest(def, action, input, credentials);
++ if (limiter) await limiter.record(db, subject, def.key);
++
++ let res;
++ try {
++ res = await egress.request(def, { ...built, ...requestOptions });
++ } catch (e) {
++ const status = e.status || 502;
++ await writeLog({ subject, callerId, callerName, app: def.key, action: action.name, risk: action.risk, ok: false, status, errorCode: e.code || 'connect.request_failed' });
++ if (e instanceof GatewayError) throw e;
++ throw new GatewayError(status, e.code || 'connect.request_failed', e.message);
++ }
++
++ // 4. the answer, redacted
++ let data = res.body;
++ try { data = JSON.parse(res.body); } catch { /* not JSON: the text stands */ }
++ const safe = redact.redact(data, Object.values(credentials));
++
++ if (res.status >= 400) {
++ await writeLog({ subject, callerId, callerName, app: def.key, action: action.name, risk: action.risk, ok: false, status: res.status, errorCode: 'connect.app_error' });
++ const err = new GatewayError(502, 'connect.app_error', `${def.name} answered ${res.status}`);
++ err.appStatus = res.status;
++ err.data = safe;
++ throw err;
++ }
++
++ // 5. the log
++ await writeLog({ subject, callerId, callerName, app: def.key, action: action.name, risk: action.risk, ok: true, status: res.status, input });
++ await callers.touch(db, callerId);
++ return { app: def.key, action: action.name, risk: action.risk, status: res.status, data: safe };
++ }
++
++ /**
++ * The walkthrough's test call: it uses the credential the person is about to paste, and it is made
++ * before anything is stored. A refused test call stores nothing.
++ */
++ async function test({ subject, appKey, credential, label = 'default', requestOptions: perCall = {} }) {
++ const requestOptions = Object.assign({}, defaultRequestOptions || {}, perCall || {});
++ const def = catalogue.find((d) => d.key === appKey);
++ if (!def) fail(404, 'connect.unknown_app', 'no such app');
++ if (!def.test) fail(400, 'connect.no_test_call', 'this app has no test call');
++ const fields = def.auth.fields || [];
++ const credentials = {};
++ for (const field of fields) {
++ if (field.name === 'default') credentials[field.name] = credential;
++ else credentials[field.name] = credential && credential[field.name] ? credential[field.name] : null;
++ }
++ for (const [name, value] of Object.entries(credentials)) {
++ if (!value) fail(422, 'connect.invalid_credential', `the ${name} is missing`);
++ }
++ const names = [];
++ const built = buildRequest({ ...def, actions: [] }, { input: { properties: {} }, request: def.test }, {}, credentials);
++ const res = await egress.request(def, { ...built, ...requestOptions });
++ let data = res.body;
++ try { data = JSON.parse(res.body); } catch { /* the text stands */ }
++ const safe = redact.redact(data, Object.values(credentials));
++ if (res.status >= 400) {
++ const err = new GatewayError(400, 'connect.test_failed', `${def.name} answered ${res.status}: the credential was refused`);
++ err.appStatus = res.status;
++ err.data = safe;
++ throw err;
++ }
++ return { ok: true, status: res.status, data: safe, summary: def.test.summary || null };
++ }
++
++ return { call, test, listActions, listLog, connectedApps, writeLog, catalogue };
++}
++
++module.exports = { createGateway, findAction, buildRequest, interpolate, interpolatePath, GatewayError, ASK_FIRST };
ddiff --git a/server/connect/ids.js b/server/connect/ids.js
nnew file mode 100644
iindex 00000000..80bb2086
---- /dev/null
++++ b/server/connect/ids.js
@@@ -0,0 +1,26 @@
++'use strict';
++/**
++ * Identifiers for the Connect tables: a prefix and a ULID (Crockford base32, uppercase, 26 characters),
++ * the shape the rest of the house uses for its rows.
++ */
++const crypto = require('crypto');
++
++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++
++/** A ULID: 10 characters of time, then 16 of randomness. */
++function ulid(now = Date.now()) {
++ let t = now;
++ let time = '';
++ for (let i = 0; i < 10; i++) { time = ALPHABET[t % 32] + time; t = Math.floor(t / 32); }
++ const rand = crypto.randomBytes(16);
++ let tail = '';
++ for (let i = 0; i < 16; i++) tail += ALPHABET[rand[i] % 32];
++ return time + tail;
++}
++
++/** A new id: 'cvd_01J…', 'cal_01J…', 'cap_01J…', 'clg_01J…'. */
++function newId(prefix) {
++ return prefix + '_' + ulid();
++}
++
++module.exports = { newId, ulid };
ddiff --git a/server/connect/limits.js b/server/connect/limits.js
nnew file mode 100644
iindex 00000000..44f6cdd7
---- /dev/null
++++ b/server/connect/limits.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * The limits: 120 calls a minute for one person in one app, and a daily cap on top.
++ *
++ * The minute window is counted in the process (it is a short window, and a restart may forgive a
++ * second of it). The daily cap is counted in the table, so it survives a restart and is the same for
++ * every process behind the load balancer.
++ */
++const DEFAULT_PER_MINUTE = 120;
++const DEFAULT_DAILY_CAP = 2000;
++
++class LimitError extends Error {
++ constructor(status, code, detail, retryAfter) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; this.retryAfter = retryAfter; }
++}
++
++function createLimiter({ perMinute = DEFAULT_PER_MINUTE, dailyCap = DEFAULT_DAILY_CAP, now = () => Date.now() } = {}) {
++ const windows = new Map();
++ const keyOf = (subject, app) => `${subject}|${app}`;
++
++ return {
++ perMinute,
++ dailyCap,
++
++ /**
++ * May this person make one more call in this app now?
++ * → { ok: true, remaining } | { ok: false, code, retryAfter }
++ */
++ async check(db, subject, app) {
++ const t = now();
++ const key = keyOf(subject, app);
++ const cutoff = t - 60000;
++ const hits = (windows.get(key) || []).filter((x) => x > cutoff);
++ if (hits.length >= perMinute) {
++ const retryAfter = Math.max(1, Math.ceil((hits[0] + 60000 - t) / 1000));
++ windows.set(key, hits);
++ return { ok: false, code: 'connect.rate_limited', retryAfter };
++ }
++ const day = new Date(t).toISOString().slice(0, 10);
++ const row = await db.prepare('SELECT calls FROM connect_usage_daily WHERE subject = ? AND app = ? AND day = ?')
++ .get(subject, app, day);
++ const used = Number((row && row.calls) || 0);
++ if (used >= dailyCap) {
++ return { ok: false, code: 'connect.daily_cap', retryAfter: Math.max(1, Math.ceil((new Date(day + 'T00:00:00Z').getTime() + 86400000 - t) / 1000)) };
++ }
++ return { ok: true, remaining: perMinute - hits.length - 1 };
++ },
++
++ /** One call has been made: it counts in the minute window and in the day's counter. */
++ async record(db, subject, app) {
++ const t = now();
++ const key = keyOf(subject, app);
++ const cutoff = t - 60000;
++ const hits = (windows.get(key) || []).filter((x) => x > cutoff);
++ hits.push(t);
++ windows.set(key, hits);
++ const day = new Date(t).toISOString().slice(0, 10);
++ await db.prepare(`INSERT INTO connect_usage_daily (subject, app, day, calls) VALUES (?, ?, ?, 1)
++ ON CONFLICT (subject, app, day) DO UPDATE SET calls = connect_usage_daily.calls + 1`)
++ .run(subject, app, day);
++ return { used: hits.length };
++ },
++
++ /** Forget the windows (a test, and a restart of the process). */
++ reset() { windows.clear(); },
++ };
++}
++
++module.exports = { createLimiter, LimitError, DEFAULT_PER_MINUTE, DEFAULT_DAILY_CAP };
ddiff --git a/server/connect/mcp.js b/server/connect/mcp.js
nnew file mode 100644
iindex 00000000..3b886c23
---- /dev/null
++++ b/server/connect/mcp.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * The MCP server (Compute plan E7): https://connect.openvibe.network/mcp, streamable HTTP.
++ *
++ * One JSON-RPC call per request, the MCP Streamable HTTP transport, protocol version 2025-06-18:
++ *
++ * initialize what this server is and what it can do
++ * notifications/initialized (no answer)
++ * ping (no answer needed, but answered)
++ * tools/list the actions of the apps this person connected and this caller was granted
++ * tools/call one action, through the gateway
++ *
++ * The caller is the person's own token, a key they made on the Apps page, or a service acting for them.
++ * A caller with no grant sees no tools at all: the list is what this caller may do, not what the
++ * person owns.
++ *
++ * The tools are the actions, named as they are (github.issue.create). A tool that fails answers with
++ * isError and a short line; nothing secret is ever in the answer.
++ */
++const { resolveCaller } = require('./auth');
++
++const PROTOCOL_VERSION = '2025-06-18';
++// The versions this server speaks. The client's own version is echoed when it is one of these, so a
++// newer client is not told it is talking to an older server than it is.
++const SUPPORTED_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
++const SERVER_INFO = { name: 'openvibe-connect', title: 'OpenVibe Connect', version: '1.0' };
++const JSONRPC_ERRORS = { parse: -32700, invalid: -32600, method: -32601, params: -32602, internal: -32603 };
++
++const send = (res, status, body, headers = {}) => {
++ res.status(status);
++ for (const [k, v] of Object.entries(headers)) res.set(k, v);
++ res.set('Cache-Control', 'no-store');
++ if (body === undefined) return res.end();
++ res.json(body);
++};
++
++const ok = (res, id, result) => send(res, 200, { jsonrpc: '2.0', id, result });
++const failRpc = (res, id, code, message) => send(res, 200, { jsonrpc: '2.0', id, error: { code, message } });
++
++/** The tools this caller may use, as MCP tools. */
++function toTools(actions) {
++ return actions.map((a) => ({
++ name: a.name,
++ description: a.description || a.name,
++ inputSchema: a.inputSchema || { type: 'object', properties: {} },
++ annotations: {
++ title: a.name,
++ readOnlyHint: a.risk === 'read',
++ destructiveHint: a.risk === 'delete' || a.risk === 'pay',
++ idempotentHint: a.risk === 'read',
++ openWorldHint: true,
++ },
++ }));
++}
++
++/**
++ * The handler for POST /mcp. → (req, res)
++ */
++function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {} } = {}) {
++ return async function mcpHandler(req, res) {
++ if (req.method !== 'POST') {
++ // The transport allows a server to refuse the server-to-client stream.
++ res.set('Allow', 'POST');
++ return send(res, 405, { error: 'connect.method_not_allowed', detail: 'this MCP server answers POST' });
++ }
++ const who = await resolveCaller(db, req, { config, publicKey, issuer });
++ if (!who.ok) {
++ return send(res, who.status || 401, { error: who.code, detail: who.detail });
++ }
++ const body = req.body;
++ if (!body || typeof body !== 'object' || Array.isArray(body)) {
++ return send(res, 400, { error: 'connect.bad_request', detail: 'one JSON-RPC call per request' });
++ }
++ const id = body.id === undefined ? null : body.id;
++ const method = String(body.method || '');
++ const params = body.params && typeof body.params === 'object' ? body.params : {};
++
++ try {
++ if (method === 'initialize') {
++ const asked = String(params.protocolVersion || '');
++ return ok(res, id, {
++ protocolVersion: SUPPORTED_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSION,
++ capabilities: { tools: { listChanged: false } },
++ serverInfo: SERVER_INFO,
++ instructions: 'The tools here act in the person\'s own apps, with the person\'s own credentials. A send, a delete or a pay action asks the person first and waits.',
++ });
++ }
++ if (method === 'notifications/initialized' || method === 'notifications/cancelled') {
++ return send(res, 202, undefined);
++ }
++ if (method === 'ping') return ok(res, id, {});
++ if (method === 'tools/list') {
++ const actions = await gateway.listActions({ subject: who.subject, callerId: who.caller.id });
++ return ok(res, id, { tools: toTools(actions) });
++ }
++ if (method === 'tools/call') {
++ const name = String(params.name || '');
++ const args = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
++ try {
++ const out = await gateway.call({
++ subject: who.subject, callerId: who.caller.id, callerName: who.caller.name,
++ actionName: name, input: args, requestOptions,
++ });
++ const text = typeof out.data === 'string' ? out.data : JSON.stringify(out.data, null, 2);
++ return ok(res, id, { content: [{ type: 'text', text }], structuredContent: { app: out.app, action: out.action, status: out.status } });
++ } catch (e) {
++ const detail = e.detail || e.message || 'the call failed';
++ return ok(res, id, { content: [{ type: 'text', text: String(detail).slice(0, 500) }], isError: true });
++ }
++ }
++ return failRpc(res, id, JSONRPC_ERRORS.method, `unknown method ${method}`);
++ } catch (e) {
++ return failRpc(res, id, JSONRPC_ERRORS.internal, String(e.message || 'the server failed').slice(0, 300));
++ }
++ };
++}
++
++module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO };
ddiff --git a/server/connect/page.js b/server/connect/page.js
nnew file mode 100644
iindex 00000000..b6a5afb2
---- /dev/null
++++ b/server/connect/page.js
@@@ -0,0 +1,256 @@
++'use strict';
++/**
++ * The Apps page (Compute plan E7): the catalogue, the walkthrough, the connected list, the log.
++ *
++ * The page is built from the kit's own components (openvibe-shared/kit), phone first: the catalogue is
++ * a list of folds (a card that opens), so on a phone one app is one screen and the page is never a
++ * long scroll. The walkthrough, the paste box and the test result live inside the fold that opens.
++ *
++ * The dynamic parts (the test result, the grants, the log, the approvals) are drawn by
++ * public/js/connect.js from the Connect API; the page itself is the shell and the catalogue.
++ */
++const kit = require('openvibe-shared/kit');
++const shell = require('openvibe-shared/shell');
++const registry = require('./apps');
++const vault = require('./vault');
++const callers = require('./callers');
++const approvals = require('./approvals');
++
++/** The page's own rules: the paste box is wide enough for a token, and the walkthrough reads well. */
++const PAGE_CSS = `<style>
++.ovk-connect .ovk-fold-body form{margin:0}
++.ovk-connect .ovk-field{display:block;margin:0 0 10px}
++.ovk-connect .ovk-field span{display:block;font-size:12px;color:var(--ovk-muted,var(--text-secondary));margin:0 0 4px}
++.ovk-connect .ovk-field input{width:100%;box-sizing:border-box;min-height:44px;padding:10px 12px;border-radius:10px;border:1px solid var(--ovk-line,var(--border));background:var(--ovk-input,var(--bg-input));color:inherit;font:inherit}
++.ovk-connect .ovk-steps{counter-reset:step;list-style:none;margin:0 0 14px;padding:0}
++.ovk-connect .ovk-steps li{counter-increment:step;position:relative;padding:0 0 12px 34px;font-size:14px;line-height:1.45}
++.ovk-connect .ovk-steps li::before{content:counter(step);position:absolute;left:0;top:0;width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:var(--ovk-accent-soft,var(--bg-input));color:var(--ovk-accent,var(--accent))}
++.ovk-connect .ovk-steps a{display:inline-block;margin-top:4px}
++.ovk-connect .ovk-result{margin:12px 0 0;padding:10px 12px;border-radius:10px;font-size:13px;background:var(--ovk-input,var(--bg-input));border:1px solid var(--ovk-line,var(--border));overflow-wrap:anywhere}
++.ovk-connect .ovk-result--bad{border-color:#b91c1c;color:#fca5a5}
++.ovk-connect .ovk-result--ok{border-color:#15803d;color:#86efac}
++.ovk-connect .ovk-grants{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
++.ovk-connect .ovk-grants-panel{margin:0 0 18px}
++.ovk-connect .ovk-grants-head,.ovk-connect .ovk-grant-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--ovk-line,var(--border));font-size:14px}
++.ovk-connect .ovk-grants-head{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--ovk-muted,var(--text-secondary))}
++.ovk-connect .ovk-grant-select{min-height:40px;border-radius:8px;border:1px solid var(--ovk-line,var(--border));background:var(--ovk-input,var(--bg-input));color:inherit;font:inherit;padding:6px 8px}
++@media (max-width:480px){
++.ovk-connect .ovk-btn{width:100%}
++.ovk-connect .ovk-row-trail{width:100%;padding-left:48px;box-sizing:border-box}
++}
++</style>`;
++
++/** The kit's own muted colour names differ between releases; these are the ones the page uses. */
++const esc = kit.esc;
++
++/** The step list of a walkthrough. */
++function stepsOf(walkthrough = []) {
++ return `<ol class="ovk-steps">${walkthrough.map((s) => `<li><strong>${esc(s.title)}</strong><br>${esc(s.text)}${
++ s.link ? `<br><a href="${esc(s.link)}" rel="noopener nofollow" target="_blank">${esc(s.linkLabel || 'Open it')}</a>` : ''
++ }</li>`).join('')}</ol>`;
++}
++
++/** The paste box, one field per credential the app needs, and the two buttons. */
++function connectForm(def) {
++ const fields = (def.auth.fields || []).map((f) => `<label class="ovk-field"><span>${esc(f.label)}</span><input type="password" autocomplete="off" spellcheck="false" data-ovk-field="${esc(f.name)}" placeholder="${esc(f.placeholder || '')}"></label>`).join('');
++ const byok = def.auth.kind === 'byok_link';
++ if (byok) {
++ const links = (def.byok && def.byok.providers || []).map((p) => `<p><a href="${esc(p.link)}" rel="noopener nofollow" target="_blank">${esc(p.linkLabel || p.name)}</a> — ${esc(p.hint || '')}</p>`).join('');
++ return `<p>${esc((def.byok && def.byok.note) || 'The key lives in OpenVibe.AI.')}</p>${links}`;
++ }
++ return `<form class="ovk-form" data-ovk-connect="${esc(def.key)}">${fields}
++ <p class="ovk-grants">
++ ${kit.button({ type: 'button', label: 'Test', icon: 'check', kind: 'quiet', attrs: { 'data-ovk-test': '1' } })}
++ ${kit.button({ type: 'button', label: 'Connect', icon: 'plus', kind: 'primary', attrs: { 'data-ovk-save': '1' } })}
++ </p>
++ <p class="ovk-result" data-ovk-result hidden></p>
++ </form>`;
++}
++
++/** The catalogue: one fold per app, with the walkthrough and the paste box inside. */
++function catalogueHtml(connected) {
++ const folds = registry.all().map((def) => {
++ const isConnected = connected.has(def.key);
++ const body = [
++ `<p>${esc(def.blurb)}</p>`,
++ `<p class="ovk-note">${esc(def.auth.label)}${(def.auth.scopes || []).length ? ' · ' + esc(def.auth.scopes.join(', ')) : ''}</p>`,
++ stepsOf(def.walkthrough),
++ connectForm(def),
++ ].join('\n');
++ return kit.fold({
++ id: `app-${def.key}`,
++ title: def.name,
++ meta: isConnected ? 'connected' : def.auth.kind === 'byok_link' ? 'in OpenVibe.AI' : 'not connected',
++ body,
++ });
++ }).join('\n');
++ return `<div class="ovk-connect-grid">${folds}</div>`;
++}
++
++/** The connected list: the credentials, the grants per caller, and Revoke. */
++function connectedHtml(list, callersList, subject) {
++ if (!list.length) {
++ return kit.empty({
++ title: 'No app is connected yet',
++ text: 'Pick an app from the catalogue, paste your own key, and your agents can work in it.',
++ icon: 'plus',
++ action: { href: '/apps', label: 'Open the catalogue' },
++ });
++ }
++ const rows = list.map((c) => {
++ const when = kit.when(c.created_at);
++ const grants = callersList.map((call) => {
++ const level = (call.grants || {})[c.app];
++ if (!level) return '';
++ return `<span class="ovk-badge">${esc(call.name)}: ${esc(level)}</span>`;
++ }).join(' ');
++ const revoke = kit.button({ type: 'button', label: 'Revoke', kind: 'danger', small: true, attrs: { 'data-ovk-revoke': c.app, 'data-ovk-label': c.label } });
++ const trail = (grants ? `<div class="ovk-grants">${grants}</div>` : '') + revoke;
++ return kit.row({
++ mark: kit.mark({ name: c.app, square: true, size: 36 }),
++ title: c.app,
++ badge: c.scopes && c.scopes.length ? kit.badge(`${c.scopes.length} scopes`) : '',
++ meta: when.text,
++ metaIso: when.iso,
++ sub: [`added ${when.text}`, c.last_used_at ? `last used ${kit.when(c.last_used_at).text}` : 'not used yet', (c.scopes || []).join(', ')].filter(Boolean).join(' · '),
++ trail,
++ attrs: { 'data-ovk-credential': c.app },
++ });
++ });
++ return kit.list(rows, { title: 'Your credentials', empty: 'No app is connected yet.' });
++}
++
++/** The callers: the keys the person made, and the grants each one holds. */
++function callersHtml(list) {
++ const rows = list.map((c) => {
++ const trail = c.kind === 'key' ? kit.button({ type: 'button', label: 'Revoke', kind: 'danger', small: true, attrs: { 'data-ovk-revoke-caller': c.id } }) : '';
++ const grants = Object.entries(c.grants || {}).map(([app, level]) => `<span class="ovk-badge">${esc(app)}: ${esc(level)}</span>`).join(' ');
++ return kit.row({
++ mark: kit.mark({ name: c.name, size: 36 }),
++ title: c.name,
++ badge: kit.badge(c.kind === 'person' ? 'you' : c.kind === 'key' ? 'your key' : 'a service'),
++ sub: [c.token_prefix ? `key …${c.token_prefix}` : '', grants].filter(Boolean).join(' · '),
++ trail,
++ attrs: { 'data-ovk-caller': c.id },
++ });
++ });
++ const maker = `<form class="ovk-form" data-ovk-make-key><label class="ovk-field"><span>Name this caller</span><input type="text" name="name" maxlength="100" placeholder="Claude Code on my laptop"></label><p>${kit.button({ type: 'submit', label: 'Make a key', icon: 'plus' })}</p><p class="ovk-result" data-ovk-key hidden></p></form>`;
++ return kit.list(rows, { title: 'Callers', empty: 'No callers yet.' }) + kit.section({ title: 'Make a key', note: 'For a caller of your own — your Claude Code, a script on your laptop. The key is shown once.', body: maker });
++}
++
++/** The log: what was done, by whom, and whether it worked. */
++function logHtml(rows) {
++ if (!rows.length) {
++ return kit.empty({ title: 'Nothing yet', text: 'Every call your agents make in your apps is written here.', icon: 'chat' });
++ }
++ const body = rows.map((r) => kit.row({
++ mark: kit.mark({ name: r.app, square: true, size: 32 }),
++ title: r.action,
++ badge: r.ok ? kit.badge('ok', 'ok') : kit.badge('failed', 'bad'),
++ meta: kit.when(r.created_at).text,
++ metaIso: kit.when(r.created_at).iso,
++ sub: [r.caller_name || r.caller_id, r.risk, r.ok ? '' : (r.error_code || 'failed')].filter(Boolean).join(' · '),
++ })).join('');
++ return kit.list(body, { title: 'The log', empty: 'Nothing yet.' });
++}
++
++/** The whole page. */
++async function renderPage(ctx, user) {
++ const subject = user ? (user.subject_id || String(user.id)) : null;
++ const tab = kit.tabOf(ctx.query || {}, ['catalogue', 'connected', 'log']);
++
++ let body;
++ let headHtml = '';
++ if (!user) {
++ body = kit.empty({
++ title: 'Sign in to connect your apps',
++ text: 'Your agents work in your own apps with your own keys. Sign in to connect them.',
++ action: { href: '/login?next=%2Fapps', label: 'Sign in' },
++ });
++ } else {
++ const list = await vault.list(ctx.db, subject);
++ const connected = new Set(list.map((c) => c.app));
++ const callersList = await callers.listCallers(ctx.db, subject);
++ const pending = (await approvals.list(ctx.db, subject, { state: 'pending' })).filter((a) => a.expires_at > new Date().toISOString());
++
++ const needsYou = pending.length ? kit.section({
++ title: 'Needs you',
++ count: pending.length,
++ note: 'A send, a delete or a pay action waits for you before it happens.',
++ body: kit.list(pending.map((a) => kit.row({
++ mark: kit.mark({ name: a.app, square: true, size: 36 }),
++ title: a.summary || a.action,
++ sub: `${a.action} · asked ${kit.when(a.created_at).text}`,
++ trail: [
++ kit.button({ type: 'button', label: 'Allow', kind: 'primary', small: true, attrs: { 'data-ovk-approval': a.id, 'data-ovk-decision': 'approve' } }),
++ kit.button({ type: 'button', label: 'Deny', kind: 'quiet', small: true, attrs: { 'data-ovk-approval': a.id, 'data-ovk-decision': 'deny' } }),
++ ].join(''),
++ }))),
++ id: 'ovk-needs-you',
++ }) : '';
++
++ const tabs = kit.tabs({
++ base: '/apps',
++ items: [
++ { key: 'catalogue', label: 'Catalogue' },
++ { key: 'connected', label: 'Connected', count: list.length || '' },
++ { key: 'log', label: 'Log' },
++ ],
++ active: tab,
++ });
++
++ if (tab === 'connected') {
++ body = needsYou + connectedHtml(list, callersList, subject) + kit.section({ title: 'Who may do what', note: 'Each caller of yours, in each app: nothing, read, or read and write.', body: '<div class="ovk-grants-panel" data-ovk-grants-panel></div>' }) + callersHtml(callersList);
++ } else if (tab === 'log') {
++ const rows = await ctx.gateway.listLog({ subject, limit: 100 });
++ body = needsYou + logHtml(rows);
++ } else {
++ body = needsYou + kit.find({ action: '/apps', name: 'q', placeholder: 'Search the apps', label: 'Search the apps' }) + catalogueHtml(connected);
++ }
++
++ headHtml = kit.head({
++ title: 'Apps',
++ sub: 'Your agents, in your other apps, with your own keys.',
++ tabs,
++ });
++ }
++
++ const canonical = String(ctx.config.baseUrl || 'https://openvibe.network').replace(/\/+$/, '') + '/apps';
++ return shell.page({
++ name: 'network',
++ service: 'network',
++ title: 'Apps',
++ siteName: 'OpenVibe.Network',
++ description: 'Connect your own apps: your agents work in them with your own keys.',
++ canonical,
++ robots: 'noindex, nofollow',
++ head: '<link rel="stylesheet" href="/shared/kit.css">\n<script src="/shared/kit-web.js" defer></script>\n<script src="/js/connect.js" defer></script>\n' + PAGE_CSS,
++ body: '<main class="ovk-page ovk-connect">\n' + (headHtml || '') + body + '</main>',
++ navLinks: [{ label: 'Your account', href: '/my' }],
++ navbar: { service: 'network' },
++ footer: { service: 'network', variant: 'compact' },
++ });
++}
++
++/** The route: GET /apps (and the root of connect.openvibe.network). */
++function createAppsPage(ctx) {
++ return async function appsPage(req, res) {
++ try {
++ const token = require('../auth/session').requestToken(req);
++ let user = null;
++ if (token) {
++ const out = await require('../auth/session').verifySession(token, ctx.authCtx);
++ if (!out.error) user = out.user;
++ }
++ const html = await renderPage({ ...ctx, query: req.query }, user);
++ res.set('Cache-Control', 'private, no-store');
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ res.type('html').send(html);
++ } catch (err) {
++ console.error('[Connect] the Apps page failed:', err.message);
++ res.status(500).type('html').send('<!doctype html><title>Apps</title><p>Something went wrong.');
++ }
++ };
++}
++
++module.exports = { createAppsPage, renderPage };
ddiff --git a/server/connect/redact.js b/server/connect/redact.js
nnew file mode 100644
iindex 00000000..82e89d0d
---- /dev/null
++++ b/server/connect/redact.js
@@@ -0,0 +1,62 @@
++'use strict';
++/**
++ * Redaction: nothing that looks like a secret leaves the gateway.
++ *
++ * An answer from an app can carry a token (the app echoed the credential back), a personal detail, or
++ * somebody else's key. Three passes catch them:
++ *
++ * 1. A field whose name says it is a secret (token, secret, password, api_key, access_token,
++ * authorization, cookie, private_key, client_secret, refresh_token) is replaced whole.
++ * 2. A value that looks like a secret (a GitHub, Slack, Discord, Google, OpenAI, Anthropic, Notion,
++ * Linear, Steam, Trello or AWS key, a JWT, a private key block) is replaced, wherever it stands.
++ * 3. The credential this call used is replaced wherever it appears, even in the middle of a sentence.
++ *
++ * The result is a copy: the answer the caller gets is the redacted one, and the original never leaves
++ * this function.
++ */
++const REDACTED = '[redacted]';
++
++const SECRET_PATTERNS = [
++ /gh[pousr]_[A-Za-z0-9]{30,}/g, // GitHub
++ /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub (fine-grained)
++ /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack
++ /sk-ant-[A-Za-z0-9_-]{20,}/g, // Anthropic
++ /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, // OpenAI
++ /AIza[0-9A-Za-z_-]{35}/g, // Google
++ /lin_api_[A-Za-z0-9]{30,}/g, // Linear
++ /ntn_[A-Za-z0-9]{30,}/g, // Notion
++ /secret_[A-Za-z0-9]{30,}/g, // Notion (older)
++ /AKIA[0-9A-Z]{16}/g, // AWS
++ /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // a JWT
++ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
++];
++
++const SECRET_FIELD = /^(?:.*_)?(?:token|secret|password|passwd|api_?key|apikey|access_?key|access_?token|refresh_?token|client_?secret|private_?key|authorization|auth_?token|cookie|session_?key|credential|bearer)(?:_.*)?$/i;
++
++/** One string: the patterns, and the credential this call used. */
++function redactString(text, secrets = []) {
++ let out = String(text);
++ for (const secret of secrets) {
++ if (!secret || secret.length < 8) continue;
++ out = out.split(secret).join(REDACTED);
++ }
++ for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, REDACTED);
++ return out;
++}
++
++/** Any value: an object, an array, a string. A copy, never the original. */
++function redact(value, secrets = [], depth = 0) {
++ if (depth > 20) return REDACTED;
++ if (typeof value === 'string') return redactString(value, secrets);
++ if (Array.isArray(value)) return value.map((v) => redact(v, secrets, depth + 1));
++ if (value && typeof value === 'object') {
++ const out = {};
++ for (const [name, child] of Object.entries(value)) {
++ out[name] = SECRET_FIELD.test(name) ? REDACTED : redact(child, secrets, depth + 1);
++ }
++ return out;
++ }
++ return value;
++}
++
++module.exports = { redact, redactString, REDACTED, SECRET_PATTERNS };
ddiff --git a/server/connect/routes.js b/server/connect/routes.js
nnew file mode 100644
iindex 00000000..5535b34f
---- /dev/null
++++ b/server/connect/routes.js
@@@ -0,0 +1,184 @@
++'use strict';
++/**
++ * The Connect API (the Apps page's own backend).
++ *
++ * GET /api/v1/connect/catalogue the ten apps and whether they are connected
++ * GET /api/v1/connect/apps/:key one app: the walkthrough, the actions, the auth kind
++ * POST /api/v1/connect/apps/:key/test the test call for a credential that is not stored yet
++ * POST /api/v1/connect/apps/:key/connect the test call, then the store (only if it worked)
++ * GET /api/v1/connect/credentials the credentials: app, label, scopes, when
++ * DELETE /api/v1/connect/credentials/:app/:label revoke one
++ * GET /api/v1/connect/callers the callers and their grants per app
++ * POST /api/v1/connect/callers make a key for one caller of the person's own
++ * DELETE /api/v1/connect/callers/:id revoke a caller
++ * PUT /api/v1/connect/callers/:id/grants one caller's grant in one app
++ * GET /api/v1/connect/log the log
++ * GET /api/v1/connect/approvals what waits for the person
++ * POST /api/v1/connect/approvals/:id approve or deny
++ * GET /api/v1/connect/export the apps (never the secrets)
++ * PUT /api/v1/connect/rules a standing rule for one recipe
++ *
++ * Every route needs the person's session. The secret never comes back: a credential is written and
++ * then only ever listed as metadata.
++ */
++const express = require('express');
++const vault = require('./vault');
++const callers = require('./callers');
++const approvals = require('./approvals');
++const registry = require('./apps');
++
++class ApiError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; }
++}
++const fail = (status, code, detail) => { throw new ApiError(status, code, detail); };
++
++function createConnectRoutes({ db, config, requireAuth, gateway, limiter = null } = {}) {
++ const router = express.Router();
++
++ // The subject of the person behind the request.
++ const subjectOf = (req) => req.user.subject_id || req.user.subject || String(req.user.id);
++
++ /** The credential the person pasted: one string, or one string per field. */
++ const credentialOf = (body, def) => {
++ const fields = def.auth.fields || [];
++ if (fields.length === 1) {
++ const value = body.credential;
++ if (value && typeof value === 'object') return value[fields[0].name] || null;
++ return typeof value === 'string' ? value : null;
++ }
++ const out = {};
++ for (const field of fields) out[field.name] = body.credentials && body.credentials[field.name] ? String(body.credentials[field.name]) : null;
++ return out;
++ };
++
++ router.use(requireAuth);
++
++ // The catalogue.
++ router.get('/catalogue', async (req, res) => {
++ const subject = subjectOf(req);
++ const list = await vault.list(db, subject);
++ const connected = new Set(list.map((c) => c.app));
++ res.json({
++ apps: registry.summaries().map((app) => ({ ...app, connected: connected.has(app.key) })),
++ });
++ });
++
++ // One app: everything the walkthrough needs.
++ router.get('/apps/:key', async (req, res) => {
++ const def = registry.get(req.params.key);
++ if (!def) fail(404, 'connect.unknown_app', 'no such app');
++ const subject = subjectOf(req);
++ const list = await vault.list(db, subject);
++ res.json({
++ app: {
++ key: def.key, name: def.name, vendor: def.vendor, blurb: def.blurb, docsUrl: def.docsUrl || null,
++ auth: { kind: def.auth.kind, label: def.auth.label, hint: def.auth.hint || null, scopes: def.auth.scopes || [], fields: def.auth.fields || [] },
++ byok: def.byok || null,
++ walkthrough: def.walkthrough || [],
++ test: def.test ? { summary: def.test.summary } : null,
++ actions: (def.actions || []).map((a) => ({ name: a.name, title: a.title, risk: a.risk, description: a.description || '' })),
++ },
++ credentials: list.filter((c) => c.app === def.key),
++ });
++ });
++
++ // The test call. Nothing is stored: the person has not said "save it" yet.
++ router.post('/apps/:key/test', async (req, res) => {
++ const def = registry.get(req.params.key);
++ if (!def) fail(404, 'connect.unknown_app', 'no such app');
++ const credential = credentialOf(req.body || {}, def);
++ if (!credential) fail(422, 'connect.invalid_credential', 'the credential is missing');
++ const out = await gateway.test({ subject: subjectOf(req), appKey: def.key, credential });
++ res.json(out);
++ });
++
++ // Connect: the test call first, and only a credential that works is stored.
++ router.post('/apps/:key/connect', async (req, res) => {
++ const def = registry.get(req.params.key);
++ if (!def) fail(404, 'connect.unknown_app', 'no such app');
++ const credential = credentialOf(req.body || {}, def);
++ if (!credential) fail(422, 'connect.invalid_credential', 'the credential is missing');
++ const subject = subjectOf(req);
++ const scopes = Array.isArray((req.body || {}).scopes) ? req.body.scopes.map(String).slice(0, 50) : [];
++ // The test call first: a refused test call stores nothing.
++ const tested = await gateway.test({ subject, appKey: def.key, credential });
++ const fields = def.auth.fields || [];
++ const stored = [];
++ for (const field of fields) {
++ const value = typeof credential === 'object' ? credential[field.name] : credential;
++ const label = fields.length === 1 ? 'default' : field.name;
++ stored.push(await vault.store(db, subject, def.key, label, value, { scopes, config }));
++ }
++ res.json({ ok: true, app: def.key, credentials: stored, test: tested });
++ });
++
++ // The credentials.
++ router.get('/credentials', async (req, res) => {
++ res.json({ credentials: await vault.list(db, subjectOf(req)) });
++ });
++
++ router.delete('/credentials/:app/:label', async (req, res) => {
++ const out = await vault.revoke(db, subjectOf(req), req.params.app, req.params.label);
++ res.json(out);
++ });
++
++ // The callers.
++ router.get('/callers', async (req, res) => {
++ res.json({ callers: await callers.listCallers(db, subjectOf(req)) });
++ });
++
++ router.post('/callers', async (req, res) => {
++ const out = await callers.createKeyCaller(db, subjectOf(req), (req.body || {}).name);
++ res.json(out);
++ });
++
++ router.delete('/callers/:id', async (req, res) => {
++ res.json(await callers.revokeCaller(db, req.params.id));
++ });
++
++ router.put('/callers/:id/grants', async (req, res) => {
++ const body = req.body || {};
++ const level = body.level === null || body.level === '' ? null : String(body.level);
++ const out = await callers.setGrant(db, req.params.id, String(body.app || ''), level, subjectOf(req));
++ res.json({ grants: out });
++ });
++
++ // The log.
++ router.get('/log', async (req, res) => {
++ res.json({ log: await gateway.listLog({ subject: subjectOf(req), limit: req.query.limit }) });
++ });
++
++ // The approvals that wait for the person.
++ router.get('/approvals', async (req, res) => {
++ const list = await approvals.list(db, subjectOf(req), { state: req.query.state || null });
++ res.json({ approvals: list });
++ });
++
++ router.post('/approvals/:id', async (req, res) => {
++ const out = await approvals.decide(db, subjectOf(req), req.params.id, String((req.body || {}).decision || ''));
++ res.json(out);
++ });
++
++ // The export: the apps, never the secrets.
++ router.get('/export', async (req, res) => {
++ res.json({ apps: await vault.exportFor(db, subjectOf(req)) });
++ });
++
++ // A standing rule for one recipe.
++ router.put('/rules', async (req, res) => {
++ const body = req.body || {};
++ const out = await approvals.setRule(db, subjectOf(req), String(body.app || ''), String(body.action || ''), body.on !== false);
++ res.json(out);
++ });
++
++ // Errors: the shape the page reads.
++ router.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
++ const status = err.status || 500;
++ if (status >= 500) console.error('[Connect]', err);
++ res.status(status).json({ error: err.code || 'connect.failed', detail: err.detail || err.message || 'the call failed' });
++ });
++
++ return router;
++}
++
++module.exports = { createConnectRoutes, ApiError };
ddiff --git a/server/connect/schema.js b/server/connect/schema.js
nnew file mode 100644
iindex 00000000..9685e248
---- /dev/null
++++ b/server/connect/schema.js
@@@ -0,0 +1,142 @@
++'use strict';
++/**
++ * A small JSON Schema validator (Compute plan E7): the actions of the catalogue carry a JSON Schema for
++ * their input, and the gateway checks the input against it before anything leaves the building.
++ *
++ * The supported keywords are the ones an action actually needs:
++ *
++ * type (a name or a list of names), properties, required, additionalProperties (false or a schema),
++ * enum, const, items, minItems, maxItems, uniqueItems, minimum, maximum, exclusiveMinimum,
++ * exclusiveMaximum, multipleOf, minLength, maxLength, pattern, allOf, anyOf, oneOf, not
++ *
++ * Anything else in a schema is ignored, so an action cannot widen itself by naming a keyword this
++ * validator does not know: an unknown keyword never loosens a check. A schema that is not an object,
++ * or a property whose schema is not an object, is a mistake in the definition and is refused.
++ *
++ * validate(schema, value) -> { valid: true } | { valid: false, errors: [{ path, message }] }
++ */
++const MAX_DEPTH = 12;
++const MAX_ERRORS = 20;
++
++const TYPE_OF = (v) => {
++ if (v === null) return 'null';
++ if (Array.isArray(v)) return 'array';
++ if (Number.isInteger(v)) return 'integer';
++ if (typeof v === 'number') return 'number';
++ return typeof v; // string | boolean | object
++};
++
++const typeMatches = (v, name) => {
++ const actual = TYPE_OF(v);
++ if (name === 'number') return actual === 'number' || actual === 'integer';
++ return actual === name;
++};
++
++function check(schema, value, path, errors, depth) {
++ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
++ errors.push({ path, message: 'the schema for this field is not an object' });
++ return;
++ }
++ if (depth > MAX_DEPTH) { errors.push({ path, message: 'the input is nested too deeply' }); return; }
++
++ if (schema.type !== undefined) {
++ const names = Array.isArray(schema.type) ? schema.type : [schema.type];
++ if (!names.some((n) => typeMatches(value, n))) {
++ errors.push({ path, message: `must be ${names.join(' or ')}` });
++ return;
++ }
++ }
++ if (schema.enum !== undefined && Array.isArray(schema.enum)) {
++ if (!schema.enum.some((e) => e === value)) errors.push({ path, message: 'must be one of the allowed values' });
++ }
++ if (schema.const !== undefined && value !== schema.const) {
++ errors.push({ path, message: 'must be the one allowed value' });
++ }
++ if (typeof value === 'number') {
++ if (typeof schema.minimum === 'number' && value < schema.minimum) errors.push({ path, message: `must be at least ${schema.minimum}` });
++ if (typeof schema.maximum === 'number' && value > schema.maximum) errors.push({ path, message: `must be at most ${schema.maximum}` });
++ if (typeof schema.exclusiveMinimum === 'number' && value <= schema.exclusiveMinimum) errors.push({ path, message: `must be more than ${schema.exclusiveMinimum}` });
++ if (typeof schema.exclusiveMaximum === 'number' && value >= schema.exclusiveMaximum) errors.push({ path, message: `must be less than ${schema.exclusiveMaximum}` });
++ if (typeof schema.multipleOf === 'number' && schema.multipleOf > 0) {
++ const q = value / schema.multipleOf;
++ if (Math.abs(q - Math.round(q)) > 1e-9) errors.push({ path, message: `must be a multiple of ${schema.multipleOf}` });
++ }
++ }
++ if (typeof value === 'string') {
++ if (typeof schema.minLength === 'number' && value.length < schema.minLength) errors.push({ path, message: `must be at least ${schema.minLength} characters` });
++ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) errors.push({ path, message: `must be at most ${schema.maxLength} characters` });
++ if (typeof schema.pattern === 'string') {
++ let re = null;
++ try { re = new RegExp(schema.pattern); } catch { re = null; }
++ if (re && !re.test(value)) errors.push({ path, message: 'has the wrong shape' });
++ }
++ }
++ if (Array.isArray(value)) {
++ if (typeof schema.minItems === 'number' && value.length < schema.minItems) errors.push({ path, message: `must have at least ${schema.minItems} items` });
++ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) errors.push({ path, message: `must have at most ${schema.maxItems} items` });
++ if (schema.uniqueItems === true) {
++ const seen = new Set(value.map((v) => JSON.stringify(v)));
++ if (seen.size !== value.length) errors.push({ path, message: 'must not repeat an item' });
++ }
++ if (schema.items && typeof schema.items === 'object') {
++ value.forEach((item, i) => check(schema.items, item, `${path}[${i}]`, errors, depth + 1));
++ }
++ }
++ if (value && typeof value === 'object' && !Array.isArray(value)) {
++ const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
++ for (const name of Array.isArray(schema.required) ? schema.required : []) {
++ if (!Object.prototype.hasOwnProperty.call(value, name)) errors.push({ path: `${path}.${name}`, message: 'is required' });
++ }
++ for (const [name, child] of Object.entries(value)) {
++ if (Object.prototype.hasOwnProperty.call(props, name)) {
++ check(props[name], child, `${path}.${name}`, errors, depth + 1);
++ } else if (schema.additionalProperties === false) {
++ errors.push({ path: `${path}.${name}`, message: 'is not an allowed field' });
++ } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
++ check(schema.additionalProperties, child, `${path}.${name}`, errors, depth + 1);
++ }
++ }
++ }
++ if (Array.isArray(schema.allOf)) {
++ for (const sub of schema.allOf) check(sub, value, path, errors, depth + 1);
++ }
++ if (Array.isArray(schema.anyOf) && schema.anyOf.length) {
++ const ok = schema.anyOf.some((sub) => {
++ const local = [];
++ check(sub, value, path, local, depth + 1);
++ return local.length === 0;
++ });
++ if (!ok) errors.push({ path, message: 'does not match any of the allowed shapes' });
++ }
++ if (Array.isArray(schema.oneOf) && schema.oneOf.length) {
++ let hits = 0;
++ for (const sub of schema.oneOf) {
++ const local = [];
++ check(sub, value, path, local, depth + 1);
++ if (!local.length) hits += 1;
++ }
++ if (hits !== 1) errors.push({ path, message: 'must match exactly one of the allowed shapes' });
++ }
++ if (schema.not && typeof schema.not === 'object') {
++ const local = [];
++ check(schema.not, value, path, local, depth + 1);
++ if (!local.length) errors.push({ path, message: 'must not match the excluded shape' });
++ }
++}
++
++/** The result of one check: { valid: true } or { valid: false, errors: [{ path, message }] }. */
++function validate(schema, value) {
++ const errors = [];
++ try {
++ check(schema, value, '', errors, 0);
++ } catch (e) {
++ errors.push({ path: '', message: `the schema could not be applied: ${e.message}` });
++ }
++ if (!errors.length) return { valid: true };
++ return { valid: false, errors: errors.slice(0, MAX_ERRORS) };
++}
++
++/** The errors as one short line, for an error message. */
++const describe = (result) => result.errors.map((e) => `${e.path || 'input'} ${e.message}`).join('; ').slice(0, 300);
++
++module.exports = { validate, describe };
ddiff --git a/server/connect/vault.js b/server/connect/vault.js
nnew file mode 100644
iindex 00000000..f3ce491d
---- /dev/null
++++ b/server/connect/vault.js
@@@ -0,0 +1,168 @@
++'use strict';
++/**
++ * The vault (Compute plan E7): a person's own credentials for the apps their agents act in.
++ *
++ * store(db, subject, app, label, value, { scopes, config }) -> the credential row (metadata only)
++ * open(db, subject, app, label, { config }) -> the plaintext, for the one call that uses it
++ * list(db, subject) -> every credential: app, label, scopes, when
++ * revoke(db, subject, app, label) -> the credential is gone (the row goes too)
++ * exportFor(db, subject) -> the apps, never the secrets
++ * eraseFor(db, subject) -> every credential of this person, gone
++ *
++ * Sealing: AES-256-GCM under a key derived from Network's KEK (NETWORK_SECRETS_KEY, the same key D11's
++ * sealed project secrets use), with the person's subject and the app as the authenticated data. A
++ * ciphertext copied onto another row (another person, another app) does not open. The plaintext is
++ * decrypted only inside the gateway, for the one request that carries it, and is never written to a
++ * page, a log, an error message or a response.
++ *
++ * Without the key the feature is off (503 connect.vault_unavailable), never plaintext.
++ */
++const crypto = require('crypto');
++const { newId } = require('./ids');
++
++const MAX_VALUE_BYTES = 8192;
++const LABEL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
++const KEY_ID = 'k1';
++const CONTROL_RE = /[\u0000-\u001f\u007f]/;
++
++/** The keys, derived once per configured secret: { id, enc }, or null when NETWORK_SECRETS_KEY is unset or malformed. */
++const keyCache = new Map();
++function keyring(config) {
++ const raw = String((config && config.secrets && config.secrets.key) || '').trim();
++ if (!raw) return null;
++ if (keyCache.has(raw)) return keyCache.get(raw);
++ const bytes = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
++ const kr = bytes.length === 32 ? {
++ id: KEY_ID,
++ enc: Buffer.from(crypto.hkdfSync('sha256', bytes, Buffer.alloc(0), 'openvibe-network connect credential sealing', 32)),
++ } : null;
++ keyCache.set(raw, kr);
++ return kr;
++}
++
++class VaultError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail || code; }
++}
++const fail = (status, code, detail) => { throw new VaultError(status, code, detail); };
++
++function requireKeys(config) {
++ const kr = keyring(config);
++ if (!kr) fail(503, 'connect.vault_unavailable', 'the credential vault is not configured on this Network (NETWORK_SECRETS_KEY)');
++ return kr;
++}
++
++/** The authenticated data: the person's subject and the app (and the row's own id). */
++const aadOf = (subject, app, id) => Buffer.from(`${subject}\n${app}\n${id}`, 'utf8');
++
++function seal(kr, subject, app, id, value) {
++ const iv = crypto.randomBytes(12);
++ const c = crypto.createCipheriv('aes-256-gcm', kr.enc, iv);
++ c.setAAD(aadOf(subject, app, id));
++ const ciphertext = Buffer.concat([c.update(value, 'utf8'), c.final()]);
++ return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };
++}
++
++/** The plaintext, or null when the row cannot be opened (wrong subject, wrong app, a copied ciphertext). */
++function openSealed(kr, row) {
++ if (!row || !row.ciphertext || row.key_id !== kr.id) return null;
++ try {
++ const d = crypto.createDecipheriv('aes-256-gcm', kr.enc, Buffer.from(row.iv, 'base64'));
++ d.setAAD(aadOf(row.subject, row.app, row.id));
++ d.setAuthTag(Buffer.from(row.tag, 'base64'));
++ return Buffer.concat([d.update(Buffer.from(row.ciphertext, 'base64')), d.final()]).toString('utf8');
++ } catch {
++ return null; // a wrong subject, a wrong app, or a tampered row: nothing comes out
++ }
++}
++
++const nowIso = () => new Date().toISOString();
++
++function cleanLabel(label) {
++ const l = String(label == null ? 'default' : label).trim().toLowerCase();
++ if (!LABEL_RE.test(l)) fail(422, 'connect.invalid_label', 'a label is 1-64 characters: a lowercase letter or digit, then letters, digits, dots, dashes or underscores');
++ return l;
++}
++function cleanValue(value) {
++ if (typeof value !== 'string' || !value.length) fail(422, 'connect.invalid_credential', 'the credential is a non-empty string');
++ const v = value.trim();
++ if (Buffer.byteLength(v, 'utf8') > MAX_VALUE_BYTES) fail(422, 'connect.credential_too_large', `a credential is at most ${MAX_VALUE_BYTES} bytes`);
++ if (CONTROL_RE.test(v)) fail(422, 'connect.invalid_credential', 'the credential must not contain control characters');
++ return v;
++}
++
++/** The metadata of one credential: never the secret. */
++function view(row) {
++ let scopes = [];
++ try { scopes = JSON.parse(row.scopes || '[]'); } catch { scopes = []; }
++ return {
++ id: row.id, app: row.app, label: row.label, scopes: Array.isArray(scopes) ? scopes : [],
++ created_at: row.created_at, last_used_at: row.last_used_at || null,
++ };
++}
++
++/**
++ * Store a credential. The caller has already made the test call: a credential the test call refuses is
++ * never stored (the walkthrough asks first, and only a credential that works is kept).
++ */
++async function store(db, subject, app, label, value, { scopes = [], config } = {}) {
++ const kr = requireKeys(config);
++ const v = cleanValue(value);
++ const l = cleanLabel(label);
++ const id = newId('cvd');
++ const sealed = seal(kr, subject, app, id, v);
++ const at = nowIso();
++ await db.prepare(`INSERT INTO connect_credentials (id, subject, app, label, scopes, key_id, iv, tag, ciphertext, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT (subject, app, label) DO UPDATE SET id = excluded.id, key_id = excluded.key_id, iv = excluded.iv, tag = excluded.tag,
++ ciphertext = excluded.ciphertext, scopes = excluded.scopes, created_at = excluded.created_at, revoked_at = NULL`)
++ .run(id, subject, app, l, JSON.stringify(Array.isArray(scopes) ? scopes.map(String) : []), kr.id, sealed.iv, sealed.tag, sealed.ciphertext, at);
++ const row = await db.prepare('SELECT * FROM connect_credentials WHERE subject = ? AND app = ? AND label = ?').get(subject, app, l);
++ return view(row);
++}
++
++/** The plaintext of one credential, for the one request that uses it. */
++async function openCredential(db, subject, app, label, { config } = {}) {
++ const kr = requireKeys(config);
++ const row = await db.prepare('SELECT * FROM connect_credentials WHERE subject = ? AND app = ? AND label = ? AND revoked_at IS NULL')
++ .get(subject, app, cleanLabel(label));
++ if (!row) return null;
++ const value = openSealed(kr, row);
++ if (value == null) fail(503, 'connect.vault_unavailable', 'this credential cannot be opened with the configured key');
++ return value;
++}
++
++/** Every credential of this person: app, label, scopes, when it was added and when it was last used. */
++async function list(db, subject) {
++ const rows = await db.prepare('SELECT * FROM connect_credentials WHERE subject = ? AND revoked_at IS NULL ORDER BY app, label').all(subject);
++ return rows.map(view);
++}
++
++/** The apps this person has a credential for (the export lists the apps, never the secrets). */
++async function exportFor(db, subject) {
++ const rows = await db.prepare('SELECT DISTINCT app FROM connect_credentials WHERE subject = ? AND revoked_at IS NULL ORDER BY app').all(subject);
++ return rows.map((r) => r.app);
++}
++
++/** Take one credential away: the row goes, so the sealed value is gone too. */
++async function revoke(db, subject, app, label) {
++ const r = await db.prepare('DELETE FROM connect_credentials WHERE subject = ? AND app = ? AND label = ?')
++ .run(subject, app, cleanLabel(label));
++ if (!r.changes) fail(404, 'connect.not_found', 'no such credential');
++ return { revoked: true };
++}
++
++/** Every credential of this person, gone (the account lifecycle: deletion erases). */
++async function eraseFor(db, subject) {
++ const r = await db.prepare('DELETE FROM connect_credentials WHERE subject = ?').run(subject);
++ return { erased: r.changes || 0 };
++}
++
++/** Note that a credential was used (the listing shows when it was last used). */
++async function touch(db, id, at = nowIso()) {
++ await db.prepare('UPDATE connect_credentials SET last_used_at = ? WHERE id = ?').run(at, id);
++}
++
++module.exports = {
++ store, open: openCredential, list, revoke, exportFor, eraseFor, touch,
++ keyring, seal, openSealed, view, VaultError, MAX_VALUE_BYTES,
++};
ddiff --git a/server/index.js b/server/index.js
iindex 15f679d5..7af8e588 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -453,6 +453,20 @@ app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router())
// Sharing (design 2026-11-06): what is shared with this person, the share picker's suggestions and
// the @name check it uses (server/sharing/routes.js).
app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));
++// OpenVibe Connect (Compute plan E7): the vault of a person's own app credentials, the catalogue and
++// the gateway that acts in their apps with them. The Apps page and the MCP server are Network's own;
++// the host connect.openvibe.network is the nginx vhost's business, not this file's.
++const connectLimiter = require('./connect/limits').createLimiter({
++ perMinute: Number(process.env.CONNECT_CALLS_PER_MINUTE) || undefined,
++ dailyCap: Number(process.env.CONNECT_DAILY_CAP) || undefined,
++});
++const connectGateway = require('./connect/gateway').createGateway({ db, config, limiter: connectLimiter });
++app.locals.connectGateway = connectGateway;
++app.use('/api/v1/connect', require('./connect/routes').createConnectRoutes({ db, config, requireAuth, gateway: connectGateway }));
++// The MCP server: https://connect.openvibe.network/mcp (streamable HTTP). One JSON-RPC call per request.
++app.post('/mcp', rateLimit({ windowMs: 60_000, max: 600 }), require('./connect/mcp').createMcpHandler({
++ db, config, gateway: connectGateway, publicKey, issuer: config.jwt.issuer,
++}));
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
@@@ -982,6 +996,16 @@ app.get('/wallet.html', (req, res) => redirectWithoutHtml(req, res, '/wallet'));
// "Shared with me" (design 2026-11-06): everything other people have shared with this person, on
// every site, in one list. Its own page, in the kit's components and with no styles of its own.
app.get('/shared', require('./sharing/page').createSharedPage({ ...sharing, authCtx: authCtx() }));
++// The Apps page (Compute plan E7): the catalogue, the walkthrough, the connected list and the log.
++// connect.openvibe.network serves the same page at its root (the vhost is the nginx one; this is the
++// page behind it).
++const connectPage = require('./connect/page').createAppsPage({ db, config, gateway: connectGateway, authCtx: authCtx() });
++app.get('/apps', connectPage);
++app.get('/apps.html', (req, res) => redirectWithoutHtml(req, res, '/apps'));
++app.get('/', (req, res, next) => {
++ if (getRequestHost(req) === 'connect.openvibe.network') return connectPage(req, res);
++ return next();
++});
app.get('/pay/:id', (_req, res) => sendPayPage(res));
// Account hub (my.html) — the apex hosts the account hub under /my
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 353bbe3b..56c204b1 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/connect-apps.test.js b/test/connect-apps.test.js
nnew file mode 100644
iindex 00000000..3feceeaa
---- /dev/null
++++ b/test/connect-apps.test.js
@@@ -0,0 +1,106 @@
++'use strict';
++// The catalogue (Compute plan E7, server/connect/apps): ten app definitions, one file each. Every
++// definition says the auth kind, the walkthrough with its links, the test call and the actions, each
++// with a name, a JSON Schema for its input, a risk level and the fixed request it makes. A definition
++// that is broken is refused when the catalogue loads, so a mistake here never becomes a capability.
++// node test/connect-apps.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const registry = require('../server/connect/apps');
++const schema = require('../server/connect/schema');
++
++(async () => {
++ const quiet = console.log;
++ console.log = () => {};
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); } };
++
++ try {
++ await check('the catalogue is ten apps, one file each', async () => {
++ const files = fs.readdirSync(path.join(__dirname, '..', 'server', 'connect', 'apps')).filter((f) => f.endsWith('.js') && f !== 'index.js');
++ assert.strictEqual(files.length, 10, 'ten definition files');
++ const apps = registry.all();
++ assert.strictEqual(apps.length, 10);
++ const keys = apps.map((a) => a.key).sort();
++ assert.deepStrictEqual(keys, ['ai', 'discord', 'github', 'google', 'linear', 'notion', 'slack', 'steam', 'todoist', 'trello']);
++ assert.strictEqual(new Set(keys).size, 10, 'every app has its own key');
++ });
++
++ await check('every definition is whole: the auth kind, the walkthrough with its links, the test call', async () => {
++ for (const def of registry.all()) {
++ const problems = registry.validateDefinition(def);
++ assert.deepStrictEqual(problems, [], def.key + ': ' + problems.join(', '));
++ assert.ok(['token', 'api_key', 'bot_token', 'byok_link'].includes(def.auth.kind), def.key);
++ assert.ok(def.walkthrough.length >= 3, def.key + ' has a walkthrough');
++ for (const step of def.walkthrough) {
++ if (step.link) assert.ok(String(step.link).startsWith('https://'), def.key + ' links are https');
++ }
++ if (def.auth.kind !== 'byok_link') {
++ assert.ok(def.test && def.test.method && def.test.path, def.key + ' has a test call');
++ assert.ok(def.apiHost && !def.apiHost.includes('/'), def.key + ' has a fixed host');
++ }
++ }
++ });
++
++ await check('every action has a name, a schema, a risk and a fixed request', async () => {
++ let actions = 0;
++ for (const def of registry.all()) {
++ for (const action of def.actions || []) {
++ actions += 1;
++ assert.ok(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/.test(action.name), action.name);
++ assert.ok(action.input && action.input.type === 'object', action.name + ' has an object schema');
++ assert.ok(['read', 'write', 'send', 'delete', 'pay'].includes(action.risk), action.name + ' has a risk');
++ assert.ok(action.request && action.request.method && action.request.path, action.name + ' has a request');
++ assert.ok(!String(action.request.path).includes('://'), action.name + ' names no URL');
++ assert.ok(!/\{[^}]*\}/.test(action.request.path.replace(/\{[a-z_]+\}/g, '')), action.name + ' has no stray placeholder');
++ // The schema must accept a good input and refuse a bad one.
++ const good = {};
++ for (const [k, v] of Object.entries(action.input.properties || {})) {
++ if (Array.isArray(v.enum) && v.enum.length) { good[k] = v.enum[0]; continue; }
++ if (v.type === 'integer' || v.type === 'number') { good[k] = 1; continue; }
++ if (v.type === 'boolean') { good[k] = true; continue; }
++ if (v.type === 'array') { good[k] = []; continue; }
++ if (v.type === 'object') { good[k] = {}; continue; }
++ if (v.pattern) {
++ const sample = String(v.pattern).includes('17') ? '12345678901234567' : (String(v.pattern).includes('2,10') ? 'es' : 'x');
++ if (new RegExp(v.pattern).test(sample)) { good[k] = sample; continue; }
++ continue; // a pattern this test cannot satisfy: the field is left out
++ }
++ good[k] = 'x';
++ }
++ const verdict = schema.validate(action.input, good);
++ assert.strictEqual(verdict.valid, true, action.name + ' accepts a good input: ' + JSON.stringify(verdict.errors || []));
++ assert.strictEqual(schema.validate(action.input, { nope: 1 }).valid, false, action.name + ' refuses an unknown field');
++ }
++ }
++ assert.ok(actions >= 30, 'the catalogue has its actions: ' + actions);
++ });
++
++ await check('the send, delete and pay actions are the ones that ask', async () => {
++ const asks = [];
++ for (const def of registry.all()) {
++ for (const action of def.actions || []) if (['send', 'delete', 'pay'].includes(action.risk)) asks.push(action.name);
++ }
++ assert.ok(asks.length >= 8, 'the catalogue has actions that ask: ' + asks.length);
++ assert.ok(asks.some((n) => n.endsWith('.comment') || n.includes('message') || n.includes('comment')), asks.join(', '));
++ });
++
++ await check('the summaries name the ten apps and their auth kinds', async () => {
++ const list = registry.summaries();
++ assert.strictEqual(list.length, 10);
++ for (const app of list) {
++ assert.ok(app.name && app.blurb, app.key);
++ assert.ok(app.auth_kind, app.key);
++ assert.ok(Array.isArray(app.actions), app.key);
++ }
++ const ai = list.find((a) => a.key === 'ai');
++ assert.strictEqual(ai.auth_kind, 'byok_link', 'the provider keys live in OpenVibe.AI');
++ assert.ok(ai.byok && ai.byok.providers.length === 2, 'OpenAI and Anthropic');
++ });
++ } finally {
++ console.log = quiet;
++ }
++ console.log(failures ? `\n${failures} failed` : '\nconnect apps: all checks passed');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/connect-callers.test.js b/test/connect-callers.test.js
nnew file mode 100644
iindex 00000000..9cb91a1a
---- /dev/null
++++ b/test/connect-callers.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++// Who may act for a person in Connect (server/connect/auth.js, callers.js, gateway.js), from Opus's security review
++// of E7 (2026-11-06):
++// - a valid service token is not enough: only the services Connect names (codes, actor) may act for a person;
++// - a service's caller row is per person, so one person's grant never opens another person's apps;
++// - a personal key always works (its secret holds no "_" for the parser to split on);
++// - a path value of "." or ".." is refused before any request.
++// node test/connect-callers.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const { resolveCaller } = require('../server/connect/auth');
++const callers = require('../server/connect/callers');
++
++(async () => {
++ const db = getDb();
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const config = { jwt: { issuer: 'https://openvibe.network' } };
++ const A = 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1';
++ const B = 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2';
++ const token = (client, person) => {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({
++ iss: config.jwt.issuer, sub: `svc:${client}`, actor_type: 'service', aud: ['openvibe.network'], cap: [], ns: [],
++ on_behalf_of: person, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ }, keys.privateKey);
++ };
++ const asReq = (t) => ({ headers: { authorization: `Bearer ${t}` } });
++ const resolve = (t) => resolveCaller(db, asReq(t), { config, publicKey: keys.publicKey });
++
++ // Only the named services.
++ const meme = await resolve(token('meme', A));
++ assert.strictEqual(meme.ok, false, 'a service Connect does not name is refused');
++ assert.strictEqual(meme.status, 403);
++ const codesA = await resolve(token('codes', A));
++ assert.ok(codesA.ok && codesA.kind === 'service' && codesA.subject === A, 'codes may act for a person');
++
++ // Per person: A's grant to codes is not B's.
++ const codesB = await resolve(token('codes', B));
++ assert.ok(codesB.ok, codesB.detail);
++ assert.notStrictEqual(codesA.caller.id, codesB.caller.id, 'codes acting for A and for B are two callers');
++ assert.strictEqual(codesB.caller.subject, B);
++ // A's grant to codes (written directly: setGrant also wants the app connected, which is not what this tests).
++ await db.prepare("INSERT INTO connect_grants (caller_id, app, level, created_at, updated_at) VALUES (?, 'github', 'write', ?, ?)").run(codesA.caller.id, new Date().toISOString(), new Date().toISOString());
++ assert.ok(await db.prepare("SELECT level FROM connect_grants WHERE caller_id = ? AND app = 'github'").get(codesA.caller.id), 'A granted codes github');
++ const bGrant = await db.prepare("SELECT level FROM connect_grants WHERE caller_id = ? AND app = 'github'").get(codesB.caller.id);
++ assert.ok(!bGrant, 'B has given codes nothing');
++
++ // Keys always parse.
++ for (let i = 0; i < 40; i++) {
++ const made = await callers.createKeyCaller(db, A, `key ${i}`);
++ assert.ok(!/_/.test(made.key.split('_').slice(-1)[0]) && /^[0-9a-f]+$/.test(made.key.slice(made.key.lastIndexOf('_') + 1)), 'the secret is hex');
++ const found = await callers.findByKey(db, made.key);
++ assert.ok(found && found.subject === A, 'every key made is found again');
++ }
++
++ // Dot segments.
++ const gateway = require('../server/connect/gateway');
++ const build = gateway.interpolatePath;
++ assert.throws(() => build('/repos/{owner}/{repo}', { owner: '..', repo: 'x' }, ['owner', 'repo']), /may not be/);
++ assert.throws(() => build('/repos/{owner}/{repo}', { owner: 'a', repo: '.' }, ['owner', 'repo']), /may not be/);
++ assert.strictEqual(build('/repos/{owner}/{repo}', { owner: 'a b', repo: 'x' }, ['owner', 'repo']), '/repos/a%20b/x');
++ console.log('connect callers: named services only, per-person service callers, keys parse, no dot segments');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/connect-gateway.test.js b/test/connect-gateway.test.js
nnew file mode 100644
iindex 00000000..bb54753d
---- /dev/null
++++ b/test/connect-gateway.test.js
@@@ -0,0 +1,201 @@
++'use strict';
++// The gateway (Compute plan E7, server/connect/gateway.js): a caller with no grant sees no action; an
++// input that fails its schema is refused before anything is sent; a send action waits for the person;
++// the egress guard refuses a redirect off the app's own host; a secret in an answer is redacted; the
++// limits are 120 calls a minute for one person in one app, and a daily cap on top; the log keeps who,
++// which action and whether it worked, and never the input of a write action.
++// node test/connect-gateway.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const vault = require('../server/connect/vault');
++const callers = require('../server/connect/callers');
++const approvals = require('../server/connect/approvals');
++const { createGateway } = require('../server/connect/gateway');
++const { createLimiter } = require('../server/connect/limits');
++const { startApp } = require('./helpers/connect-app');
++
++(async () => {
++ const quiet = console.log;
++ console.log = () => {};
++ const db = getDb();
++ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (11, 'other', 'x', 'user'), (12, 'third', 'x', 'user')`).run();
++ const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());
++ const other = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 11').get());
++ const third = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 12').get());
++ const config = { secrets: { key: crypto.randomBytes(32).toString('base64') } };
++ const SECRET = 'ghp_' + crypto.randomBytes(18).toString('hex');
++
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); } };
++
++ // The app under test: its own host, its own actions, its own answers.
++ const seen = [];
++ const app = await startApp((req, res) => {
++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization });
++ const answer = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
++ if (req.url.startsWith('/redirect')) {
++ res.writeHead(302, { location: 'https://evil.example.com/steal' });
++ return res.end();
++ }
++ if (req.url.startsWith('/read')) return answer(200, { login: 'octocat', access_token: SECRET, note: 'the token is ' + SECRET });
++ if (req.url.startsWith('/write')) return answer(200, { ok: true, wrote: true });
++ if (req.url.startsWith('/send')) return answer(200, { ok: true, sent: true });
++ if (req.url.startsWith('/other')) return answer(200, { ok: true });
++ return answer(404, { message: 'Not Found' });
++ });
++
++ const def = {
++ key: 'testapp', name: 'Test App', apiHost: app.host,
++ auth: { kind: 'token', fields: [{ name: 'default', label: 'Token', where: 'header', header: 'Authorization', scheme: 'Bearer' }] },
++ test: { method: 'GET', path: '/read' },
++ actions: [
++ { name: 'testapp.read', title: 'Read', risk: 'read', description: 'read', input: { type: 'object', properties: { q: { type: 'string' } }, required: [], additionalProperties: false }, request: { method: 'GET', path: '/read' } },
++ { name: 'testapp.write', title: 'Write', risk: 'write', description: 'write', input: { type: 'object', properties: { title: { type: 'string', minLength: 1 } }, required: ['title'], additionalProperties: false }, request: { method: 'POST', path: '/write', body: { title: '{title}' } } },
++ { name: 'testapp.send', title: 'Send', risk: 'send', description: 'send', input: { type: 'object', properties: { to: { type: 'string', minLength: 1 } }, required: ['to'], additionalProperties: false }, request: { method: 'POST', path: '/send', body: { to: '{to}' } } },
++ { name: 'testapp.remove', title: 'Delete', risk: 'delete', description: 'delete', input: { type: 'object', properties: { id: { type: 'string', minLength: 1 } }, required: ['id'], additionalProperties: false }, request: { method: 'DELETE', path: '/other' } },
++ { name: 'testapp.redirect', title: 'Redirect', risk: 'read', description: 'redirect', input: { type: 'object', properties: {}, additionalProperties: false }, request: { method: 'GET', path: '/redirect' } },
++ ],
++ };
++
++ const limiter = createLimiter({ perMinute: 120, dailyCap: 2000 });
++ const gateway = createGateway({ db, config, defs: [def], limiter, approvalWaitMs: 8000, defaultRequestOptions: app.options });
++
++ try {
++ const caller = await callers.ensurePersonCaller(db, owner, 'The owner');
++ await vault.store(db, owner, 'testapp', 'default', 'the-token', { config });
++ await vault.store(db, other, 'testapp', 'default', 'the-token', { config });
++ await vault.store(db, third, 'testapp', 'default', 'the-token', { config });
++
++ await check('an ungranted caller sees no actions, and a read grant shows only the read ones', async () => {
++ assert.deepStrictEqual(await gateway.listActions({ subject: owner, callerId: caller.id }), [], 'no grant, no tools');
++ await callers.setGrant(db, caller.id, 'testapp', 'read', owner);
++ const tools = await gateway.listActions({ subject: owner, callerId: caller.id });
++ assert.deepStrictEqual(tools.map((t) => t.name).sort(), ['testapp.read', 'testapp.redirect'], 'only the read actions');
++ await callers.setGrant(db, caller.id, 'testapp', 'write', owner);
++ const more = await gateway.listActions({ subject: owner, callerId: caller.id });
++ assert.deepStrictEqual(more.map((t) => t.name).sort(), ['testapp.read', 'testapp.redirect', 'testapp.remove', 'testapp.send', 'testapp.write']);
++ });
++
++ await check('an input that fails its schema is refused, and nothing is sent', async () => {
++ const before = seen.length;
++ let err = null;
++ try { await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.write', input: { title: '' } }); } catch (e) { err = e; }
++ assert.ok(err, 'the call is refused');
++ assert.strictEqual(err.status, 422);
++ assert.strictEqual(err.code, 'connect.invalid_input');
++ assert.strictEqual(seen.length, before, 'the app was never called');
++ let missing = null;
++ try { await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.write', input: {} }); } catch (e) { missing = e; }
++ assert.strictEqual(missing.status, 422, 'a missing required field is refused');
++ let extra = null;
++ try { await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.write', input: { title: 'x', nope: 1 } }); } catch (e) { extra = e; }
++ assert.strictEqual(extra.status, 422, 'an unknown field is refused');
++ });
++
++ await check('a send action waits for the person, and only then happens', async () => {
++ const pending = gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.send', input: { to: 'someone' } });
++ pending.catch(() => {}); // the wait is decided below; a refusal must not crash the file
++ // The request is written down and the app is not called yet.
++ let rows = await approvals.list(db, owner, { state: 'pending' });
++ for (let i = 0; i < 40 && !rows.length; i++) { await new Promise((r) => setTimeout(r, 50)); rows = await approvals.list(db, owner, { state: 'pending' }); }
++ assert.strictEqual(rows.length, 1, 'the request waits for the person');
++ assert.ok(!seen.some((s) => s.url.startsWith('/send')), 'the app was not called yet');
++ await approvals.decide(db, owner, rows[0].id, 'approve');
++ const out = await pending;
++ assert.strictEqual(out.status, 200);
++ assert.ok(seen.some((s) => s.url.startsWith('/send')), 'the app was called after the approval');
++ });
++
++ await check('a denied send action never happens', async () => {
++ const pending = gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.send', input: { to: 'someone else' } });
++ pending.catch(() => {});
++ let rows = await approvals.list(db, owner, { state: 'pending' });
++ for (let i = 0; i < 40 && !rows.length; i++) { await new Promise((r) => setTimeout(r, 50)); rows = await approvals.list(db, owner, { state: 'pending' }); }
++ await approvals.decide(db, owner, rows[0].id, 'deny');
++ let err = null;
++ try { await pending; } catch (e) { err = e; }
++ assert.ok(err, 'the call fails');
++ assert.strictEqual(err.code, 'connect.approval_denied');
++ assert.ok(!seen.some((s) => s.url === '/send' && s.body === 'someone else'), 'the app was not called');
++ });
++
++ await check('a delete action asks as well', async () => {
++ const pending = gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.remove', input: { id: 'x' } });
++ pending.catch(() => {});
++ let rows = await approvals.list(db, owner, { state: 'pending' });
++ for (let i = 0; i < 40 && !rows.length; i++) { await new Promise((r) => setTimeout(r, 50)); rows = await approvals.list(db, owner, { state: 'pending' }); }
++ assert.strictEqual(rows.length, 1, 'the delete waits');
++ await approvals.decide(db, owner, rows[0].id, 'deny');
++ await pending.catch(() => {});
++ });
++
++ await check('the egress guard refuses a redirect off the app\'s own host', async () => {
++ let err = null;
++ try { await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.redirect', input: {} }); } catch (e) { err = e; }
++ assert.ok(err, 'the redirect is refused');
++ assert.strictEqual(err.code, 'connect.egress_denied');
++ assert.ok(/not its own host/.test(err.detail), err.detail);
++ });
++
++ await check('a secret in an answer is redacted', async () => {
++ const out = await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.read', input: { q: 'x' } });
++ const text = JSON.stringify(out.data);
++ assert.ok(!text.includes(SECRET), 'the secret is not in the answer');
++ assert.ok(text.includes('[redacted]'), 'the secret is redacted');
++ assert.ok(out.data.login === 'octocat', 'the rest of the answer stands');
++ });
++
++ await check('the log says who, which action and whether it worked, and keeps no write input', async () => {
++ await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.write', input: { title: 'a private title' } });
++ const rows = await db.prepare('SELECT * FROM connect_log WHERE subject = ? ORDER BY created_at DESC').all(owner);
++ assert.ok(rows.length, 'the log has rows');
++ const write = rows.find((r) => r.action === 'testapp.write');
++ assert.ok(write, 'the write is logged');
++ assert.strictEqual(Number(write.ok), 1);
++ assert.strictEqual(write.caller_name, 'The owner');
++ assert.strictEqual(write.input, null, 'the input of a write action is never written');
++ const read = rows.find((r) => r.action === 'testapp.read');
++ assert.ok(read && read.input, 'a read keeps its input');
++ assert.ok(!JSON.stringify(rows).includes(SECRET), 'no secret in the log');
++ });
++
++ await check('the limit is 120 calls a minute for one person in one app', async () => {
++ const small = createLimiter({ perMinute: 120, dailyCap: 100000 });
++ const g = createGateway({ db, config, defs: [def], limiter: small, defaultRequestOptions: app.options });
++ for (let i = 0; i < 120; i++) {
++ await g.call({ subject: other, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.read', input: {} });
++ }
++ let err = null;
++ try { await g.call({ subject: other, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.read', input: {} }); } catch (e) { err = e; }
++ assert.ok(err, 'the 121st call is refused');
++ assert.strictEqual(err.status, 429);
++ assert.strictEqual(err.code, 'connect.rate_limited');
++ });
++
++ await check('the daily cap is on top of the minute window', async () => {
++ const daily = createLimiter({ perMinute: 1000, dailyCap: 2 });
++ const g = createGateway({ db, config, defs: [def], limiter: daily, defaultRequestOptions: app.options });
++ await g.call({ subject: third, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.read', input: {} });
++ await g.call({ subject: third, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.read', input: {} });
++ let err = null;
++ try { await g.call({ subject: third, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.read', input: {} }); } catch (e) { err = e; }
++ assert.ok(err, 'the third call is refused');
++ assert.strictEqual(err.code, 'connect.daily_cap');
++ });
++
++ await check('a standing rule lets one recipe stop asking', async () => {
++ await approvals.setRule(db, owner, 'testapp', 'testapp.send', true);
++ const out = await gateway.call({ subject: owner, callerId: caller.id, callerName: 'The owner', actionName: 'testapp.send', input: { to: 'with a rule' } });
++ assert.strictEqual(out.status, 200, 'the rule let it through');
++ const rows = await approvals.list(db, owner, { state: 'pending' });
++ assert.strictEqual(rows.length, 0, 'nothing waits');
++ });
++ } finally {
++ await app.stop();
++ console.log = quiet;
++ }
++ console.log(failures ? `\n${failures} failed` : '\nconnect gateway: all checks passed');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/connect-mcp.test.js b/test/connect-mcp.test.js
nnew file mode 100644
iindex 00000000..1b2e4a39
---- /dev/null
++++ b/test/connect-mcp.test.js
@@@ -0,0 +1,130 @@
++'use strict';
++// The MCP server (Compute plan E7, server/connect/mcp.js): a real MCP client lists the tools of the apps
++// the person connected and the caller was granted, and calls one. The client's own token is the key
++// the person made on the Apps page. A caller with no grant sees no tools; an unknown token is
++// refused; a call that fails answers with isError, not with a crash.
++// node test/connect-mcp.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
++const { StreamableHTTPClientTransport } = require('@modelcontextprotocol/sdk/client/streamableHttp.js');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const vault = require('../server/connect/vault');
++const callers = require('../server/connect/callers');
++const { createGateway } = require('../server/connect/gateway');
++const { createMcpHandler } = require('../server/connect/mcp');
++const { startApp } = require('./helpers/connect-app');
++
++(async () => {
++ const quiet = console.log;
++ console.log = () => {};
++ const db = getDb();
++ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user')`).run();
++ const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());
++ const config = { secrets: { key: crypto.randomBytes(32).toString('base64') }, jwt: { issuer: 'https://openvibe.network' } };
++
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); } };
++
++ const seen = [];
++ const app = await startApp((req, res) => {
++ seen.push(req.url);
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ login: 'octocat', id: 7 }));
++ });
++ const def = {
++ key: 'testapp', name: 'Test App', apiHost: app.host,
++ auth: { kind: 'token', fields: [{ name: 'default', label: 'Token', where: 'header', header: 'Authorization', scheme: 'Bearer' }] },
++ test: { method: 'GET', path: '/read' },
++ actions: [
++ { name: 'testapp.read', title: 'Read', risk: 'read', description: 'read a thing', input: { type: 'object', properties: { q: { type: 'string' } }, required: [], additionalProperties: false }, request: { method: 'GET', path: '/read' } },
++ ],
++ };
++
++ const gateway = createGateway({ db, config, defs: [def], defaultRequestOptions: app.options });
++ const expressApp = express();
++ expressApp.use(express.json({ limit: '256kb' }));
++ expressApp.post('/mcp', createMcpHandler({ db, config, gateway }));
++ const server = http.createServer(expressApp);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${server.address().port}/mcp`;
++
++ const connect = async (token) => {
++ const client = new Client({ name: 'connect-test', version: '1.0' }, { capabilities: {} });
++ const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: 'Bearer ' + token } } });
++ await client.connect(transport);
++ return client;
++ };
++
++ try {
++ await vault.store(db, owner, 'testapp', 'default', 'the-token', { config });
++ const made = await callers.createKeyCaller(db, owner, 'Claude Code');
++ const key = made.key;
++
++ await check('a real MCP client lists the tools of the connected apps', async () => {
++ const client = await connect(key);
++ try {
++ const listed = await client.listTools();
++ const tools = listed.tools || listed || [];
++ assert.strictEqual(tools.length, 0, 'no grant yet, no tools');
++ await callers.setGrant(db, made.caller.id, 'testapp', 'read', owner);
++ const after = await client.listTools();
++ const afterTools = after.tools || after || [];
++ assert.strictEqual(afterTools.length, 1);
++ assert.strictEqual(afterTools[0].name, 'testapp.read');
++ assert.ok(afterTools[0].inputSchema, 'the tool carries its schema');
++ assert.strictEqual(afterTools[0].annotations.readOnlyHint, true);
++ } finally { await client.close(); }
++ });
++
++ await check('a real MCP client calls a tool and gets the answer', async () => {
++ const client = await connect(key);
++ try {
++ const out = await client.callTool({ name: 'testapp.read', arguments: { q: 'hello' } });
++ assert.ok(!out.isError, 'the call worked');
++ const text = out.content.map((c) => c.text || '').join('\n');
++ assert.ok(text.includes('octocat'), text);
++ assert.ok(seen.some((u) => u.startsWith('/read')), 'the app was called');
++ } finally { await client.close(); }
++ });
++
++ await check('a call with a bad input is refused, and the answer says so', async () => {
++ const client = await connect(key);
++ try {
++ const out = await client.callTool({ name: 'testapp.read', arguments: { q: 42 } });
++ assert.strictEqual(out.isError, true, 'the bad input is refused');
++ } finally { await client.close(); }
++ });
++
++ await check('an unknown token is refused', async () => {
++ let refused = false;
++ try { await connect('ovc_cal_00000000000000000000000000_nope'); } catch (e) { refused = true; }
++ assert.ok(refused, 'the client cannot connect');
++ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) });
++ assert.strictEqual(r.status, 401);
++ });
++
++ await check('the server speaks the MCP initialize handshake', async () => {
++ const r = await fetch(url, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', authorization: 'Bearer ' + key },
++ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'x', version: '1' } } }),
++ });
++ assert.strictEqual(r.status, 200);
++ const body = await r.json();
++ assert.strictEqual(body.jsonrpc, '2.0');
++ assert.strictEqual(body.result.protocolVersion, '2025-06-18');
++ assert.ok(body.result.capabilities.tools, 'the server offers tools');
++ assert.ok(body.result.serverInfo.name, 'the server says its name');
++ });
++ } finally {
++ await app.stop();
++ server.close();
++ console.log = quiet;
++ }
++ console.log(failures ? `\n${failures} failed` : '\nconnect mcp: all checks passed');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/connect-vault.test.js b/test/connect-vault.test.js
nnew file mode 100644
iindex 00000000..2765a621
---- /dev/null
++++ b/test/connect-vault.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++// The vault (Compute plan E7, server/connect/vault.js): a credential is sealed at rest under Network's
++// KEK, with the person's subject and the app as the authenticated data; a wrong subject cannot decrypt
++// it; a credential the test call refuses is never stored; the export lists the apps and never the
++// secrets; the deletion erases them; without the key the feature is off.
++// node test/connect-vault.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const vault = require('../server/connect/vault');
++const { startApp, json } = require('./helpers/connect-app');
++
++(async () => {
++ const quiet = console.log;
++ console.log = () => {};
++ const db = getDb();
++ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (10, 'owner', 'x', 'user'), (11, 'other', 'x', 'user')`).run();
++ const owner = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 10').get());
++ const other = await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 11').get());
++
++ const KEY = crypto.randomBytes(32).toString('base64');
++ const config = { secrets: { key: KEY } };
++ const SECRET = 'github_pat_' + crypto.randomBytes(20).toString('hex');
++
++ let failures = 0;
++ const check = async (name, fn) => { try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); } };
++
++ try {
++ await check('a credential is sealed at rest: the row holds ciphertext, never the secret', async () => {
++ const view = await vault.store(db, owner, 'github', 'default', SECRET, { scopes: ['Issues: read and write'], config });
++ assert.strictEqual(view.app, 'github');
++ assert.strictEqual(view.label, 'default');
++ assert.deepStrictEqual(view.scopes, ['Issues: read and write']);
++ assert.ok(!('value' in view) && !('ciphertext' in view), 'the view is metadata only');
++ const row = await db.prepare('SELECT * FROM connect_credentials WHERE subject = ? AND app = ?').get(owner, 'github');
++ assert.ok(row.ciphertext && row.iv && row.tag, 'sealed');
++ assert.ok(!JSON.stringify(row).includes(SECRET), 'the secret is nowhere in the row');
++ const opened = await vault.open(db, owner, 'github', 'default', { config });
++ assert.strictEqual(opened, SECRET, 'the gateway can open it');
++ });
++
++ await check('a wrong subject cannot decrypt it, and a ciphertext copied onto another row does not open', async () => {
++ const row = await db.prepare('SELECT * FROM connect_credentials WHERE subject = ? AND app = ?').get(owner, 'github');
++ const kr = vault.keyring(config);
++ assert.strictEqual(vault.openSealed(kr, row), SECRET, 'the right row opens');
++ assert.strictEqual(vault.openSealed(kr, { ...row, subject: other }), null, 'another person\'s row does not open');
++ assert.strictEqual(vault.openSealed(kr, { ...row, app: 'slack' }), null, 'another app\'s row does not open');
++ assert.strictEqual(await vault.open(db, other, 'github', 'default', { config }), null, 'another person gets nothing');
++ });
++
++ await check('a refused test call stores nothing', async () => {
++ const app = await startApp((req, res) => {
++ res.writeHead(401, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ message: 'Bad credentials' }));
++ });
++ try {
++ const def = { key: 'github', name: 'GitHub', apiHost: app.host, auth: { kind: 'token', fields: [{ name: 'default', label: 'Token', where: 'header', header: 'Authorization', scheme: 'Bearer' }] }, test: { method: 'GET', path: '/user' }, actions: [] };
++ const { createGateway } = require('../server/connect/gateway');
++ const gateway = createGateway({ db, config, defs: [def] });
++ let refused = null;
++ try {
++ await gateway.test({ subject: owner, appKey: 'github', credential: 'wrong-token', requestOptions: app.options });
++ } catch (e) { refused = e; }
++ assert.ok(refused, 'the refused test call throws');
++ assert.strictEqual(refused.status, 400);
++ const rows = await db.prepare('SELECT COUNT(*) AS c FROM connect_credentials WHERE subject = ? AND app = ?').get(owner, 'github');
++ assert.strictEqual(Number(rows.c), 1, 'only the credential stored before is there');
++ } finally { await app.stop(); }
++ });
++
++ await check('the export lists the apps, never the secrets', async () => {
++ await vault.store(db, owner, 'slack', 'default', 'xoxb-' + crypto.randomBytes(8).toString('hex'), { config });
++ const apps = await vault.exportFor(db, owner);
++ assert.deepStrictEqual(apps.sort(), ['github', 'slack']);
++ const text = JSON.stringify(apps);
++ assert.ok(!text.includes(SECRET) && !text.includes('xoxb-'), 'no secret in the export');
++ });
++
++ await check('the deletion erases the credentials', async () => {
++ const out = await vault.eraseFor(db, owner);
++ assert.ok(out.erased >= 2, 'the credentials are gone');
++ assert.deepStrictEqual(await vault.list(db, owner), []);
++ assert.deepStrictEqual(await vault.exportFor(db, owner), []);
++ const rows = await db.prepare('SELECT COUNT(*) AS c FROM connect_credentials WHERE subject = ?').get(owner);
++ assert.strictEqual(Number(rows.c), 0, 'the rows are gone, not just hidden');
++ });
++
++ await check('without the key the feature is off (503), never plaintext', async () => {
++ const noKey = { secrets: { key: '' } };
++ let err = null;
++ try { await vault.store(db, owner, 'github', 'default', SECRET, { config: noKey }); } catch (e) { err = e; }
++ assert.ok(err && err.status === 503 && err.code === 'connect.vault_unavailable', 'the vault is off');
++ const rows = await db.prepare('SELECT COUNT(*) AS c FROM connect_credentials WHERE subject = ? AND app = ?').get(owner, 'github');
++ assert.strictEqual(Number(rows.c), 0, 'nothing was stored');
++ });
++
++ await check('a credential is listed with its app, label, scopes and times', async () => {
++ await vault.store(db, owner, 'github', 'default', SECRET, { scopes: ['Issues: read'], config });
++ const list = await vault.list(db, owner);
++ assert.strictEqual(list.length, 1);
++ assert.strictEqual(list[0].app, 'github');
++ assert.strictEqual(list[0].label, 'default');
++ assert.deepStrictEqual(list[0].scopes, ['Issues: read']);
++ assert.ok(list[0].created_at, 'when it was added');
++ assert.strictEqual(list[0].last_used_at, null, 'not used yet');
++ await vault.touch(db, list[0].id);
++ const after = await vault.list(db, owner);
++ assert.ok(after[0].last_used_at, 'when it was last used');
++ });
++ } finally {
++ console.log = quiet;
++ }
++ console.log(failures ? `\n${failures} failed` : '\nconnect vault: all checks passed');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/fixtures/connect-cert.pem b/test/fixtures/connect-cert.pem
nnew file mode 100644
iindex 00000000..0ffcad88
---- /dev/null
++++ b/test/fixtures/connect-cert.pem
@@@ -0,0 +1,19 @@
++-----BEGIN CERTIFICATE-----
++MIIDFTCCAf2gAwIBAgIUR928ZQPQDSiM6wv2auL5Z0FvagEwDQYJKoZIhvcNAQEL
++BQAwGTEXMBUGA1UEAwwOYXBpLnRlc3QubG9jYWwwIBcNMjYxMTA2MTkyNzQyWhgP
++MjEyNjEwMTMxOTI3NDJaMBkxFzAVBgNVBAMMDmFwaS50ZXN0LmxvY2FsMIIBIjAN
++BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+TjZGjV68BWSrP/amDWX+Kcg08mn
++59arpMYKrBuuLhRIRFmw57DGl6K/muXkzCF2EWt1LZVwuQMO0DtEuiJ1jp/0fC3R
++yebD3A8wyDmvxkMleP/cALx61dn7twRLGTrIUcsyWPFKmYwvqebCLZrzzLYbaaPS
++ECj2c387NDIK9bgjXaOB+iI74+1CXLHlD84BjegemYgecCYKrJKvIH/Es7VaKjN+
++P5YfuW3gO7w5c376DE6/a+AiiP+43y6rOREM+/OioNtXweETeEk+FBHd1OPU+IxO
++RBo5PSfFyllwSBokT67/xSJkOPNluIZnWHFUT20jNTGJxH1qRUbboX/mzQIDAQAB
++o1MwUTAdBgNVHQ4EFgQUnPiC64NDRjHb7GT+/0qmzWBt06IwHwYDVR0jBBgwFoAU
++nPiC64NDRjHb7GT+/0qmzWBt06IwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B
++AQsFAAOCAQEA4VhjDLNlFHYTuUAMwjhF2O53TWUtZT0sKGDmAK9gBv9KB2qVJvqq
++k7B331ubyTIp0TY/gUD5Fl60duqpR+5FIXQqn81fGymEuEdGgklNRmQaF6Z+37Aw
++p4HFt9rQwJR2gnspiUvNVLs0bv636OEue6ZhLWfk8Mhu0Tk1s5bE4drUd31J8OO2
++JvdDjxrjOtjvSbJN8dlTE7W92/45dTeLOgxx2ugidMI0Ty3o+dfBDuE4XCfdRl4h
++UEfIrJA6Rg9sjoDnBNeVfhKPSGVyiRYYJHMg/YYWNuy9F7pwqonCyyb0EMogsZpg
++giluo4OVwRSyIIvGSEh3k04KidM3PzuBDw==
++-----END CERTIFICATE-----
ddiff --git a/test/fixtures/connect-key.pem b/test/fixtures/connect-key.pem
nnew file mode 100644
iindex 00000000..3ef5b1b8
---- /dev/null
++++ b/test/fixtures/connect-key.pem
@@@ -0,0 +1,28 @@
++-----BEGIN PRIVATE KEY-----
++MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD5ONkaNXrwFZKs
++/9qYNZf4pyDTyafn1qukxgqsG64uFEhEWbDnsMaXor+a5eTMIXYRa3UtlXC5Aw7Q
++O0S6InWOn/R8LdHJ5sPcDzDIOa/GQyV4/9wAvHrV2fu3BEsZOshRyzJY8UqZjC+p
++5sItmvPMthtpo9IQKPZzfzs0Mgr1uCNdo4H6Ijvj7UJcseUPzgGN6B6ZiB5wJgqs
++kq8gf8SztVoqM34/lh+5beA7vDlzfvoMTr9r4CKI/7jfLqs5EQz786Kg21fB4RN4
++ST4UEd3U49T4jE5EGjk9J8XKWXBIGiRPrv/FImQ482W4hmdYcVRPbSM1MYnEfWpF
++Rtuhf+bNAgMBAAECggEABmUR0us/2oNGfXnaW7tNBWmKTiAvIgWvMpONgAWxsAWH
++JnmYdDJroOuOhwDhqASJE7VouHWfZJFUMgHQ9rpPGV+DSJzHTwVEzdOyT1yy3W6S
++hdfrFfneO0Abygme5mTbtUxnUQ4Mak69lFqAXn1/c68N1rh5H04lxXON+/Zv751V
++jUT7wVDA2rVUb4YTy2loSGyXFfWqVbf6myyHbyn8Efjx9Tt5monx0R0wt1m4XaiB
++z+rtpNUpYq9yZC3B+G7LGdrR4LzvqETRw6hWMkGxKbMNwWyoUTirc5OTddxAsGeJ
++MGQkUd+e62mw6rHePgnJV1p2qTgblUW4KTbEnvPdswKBgQD8nsVtFS49i9aI/0jg
++0x+++33ilxd6YOVi7Ru5kGK2xa2n64qaOfdbJqsjG5FNdwAGFWpozpZhX3yJWZ4m
++ueWIR0iBJcNlolX/3XuZjdEDWpn7o7BWqq+sScdcKqH7sm5lURHH4Ppy6tcZB0xZ
++BYBeKdq9ZADzWmU4N8667QUQ+wKBgQD8jnAscfcyKdE6xLsJLWd1l3QcN1/2UdId
++cS1/poI+434Bo6n8XSk5f45B10iw5ch4AgI9EQQNPW1MB1XG9DGOzoiUFisG3zt2
++QGjyza+oD1MESGXxu/v+QG8yM83CWReV3djIDy9JK6qVhJsIymev1V/DoN5CRPvf
++Ri2rhKKs1wKBgCJdKrgLAI5IvndGWtCSh6gAKACx0vTHx4xserAEbxkzzMqh5OnT
++5s0UdoVBE8P44xzwvtfg+l3mkldQxI26Dfo60CcZTvo9WfuDUnPx8bPGGFo9yxgP
++AfSq2J62AfDuNcw8efG5rfgB8B6sentFn0+v29yVhPeVnV1HYizVXzIPAoGBAIo7
++8FvzWQkE6HeRPIpuZqVva2cSTB0HdckVrIClA78mR07gZOczmp/VGGkjS68/ZjWB
++a/Y+vQf+KjemACYwfHucwhbD93MAdoxn/sl/GXkUQi0i72GbsfbdD7/bqPCKUSV/
++UutVnF0hYHIMLctm9c/BK3RpNZ3yIiZ4RAEQTPXrAoGBAOtp1DOfWbH0kPNmpLYj
++PDPyQmuWVvnX6CBxH/aPFkVU0pnzksazCV53BkJh5WgAdRmo4PXWH3wb1mGljyKA
++6v92TISgVOzDe96syPjGh4meV6MpxWxlYLi8IpWuPHyh2mBQ0F63XkJUb85OlOAv
++r/h6VQ9pJuoSmy+RoRYebl4n
++-----END PRIVATE KEY-----
ddiff --git a/test/helpers/connect-app.js b/test/helpers/connect-app.js
nnew file mode 100644
iindex 00000000..58a42919
---- /dev/null
++++ b/test/helpers/connect-app.js
@@@ -0,0 +1,42 @@
++'use strict';
++/**
++ * A stand-in for an app's API, for the Connect tests: https on loopback, with a scripted answer.
++ *
++ * The gateway never calls an address a test chose: it calls the app's own host, through the egress
++ * guard. The test injects the resolver (lookup) and the TLS options, so the guard is exercised for
++ * real (the host name is checked, the redirect is checked, the literal is checked) while the socket
++ * lands on the test's own server.
++ */
++const https = require('https');
++const fs = require('fs');
++const path = require('path');
++
++const KEY = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'connect-key.pem'));
++const CERT = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'connect-cert.pem'));
++
++/** Starts the stand-in on a free port. → { port, host, options, stop } */
++async function startApp(handler) {
++ const server = https.createServer({ key: KEY, cert: CERT }, handler);
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ const port = server.address().port;
++ return {
++ port,
++ // A public-looking host name: the guard refuses *.local and names without a dot.
++ host: 'api.test.example:' + port,
++ // The resolver the guard uses: the app's host name always lands on this server.
++ options: {
++ lookup: (hostname, opts, cb) => cb(null, [{ address: '127.0.0.1', family: 4 }]),
++ isAllowed: () => true,
++ tls: { rejectUnauthorized: false },
++ },
++ stop: () => new Promise((resolve) => server.close(resolve)),
++ };
++}
++
++/** The JSON answer of one request, as a string. */
++const json = (status, body, headers = {}) => {
++ const text = typeof body === 'string' ? body : JSON.stringify(body);
++ return { status, headers: { 'content-type': 'application/json', ...headers }, body: text };
++};
++
++module.exports = { startApp, json };
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex 8626129f..a56a9f19 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'consent.js', 'labels.js', 'security.js', 'approvals.js', 'apps.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 273d8ca2..3ff3afbd 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -110,6 +110,7 @@ const PATTERNS = {
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
++ 'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 56f705bd..859987ed 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');