openvibers/openvibe.network Commits
Connect apps, phase 1 (E7): the vault, the catalogue and the gateway (#600)
Changes
@@@ -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"nnew file mode 100644@@@ -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)++);@@@ -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"++ }}}}@@@ -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"}}nnew file mode 100644@@@ -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();++ });++})();nnew file mode 100644@@@ -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 }));++ }++}@@@ -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)) {@@@ -113,6 +113,7 @@<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & 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>nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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: [],++};nnew file mode 100644@@@ -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}' },++ },++ ],++};nnew file mode 100644@@@ -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}' },++ },++ },++ ],++};nnew file mode 100644@@@ -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' } },++ },++ ],++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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}' },++ },++ },++ },++ ],++};nnew file mode 100644@@@ -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}' } }],++ },++ },++ },++ ],++};nnew file mode 100644@@@ -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}' } },++ },++ ],++};nnew file mode 100644@@@ -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}' } },++ },++ ],++};nnew file mode 100644@@@ -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}' } },++ },++ ],++};nnew file mode 100644@@@ -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}' },++ },++ ],++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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@@@ -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.nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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-----nnew file mode 100644@@@ -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-----nnew file mode 100644@@@ -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 };@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst 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]);@@@ -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'],@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -10,6 +10,12 @@ condition = "AND"
@@ -10,6 +10,12 @@ condition = "AND"
paths = ['''^test/security-ssrf\.test\.js$''']
paths = ['''^test/security-ssrf\.test\.js$''']
regexes = ['''^tBHItJI5svbpez7KI4CCXg$''']
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]]
[[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"
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"
condition = "AND"
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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)
+);
@@ -29,7 +29,8 @@
@@ -29,7 +29,8 @@
"web-push": "^3.6.7"
"web-push": "^3.6.7"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "@modelcontextprotocol/sdk": "^1.32.2"
}
}
},
},
"node_modules/@acemir/cssom": {
"node_modules/@acemir/cssom": {
@@ -175,12 +176,493 @@
@@ -175,12 +176,493 @@
"devOptional": true,
"devOptional": true,
"license": "Apache-2.0"
"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": {
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
"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": {
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -589,6 +1071,21 @@
@@ -589,6 +1071,21 @@
"url": "https://opencollective.com/express"
"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": {
"node_modules/css-select": {
"version": "7.0.0",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
@@ -890,6 +1387,29 @@
@@ -890,6 +1387,29 @@
"node": ">= 0.6"
"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": {
"node_modules/express": {
"version": "4.22.2",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -1125,6 +1645,16 @@
@@ -1125,6 +1645,16 @@
"url": "https://github.com/sponsors/EvanHahn"
"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": {
"node_modules/html-escaper": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -1355,6 +1885,16 @@
@@ -1355,6 +1885,16 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"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": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1373,18 +1913,49 @@
@@ -1373,18 +1913,49 @@
"node": ">=8"
"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": {
"node_modules/isarray": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
"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": {
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"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": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1721,6 +2292,16 @@
@@ -1721,6 +2292,16 @@
"node": ">= 0.8"
"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": {
"node_modules/openvibe-contracts": {
"version": "0.298.0",
"version": "0.298.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
@@ -1832,6 +2413,16 @@
@@ -1832,6 +2413,16 @@
"node": ">=8"
"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": {
"node_modules/path-to-regexp": {
"version": "0.1.13",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -1927,6 +2518,16 @@
@@ -1927,6 +2518,16 @@
"split2": "^4.1.0"
"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": {
"node_modules/pngjs": {
"version": "5.0.0",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -2121,6 +2722,59 @@
@@ -2121,6 +2722,59 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
"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": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2216,6 +2870,29 @@
@@ -2216,6 +2870,29 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
"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": {
"node_modules/side-channel": {
"version": "1.1.1",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -2487,6 +3164,22 @@
@@ -2487,6 +3164,22 @@
"node": ">= 16"
"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": {
"node_modules/which-module": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@ -2507,6 +3200,13 @@
@@ -2507,6 +3200,13 @@
"node": ">=8"
"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": {
"node_modules/ws": {
"version": "8.21.3",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@@ -2577,6 +3277,26 @@
@@ -2577,6 +3277,26 @@
"engines": {
"engines": {
"node": ">=6"
"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"
+ }
}
}
}
}
}
}
@@ -33,6 +33,7 @@
@@ -33,6 +33,7 @@
"web-push": "^3.6.7"
"web-push": "^3.6.7"
},
},
"devDependencies": {
"devDependencies": {
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "@modelcontextprotocol/sdk": "^1.32.2"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,256 @@
@@ -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();
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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 }));
+ }
+}
@@ -161,6 +161,7 @@ function showSection(id, btn) {
@@ -161,6 +161,7 @@ function showSection(id, btn) {
if (id === 'content' && typeof loadAdultSection === 'function') loadAdultSection();
if (id === 'content' && typeof loadAdultSection === 'function') loadAdultSection();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
if (id === 'machines' && typeof loadMachines === 'function') loadMachines();
+ if (id === 'apps' && typeof loadConnectApps === 'function') loadConnectApps();
}
}
function openInitialSection() {
function openInitialSection() {
@@ -169,7 +170,7 @@ function openInitialSection() {
@@ -169,7 +170,7 @@ function openInitialSection() {
const hash = window.location.hash.replace(/^#/, '');
const hash = window.location.hash.replace(/^#/, '');
const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';
const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
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');
return showSection('anon');
}
}
if (document.getElementById('sec-' + routeSection)) {
if (document.getElementById('sec-' + routeSection)) {
@@ -113,6 +113,7 @@
@@ -113,6 +113,7 @@
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
<button onclick="showSection('content',this)"><i class="fa-solid fa-shield-halved"></i> Content</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('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('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('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>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
@@ -384,6 +385,18 @@
@@ -384,6 +385,18 @@
</div>
</div>
</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="section" id="sec-linked">
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
@@ -555,6 +568,7 @@
@@ -555,6 +568,7 @@
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/labels.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/approvals.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="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</body>
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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: [],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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}' },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -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}' },
+ },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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' } },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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}' },
+ },
+ },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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}' } }],
+ },
+ },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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}' } },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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}' } },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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}' } },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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}' },
+ },
+ ],
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,304 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,256 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -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,
+};
@@ -453,6 +453,20 @@ app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router())
@@ -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
// 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).
// the @name check it uses (server/sharing/routes.js).
app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));
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).
// 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 }));
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
// 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'));
@@ -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
// "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.
// 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() }));
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));
app.get('/pay/:id', (_req, res) => sendPayPage(res));
// Account hub (my.html) — the apex hosts the account hub under /my
// Account hub (my.html) — the apex hosts the account hub under /my
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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-----
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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-----
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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 };
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -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();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// 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');
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 tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -110,6 +110,7 @@ const PATTERNS = {
@@ -110,6 +110,7 @@ const PATTERNS = {
};
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
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/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/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'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');