openvibers/openvibe.codes Commits

OpenVibe.Codes service (Wave 20): SSR developer portal over Network's projects API (PKCE S256 sign-in, httpOnly session; projects, members, apps, secrets shown once and never stored, credential rotate/revoke, grant scope editor offering only grantable capabilities, quotas as recorded limits, audit; Network problems surfaced as answered), docs generated at boot from openvibe-contracts v0.26.0 and openvibe-sdk v0.2.2 (contracts with field tables and fixtures, capability catalog, event types, SDK reference, ADR-derived policy pages) plus the live registry, OAuth callback helper and non-exchanging test callback, webhook verifier and signed sample generator, grant-respecting Media/Events playgrounds with the app's own token through the SDK, app and mod manifest validation, release metadata with codes.app.published|deprecated|revoked through the SDK outbox, trust tiers as metadata; tests with mock Network, Events and Media

OP OpenVibers committed 23 Sept 2026 · 0deb3d7db2 · 46 files, +7107 −0


Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..7f6dc38
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1549 @@
++{
++ "name": "openvibe-codes",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-codes",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "busboy": "^1.6.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "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/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/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==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "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==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.26.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
++ "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.2.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
++ "integrity": "sha512-w8yl/5+9eend8I2O/DzAJnEAL2N3GTUY7c7pzK+MLJuLAGD8FmJJQ9gwbgyrq70Snjt3ebrg3TlpORhRAp2kgA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.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",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "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/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/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==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.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==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..1530fba
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,37 @@
++{
++ "name": "openvibe-codes",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Codes: the developer portal. Projects, apps, credentials and grants over OpenVibe.Network's API (Network owns them), docs generated from the pinned contracts and SDK, OAuth callback helper, webhook tester, grant-respecting playgrounds, manifest validation and release metadata. Server-rendered and useful without JavaScript.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js"
++ },
++ "keywords": [
++ "openvibe",
++ "developer-portal",
++ "oauth",
++ "webhooks",
++ "sdk"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Codes",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "busboy": "^1.6.0",
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/public/css/codes.css b/public/css/codes.css
nnew file mode 100644
iindex 0000000..8c8440f
---- /dev/null
++++ b/public/css/codes.css
@@@ -0,0 +1,67 @@
++/* OpenVibe.Codes — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */
++:root { color-scheme: dark; }
++* { box-sizing: border-box; }
++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--accent-light, #60a5fa); }
++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.skip { position: absolute; left: -9999px; }
++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
++.account-bar { max-width: 1040px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
++.page { max-width: 1040px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
++h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
++h1 small { font-size: .95rem; color: var(--text-secondary, #96a7c2); font-weight: 400; }
++h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
++h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
++.lead { color: var(--text-secondary, #96a7c2); font-size: 1.08rem; }
++.muted { color: var(--text-muted, #7386a3); }
++.small { font-size: .85rem; }
++.versions { color: var(--text-secondary, #96a7c2); font-size: .9rem; }
++.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
++code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
++code { overflow-wrap: anywhere; }
++pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }
++pre code { overflow-wrap: normal; }
++.table-wrap { overflow-x: auto; }
++table { border-collapse: collapse; width: 100%; font-size: .92rem; }
++th, td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--border, #1f2d47); }
++th { color: var(--text-secondary, #96a7c2); font-weight: 600; }
++a.grantable { font-weight: 600; }
++table.caps tr:has(a.grantable) { background: color-mix(in srgb, var(--success, #22c55e) 8%, transparent); }
++.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
++.facts dt { color: var(--text-secondary, #96a7c2); }
++.facts dd { margin: 0; overflow-wrap: anywhere; }
++ul.plain { list-style: none; padding: 0; margin: 0; }
++ul.cards { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
++ul.cards li { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; background: var(--bg-card, #131c2e); display: flex; flex-direction: column; gap: 4px; }
++ul.cards span { color: var(--text-secondary, #96a7c2); font-size: .9rem; }
++ol.steps li { margin: .4rem 0; }
++.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; }
++.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
++.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
++.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
++.notice, .problem, .result { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
++.notice.ok, .result.ok { border-left-color: var(--success, #22c55e); }
++.notice.warn { border-left-color: var(--warning, #f59e0b); }
++.notice.bad, .problem, .result.bad { border-left-color: var(--error, #ef4444); }
++.problem p, .result p, .notice p { margin: .3rem 0; }
++button, .button { font: inherit; display: inline-block; padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-tertiary, #172238); color: var(--text-primary, #e6edf7); cursor: pointer; text-decoration: none; }
++button:hover, .button:hover { border-color: var(--accent, #3b82f6); }
++button.danger { border-color: var(--error, #ef4444); }
++input, select, textarea { font: inherit; padding: 5px 8px; border-radius: 6px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); max-width: 100%; }
++textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
++form.stack { display: flex; flex-direction: column; gap: 10px; max-width: 760px; margin: 10px 0; }
++form.stack label { display: flex; flex-direction: column; gap: 4px; }
++form.stack label:has(input[type=checkbox]), form.stack label:has(input[type=radio]) { flex-direction: row; align-items: center; gap: 8px; }
++form.stack button { align-self: flex-start; }
++form.inline-form { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0; }
++form.inline-box { border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; }
++fieldset { border: 1px solid var(--border, #1f2d47); border-radius: 8px; display: flex; flex-direction: column; gap: 6px; }
++.secret-box { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--warning, #f59e0b); border-radius: 10px; padding: 12px 14px; max-width: 760px; }
++.secret-box label { display: flex; flex-direction: column; gap: 4px; }
++.secret-box input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; width: 100%; }
++.decl { border-top: 1px solid var(--border, #1f2d47); }
++.decl h2 small { color: var(--text-muted, #7386a3); font-weight: 400; font-size: .85rem; }
++.toc ul { columns: 2; font-size: .9rem; }
++.prose { max-width: 820px; }
++.buttons { display: flex; gap: 8px; flex-wrap: wrap; }
++@media (max-width: 640px) { .facts { grid-template-columns: 1fr; } .facts dt { margin-top: 6px; } .toc ul { columns: 1; } }
ddiff --git a/public/js/copy.js b/public/js/copy.js
nnew file mode 100644
iindex 0000000..269b55a
---- /dev/null
++++ b/public/js/copy.js
@@@ -0,0 +1,15 @@
++// OpenVibe.Codes — optional copy buttons (the value is always selectable without JavaScript).
++(function () {
++ 'use strict';
++ document.querySelectorAll('button.copy[data-copy]').forEach(function (btn) {
++ var target = document.querySelector(btn.getAttribute('data-copy'));
++ if (!target || !navigator.clipboard) return;
++ btn.hidden = false;
++ btn.addEventListener('click', function () {
++ navigator.clipboard.writeText(target.value || target.textContent || '').then(function () {
++ btn.textContent = 'Copied';
++ setTimeout(function () { btn.textContent = 'Copy secret'; }, 2000);
++ }, function () { target.select && target.select(); });
++ });
++ });
++})();
ddiff --git a/public/js/webhook.js b/public/js/webhook.js
nnew file mode 100644
iindex 0000000..d6fa793
---- /dev/null
++++ b/public/js/webhook.js
@@@ -0,0 +1,49 @@
++// OpenVibe.Codes — webhook signature check computed in the browser (Web Crypto), so the secret
++// never leaves this page. Without JavaScript (or Web Crypto) the form posts to Codes instead,
++// which computes the same HMAC and forgets the secret.
++(function () {
++ 'use strict';
++ var form = document.getElementById('verify-form');
++ var out = document.getElementById('verify-result');
++ if (!form || !out || !window.crypto || !window.crypto.subtle || !window.TextEncoder) return;
++
++ function hex(buf) {
++ return Array.prototype.map.call(new Uint8Array(buf), function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
++ }
++ // Compare every character regardless of where the first difference is.
++ function equal(a, b) {
++ if (a.length !== b.length) return false;
++ var diff = 0;
++ for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
++ return diff === 0;
++ }
++ function show(ok, lines) {
++ out.textContent = '';
++ var box = document.createElement('div');
++ box.className = 'result ' + (ok ? 'ok' : 'bad');
++ box.setAttribute('role', 'status');
++ lines.forEach(function (l, i) {
++ var p = document.createElement('p');
++ if (i === 0) { var s = document.createElement('strong'); s.textContent = l; p.appendChild(s); } else { p.textContent = l; }
++ box.appendChild(p);
++ });
++ out.appendChild(box);
++ }
++
++ form.addEventListener('submit', function (e) {
++ e.preventDefault();
++ var body = form.elements.body.value.replace(/\r\n/g, '\n');
++ var given = form.elements.signature.value.trim();
++ var secret = form.elements.secret.value;
++ if (!body || !secret) { show(false, ['Enter the raw body and the secret.']); return; }
++ var enc = new TextEncoder();
++ crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
++ .then(function (key) { return crypto.subtle.sign('HMAC', key, enc.encode(body)); })
++ .then(function (sig) {
++ var expected = 'sha256=' + hex(sig);
++ var ok = equal(given, expected);
++ show(ok, [ok ? 'Signature valid.' : 'Signature NOT valid.', 'Expected: ' + expected, 'Given: ' + (given || '(none)'), 'Computed in your browser; nothing was sent.']);
++ })
++ .catch(function () { form.submit(); });
++ });
++})();
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..c2a3037
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,147 @@
++'use strict';
++
++/**
++ * OpenVibe.Codes — Express app factory. server/index.js listens and starts the outbox relay; tests
++ * build their own instance with a temp database, an injectable clock and mock neighbours.
++ *
++ * / landing, policy, public release pages, staff trust (http/pages.js)
++ * /docs generated reference (http/docs.js)
++ * /oauth, /tools/webhooks, /manifests/validate developer tools (http/tools.js)
++ * /projects the signed-in portal over Network's projects API (http/portal.js)
++ * /releases/:id/* release actions (http/portal.js)
++ * /api/v1 JSON API (http/api.js)
++ * /auth/* Network SSO with PKCE (auth/sso.js)
++ * /api/health, /api/ready, /release.json, /metrics (loopback only)
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createKeyStore } = require('./auth/keys');
++const { createSso } = require('./auth/sso');
++const { createNetworkClient } = require('./clients/network');
++const { createCodesOutbox } = require('./events/outbox');
++const { generate } = require('./docs/generate');
++const { createTrust } = require('./domain/trust');
++const { createReleases } = require('./domain/releases');
++const { createPlayground } = require('./domain/playground');
++const { createDocsRoutes } = require('./http/docs');
++const { createToolRoutes } = require('./http/tools');
++const { createPageRoutes } = require('./http/pages');
++const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
++const { createApi } = require('./http/api');
++const { createCodesReadiness } = require('./observability');
++const { assetVersion, send } = require('./render/layout');
++const { html } = require('./render/html');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++
++/** opts: config, store | dbPath, now (clock), fetchImpl, log */
++function createApp(opts = {}) {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl;
++ const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
++
++ const docs = generate({ now: store.now });
++ const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
++ const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
++ const network = createNetworkClient({ config, fetchImpl, log });
++ const outbox = createCodesOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
++ const trust = createTrust({ store });
++ const releases = createReleases({ store, outbox, trust });
++ const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
++
++ const ctx = { config, store, docs, keys, sso, network, outbox, trust, releases, playground, log };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'codes', root: path.join(__dirname, '..') });
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'codes', release: release.release });
++ app.locals.metrics = metrics.registry;
++ app.locals.ctx = ctx;
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // The shared chrome (theme-loader, navbar) comes from the Network; the inline init is ours.
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ connectSrc: ["'self'", 'https://openvibe.network'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'none'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ frameguard: { action: 'deny' },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Machine endpoints ───────────────────────────────────
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
++ app.get('/release.json', release.handler);
++ const readiness = createCodesReadiness({ store, keys, network, outbox, docs, config, release: release.release });
++ app.get('/api/ready', readiness.handler);
++
++ // ── Who is asking (verified offline; refreshed when expired) ──
++ app.use(sso.middleware());
++
++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'codes', service: 'codes', host: 'openvibe.codes', name: 'OpenVibe.Codes', profile: 'ugc' })); }
++
++ // ── Static assets (content-hashed ?v= → immutable) ──────
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', v && v === assetVersion(rel) ? 'public, max-age=31536000, immutable' : 'public, max-age=300');
++ },
++ }));
++
++ // ── API ─────────────────────────────────────────────────
++ app.use('/api/v1', createApi(ctx));
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ // ── Pages ───────────────────────────────────────────────
++ app.use(rateLimit({ windowMs: 60_000, limit: 300, standardHeaders: true, legacyHeaders: false }));
++ app.use('/docs', createDocsRoutes(ctx));
++ app.use('/projects', rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false, skip: (req) => req.method === 'GET' }), createPortalRoutes(ctx));
++ app.use('/releases', createReleaseActionRoutes(ctx));
++ app.use(createToolRoutes(ctx));
++ app.use(createPageRoutes(ctx));
++ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try the <a href="/docs">docs</a>.</p>` }));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ // Never log request bodies: forms here carry secrets.
++ log.error('[Codes]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', 'private, no-store');
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return res.status(413).type('text/plain').send('That form was too large.');
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ });
++
++ return { app, ctx };
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/forms.js b/server/auth/forms.js
nnew file mode 100644
iindex 0000000..31d220e
---- /dev/null
++++ b/server/auth/forms.js
@@@ -0,0 +1,31 @@
++'use strict';
++
++/**
++ * Form tokens (CSRF) for every state-changing form. The session cookies are SameSite=Lax and
++ * httpOnly; the token is the second lock: an HMAC of the signed-in subject under CODES_FORM_SECRET
++ * (a random per-process key when unset, so forms opened before a restart must be reloaded).
++ * A POST whose Origin header names another site is refused before the token is even looked at.
++ */
++const crypto = require('crypto');
++
++const fallback = crypto.randomBytes(32).toString('hex');
++
++function csrfToken(config, viewer) {
++ if (!viewer || !viewer.subject) return '';
++ return crypto.createHmac('sha256', config.formSecret || fallback).update(`codes-form:${viewer.subject}`).digest('base64url').slice(0, 32);
++}
++
++function checkCsrf(config, viewer, token) {
++ const expected = csrfToken(config, viewer);
++ if (!expected || typeof token !== 'string' || token.length !== expected.length) return false;
++ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
++}
++
++/** True when the request's Origin (if any) is this site. */
++function sameOrigin(config, req) {
++ const origin = req.headers.origin;
++ if (!origin || origin === 'null') return !origin;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++}
++
++module.exports = { csrfToken, checkCsrf, sameOrigin };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..4658515
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,74 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched from GET /api/.well-known/jwks (internal URL first, then
++ * the public one) and cached. Tokens are verified offline against it: the person's access token
++ * (sign-in) and, in playgrounds, the app token a developer brings.
++ */
++const crypto = require('crypto');
++
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ let publicKey = null;
++ let lastFetch = 0;
++ let inflight = null;
++
++ async function fetchKey() {
++ for (const base of [config.networkInternalUrl, config.networkUrl]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
++ if (!res.ok) continue;
++ const jwks = await res.json();
++ if (jwks && typeof jwks.public_key === 'string') { publicKey = jwks.public_key; return publicKey; }
++ const jwk = jwks && Array.isArray(jwks.keys) ? jwks.keys.find((k) => k.kty === 'RSA') : null;
++ if (jwk) { publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' }); return publicKey; }
++ } catch (err) {
++ log.warn(`[Codes] JWKS fetch failed from ${base}: ${err && err.name === 'TimeoutError' ? 'timeout' : (err && err.message)}`);
++ }
++ }
++ return null;
++ }
++
++ async function ensure() {
++ if (publicKey) return publicKey;
++ if (inflight) return inflight;
++ if (Date.now() - lastFetch < 30_000) return null; // don't hammer a Network that is down
++ lastFetch = Date.now();
++ inflight = fetchKey().finally(() => { inflight = null; });
++ return inflight;
++ }
++
++ return { ensure, get: () => publicKey, loaded: () => Boolean(publicKey) };
++}
++
++const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
++
++/**
++ * Verify an RS256 JWT signed by the Network. Returns { ok, claims } or { ok: false, reason, expired }.
++ * `audience` is optional (Network user tokens are checked by issuer, like every OpenVibe site).
++ */
++function verifyJwt(token, { publicKey, issuer, audience, now = Date.now(), skewSec = 30 }) {
++ const fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
++ if (!publicKey) return fail('signing key not loaded');
++ const parts = typeof token === 'string' ? token.split('.') : [];
++ if (parts.length !== 3) return fail('not a JWT');
++ let header, claims;
++ try { header = b64json(parts[0]); claims = b64json(parts[1]); } catch { return fail('undecodable token'); }
++ if (!header || header.alg !== 'RS256') return fail('only RS256 tokens are accepted');
++ let good = false;
++ try { good = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url')); } catch { good = false; }
++ if (!good) return fail('signature does not verify');
++ if (!claims || typeof claims !== 'object') return fail('no claims');
++ if (issuer && claims.iss !== issuer) return fail('wrong issuer');
++ if (audience) {
++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
++ if (!aud.includes(audience)) return fail(`token is not for ${audience}`);
++ }
++ const t = Math.floor(now / 1000);
++ if (typeof claims.iat === 'number' && claims.iat - skewSec > t) return fail('issued in the future');
++ if (typeof claims.exp !== 'number') return fail('no expiry');
++ if (claims.exp + skewSec < t) return fail('expired', { expired: true, claims });
++ return { ok: true, claims };
++}
++
++module.exports = { createKeyStore, verifyJwt };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..4b3b336
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,223 @@
++'use strict';
++
++/**
++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client
++ * `codes` (the same shape as OpenVibe.Billing's console).
++ *
++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
++ * (?silent=1 adds prompt=none; ?next= a same-site path)
++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
++ * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie)
++ *
++ * Codes calls the Network's /api/v1/projects API with the person's access token, so it keeps that
++ * token — in an httpOnly cookie (codes_at) no script can read, unlike the navbar-readable ov_token
++ * other sites use: this portal shows client secrets once, and a script-readable token would let an
++ * injected script mint more. The refresh token (codes_rt) is httpOnly too. Neither is ever stored
++ * server-side, rendered or logged.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { verifyJwt } = require('./keys');
++
++const ACCESS_COOKIE = 'codes_at';
++const REFRESH_COOKIE = 'codes_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'codes_oauth';
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++/** Same-site relative paths only (never protocol-relative, never another origin). */
++function sanitizeNext(next) {
++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
++ return next;
++}
++
++function authorizeUrl(config, { state, challenge, silent = false }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.oauth.clientId,
++ redirect_uri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ if (silent) q.set('prompt', 'none');
++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
++}
++
++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
++
++ function setSession(res, data) {
++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, cookieBase());
++ res.clearCookie(REFRESH_COOKIE, cookieBase());
++ }
++
++ /** POST the Network token endpoint (internal URL). Throws { status, error } on refusal. */
++ async function tokenGrant(fields) {
++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
++ let res;
++ try {
++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(10_000),
++ });
++ } catch (err) {
++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
++ { status: res.status, error: (data && data.error) || 'invalid_grant' });
++ }
++ return data;
++ }
++
++ function viewerFromClaims(claims, token) {
++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
++ const role = typeof claims.role === 'string' ? claims.role : 'user';
++ return {
++ kind: 'user',
++ subject,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
++ role,
++ staff: role === 'admin' || (subject && config.staffSubjects.includes(subject)),
++ // The person's Network access token: used for server-side Network calls, never rendered.
++ token,
++ };
++ }
++
++ /** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
++ function refresh(req, res) {
++ if (!req.codesRefresh) req.codesRefresh = doRefresh(req, res);
++ return req.codesRefresh;
++ }
++
++ async function doRefresh(req, res) {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!rt) return null;
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
++ setSession(res, data);
++ const v = verifyJwt(data.access_token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });
++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
++ } catch (err) {
++ if (err.status && err.status < 500) clearSession(res);
++ return null;
++ }
++ }
++
++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
++ function middleware() {
++ return async (req, res, next) => {
++ req.viewer = { kind: 'anonymous' };
++ const token = req.cookies && req.cookies[ACCESS_COOKIE];
++ if (!token) return next();
++ await keys.ensure();
++ const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });
++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
++ if (v.expired) {
++ const fresh = await refresh(req, res);
++ if (fresh) { req.viewer = fresh; return next(); }
++ }
++ if (keys.loaded()) clearSession(res);
++ next();
++ };
++ }
++
++ /**
++ * Run fn(token) as the viewer; when the Network answers 401 (expired or revoked session), refresh
++ * once and run it again. Other failures are returned to the caller unchanged.
++ */
++ async function asViewer(req, res, fn) {
++ try {
++ return await fn(req.viewer.token);
++ } catch (err) {
++ if (!err || err.status !== 401) throw err;
++ const fresh = await refresh(req, res);
++ if (!fresh) throw err;
++ req.viewer = fresh;
++ return fn(fresh.token);
++ }
++ }
++
++ function routes() {
++ const r = express.Router();
++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
++
++ r.get('/login', (req, res) => {
++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
++ const state = crypto.randomBytes(16).toString('hex');
++ const { verifier, challenge } = pkcePair();
++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next), silent }), flowOpts());
++ res.set('Cache-Control', 'private, no-store');
++ res.redirect(authorizeUrl(config, { state, challenge, silent }));
++ });
++
++ r.get('/callback', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ let flow = null;
++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
++ const next = flow ? sanitizeNext(flow.next) : '/';
++ const { code, state, error } = req.query;
++ if (error) {
++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
++ }
++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
++ }
++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
++ setSession(res, data);
++ return res.redirect(next);
++ } catch (err) {
++ log.warn('[Codes] sign-in exchange failed:', err.status || '', err.error || '');
++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
++ }
++ });
++
++ r.get('/logout', async (req, res) => {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (rt) {
++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ res.redirect(sanitizeNext(req.query.next));
++ });
++
++ r.get('/me', (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const v = req.viewer;
++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role } });
++ });
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant, pkcePair };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/clients/network.js b/server/clients/network.js
nnew file mode 100644
iindex 0000000..ed16e54
---- /dev/null
++++ b/server/clients/network.js
@@@ -0,0 +1,121 @@
++'use strict';
++
++/**
++ * OpenVibe.Network through openvibe-sdk.
++ *
++ * projects /api/v1/projects… with the SIGNED-IN PERSON's access token (Bearer, server-side).
++ * Network owns projects, members, apps, credentials, grants and quotas (ADR-014); Codes
++ * renders what it answers and forwards what the person asks. It never stores any of it.
++ * registry /api/v1/registry/* and /.well-known/openvibe (public, cached briefly).
++ * appToken /oauth/token client_credentials for an app — only in playgrounds, with the secret the
++ * developer typed for that one request (never stored, never logged, never echoed).
++ *
++ * Mutations are never retried (the projects API does not dedupe), and a failure is returned as the
++ * Network's own problem (status, code, detail, request id) so pages can show it honestly.
++ */
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { createRegistryClient } = require('openvibe-sdk/registry');
++
++const enc = encodeURIComponent;
++
++/** A failure as pages show it. Never contains a request body. */
++function problemOf(err) {
++ if (isOpenVibeError(err)) {
++ const unreachable = !err.status || err.code.startsWith('sdk.');
++ return {
++ status: unreachable ? 502 : err.status,
++ code: unreachable ? `network.unreachable` : err.code,
++ detail: unreachable ? `OpenVibe.Network did not answer (${err.code.replace(/^sdk\./, '')}).` : (err.detail || err.title || ''),
++ requestId: err.requestId || null,
++ unreachable,
++ };
++ }
++ return { status: 500, code: 'codes.internal', detail: 'unexpected error', requestId: null, unreachable: false };
++}
++
++function createNetworkClient({ config, fetchImpl, log = console }) {
++ const base = config.networkInternalUrl;
++ const client = createClient({
++ network: base, autoDiscover: false, baseUrls: { network: base }, retries: 1, timeoutMs: 8000, deadlineMs: 15000,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ onWarning: (m) => log.warn('[Codes] sdk:', m),
++ });
++
++ const call = (token, method, path, json) => client.json({
++ baseUrl: base, path: `/api/v1/projects${path}`, method, token,
++ ...(json !== undefined ? { json } : {}),
++ ...(method === 'GET' ? {} : { idempotencyKey: false, retries: 0 }),
++ });
++
++ const P = (p) => `/${enc(p)}`;
++ const A = (p, a) => `/${enc(p)}/apps/${enc(a)}`;
++ const projects = {
++ catalog: (t) => call(t, 'GET', '/catalog'),
++ list: (t, { all } = {}) => call(t, 'GET', all ? '?all=1' : ''),
++ create: (t, body) => call(t, 'POST', '', body),
++ get: (t, p) => call(t, 'GET', P(p)),
++ rename: (t, p, body) => call(t, 'PATCH', P(p), body),
++ archive: (t, p) => call(t, 'POST', `${P(p)}/archive`, {}),
++ members: (t, p) => call(t, 'GET', `${P(p)}/members`),
++ addMember: (t, p, body) => call(t, 'POST', `${P(p)}/members`, body),
++ updateMember: (t, p, s, body) => call(t, 'PATCH', `${P(p)}/members/${enc(s)}`, body),
++ removeMember: (t, p, s) => call(t, 'DELETE', `${P(p)}/members/${enc(s)}`),
++ apps: (t, p) => call(t, 'GET', `${P(p)}/apps`),
++ createApp: (t, p, body) => call(t, 'POST', `${P(p)}/apps`, body),
++ app: (t, p, a) => call(t, 'GET', A(p, a)),
++ updateApp: (t, p, a, body) => call(t, 'PATCH', A(p, a), body),
++ revokeApp: (t, p, a) => call(t, 'DELETE', A(p, a)),
++ credentials: (t, p, a) => call(t, 'GET', `${A(p, a)}/credentials`),
++ rotate: (t, p, a, body) => call(t, 'POST', `${A(p, a)}/credentials/rotate`, body),
++ revokeCredential: (t, p, a, c) => call(t, 'POST', `${A(p, a)}/credentials/${enc(c)}/revoke`, {}),
++ grants: (t, p, a) => call(t, 'GET', `${A(p, a)}/grants`),
++ requestGrant: (t, p, a, capability) => call(t, 'POST', `${A(p, a)}/grants`, { capability }),
++ decideGrant: (t, p, a, cap, decision) => (decision === 'revoked'
++ ? call(t, 'DELETE', `${A(p, a)}/grants/${enc(cap)}`)
++ : call(t, 'POST', `${A(p, a)}/grants/${enc(cap)}/${decision === 'approved' ? 'approve' : 'deny'}`, {})),
++ quotas: (t, p) => call(t, 'GET', `${P(p)}/quotas`),
++ audit: (t, p, { before } = {}) => call(t, 'GET', `${P(p)}/audit${before ? `?before=${enc(before)}` : ''}`),
++ };
++
++ // Registry: public, cached for registryTtlMs so a page view never waits on Network twice.
++ const registryClient = createRegistryClient(client, { baseUrl: base });
++ const cache = new Map();
++ async function cached(key, fn) {
++ const hit = cache.get(key);
++ if (hit && Date.now() - hit.at < config.registryTtlMs) return hit.value;
++ const value = await fn();
++ cache.set(key, { at: Date.now(), value });
++ return value;
++ }
++ const registry = {
++ services: () => cached('services', () => registryClient.services()),
++ descriptor: () => cached('descriptor', () => client.json({ baseUrl: base, path: '/.well-known/openvibe', auth: false })),
++ };
++
++ /**
++ * Client-credentials token for an APP (playgrounds). The secret is used for this one request
++ * and dropped; failures come back as the Network's OAuth error, never with the secret.
++ */
++ async function appToken({ appId, clientSecret, audience, scope }) {
++ const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: appId, client_secret: clientSecret, audience, scope });
++ const doFetch = fetchImpl || globalThis.fetch;
++ let res;
++ try {
++ res = await doFetch(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(8000),
++ });
++ } catch (err) {
++ return { ok: false, status: 502, code: 'network.unreachable', detail: `OpenVibe.Network did not answer (${err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable'}).` };
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || typeof data.access_token !== 'string') {
++ return { ok: false, status: res.status, code: (data && data.error) || `http.${res.status}`, detail: (data && data.error_description) || '' };
++ }
++ return { ok: true, token: data.access_token, scope: data.scope || '' };
++ }
++
++ return { projects, registry, appToken, problemOf };
++}
++
++module.exports = { createNetworkClient, problemOf };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..e8e96c4
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,78 @@
++'use strict';
++
++/**
++ * OpenVibe.Codes configuration. Every value comes from the environment (production:
++ * /etc/openvibe/codes.env, see .env.example). Only environment variable NAMES appear in code and
++ * docs; secrets are never logged.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ */
++require('dotenv').config();
++const contracts = require('openvibe-contracts');
++
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
++const originOf = (id, fallback) => {
++ const m = contracts.services.get(id);
++ return (m && m.publicOrigin) || fallback;
++};
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4900);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.codes' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++
++ return {
++ service: 'codes',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++
++ dbPath: env.CODES_DB_PATH || './data/codes.db',
++
++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
++ // projects API (/api/v1/projects, called server-side with the signed-in person's token),
++ // the registry, and client-credentials tokens (Codes' own and, in playgrounds, the app's).
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'codes',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // Signs the per-person form token (CSRF). Unset: a random per-process key.
++ formSecret: env.CODES_FORM_SECRET || '',
++
++ // Staff (trust tiers): Network admins, plus these subjects (usr_…).
++ staffSubjects: list(env.CODES_STAFF_SUBJECTS),
++
++ // Where playgrounds call, with the APP's own token (never Codes' credentials). Defaults are
++ // the public origins published in openvibe-contracts' service manifests.
++ playground: {
++ eventsUrl: trim(env.CODES_PLAYGROUND_EVENTS_URL || originOf('events', 'https://events.openvibe.network')),
++ mediaUrl: trim(env.CODES_PLAYGROUND_MEDIA_URL || originOf('media', 'https://openvibe.media')),
++ maxUploadBytes: int(env.CODES_PLAYGROUND_MAX_UPLOAD_BYTES, 1024 * 1024),
++ runsPerHour: int(env.CODES_PLAYGROUND_RUNS_PER_HOUR, 60),
++ },
++
++ // Registry answers are cached this long (Network's own health poll runs every 60 s).
++ registryTtlMs: int(env.CODES_REGISTRY_TTL_MS, 30 * 1000),
++
++ // OpenVibe.Events: the outbox relay runs only when EVENTS_URL and the client secret are set.
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..7035c37
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,124 @@
++'use strict';
++
++/**
++ * OpenVibe.Codes' own SQLite database (WAL, created on boot, idempotent).
++ *
++ * Codes owns ONLY what is Codes' by ADR-014: release metadata keyed to Network app ids, trust tiers
++ * (metadata, never authority), validated manifests, and playground run logs. Projects, members,
++ * apps, credentials, grants and quotas belong to OpenVibe.Network and are never copied here.
++ *
++ * No table has a column for a secret, token or credential, and nothing written here ever contains
++ * one (test/secrets.test.js scans every table).
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { ids } = require('openvibe-contracts');
++
++const TABLES = ['manifests', 'releases', 'release_log', 'trust', 'trust_history', 'playground_runs'];
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS manifests (
++ id TEXT PRIMARY KEY, -- mfs_<ULID>
++ kind TEXT NOT NULL CHECK (kind IN ('app', 'mod')),
++ app_id TEXT NOT NULL, -- Network app (app_<ULID>) that publishes it
++ project_id TEXT NOT NULL, -- Network project (prj_<ULID>) at the time of creation
++ subject_id TEXT NOT NULL, -- the manifest's own id (app_… or mod_…)
++ version TEXT NOT NULL,
++ body TEXT NOT NULL, -- the manifest JSON exactly as validated
++ contracts_version TEXT NOT NULL, -- openvibe-contracts release it was validated with
++ created_by TEXT NOT NULL, -- user:usr_… or app:app_…
++ created_at TEXT NOT NULL
++);
++CREATE INDEX IF NOT EXISTS idx_manifests_app ON manifests(app_id);
++
++CREATE TABLE IF NOT EXISTS releases (
++ id TEXT PRIMARY KEY, -- rel_<ULID>
++ app_id TEXT NOT NULL,
++ project_id TEXT NOT NULL,
++ environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),
++ kind TEXT NOT NULL CHECK (kind IN ('app', 'mod')),
++ subject_id TEXT NOT NULL,
++ name TEXT NOT NULL,
++ version TEXT NOT NULL,
++ manifest_id TEXT NOT NULL REFERENCES manifests(id),
++ status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'deprecated', 'revoked')),
++ compatibility TEXT NOT NULL DEFAULT '{}',
++ notes TEXT NOT NULL DEFAULT '',
++ created_by TEXT NOT NULL,
++ created_at TEXT NOT NULL,
++ published_by TEXT, published_at TEXT,
++ deprecated_by TEXT, deprecated_at TEXT, deprecation_reason TEXT, replacement TEXT,
++ revoked_by TEXT, revoked_at TEXT, revocation_reason TEXT,
++ UNIQUE (app_id, kind, subject_id, version)
++);
++CREATE INDEX IF NOT EXISTS idx_releases_app ON releases(app_id, created_at);
++
++CREATE TABLE IF NOT EXISTS release_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ release_id TEXT NOT NULL,
++ action TEXT NOT NULL,
++ actor TEXT NOT NULL,
++ at TEXT NOT NULL,
++ detail TEXT NOT NULL DEFAULT '{}'
++);
++CREATE INDEX IF NOT EXISTS idx_release_log_release ON release_log(release_id, id);
++CREATE TRIGGER IF NOT EXISTS release_log_append_only_update BEFORE UPDATE ON release_log
++ BEGIN SELECT RAISE(ABORT, 'release_log is append-only'); END;
++CREATE TRIGGER IF NOT EXISTS release_log_append_only_delete BEFORE DELETE ON release_log
++ BEGIN SELECT RAISE(ABORT, 'release_log is append-only'); END;
++
++CREATE TABLE IF NOT EXISTS trust (
++ app_id TEXT PRIMARY KEY,
++ tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),
++ note TEXT NOT NULL DEFAULT '',
++ set_by TEXT NOT NULL,
++ set_at TEXT NOT NULL
++);
++CREATE TABLE IF NOT EXISTS trust_history (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ app_id TEXT NOT NULL,
++ from_tier TEXT NOT NULL,
++ to_tier TEXT NOT NULL,
++ note TEXT NOT NULL DEFAULT '',
++ set_by TEXT NOT NULL,
++ set_at TEXT NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS playground_runs (
++ id TEXT PRIMARY KEY, -- run_<ULID>
++ at TEXT NOT NULL,
++ actor TEXT NOT NULL, -- user:usr_…
++ project_id TEXT NOT NULL,
++ app_id TEXT NOT NULL,
++ kind TEXT NOT NULL CHECK (kind IN ('events', 'media')),
++ capability TEXT NOT NULL,
++ credential TEXT NOT NULL CHECK (credential IN ('none', 'client_secret', 'access_token')),
++ outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'refused', 'failed')),
++ stage TEXT NOT NULL, -- app | grant | token | call | done
++ http_status INTEGER,
++ code TEXT,
++ detail TEXT NOT NULL DEFAULT '',
++ ref TEXT -- event_id or media key on success
++);
++CREATE INDEX IF NOT EXISTS idx_runs_app ON playground_runs(app_id, at);
++CREATE INDEX IF NOT EXISTS idx_runs_actor ON playground_runs(actor, at);
++`;
++
++function openStore(dbPath, { now = () => Date.now() } = {}) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ db.exec(SCHEMA);
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ close: () => db.close(),
++ };
++}
++
++module.exports = { openStore, TABLES };
ddiff --git a/server/docs/dts.js b/server/docs/dts.js
nnew file mode 100644
iindex 0000000..c1c472f
---- /dev/null
++++ b/server/docs/dts.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++/**
++ * A small reader for the SDK's hand-maintained .d.ts files: every top-level `export …` statement
++ * with the /** doc comment right above it. Not a TypeScript parser — it only has to split the
++ * statements the SDK writes (interfaces, types, declared functions, consts and classes,
++ * re-exports), keeping each statement's text verbatim so the reference cannot drift from the file.
++ */
++
++const KINDS = [
++ [/^export\s+(?:declare\s+)?interface\s+([A-Za-z0-9_$]+)/, 'interface'],
++ [/^export\s+(?:declare\s+)?type\s+([A-Za-z0-9_$]+)/, 'type'],
++ [/^export\s+declare\s+function\s+([A-Za-z0-9_$]+)/, 'function'],
++ [/^export\s+declare\s+const\s+([A-Za-z0-9_$]+)/, 'const'],
++ [/^export\s+declare\s+class\s+([A-Za-z0-9_$]+)/, 'class'],
++ [/^export\s+\*\s+as\s+([A-Za-z0-9_$]+)/, 're-export'],
++ [/^export\s+\*\s+from\s+['"]([^'"]+)['"]/, 're-export'],
++ [/^export\s+type\s+\{([^}]*)\}\s+from/, 're-export'],
++ [/^export\s+\{([^}]*)\}/, 're-export'],
++];
++
++function cleanDoc(raw) {
++ return raw.replace(/^\/\*\*|\*\/$/g, '').split('\n').map((l) => l.replace(/^\s*\*\s?/, '')).join('\n').trim();
++}
++
++/** Split `source` into [{ kind, name, doc, text }]. */
++function parseDts(source) {
++ const out = [];
++ const src = String(source).replace(/\r\n/g, '\n');
++ let i = 0;
++ let pendingDoc = '';
++ while (i < src.length) {
++ // Skip whitespace.
++ const ws = src.slice(i).match(/^\s+/);
++ if (ws) { i += ws[0].length; continue; }
++ if (src.startsWith('/**', i)) {
++ const end = src.indexOf('*/', i + 3);
++ if (end < 0) break;
++ pendingDoc = cleanDoc(src.slice(i, end + 2));
++ i = end + 2;
++ continue;
++ }
++ if (src.startsWith('//', i)) { const nl = src.indexOf('\n', i); i = nl < 0 ? src.length : nl + 1; continue; }
++ if (src.startsWith('/*', i)) { const end = src.indexOf('*/', i + 2); i = end < 0 ? src.length : end + 2; pendingDoc = ''; continue; }
++
++ // One statement: to the `;` or the closing `}` at depth 0 (interfaces and classes end at `}`).
++ let depth = 0;
++ let j = i;
++ let sawBrace = false;
++ let inStr = null;
++ for (; j < src.length; j++) {
++ const ch = src[j];
++ if (inStr) { if (ch === inStr && src[j - 1] !== '\\') inStr = null; continue; }
++ if (ch === '\'' || ch === '"' || ch === '`') { inStr = ch; continue; }
++ if (ch === '{' || ch === '(' || ch === '<' || ch === '[') { if (ch === '{') sawBrace = true; depth++; continue; }
++ if (ch === '}' || ch === ')' || ch === ']' || (ch === '>' && src[j - 1] !== '=')) {
++ depth = Math.max(0, depth - 1);
++ if (ch === '}' && depth === 0 && sawBrace && /^export\s+(?:declare\s+)?(?:interface|class)\b/.test(src.slice(i, i + 40))) { j++; break; }
++ continue;
++ }
++ if (ch === ';' && depth === 0) { j++; break; }
++ }
++ const text = src.slice(i, j).trim();
++ i = j;
++ if (!text.startsWith('export')) { pendingDoc = ''; continue; }
++ let kind = 'export';
++ let name = '';
++ for (const [re, k] of KINDS) {
++ const m = text.match(re);
++ if (m) { kind = k; name = m[1].trim(); break; }
++ }
++ if (kind === 'import') { pendingDoc = ''; continue; }
++ out.push({ kind, name: name || text.slice(0, 60), doc: pendingDoc, text });
++ pendingDoc = '';
++ }
++ return out;
++}
++
++module.exports = { parseDts };
ddiff --git a/server/docs/generate.js b/server/docs/generate.js
nnew file mode 100644
iindex 0000000..4b37087
---- /dev/null
++++ b/server/docs/generate.js
@@@ -0,0 +1,178 @@
++'use strict';
++
++/**
++ * Generated documentation (roadmap §30.5: contract → generated docs → published package version).
++ *
++ * Built ONCE at boot from the packages this release pins — openvibe-contracts (catalog, JSON
++ * schemas, fixtures, capability and service manifests, ADRs) and openvibe-sdk (its .d.ts files) —
++ * so every page says exactly which versions it was generated from and nothing is hand-written that
++ * could drift. Upgrading a pin and restarting regenerates everything.
++ */
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const contractsRegistry = require('openvibe-contracts/lib/registry');
++const { parseDts } = require('./dts');
++
++const CONTRACTS_ROOT = path.dirname(require.resolve('openvibe-contracts/package.json'));
++const SDK_ROOT = path.dirname(require.resolve('openvibe-sdk/package.json'));
++const contractsPkg = require('openvibe-contracts/package.json');
++const sdkPkg = require('openvibe-sdk/package.json');
++
++/** Visibilities an app may ever be granted (Network's grantability rule, ADR-014). */
++const GRANTABLE_VISIBILITIES = new Set(['public', 'partner']);
++
++function readJsonDir(dir) {
++ try {
++ return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort()
++ .map((f) => ({ name: f.replace(/\.json$/, ''), body: JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) }));
++ } catch { return []; }
++}
++
++/** Absolute $id for a $ref relative to the schema that holds it. */
++function resolveRef(ref, baseId) {
++ try { return new URL(ref, baseId).toString(); } catch { return ref; }
++}
++
++function typeOf(s) {
++ if (!s || typeof s !== 'object') return 'any';
++ if (s.$ref) return 'ref';
++ if (s.const !== undefined) return 'const';
++ if (s.enum) return 'enum';
++ if (Array.isArray(s.type)) return s.type.join(' | ');
++ if (s.type) return s.type;
++ if (s.oneOf || s.anyOf) return 'one of';
++ return 'any';
++}
++
++function constraintsOf(s) {
++ const out = [];
++ if (!s || typeof s !== 'object') return out;
++ if (s.enum) out.push(`one of ${s.enum.map((v) => JSON.stringify(v)).join(', ')}`);
++ if (s.const !== undefined) out.push(`= ${JSON.stringify(s.const)}`);
++ if (s.pattern) out.push(`pattern ${s.pattern}`);
++ if (s.format) out.push(`format ${s.format}`);
++ for (const k of ['minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems']) if (s[k] !== undefined) out.push(`${k} ${s[k]}`);
++ if (s.uniqueItems) out.push('unique items');
++ if (s.default !== undefined) out.push(`default ${JSON.stringify(s.default)}`);
++ if (s.additionalProperties === false && s.type === 'object') out.push('no other fields');
++ return out;
++}
++
++/** Rows { path, type, required, description, constraints, ref } for a schema, nested with dotted paths. */
++function fieldRows(schema, baseId, prefix = '', depth = 0) {
++ const rows = [];
++ if (!schema || typeof schema !== 'object' || depth > 6) return rows;
++ const props = schema.properties || {};
++ const required = new Set(schema.required || []);
++ for (const [name, s] of Object.entries(props)) {
++ const p = prefix ? `${prefix}.${name}` : name;
++ const item = s && s.type === 'array' && s.items ? s.items : null;
++ rows.push({
++ path: p,
++ type: item ? `array of ${typeOf(item)}` : typeOf(s),
++ required: required.has(name),
++ description: (s && s.description) || '',
++ constraints: [...constraintsOf(s), ...(item ? constraintsOf(item).map((c) => `items: ${c}`) : [])],
++ ref: s && s.$ref ? resolveRef(s.$ref, baseId) : (item && item.$ref ? resolveRef(item.$ref, baseId) : null),
++ });
++ if (s && s.type === 'object' && s.properties) rows.push(...fieldRows(s, baseId, p, depth + 1));
++ if (item && item.type === 'object' && item.properties) rows.push(...fieldRows(item, baseId, `${p}[]`, depth + 1));
++ }
++ return rows;
++}
++
++function adrIndex() {
++ const dir = path.join(CONTRACTS_ROOT, 'docs', 'adr');
++ let files = [];
++ try { files = fs.readdirSync(dir).filter((f) => /^ADR-\d+.*\.md$/.test(f)).sort(); } catch { return []; }
++ return files.map((f) => {
++ const markdown = fs.readFileSync(path.join(dir, f), 'utf8');
++ const title = (markdown.match(/^#\s+(.+)$/m) || [null, f])[1].trim();
++ const status = (markdown.match(/^\*\*Status:\*\*\s*(.+)$/m) || [null, ''])[1].trim();
++ const id = (f.match(/^(ADR-\d+)/) || [null, f])[1];
++ return { id, file: f, title, status, markdown };
++ });
++}
++
++function sdkModules() {
++ const exp = sdkPkg.exports || {};
++ const out = [];
++ for (const [subpath, target] of Object.entries(exp)) {
++ if (!target || typeof target !== 'object' || !target.types) continue;
++ const file = path.join(SDK_ROOT, target.types);
++ let source = '';
++ try { source = fs.readFileSync(file, 'utf8'); } catch { continue; }
++ const name = subpath === '.' ? 'openvibe-sdk' : `openvibe-sdk/${subpath.replace(/^\.\//, '')}`;
++ out.push({
++ slug: subpath === '.' ? 'index' : subpath.replace(/^\.\//, '').replace(/\//g, '-'),
++ name,
++ typesFile: target.types.replace(/^\.\//, ''),
++ browser: target.browser === null ? 'server only' : (target.browser ? 'browser build available' : 'both'),
++ declarations: parseDts(source),
++ });
++ }
++ // contracts.d.ts is re-exported by core; document it too.
++ const extra = path.join(SDK_ROOT, 'types', 'contracts.d.ts');
++ if (fs.existsSync(extra)) out.push({ slug: 'contracts-types', name: 'openvibe-sdk (contract types)', typesFile: 'types/contracts.d.ts', browser: 'types only', declarations: parseDts(fs.readFileSync(extra, 'utf8')) });
++ return out;
++}
++
++function generate({ now = () => Date.now() } = {}) {
++ const deprecations = new Map((contractsRegistry.deprecations || []).map((d) => [d.id, d]));
++ const byDollarId = new Map();
++ const contractList = contracts.catalog.map((entry) => {
++ const schema = contracts.schema(entry.id);
++ byDollarId.set(schema.$id, entry.id);
++ const fixtures = {
++ valid: readJsonDir(path.join(CONTRACTS_ROOT, 'fixtures', entry.id, 'valid')),
++ invalid: readJsonDir(path.join(CONTRACTS_ROOT, 'fixtures', entry.id, 'invalid')),
++ };
++ return {
++ ...entry, $id: schema.$id, title: schema.title || entry.id, description: schema.description || '', schema,
++ fields: fieldRows(schema, schema.$id), fixtures, deprecation: deprecations.get(entry.id) || null,
++ };
++ });
++ for (const c of contractList) for (const f of c.fields) f.refId = f.ref ? (byDollarId.get(f.ref) || null) : null;
++
++ const capabilities = contracts.capabilities.manifests.map((c) => ({
++ ...c, grantable: GRANTABLE_VISIBILITIES.has(c.visibility) && c.status === 'active',
++ }));
++
++ const events = new Map();
++ for (const m of contracts.services.manifests) {
++ for (const t of m.eventsProduced || []) {
++ if (!events.has(t)) events.set(t, { type: t, producers: [], consumers: [] });
++ events.get(t).producers.push(m.id);
++ }
++ }
++ for (const m of contracts.services.manifests) {
++ for (const t of m.eventsConsumed || []) {
++ for (const [type, e] of events) {
++ const pattern = String(t);
++ const hit = pattern === type || (pattern.endsWith('*') && type.startsWith(pattern.slice(0, -1)));
++ if (hit && !e.consumers.includes(m.id)) e.consumers.push(m.id);
++ }
++ }
++ }
++
++ return {
++ generatedAt: new Date(now()).toISOString(),
++ contractsVersion: contractsPkg.version,
++ contractsLicense: contractsPkg.license,
++ sdkVersion: sdkPkg.version,
++ sdkLicense: sdkPkg.license,
++ sdkContractsRange: (() => { try { return require('openvibe-sdk/core').CONTRACTS_RANGE; } catch { return null; } })(),
++ contracts: contractList,
++ contract: (id) => contractList.find((c) => c.id === id) || null,
++ capabilities,
++ capability: (id) => capabilities.find((c) => c.id === id) || null,
++ services: contracts.services.manifests,
++ events: [...events.values()].sort((a, b) => a.type.localeCompare(b.type)),
++ eventTypes: new Set(events.keys()),
++ sdk: sdkModules(),
++ adrs: adrIndex(),
++ };
++}
++
++module.exports = { generate, fieldRows, GRANTABLE_VISIBILITIES, CONTRACTS_ROOT, SDK_ROOT };
ddiff --git a/server/domain/manifests.js b/server/domain/manifests.js
nnew file mode 100644
iindex 0000000..80a3c39
---- /dev/null
++++ b/server/domain/manifests.js
@@@ -0,0 +1,167 @@
++'use strict';
++
++/**
++ * Manifest validation with openvibe-contracts.
++ *
++ * mod mods.mod-manifest@1 from the pinned openvibe-contracts (contracts.validate)
++ * app codes.app-manifest@1 — published by Contracts once released; until then the proposal in
++ * docs/contracts-proposal/codes/app-manifest.v1.json, compiled into openvibe-contracts' own
++ * Ajv instance so its $refs resolve to the released identity schemas
++ *
++ * Schema errors come first; then checks the schema cannot express, against the pinned catalog:
++ * every requested capability must exist and be grantable to apps (active + public/partner), ranges
++ * must parse, consumed events should be produced by someone. With an app context (a release), the
++ * manifest must also describe THAT app: same id, project and environment.
++ */
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const contractsRegistry = require('openvibe-contracts/lib/registry');
++const { satisfiesRange } = require('openvibe-sdk/core');
++const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
++
++const contractsVersion = require('openvibe-contracts/package.json').version;
++const sdkVersion = require('openvibe-sdk/package.json').version;
++const APP_SCHEMA_FILE = path.join(__dirname, '..', '..', 'docs', 'contracts-proposal', 'codes', 'app-manifest.v1.json');
++
++let appValidator = null;
++let appSchemaSource = null;
++function appSchema() {
++ if (appValidator) return appValidator;
++ try {
++ const entry = contracts.resolve('codes.app-manifest@1');
++ appSchemaSource = { released: true, id: entry.id, version: entry.version };
++ appValidator = (value) => contracts.validate('codes.app-manifest@1', value);
++ } catch {
++ const schema = JSON.parse(fs.readFileSync(APP_SCHEMA_FILE, 'utf8'));
++ const { ajv } = contractsRegistry;
++ const fn = ajv.getSchema(schema.$id) || (ajv.addSchema(schema), ajv.getSchema(schema.$id));
++ appSchemaSource = { released: false, id: 'codes.app-manifest', version: '1.0.0 (proposal)' };
++ appValidator = (value) => {
++ const valid = fn(value);
++ return { valid, errors: valid ? [] : fn.errors.map((e) => ({ path: e.instancePath || '/', message: e.message })) };
++ };
++ }
++ return appValidator;
++}
++
++function rangeError(range) {
++ try { satisfiesRange('0.0.0', range); return null; } catch (err) { return err.message; }
++}
++
++/** Starter manifests for the editor (valid shapes; the ids are the developer's own). */
++function template(kind, { appId, projectId, environment, name, subject } = {}) {
++ if (kind === 'mod') {
++ return {
++ id: 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ name: name || 'My mod',
++ version: '0.1.0',
++ description: '',
++ publisher: appId ? { type: 'app', id: appId } : { type: 'user', id: subject || 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++ target: 'games.browser',
++ runtime: 'games-content@1',
++ permissions: { capabilities: [] },
++ resources: { cpuMs: 1, memoryMb: 0, storageMb: 0 },
++ compatibility: { runtime: '>=1.0.0 <2.0.0', contracts: `^${contractsVersion}` },
++ };
++ }
++ return {
++ id: appId || 'app_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ name: name || 'My app',
++ version: '0.1.0',
++ publisher: appId ? { type: 'app', id: appId } : { type: 'user', id: subject || 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++ project_id: projectId || 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ environment: environment || 'sandbox',
++ capabilities: [],
++ compatibility: { contracts: `^${contractsVersion}`, sdk: `^${sdkVersion}` },
++ };
++}
++
++/**
++ * validate(kind, manifest, { app, viewerSubject, eventTypes })
++ * app: the Network app view (id, project_id, environment, grants) when validating for a release
++ * → { valid, errors: [{ path, message }], warnings: [{ path, message }], schema: { id, version, released } }
++ */
++function validate(kind, manifest, { app = null, viewerSubject = null, eventTypes = null } = {}) {
++ const errors = [];
++ const warnings = [];
++ if (kind !== 'app' && kind !== 'mod') return { valid: false, errors: [{ path: '/', message: 'kind is app or mod' }], warnings, schema: null };
++ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
++ return { valid: false, errors: [{ path: '/', message: 'a manifest is a JSON object' }], warnings, schema: null };
++ }
++ let schema;
++ if (kind === 'mod') {
++ const r = contracts.validate('mods.mod-manifest@1', manifest);
++ errors.push(...r.errors);
++ const e = contracts.resolve('mods.mod-manifest@1');
++ schema = { id: e.id, version: e.version, released: true };
++ } else {
++ const r = appSchema()(manifest);
++ errors.push(...r.errors);
++ schema = appSchemaSource;
++ }
++
++ // Requested capabilities: must exist in the pinned catalog and be grantable to apps.
++ const requested = kind === 'mod'
++ ? (manifest.permissions && Array.isArray(manifest.permissions.capabilities) ? manifest.permissions.capabilities : [])
++ : (Array.isArray(manifest.capabilities) ? manifest.capabilities : []);
++ const base = kind === 'mod' ? '/permissions/capabilities' : '/capabilities';
++ requested.forEach((id, i) => {
++ if (typeof id !== 'string') return;
++ const cap = contracts.capabilities.get(id);
++ if (!cap) { errors.push({ path: `${base}/${i}`, message: `${id} is not in openvibe-contracts ${contractsVersion}` }); return; }
++ if (!GRANTABLE_VISIBILITIES.has(cap.visibility)) { errors.push({ path: `${base}/${i}`, message: `${id} is ${cap.visibility}: it is never granted to apps or mods` }); return; }
++ if (cap.status !== 'active') warnings.push({ path: `${base}/${i}`, message: `${id} is ${cap.status}: it cannot be granted until it is active` });
++ if (cap.visibility === 'partner') warnings.push({ path: `${base}/${i}`, message: `${id} is partner-only: staff must add it to the project's allowance by hand` });
++ if (app && Array.isArray(app.grants) && !app.grants.includes(id)) warnings.push({ path: `${base}/${i}`, message: `${id} is not granted to this app yet (request it on the app page; Network decides)` });
++ });
++
++ // Ranges.
++ const comp = manifest.compatibility && typeof manifest.compatibility === 'object' ? manifest.compatibility : {};
++ for (const key of ['contracts', 'sdk', 'runtime']) {
++ if (typeof comp[key] !== 'string') continue;
++ const bad = rangeError(comp[key]);
++ if (bad) { errors.push({ path: `/compatibility/${key}`, message: `not a semver range: ${bad}` }); continue; }
++ if (key === 'contracts' && !satisfiesRange(contractsVersion, comp[key])) warnings.push({ path: '/compatibility/contracts', message: `the network's pinned openvibe-contracts ${contractsVersion} is outside ${comp[key]}` });
++ if (key === 'sdk' && !satisfiesRange(sdkVersion, comp[key])) warnings.push({ path: '/compatibility/sdk', message: `the current openvibe-sdk ${sdkVersion} is outside ${comp[key]}` });
++ }
++
++ // Events the app or mod wants delivered.
++ const consumes = kind === 'mod'
++ ? (manifest.permissions && Array.isArray(manifest.permissions.events) ? manifest.permissions.events : [])
++ : (manifest.events && Array.isArray(manifest.events.consumes) ? manifest.events.consumes : []);
++ if (eventTypes) {
++ consumes.forEach((t, i) => {
++ if (typeof t !== 'string') return;
++ const family = t.endsWith('.*') ? t.slice(0, -1) : null;
++ const known = family ? [...eventTypes].some((x) => x.startsWith(family)) : eventTypes.has(t);
++ if (!known) warnings.push({ path: kind === 'mod' ? `/permissions/events/${i}` : `/events/consumes/${i}`, message: `no service in openvibe-contracts ${contractsVersion} declares it produces ${t}` });
++ });
++ }
++
++ // For a release: the manifest must describe this very app.
++ if (app) {
++ if (kind === 'app') {
++ if (manifest.id !== app.id) errors.push({ path: '/id', message: `must be this app's id ${app.id}` });
++ if (manifest.project_id !== app.project_id) errors.push({ path: '/project_id', message: `must be this app's project ${app.project_id}` });
++ if (manifest.environment !== undefined && manifest.environment !== app.environment) errors.push({ path: '/environment', message: `this app is ${app.environment}` });
++ }
++ const pub = manifest.publisher;
++ if (pub && typeof pub === 'object') {
++ const ok = (pub.type === 'app' && pub.id === app.id) || (pub.type === 'user' && viewerSubject && pub.id === viewerSubject);
++ if (!ok) errors.push({ path: '/publisher', message: `must be this app (app ${app.id})${viewerSubject ? ` or you (user ${viewerSubject})` : ''}` });
++ }
++ }
++
++ return { valid: errors.length === 0, errors, warnings, schema };
++}
++
++/** Parse a manifest from form text. → { manifest } | { error } */
++function parse(text) {
++ const s = String(text || '').trim();
++ if (!s) return { error: 'paste a manifest (JSON)' };
++ if (s.length > 64 * 1024) return { error: 'a manifest is at most 64 KB' };
++ try { return { manifest: JSON.parse(s) }; } catch (err) { return { error: `not valid JSON: ${err.message.slice(0, 200)}` }; }
++}
++
++module.exports = { validate, parse, template, appSchemaInfo: () => (appSchema(), appSchemaSource), APP_SCHEMA_FILE };
ddiff --git a/server/domain/playground.js b/server/domain/playground.js
nnew file mode 100644
iindex 0000000..50bbd84
---- /dev/null
++++ b/server/domain/playground.js
@@@ -0,0 +1,203 @@
++'use strict';
++
++/**
++ * Playgrounds that cannot exceed the project's grants (ADR-014 acceptance).
++ *
++ * events publish one test event needs events.event.publish (audience openvibe.events)
++ * media upload one small file (sandbox) needs media.object.upload (audience openvibe.media,
++ * namespace = project id)
++ *
++ * The order is fixed and every step can refuse:
++ * 1. app the app (as Network reports it to the signed-in member) is active and is a SANDBOX app
++ * 2. grant Network lists the capability among the app's APPROVED grants; if not, nothing else
++ * happens — no token is requested, no service is called — and the page says which
++ * grant is missing and whether it can ever be granted (the pinned catalog's visibility)
++ * 3. input the request is valid (an event envelope validates as events.event-envelope@1)
++ * 4. token the APP's own token: minted by Network from the client secret the developer typed for
++ * this request, or pasted; then verified offline — signed by Network, for this app, this
++ * project, env sandbox, this audience, and carrying the capability
++ * 5. call the service is called through openvibe-sdk with that token, and its answer is shown
++ *
++ * Codes' own credentials are never used, no shared loopback key exists anywhere, and the secret or token
++ * is never stored, logged or rendered: run logs keep only ids, outcome, status and problem code,
++ * with any echo of the credential scrubbed from the detail.
++ */
++const contracts = require('openvibe-contracts');
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { createEventsClient } = require('openvibe-sdk/events');
++const { createMediaClient } = require('openvibe-sdk/media');
++const { verifyJwt } = require('../auth/keys');
++const { GRANTABLE_VISIBILITIES } = require('../docs/generate');
++
++const contractsVersion = require('openvibe-contracts/package.json').version;
++
++const KINDS = {
++ // `needs`: the capability the APP must hold (owned by Events / Media; Codes guards nothing with it).
++ events: { needs: 'events.event.publish', audience: 'openvibe.events', label: 'Events: publish a test event' },
++ media: { needs: 'media.object.upload', audience: 'openvibe.media', label: 'Media: upload a file to the sandbox' },
++};
++
++function scrub(text, secrets) {
++ let s = String(text || '').slice(0, 500);
++ for (const x of secrets) if (x && x.length >= 8) s = s.split(x).join('[redacted]');
++ return s.replace(/ovsec_[A-Za-z0-9_-]+/g, '[redacted]').replace(/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[redacted token]');
++}
++
++/** Why a capability is not granted, from the pinned catalog. */
++function grantAdvice(capability) {
++ const cap = contracts.capabilities.get(capability);
++ if (!cap) return { grantable: false, text: `${capability} is not in openvibe-contracts ${contractsVersion}.` };
++ if (!GRANTABLE_VISIBILITIES.has(cap.visibility)) {
++ return { grantable: false, text: `${capability} is ${cap.visibility} in openvibe-contracts ${contractsVersion}: Network never grants it to apps, so this playground cannot run until ${cap.owner} publishes a public capability for it.` };
++ }
++ if (cap.status !== 'active') return { grantable: false, text: `${capability} is ${cap.status}; it can be granted once it is active.` };
++ return { grantable: true, text: `Request ${capability} on the app page. An owner or admin approves it, and only inside the project's allowance (staff set the allowance).` };
++}
++
++function createPlayground({ store, config, network, keys, fetchImpl, log = console }) {
++ const { db } = store;
++
++ function record(run) {
++ const id = store.newId('run');
++ db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(id, store.iso(), run.actor, run.project_id, run.app_id, run.kind, run.capability, run.credential, run.outcome, run.stage,
++ run.http_status || null, run.code || null, run.detail || '', run.ref || null);
++ return id;
++ }
++
++ const runsFor = (appId, limit = 20) => db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);
++ const recentCount = (actor) => db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString()).n;
++
++ /** Steps 1–2 only: may this app run this playground at all? */
++ function precheck(app, kind) {
++ const k = KINDS[kind];
++ if (!k) return { ok: false, stage: 'input', code: 'playground.unknown', detail: 'no such playground' };
++ if (app.revoked_at) return { ok: false, stage: 'app', code: 'playground.app_revoked', detail: 'this app is revoked in Network' };
++ if (app.environment !== 'sandbox') return { ok: false, stage: 'app', code: 'playground.production_app', detail: 'playgrounds run only sandbox apps; create a sandbox app in this project to try things out' };
++ const grants = Array.isArray(app.grants) ? app.grants : [];
++ if (!grants.includes(k.needs)) {
++ const advice = grantAdvice(k.needs);
++ return { ok: false, stage: 'grant', code: 'playground.grant_missing', detail: `this app does not hold ${k.needs} (audience ${k.audience}). ${advice.text}`, missing: k.needs, grantable: advice.grantable };
++ }
++ return { ok: true };
++ }
++
++ /** Build the events envelope from form input. → { envelope } | { error } */
++ function eventInput(input) {
++ let payload = {};
++ const raw = String(input.payload || '').trim();
++ if (raw) {
++ if (raw.length > 16 * 1024) return { error: 'payload is at most 16 KB' };
++ try { payload = JSON.parse(raw); } catch (err) { return { error: `payload is not JSON: ${err.message.slice(0, 120)}` }; }
++ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { error: 'payload must be a JSON object' };
++ }
++ const envelope = {
++ event_id: contracts.ids.newId('event', store.now()),
++ event_type: String(input.event_type || '').trim(),
++ version: 1,
++ source: String(input.source || '').trim(),
++ actor: { type: 'app', id: input.appId },
++ timestamp: new Date(store.now()).toISOString(),
++ visibility: 'internal',
++ subject: { type: String(input.subject_type || 'test').trim().slice(0, 40) || 'test', id: String(input.subject_id || 'test-1').trim().slice(0, 120) || 'test-1' },
++ payload,
++ };
++ const v = contracts.validate('events.event-envelope@1', envelope);
++ if (!v.valid) return { error: `the event does not validate as events.event-envelope@1: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}` };
++ return { envelope };
++ }
++
++ async function appToken(app, k, credential) {
++ let token;
++ if (credential.type === 'client_secret') {
++ const got = await network.appToken({ appId: app.id, clientSecret: credential.value, audience: k.audience, scope: k.needs });
++ if (!got.ok) return { ok: false, http_status: got.status, code: got.code, detail: `Network refused the token request: ${got.detail || got.code}` };
++ token = got.token;
++ } else {
++ token = credential.value;
++ }
++ await keys.ensure();
++ const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, audience: k.audience, now: store.now() });
++ if (!v.ok) return { ok: false, code: 'playground.token_invalid', detail: `the token is not usable: ${v.reason}` };
++ const c = v.claims;
++ const problems = [];
++ if (c.sub !== `app:${app.id}`) problems.push(`it belongs to ${String(c.sub).slice(0, 60)}, not app:${app.id}`);
++ if (c.project_id !== undefined && c.project_id !== app.project_id) problems.push('it is for another project');
++ if (c.env !== 'sandbox') problems.push(`its env is ${c.env === undefined ? 'unset' : c.env}, not sandbox`);
++ if (!Array.isArray(c.cap) || !c.cap.includes(k.needs)) problems.push(`it does not carry ${k.needs}`);
++ if (problems.length) return { ok: false, code: 'playground.token_refused', detail: `the token was refused: ${problems.join('; ')}` };
++ return { ok: true, token };
++ }
++
++ function sdkClient(token) {
++ return createClient({
++ autoDiscover: false, retries: 0, timeoutMs: 10_000, deadlineMs: 15_000, token,
++ baseUrls: { events: config.playground.eventsUrl, media: config.playground.mediaUrl },
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ }
++
++ /**
++ * run({ actor, app, kind, credential: { type: 'client_secret' | 'access_token', value }, input, file })
++ * → { outcome: 'ok' | 'refused' | 'failed', stage, code, detail, http_status, result, runId }
++ */
++ async function run({ actor, app, kind, credential, input = {}, file = null }) {
++ const k = KINDS[kind];
++ const secrets = [credential && credential.value].filter(Boolean);
++ const base = { actor, project_id: app.project_id, app_id: app.id, kind, capability: k ? k.needs : String(kind), credential: 'none' };
++ const finish = (out) => {
++ const safe = { ...out, detail: scrub(out.detail, secrets) };
++ const runId = record({ ...base, ...safe });
++ return { ...safe, runId };
++ };
++
++ if (recentCount(actor) >= config.playground.runsPerHour) {
++ return { outcome: 'refused', stage: 'rate', code: 'playground.rate_limited', detail: `at most ${config.playground.runsPerHour} playground runs an hour` };
++ }
++ const pre = precheck(app, kind);
++ if (!pre.ok) return { ...finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };
++
++ let envelope = null;
++ if (kind === 'events') {
++ const e = eventInput({ ...input, appId: app.id });
++ if (e.error) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
++ envelope = e.envelope;
++ } else {
++ if (!file || !file.buffer || !file.buffer.length) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: 'choose a file' });
++ if (file.buffer.length > config.playground.maxUploadBytes) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: `playground uploads are at most ${config.playground.maxUploadBytes} bytes` });
++ }
++
++ if (!credential || !credential.value || !['client_secret', 'access_token'].includes(credential.type)) {
++ return finish({ outcome: 'refused', stage: 'token', code: 'playground.no_credential', detail: 'enter the app\'s client secret (used once, never stored) or an access token you minted for it' });
++ }
++ base.credential = credential.type;
++ const tok = await appToken(app, k, credential);
++ if (!tok.ok) return finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });
++
++ const client = sdkClient(tok.token);
++ try {
++ if (kind === 'events') {
++ const events = createEventsClient(client, { source: envelope.source, baseUrl: config.playground.eventsUrl });
++ const out = await events.publish(envelope);
++ return { ...finish({ outcome: 'ok', stage: 'done', http_status: 200, detail: out.duplicate ? 'accepted (duplicate event_id)' : `accepted as seq ${out.seq}`, ref: out.event_id }), result: { event_id: out.event_id, seq: out.seq, duplicate: out.duplicate } };
++ }
++ const media = createMediaClient(client, { app: app.project_id, baseUrl: config.playground.mediaUrl, publicOrigin: config.playground.mediaUrl });
++ const out = await media.files.upload(file.buffer, { filename: file.filename, contentType: file.mimeType });
++ return { ...finish({ outcome: 'ok', stage: 'done', http_status: 201, detail: `stored ${out.size} bytes`, ref: out.key }), result: { key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256 } };
++ } catch (err) {
++ if (isOpenVibeError(err)) {
++ const service = kind === 'events' ? 'OpenVibe.Events' : 'OpenVibe.Media';
++ const unreachable = !err.status;
++ return finish({ outcome: 'failed', stage: 'call', http_status: err.status || null, code: err.code,
++ detail: unreachable ? `${service} did not answer (${err.code})` : `${service} answered ${err.status}: ${err.detail || err.title || err.code}` });
++ }
++ log.error('[Codes] playground error:', scrub(err && err.message, secrets));
++ return finish({ outcome: 'failed', stage: 'call', code: 'playground.error', detail: 'unexpected error calling the service' });
++ }
++ }
++
++ return { run, precheck, runsFor, grantAdvice, KINDS };
++}
++
++module.exports = { createPlayground, KINDS, grantAdvice, scrub };
ddiff --git a/server/domain/releases.js b/server/domain/releases.js
nnew file mode 100644
iindex 0000000..47fbc13
---- /dev/null
++++ b/server/domain/releases.js
@@@ -0,0 +1,184 @@
++'use strict';
++
++/**
++ * Release metadata — Codes-owned records keyed to Network app ids.
++ *
++ * draft → published → deprecated → revoked (revoked is terminal; a draft can be revoked too)
++ *
++ * Who may act is decided from the Network's answer about the actor (their project role and the
++ * app's environment), mirroring Network's own rule: developer+ for sandbox apps, admin+ for
++ * production apps; Codes staff may revoke any release. An app acting with its own token (API,
++ * codes.release.manage) may manage only its own releases.
++ *
++ * A release carries its validated manifest, its compatibility ranges and — when shown — the app's
++ * current trust tier, which is metadata only: it never grants or bypasses anything.
++ */
++const { validate: validateManifest } = require('./manifests');
++
++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
++const contractsVersion = require('openvibe-contracts/package.json').version;
++
++class ReleaseError extends Error {
++ constructor(status, code, detail, extra = {}) { super(detail || code); this.status = status; this.code = code; this.detail = detail; Object.assign(this, extra); }
++}
++const fail = (status, code, detail, extra) => { throw new ReleaseError(status, code, detail, extra); };
++
++/** Role needed to manage releases of an app in this environment (Network's rule for apps). */
++const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
++
++function createReleases({ store, outbox, trust }) {
++ const { db } = store;
++
++ function view(r) {
++ if (!r) return null;
++ return {
++ id: r.id, app_id: r.app_id, project_id: r.project_id, environment: r.environment, kind: r.kind,
++ subject_id: r.subject_id, name: r.name, version: r.version, status: r.status, manifest_id: r.manifest_id,
++ compatibility: JSON.parse(r.compatibility || '{}'), notes: r.notes,
++ created_at: r.created_at, created_by: r.created_by,
++ published_at: r.published_at || null, deprecated_at: r.deprecated_at || null, deprecation_reason: r.deprecation_reason || null,
++ replacement: r.replacement || null, revoked_at: r.revoked_at || null, revocation_reason: r.revocation_reason || null,
++ trust: trust.get(r.app_id),
++ };
++ }
++
++ const get = (id) => view(db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id)));
++ const manifestOf = (id) => {
++ const m = db.prepare('SELECT * FROM manifests WHERE id = ?').get(String(id));
++ return m ? { id: m.id, kind: m.kind, body: JSON.parse(m.body), contracts_version: m.contracts_version, created_at: m.created_at } : null;
++ };
++ const listForApp = (appId, { includeDrafts = false } = {}) => db.prepare(
++ `SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,
++ ).all(String(appId)).map(view);
++ const recentPublic = (limit = 20) => db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit).map(view);
++ const log = (releaseId) => db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId)
++ .map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));
++
++ function writeLog(releaseId, action, actor, detail = {}) {
++ db.prepare('INSERT INTO release_log (release_id, action, actor, at, detail) VALUES (?, ?, ?, ?, ?)')
++ .run(releaseId, action, actor, store.iso(), JSON.stringify(detail));
++ }
++
++ /**
++ * actor: { label: 'user:usr_…' | 'app:app_…', subject, kind: 'user' | 'app', role?, staff? }
++ * role is the person's project role as Network reported it; apps act on themselves only.
++ */
++ function assertCanManage(actor, app, { revoke = false } = {}) {
++ if (actor.kind === 'app') {
++ if (actor.subject !== app.id) fail(403, 'release.forbidden', 'an app manages only its own releases');
++ return;
++ }
++ if (revoke && actor.staff) return;
++ const need = manageRole(app.environment);
++ if (!actor.role || RANK[actor.role] < RANK[need]) fail(403, 'release.forbidden', `managing ${app.environment} releases needs the ${need} role in the project (Network reports ${actor.role || 'no role'})`);
++ }
++
++ function event(type, rel, actor, extra = {}) {
++ const [atype, aid] = actor.label.split(':');
++ return outbox.emit({
++ event_type: type,
++ actor: { type: atype === 'app' ? 'app' : 'user', id: aid },
++ subject: { type: 'app', id: rel.app_id },
++ visibility: 'public',
++ payload: {
++ project_id: rel.project_id, release_id: rel.id, kind: rel.kind, subject_id: rel.subject_id, version: rel.version,
++ environment: rel.environment, trust_tier: trust.get(rel.app_id).tier, ...extra,
++ },
++ }, { traceparent: actor.traceparent });
++ }
++
++ /**
++ * Create a draft from a manifest. app: { id, project_id, environment, grants, revoked_at } as
++ * Network reported it (or, for an app token, as its claims say).
++ */
++ function createDraft({ actor, app, kind, manifest, notes = '', eventTypes }) {
++ if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
++ assertCanManage(actor, app);
++ const result = validateManifest(kind, manifest, { app, viewerSubject: actor.kind === 'user' ? actor.subject : null, eventTypes });
++ if (!result.valid) fail(422, 'manifest.invalid', 'the manifest does not validate', { validation: result });
++ const subjectId = manifest.id;
++ const exists = db.prepare('SELECT id FROM releases WHERE app_id = ? AND kind = ? AND subject_id = ? AND version = ?').get(app.id, kind, subjectId, manifest.version);
++ if (exists) fail(409, 'release.exists', `version ${manifest.version} of ${subjectId} already has a release (${exists.id}); versions are immutable`);
++ const cleanNotes = String(notes || '').slice(0, 2000);
++ const manifestId = store.newId('mfs');
++ const releaseId = store.newId('rel');
++ const t = store.iso();
++ db.transaction(() => {
++ db.prepare(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(manifestId, kind, app.id, app.project_id, subjectId, manifest.version, JSON.stringify(manifest), contractsVersion, actor.label, t);
++ db.prepare(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)`)
++ .run(releaseId, app.id, app.project_id, app.environment, kind, subjectId, String(manifest.name).slice(0, 80), manifest.version, manifestId,
++ JSON.stringify(manifest.compatibility || {}), cleanNotes, actor.label, t);
++ writeLog(releaseId, 'created', actor.label, { version: manifest.version, kind, warnings: result.warnings.length });
++ })();
++ return { release: get(releaseId), validation: result };
++ }
++
++ function load(id) {
++ const r = db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id));
++ if (!r) fail(404, 'release.not_found', 'no such release');
++ return r;
++ }
++
++ function publish({ actor, releaseId, app }) {
++ const r = load(releaseId);
++ if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
++ if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
++ assertCanManage(actor, { ...app, environment: r.environment });
++ if (r.status !== 'draft') fail(409, 'release.not_draft', `release is ${r.status}`);
++ let env;
++ db.transaction(() => {
++ db.prepare("UPDATE releases SET status = 'published', published_by = ?, published_at = ? WHERE id = ? AND status = 'draft'").run(actor.label, store.iso(), r.id);
++ writeLog(r.id, 'published', actor.label);
++ env = event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });
++ })();
++ return { release: get(r.id), event_id: env.event_id };
++ }
++
++ function deprecate({ actor, releaseId, app, reason, replacement }) {
++ const r = load(releaseId);
++ if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
++ assertCanManage(actor, { ...app, environment: r.environment });
++ if (r.status !== 'published') fail(409, 'release.not_published', `release is ${r.status}`);
++ const why = String(reason || '').trim().slice(0, 500);
++ if (!why) fail(422, 'release.invalid', 'say why the release is deprecated');
++ let repl = null;
++ if (replacement) {
++ const rr = db.prepare('SELECT id, app_id, status FROM releases WHERE id = ?').get(String(replacement));
++ if (!rr || rr.app_id !== r.app_id || rr.status !== 'published') fail(422, 'release.invalid', 'the replacement must be a published release of the same app');
++ repl = rr.id;
++ }
++ let env;
++ db.transaction(() => {
++ db.prepare("UPDATE releases SET status = 'deprecated', deprecated_by = ?, deprecated_at = ?, deprecation_reason = ?, replacement = ? WHERE id = ?")
++ .run(actor.label, store.iso(), why, repl, r.id);
++ writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
++ env = event('codes.app.deprecated', r, actor, { reason: why, replacement: repl });
++ })();
++ return { release: get(r.id), event_id: env.event_id };
++ }
++
++ function revoke({ actor, releaseId, app, reason }) {
++ const r = load(releaseId);
++ if (app && app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
++ assertCanManage(actor, { ...(app || { id: r.app_id }), environment: r.environment }, { revoke: true });
++ if (r.status === 'revoked') fail(409, 'release.revoked', 'release is already revoked');
++ const why = String(reason || '').trim().slice(0, 500);
++ if (!why) fail(422, 'release.invalid', 'say why the release is revoked');
++ const wasPublic = r.status === 'published' || r.status === 'deprecated';
++ let env = null;
++ db.transaction(() => {
++ db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);
++ writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });
++ // A draft was never public: nobody downstream knows it, so there is nothing to announce.
++ if (wasPublic) env = event('codes.app.revoked', r, actor, { reason: why });
++ })();
++ return { release: get(r.id), event_id: env ? env.event_id : null };
++ }
++
++ return { get, manifestOf, listForApp, recentPublic, log, createDraft, publish, deprecate, revoke, manageRole };
++}
++
++module.exports = { createReleases, ReleaseError, manageRole, RANK };
ddiff --git a/server/domain/trust.js b/server/domain/trust.js
nnew file mode 100644
iindex 0000000..f86e806
---- /dev/null
++++ b/server/domain/trust.js
@@@ -0,0 +1,46 @@
++'use strict';
++
++/**
++ * Trust tiers (roadmap §15.17): untrusted, verified, trusted, platform-maintained.
++ *
++ * METADATA ONLY. "Actual authority comes from grants. Trust may affect default quotas/review
++ * requirements, never bypass policy." Nothing in Codes (or anywhere) reads a tier to allow an
++ * action; the tier is shown next to releases and carried in codes.app.* event payloads.
++ * Every app starts untrusted; Codes staff change it, with a note, and every change is kept.
++ */
++const TIERS = ['untrusted', 'verified', 'trusted', 'platform-maintained'];
++const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createTrust({ store }) {
++ const { db } = store;
++
++ function get(appId) {
++ const r = db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));
++ return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'untrusted', note: '', set_by: null, set_at: null };
++ }
++
++ function set({ appId, tier, note, actor }) {
++ if (!actor || !actor.staff) throw Object.assign(new Error('only Codes staff set trust tiers'), { status: 403, code: 'trust.staff_only' });
++ if (!APP_ID_RE.test(String(appId))) throw Object.assign(new Error('not an app id'), { status: 422, code: 'trust.invalid' });
++ if (!TIERS.includes(tier)) throw Object.assign(new Error(`tier is one of ${TIERS.join(', ')}`), { status: 422, code: 'trust.invalid' });
++ const clean = String(note || '').trim().slice(0, 500);
++ if (!clean) throw Object.assign(new Error('say why (the note is public)'), { status: 422, code: 'trust.invalid' });
++ const before = get(appId).tier;
++ const t = store.iso();
++ db.transaction(() => {
++ db.prepare(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT(app_id) DO UPDATE SET tier = excluded.tier, note = excluded.note, set_by = excluded.set_by, set_at = excluded.set_at`)
++ .run(appId, tier, clean, actor.label, t);
++ db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(appId, before, tier, clean, actor.label, t);
++ })();
++ return get(appId);
++ }
++
++ const history = (appId) => db.prepare('SELECT from_tier, to_tier, note, set_by, set_at FROM trust_history WHERE app_id = ? ORDER BY id DESC').all(String(appId));
++ const listSet = () => db.prepare('SELECT * FROM trust ORDER BY set_at DESC LIMIT 200').all();
++
++ return { get, set, history, listSet, TIERS };
++}
++
++module.exports = { createTrust, TIERS };
ddiff --git a/server/domain/webhooks.js b/server/domain/webhooks.js
nnew file mode 100644
iindex 0000000..8873ccd
---- /dev/null
++++ b/server/domain/webhooks.js
@@@ -0,0 +1,96 @@
++'use strict';
++
++/**
++ * Webhook tester and signed-event inspector.
++ *
++ * OpenVibe.Events delivers `{ "event": <events.event-envelope@1>, "seq": <n> }` with
++ * X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the RAW body under the subscription secret>.
++ * Verification here uses openvibe-sdk's own signDelivery/verifyDelivery (constant-time compare), so
++ * the tester agrees with what a receiver built on the SDK does. The secret a developer types is used
++ * for one computation and dropped: never stored, logged or rendered back.
++ */
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { signDelivery, verifyDelivery } = require('openvibe-sdk/events');
++
++const MAX_BODY = 256 * 1024;
++
++/**
++ * → { valid, reason, expected, given, bodyBytes, parsed }
++ * expected is the signature the secret produces for this body (showing it does not reveal the
++ * secret, and it is what a developer needs to see why a comparison failed).
++ */
++function inspect({ rawBody, signature, secret }) {
++ const body = typeof rawBody === 'string' ? rawBody : '';
++ const given = typeof signature === 'string' ? signature.trim() : '';
++ if (!body) return { valid: false, reason: 'paste the raw request body exactly as received (byte for byte)' };
++ if (Buffer.byteLength(body) > MAX_BODY) return { valid: false, reason: 'body is larger than 256 KB' };
++ if (!secret) return { valid: false, reason: 'enter the subscription secret' };
++ const expected = signDelivery(body, secret);
++ const valid = verifyDelivery(body, given, secret);
++ let reason = null;
++ if (!valid) {
++ if (!given) reason = 'no X-OpenVibe-Signature value given';
++ else if (!given.startsWith('sha256=')) reason = 'the header value must start with sha256=';
++ else if (given.length !== expected.length) reason = 'wrong length: sha256= followed by 64 lowercase hex characters';
++ else reason = 'the signature does not match this body and secret (check the body was not re-serialised: whitespace and key order matter)';
++ }
++ let parsed = null;
++ try {
++ const obj = JSON.parse(body);
++ if (obj && typeof obj === 'object' && obj.event) {
++ const v = contracts.validate('events.event-envelope@1', obj.event);
++ parsed = { event_type: obj.event.event_type || null, event_id: obj.event.event_id || null, seq: obj.seq ?? null, envelopeValid: v.valid, envelopeErrors: v.errors };
++ } else {
++ parsed = { note: 'JSON, but not an Events delivery ({ event, seq })' };
++ }
++ } catch { parsed = { note: 'not JSON' }; }
++ return { valid, reason, expected, given, bodyBytes: Buffer.byteLength(body), parsed };
++}
++
++/**
++ * A sample delivery for an event type from the contracts catalog, signed with the given secret.
++ * The envelope is valid events.event-envelope@1. Contracts define no per-event payload schemas yet,
++ * so the payload is empty and the page says so rather than inventing fields.
++ */
++function sample({ eventType, secret, producer, now = Date.now() }) {
++ const source = producer || String(eventType).split('.')[0];
++ const envelope = {
++ event_id: contracts.ids.newId('event', now),
++ event_type: eventType,
++ version: 1,
++ source,
++ actor: { type: 'service', id: source },
++ timestamp: new Date(now).toISOString(),
++ visibility: 'internal',
++ subject: { type: 'example', id: 'example-1' },
++ payload: {},
++ trace_id: crypto.randomBytes(16).toString('hex'),
++ };
++ const check = contracts.validate('events.event-envelope@1', envelope);
++ const seq = 1;
++ const body = JSON.stringify({ event: envelope, seq });
++ const subscriptionId = `sub_${contracts.ids.ulid(now)}`;
++ const headers = {
++ 'Content-Type': 'application/json',
++ 'User-Agent': 'OpenVibe.Events/0.1',
++ 'X-OpenVibe-Event-Id': envelope.event_id,
++ 'X-OpenVibe-Event-Type': eventType,
++ 'X-OpenVibe-Seq': String(seq),
++ 'X-OpenVibe-Subscription-Id': subscriptionId,
++ 'X-OpenVibe-Delivery-Attempt': '1',
++ 'X-OpenVibe-Hops': '0',
++ 'X-OpenVibe-Signature': signDelivery(body, secret),
++ traceparent: `00-${envelope.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
++ };
++ return { body, headers, envelopeValid: check.valid, envelopeErrors: check.errors };
++}
++
++/** A curl command that replays the sample against the developer's OWN endpoint (Codes never sends it). */
++function curlFor(headers, body, endpoint = 'https://your-app.example/webhooks/openvibe') {
++ const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
++ const hs = Object.entries(headers).map(([k, v]) => ` -H ${q(`${k}: ${v}`)}`).join(' \\\n');
++ return `curl -X POST ${q(endpoint)} \\\n${hs} \\\n --data-binary ${q(body)}`;
++}
++
++module.exports = { inspect, sample, curlFor, MAX_BODY };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..e6a72e1
---- /dev/null
++++ b/server/events/outbox.js
@@@ -0,0 +1,63 @@
++'use strict';
++
++/**
++ * Codes → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
++ *
++ * codes.app.published a release became public subject { type: 'app', id: app_… }
++ * codes.app.deprecated a public release was deprecated (payload: release id, version, kind,
++ * codes.app.revoked a public release was revoked environment, trust tier, reason…)
++ *
++ * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
++ * if its change committed. The relay publishes with Codes' OWN service token (events.event.publish,
++ * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise rows
++ * wait in event_outbox and /api/ready reports the relay as off. Payloads never carry a secret.
++ */
++const { createClient } = require('openvibe-sdk/core');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
++
++const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked'];
++
++function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
++ const enabled = Boolean(config.events.url && config.oauth.clientSecret);
++ const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0, autoDiscover: false };
++ if (fetchImpl) clientOpts.fetch = fetchImpl;
++ if (enabled) {
++ clientOpts.tokenProvider = createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ } else {
++ clientOpts.getToken = async () => { throw new Error('events relay disabled (EVENTS_URL / OV_OAUTH_CLIENT_SECRET unset)'); };
++ }
++ const events = createEventsClient(createClient(clientOpts), { source: 'codes' });
++ let lastError = null;
++ const outbox = createOutbox(db, {
++ events,
++ intervalMs: config.events.intervalMs,
++ now,
++ onError: (err) => {
++ const msg = err && err.message;
++ if (msg !== lastError) log.warn('[Codes] event publish failed (will retry):', msg);
++ lastError = msg;
++ },
++ });
++ outbox.ensureSchema();
++
++ /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
++ function emit(envelope, { traceparent } = {}) {
++ if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`Codes does not produce ${envelope.event_type}`);
++ return outbox.enqueue(envelope, { traceparent });
++ }
++
++ return {
++ emit,
++ outbox,
++ enabled,
++ start() { if (enabled) outbox.start(); },
++ stop: () => outbox.stop(),
++ status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
++ };
++}
++
++module.exports = { createCodesOutbox, EVENT_TYPES };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..60ff98b
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,137 @@
++'use strict';
++
++/**
++ * JSON API (/api/v1). Errors are RFC 9457 problems (openvibe-contracts http.sendProblem).
++ *
++ * GET /docs/versions the pinned versions the docs were generated from
++ * POST /manifests/validate { kind: app|mod, manifest } → { valid, errors, warnings }
++ * GET /apps/:app/releases public releases of an app (never drafts) + trust tier
++ * GET /apps/:app/trust the app's trust tier (metadata only)
++ * GET /releases/:id one public release with its manifest
++ *
++ * An app managing its OWN releases with its own token (proposed capability codes.release.manage,
++ * audience openvibe.codes — inert until Contracts releases it and Network can grant it):
++ * POST /apps/:app/releases { kind, manifest, notes, publish } → draft (or published)
++ * POST /releases/:id/publish | /deprecate { reason, replacement } | /revoke { reason }
++ *
++ * No route accepts a shared loopback key of any kind. Tokens are verified offline against Network's
++ * key: issuer, audience openvibe.codes, expiry, sub = app:<the app in the path>, and the capability.
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const rateLimit = require('express-rate-limit');
++const { http } = require('openvibe-contracts');
++const manifests = require('../domain/manifests');
++const { ReleaseError } = require('../domain/releases');
++const { verifyJwt } = require('../auth/keys');
++
++const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
++// Proposed in docs/capabilities-proposal/codes.release.manage.json. Checked by hand against the verified
++// token's `cap` because openvibe-contracts' requireCapability() refuses capabilities it does not know
++// yet; once a contracts release carries it, switch these routes to requireCapability(MANAGE, …).
++const MANAGE = 'codes.release.manage';
++
++function createApi(ctx) {
++ const { config, docs, releases, trust, keys, store } = ctx;
++ const r = asyncRouter();
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ const json = express.json({ limit: '96kb' });
++ const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
++ const problem = (req, res, status, code, detail, extra) => http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
++
++ r.get('/docs/versions', (req, res) => {
++ res.json({ contracts: docs.contractsVersion, sdk: docs.sdkVersion, generated_at: docs.generatedAt, contracts_count: docs.contracts.length, capabilities_count: docs.capabilities.length });
++ });
++
++ r.post('/manifests/validate', limiter, json, (req, res) => {
++ const b = req.body || {};
++ const kind = b.kind === 'mod' ? 'mod' : (b.kind === 'app' ? 'app' : null);
++ if (!kind) return problem(req, res, 422, 'manifest.kind', 'kind is app or mod');
++ const v = manifests.validate(kind, b.manifest, { eventTypes: docs.eventTypes });
++ res.status(v.valid ? 200 : 422).json({ ...v, contracts_version: docs.contractsVersion });
++ });
++
++ r.get('/apps/:app/releases', (req, res) => {
++ if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });
++ });
++ r.get('/apps/:app/trust', (req, res) => {
++ if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
++ });
++ r.get('/releases/:id', (req, res) => {
++ const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
++ if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
++ res.set('Cache-Control', 'public, max-age=60');
++ const m = releases.manifestOf(rel.manifest_id);
++ res.json({ release: rel, manifest: m ? m.body : null });
++ });
++
++ // ── App token: an app manages its own releases ──────────
++ async function appPrincipal(req, res, appId) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) { problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'); return null; }
++ await keys.ensure();
++ const v = verifyJwt(h.slice(7).trim(), { publicKey: keys.get(), issuer: config.networkIssuer, audience: 'openvibe.codes', now: store.now() });
++ if (!v.ok) { problem(req, res, 401, v.expired ? 'token.expired' : 'token.invalid', v.reason); return null; }
++ const c = v.claims;
++ if (typeof c.sub !== 'string' || !c.sub.startsWith('app:')) { problem(req, res, 403, 'capability.denied', 'only app tokens manage releases here; people use the portal'); return null; }
++ if (!Array.isArray(c.cap) || !c.cap.includes(MANAGE)) { problem(req, res, 403, 'capability.denied', `${MANAGE} not granted`); return null; }
++ const self = c.sub.slice(4);
++ if (appId && self !== appId) { problem(req, res, 403, 'release.forbidden', 'an app manages only its own releases'); return null; }
++ return {
++ actor: { kind: 'app', label: c.sub, subject: self, traceparent: req.ov.traceparent },
++ app: { id: self, project_id: typeof c.project_id === 'string' ? c.project_id : (Array.isArray(c.ns) ? c.ns[0] : null), environment: c.env === 'production' ? 'production' : 'sandbox', revoked_at: null },
++ };
++ }
++ const fail = (req, res, err) => {
++ if (err instanceof ReleaseError) return problem(req, res, err.status, err.code, err.detail, err.validation ? { validation: err.validation } : undefined);
++ ctx.log.error('[Codes] api error:', err && err.message ? err.message.slice(0, 200) : err);
++ return problem(req, res, 500, 'internal.error', 'Internal error');
++ };
++
++ r.post('/apps/:app/releases', limiter, json, async (req, res) => {
++ if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
++ const p = await appPrincipal(req, res, req.params.app);
++ if (!p) return;
++ if (!p.app.project_id) return problem(req, res, 403, 'token.invalid_claims', 'the token names no project');
++ const b = req.body || {};
++ try {
++ const kind = b.kind === 'mod' ? 'mod' : 'app';
++ const out = releases.createDraft({ actor: p.actor, app: p.app, kind, manifest: b.manifest, notes: b.notes, eventTypes: docs.eventTypes });
++ let release = out.release;
++ if (b.publish === true) release = releases.publish({ actor: p.actor, releaseId: release.id, app: p.app }).release;
++ res.status(201).json({ release, warnings: out.validation.warnings });
++ } catch (err) { fail(req, res, err); }
++ });
++
++ for (const action of ['publish', 'deprecate', 'revoke']) {
++ r.post(`/releases/:id/${action}`, limiter, json, async (req, res) => {
++ const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
++ if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
++ const p = await appPrincipal(req, res, rel.app_id);
++ if (!p) return;
++ const b = req.body || {};
++ try {
++ const out = action === 'publish' ? releases.publish({ actor: p.actor, releaseId: rel.id, app: p.app })
++ : action === 'deprecate' ? releases.deprecate({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason, replacement: b.replacement })
++ : releases.revoke({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason });
++ res.json(out);
++ } catch (err) { fail(req, res, err); }
++ });
++ }
++
++ // eslint-disable-next-line no-unused-vars
++ r.use((err, req, res, _next) => {
++ if (err && err.type === 'entity.parse.failed') return problem(req, res, 400, 'request.malformed_json', 'body is not valid JSON');
++ if (err && err.type === 'entity.too.large') return problem(req, res, 413, 'request.too_large', 'body too large');
++ ctx.log.error('[Codes] api error:', err && err.message ? err.message.slice(0, 200) : err);
++ return problem(req, res, 500, 'internal.error', 'Internal error');
++ });
++ return r;
++}
++
++module.exports = { createApi, MANAGE };
ddiff --git a/server/http/docs.js b/server/http/docs.js
nnew file mode 100644
iindex 0000000..1d817fb
---- /dev/null
++++ b/server/http/docs.js
@@@ -0,0 +1,229 @@
++'use strict';
++
++/**
++ * Generated documentation pages. Every page states the package versions it was generated from and
++ * renders ONLY what the pinned packages (or, for the service registry, the Network) say.
++ *
++ * /docs index + versions
++ * /docs/contracts[/:id] catalog; one page per contract: field table, fixtures, raw schema
++ * /docs/contracts/:id.json the schema itself
++ * /docs/capabilities[/:id] capability catalog; grantable (public/partner, active) highlighted
++ * /docs/events event types from the service manifests
++ * /docs/services the Network's registry with health as reported (never invented)
++ * /docs/sdk[/:module] SDK reference from its .d.ts files
++ * /docs/adr/:id ADRs as published in openvibe-contracts
++ */
++const { asyncRouter } = require('./router');
++const { html, raw, table, code, badge, time, problemBox } = require('../render/html');
++const { send } = require('../render/layout');
++const { markdown } = require('../render/markdown');
++
++function createDocsRoutes(ctx) {
++ const { docs, config, network } = ctx;
++ const r = asyncRouter();
++ const PUBLIC_CACHE = 'public, max-age=300';
++
++ const versions = () => html`<p class="versions">Generated at ${time(docs.generatedAt)} from
++<a href="https://github.com/OpenVibers/OpenVibe.Contracts/tree/v${docs.contractsVersion}"><code>openvibe-contracts v${docs.contractsVersion}</code></a> and
++<a href="https://github.com/OpenVibers/OpenVibe.SDK/tree/v${docs.sdkVersion}"><code>openvibe-sdk v${docs.sdkVersion}</code></a>.</p>`;
++ const page = (req, res, o, status = 200) => send(res, status, { index: true, cache: PUBLIC_CACHE, viewer: req.viewer, config, path: req.originalUrl, ...o });
++
++ r.get('/', (req, res) => {
++ const grantable = docs.capabilities.filter((c) => c.grantable).length;
++ page(req, res, {
++ title: 'Docs',
++ description: `OpenVibe platform reference generated from openvibe-contracts v${docs.contractsVersion} and openvibe-sdk v${docs.sdkVersion}.`,
++ crumbs: [{ label: 'Docs' }],
++ body: html`<h1>Platform reference</h1>${versions()}
++<p>Nothing on these pages is written by hand: each one is rendered from the published packages above when Codes starts, so it always matches what the platform runs against. If something is missing here, it is not part of the public surface yet (<a href="/policy/compatibility">why</a>).</p>
++<ul class="cards">
++<li><a href="/docs/contracts"><strong>Contracts</strong></a><span>${docs.contracts.length} JSON Schemas with fields, examples and versions</span></li>
++<li><a href="/docs/capabilities"><strong>Capabilities</strong></a><span>${docs.capabilities.length} capabilities; ${grantable} can be granted to apps</span></li>
++<li><a href="/docs/events"><strong>Events</strong></a><span>${docs.events.length} event types services declare they produce</span></li>
++<li><a href="/docs/services"><strong>Services</strong></a><span>the registry as OpenVibe.Network reports it, with health</span></li>
++<li><a href="/docs/sdk"><strong>SDK</strong></a><span>${docs.sdk.length} modules of openvibe-sdk from their type definitions</span></li>
++<li><a href="/policy/rfc"><strong>Decisions</strong></a><span>${docs.adrs.length} architecture decision records</span></li>
++</ul>
++<h2>Install</h2>
++<pre><code>npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v${docs.sdkVersion}
++npm install https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v${docs.contractsVersion}</code></pre>
++<p>The SDK is ${docs.sdkLicense}; the contracts are ${docs.contractsLicense}. The SDK release is tested against contracts <code>${docs.sdkContractsRange || 'n/a'}</code>.</p>`,
++ });
++ });
++
++ // ── Contracts ───────────────────────────────────────────
++ r.get('/contracts', (req, res) => {
++ page(req, res, {
++ title: 'Contracts',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Contracts' }],
++ body: html`<h1>Contracts</h1>${versions()}
++<p>A contract id is permanent. Minor versions only add optional fields; a breaking change is a new major with a deprecation window (<a href="/policy/compatibility">policy</a>).</p>
++${table(['Contract', 'Version', 'Owner', 'Visibility', 'Status', 'Compatibility'], docs.contracts.map((c) => [
++ html`<a href="/docs/contracts/${c.id}">${c.id}</a>${c.deprecation ? html` ${badge('deprecated', 'warn')}` : ''}`,
++ c.version, c.owner, c.visibility, c.status, c.compatibility,
++ ]))}`,
++ });
++ });
++
++ r.get('/contracts/:id.json', (req, res, next) => {
++ const c = docs.contract(req.params.id);
++ if (!c) return next();
++ res.set('Cache-Control', PUBLIC_CACHE).type('application/schema+json').send(JSON.stringify(c.schema, null, 2));
++ });
++
++ r.get('/contracts/:id', (req, res, next) => {
++ const c = docs.contract(req.params.id);
++ if (!c) return next();
++ const major = c.version.split('.')[0];
++ const fixture = (f, ok) => html`<details${ok ? raw(' open') : ''}><summary>${ok ? 'Valid' : 'Rejected'}: ${f.name}</summary><pre><code>${JSON.stringify(f.body, null, 2)}</code></pre></details>`;
++ page(req, res, {
++ title: `${c.id}@${major}`,
++ description: c.description.slice(0, 200),
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Contracts', href: '/docs/contracts' }, { label: c.id }],
++ body: html`<h1>${c.title} <small><code>${c.id}@${major}</code></small></h1>${versions()}
++<dl class="facts"><dt>Version</dt><dd>${c.version}</dd><dt>Owner</dt><dd>${c.owner}</dd><dt>Visibility</dt><dd>${c.visibility}</dd><dt>Status</dt><dd>${c.status}</dd>
++<dt>Compatibility</dt><dd>${c.compatibility}</dd>${c.adr ? html`<dt>Decision</dt><dd><a href="/docs/adr/${c.adr}">${c.adr}</a></dd>` : ''}
++<dt>Schema</dt><dd><a href="/docs/contracts/${c.id}.json"><code>${c.$id}</code></a></dd></dl>
++${c.deprecation ? html`<div class="notice warn">Deprecated since ${c.deprecation.since}; use ${c.deprecation.replacement}. Removed after ${c.deprecation.removeAfter}. ${c.deprecation.reason || ''}</div>` : ''}
++<p>${c.description}</p>
++<h2>Fields</h2>
++${table(['Field', 'Type', 'Required', 'Description', 'Constraints'], c.fields.map((f) => [
++ code(f.path),
++ f.refId ? html`<a href="/docs/contracts/${f.refId}">${f.refId}</a>` : (f.ref ? code(f.ref) : f.type),
++ f.required ? 'yes' : '',
++ f.description,
++ f.constraints.length ? html`<ul class="plain">${f.constraints.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '',
++ ]), { empty: 'This schema has no named fields.' })}
++<h2>Examples</h2>
++<p class="muted">From the contract's own test fixtures: valid ones validate, rejected ones must fail.</p>
++${c.fixtures.valid.map((f) => fixture(f, true))}${c.fixtures.invalid.map((f) => fixture(f, false))}
++${!c.fixtures.valid.length && !c.fixtures.invalid.length ? html`<p class="muted">No fixtures ship with this contract.</p>` : ''}
++<h2>Validate</h2>
++<pre><code>const contracts = require('openvibe-contracts');
++contracts.validate('${c.id}@${major}', value); // { valid, errors: [{ path, message }] }</code></pre>`,
++ });
++ });
++
++ // ── Capabilities ────────────────────────────────────────
++ r.get('/capabilities', (req, res) => {
++ const owners = [...new Set(docs.capabilities.map((c) => c.owner))].sort();
++ const filter = typeof req.query.owner === 'string' && owners.includes(req.query.owner) ? req.query.owner : null;
++ const onlyGrantable = req.query.grantable === '1';
++ const list = docs.capabilities.filter((c) => (!filter || c.owner === filter) && (!onlyGrantable || c.grantable));
++ page(req, res, {
++ title: 'Capabilities',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Capabilities' }],
++ body: html`<h1>Capabilities</h1>${versions()}
++<p>A capability is an action a principal may invoke, checked at the owning service against the token's grants. <strong>Highlighted rows can be granted to apps</strong>: only active capabilities whose visibility is <code>public</code> (or <code>partner</code>, when staff put one in a project's allowance). <code>first-party</code> and <code>internal</code> capabilities are never granted to apps.</p>
++<form method="get" action="/docs/capabilities" class="inline-form"><label>Owner <select name="owner"><option value="">all</option>${owners.map((o) => html`<option value="${o}"${o === filter ? raw(' selected') : ''}>${o}</option>`)}</select></label>
++<label><input type="checkbox" name="grantable" value="1"${onlyGrantable ? raw(' checked') : ''}> grantable to apps only</label> <button type="submit">Filter</button></form>
++${table(['Capability', 'Owner', 'Visibility', 'Status', 'Description', 'Quota class'], list.map((c) => [
++ html`<a href="/docs/capabilities/${c.id}" class="${c.grantable ? 'grantable' : ''}">${c.id}</a>${c.grantable ? html` ${badge('grantable', 'ok')}` : ''}`,
++ c.owner, c.visibility, c.status, c.description || '', c.quotaClass,
++ ]), { cls: 'caps' })}`,
++ });
++ });
++
++ r.get('/capabilities/:id', (req, res, next) => {
++ const c = docs.capability(req.params.id);
++ if (!c) return next();
++ page(req, res, {
++ title: c.id,
++ description: (c.description || '').slice(0, 200),
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Capabilities', href: '/docs/capabilities' }, { label: c.id }],
++ body: html`<h1><code>${c.id}</code></h1>${versions()}
++${c.grantable ? html`<div class="notice ok">Grantable to apps: request it for an app on its project page. Network approves it only inside the project's allowance.</div>`
++ : html`<div class="notice">Not grantable to apps (${c.status}, ${c.visibility}).</div>`}
++<p>${c.description || ''}</p>
++<dl class="facts"><dt>Owner</dt><dd>${c.owner} (token audience <code>openvibe.${c.owner}</code>)</dd><dt>Version</dt><dd>${c.version}</dd>
++<dt>Status</dt><dd>${c.status}</dd><dt>Visibility</dt><dd>${c.visibility}</dd>
++<dt>Resource constraints</dt><dd>${c.resourceConstraints.join(', ') || 'none'}</dd><dt>Quota class</dt><dd>${c.quotaClass}</dd>
++<dt>Permissions</dt><dd>${c.permissions.join(', ') || '—'}</dd>
++${c.inputSchema ? html`<dt>Input</dt><dd>${docs.contract(c.inputSchema.split('@')[0]) ? html`<a href="/docs/contracts/${c.inputSchema.split('@')[0]}">${c.inputSchema}</a>` : c.inputSchema}</dd>` : ''}
++${c.outputSchema ? html`<dt>Output</dt><dd>${c.outputSchema}</dd>` : ''}
++<dt>Events</dt><dd>${c.events.length ? c.events.join(', ') : '—'}</dd>
++<dt>Implemented by</dt><dd>${(c.implementedBy || []).length ? html`<ul class="plain">${c.implementedBy.map((x) => html`<li><code>${x}</code></li>`)}</ul>` : '—'}</dd></dl>`,
++ });
++ });
++
++ // ── Events ──────────────────────────────────────────────
++ r.get('/events', (req, res) => {
++ page(req, res, {
++ title: 'Event types',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Events' }],
++ body: html`<h1>Event types</h1>${versions()}
++<p>Every event type a service manifest declares it produces, and who declares they consume it. Events travel as <a href="/docs/contracts/events.event-envelope">events.event-envelope@1</a>; deliveries are signed (<a href="/tools/webhooks">webhook tester</a>). Contracts do not define per-event payload schemas yet.</p>
++${table(['Event type', 'Produced by', 'Consumed by'], docs.events.map((e) => [code(e.type), e.producers.join(', '), e.consumers.join(', ') || '—']))}`,
++ });
++ });
++
++ // ── Services (live registry) ────────────────────────────
++ r.get('/services', async (req, res) => {
++ let services = null;
++ let problem = null;
++ try { services = await network.registry.services(); } catch (err) { problem = network.problemOf(err); }
++ const rows = (services || []).map((s) => {
++ const rt = s.runtime || {};
++ return [
++ html`<strong>${s.name || s.id}</strong><br><code>${s.id}</code>`,
++ s.status,
++ (s.domains || []).join(', ') || '—',
++ html`${badge(rt.status || 'unknown', rt.status === 'up' ? 'ok' : (rt.status === 'down' ? 'bad' : ''))}${rt.reason ? html`<br><span class="small muted">${rt.reason}</span>` : ''}`,
++ rt.checked_at ? time(rt.checked_at) : '—',
++ (s.capabilities || []).length,
++ ];
++ });
++ send(res, problem ? 502 : 200, {
++ index: true, cache: 'public, max-age=30', viewer: req.viewer, config, path: req.originalUrl,
++ title: 'Services',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'Services' }],
++ body: html`<h1>Service registry</h1>
++<p>As OpenVibe.Network's registry reports it (<code>GET /api/v1/registry/services</code>), fetched at most every ${Math.round(config.registryTtlMs / 1000)} s. Health is Network's own check with the time it ran; a stale or missing check reads <em>unknown</em>, never up.</p>
++${problem ? html`${problemBox(problem, { title: 'The registry could not be read' })}
++<p>The service manifests published in <code>openvibe-contracts v${docs.contractsVersion}</code> are below, without health (nothing here says whether they run).</p>
++${table(['Service', 'Status (manifest)', 'Domains'], docs.services.map((s) => [html`<strong>${s.name}</strong><br><code>${s.id}</code>`, s.status, (s.domains || []).join(', ') || '—']))}`
++ : table(['Service', 'Maturity', 'Domains', 'Health (as reported)', 'Checked', 'Capabilities'], rows)}`,
++ });
++ });
++
++ // ── SDK ─────────────────────────────────────────────────
++ r.get('/sdk', (req, res) => {
++ page(req, res, {
++ title: 'SDK reference',
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'SDK' }],
++ body: html`<h1>openvibe-sdk v${docs.sdkVersion}</h1>${versions()}
++<p><strong>If a capability is not in the SDK, it is not public.</strong> Apps call services through the SDK; a route with no SDK wrapper is internal and can change without notice.</p>
++${table(['Module', 'Where', 'Declarations', 'Types file'], docs.sdk.map((m) => [
++ html`<a href="/docs/sdk/${m.slug}"><code>${m.name}</code></a>`, m.browser, m.declarations.length, code(m.typesFile),
++ ]))}`,
++ });
++ });
++
++ r.get('/sdk/:module', (req, res, next) => {
++ const m = docs.sdk.find((x) => x.slug === req.params.module);
++ if (!m) return next();
++ page(req, res, {
++ title: m.name,
++ crumbs: [{ label: 'Docs', href: '/docs' }, { label: 'SDK', href: '/docs/sdk' }, { label: m.name }],
++ body: html`<h1><code>${m.name}</code></h1>${versions()}
++<p class="muted">From <code>${m.typesFile}</code> (${m.browser}). Declarations are shown verbatim.</p>
++<nav class="toc"><ul class="plain">${m.declarations.map((d, i) => html`<li><a href="#d${i}">${d.kind} ${d.name}</a></li>`)}</ul></nav>
++${m.declarations.map((d, i) => html`<section id="d${i}" class="decl"><h2><small>${d.kind}</small> ${d.name}</h2>${d.doc ? html`<p>${d.doc}</p>` : ''}<pre><code>${d.text}</code></pre></section>`)}`,
++ });
++ });
++
++ r.get('/adr/:id', (req, res, next) => {
++ const a = docs.adrs.find((x) => x.id === req.params.id);
++ if (!a) return next();
++ page(req, res, {
++ title: a.title,
++ crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Decisions', href: '/policy/rfc' }, { label: a.id }],
++ body: html`<p class="versions">Published in <code>openvibe-contracts v${docs.contractsVersion}</code> (<code>docs/adr/${a.file}</code>), rendered as is.</p>
++<article class="prose">${raw(markdown(a.markdown, { headingOffset: 0 }))}</article>`,
++ });
++ });
++
++ return r;
++}
++
++module.exports = { createDocsRoutes };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..2ad1e2f
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,246 @@
++'use strict';
++
++/**
++ * Public pages: the landing page, policy pages, public release and trust pages, the staff trust
++ * console, robots.txt and the sitemap.
++ *
++ * Policy pages are derived, not paraphrased: the compatibility and deprecation policy renders
++ * ADR-002 and ADR-016 exactly as openvibe-contracts publishes them; the decision index is the ADR
++ * directory; licensing reads the license fields of the packages Codes runs.
++ */
++const fs = require('fs');
++const path = require('path');
++const express = require('express');
++const { asyncRouter } = require('./router');
++const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
++const { send } = require('../render/layout');
++const { markdown } = require('../render/markdown');
++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
++const { TABLES } = require('../db');
++const { EVENT_TYPES } = require('../events/outbox');
++
++const ROOT = path.join(__dirname, '..', '..');
++const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
++
++function createPageRoutes(ctx) {
++ const { config, docs, releases, trust, network, sso } = ctx;
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '32kb' });
++ const PUBLIC_CACHE = 'public, max-age=300';
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++
++ r.get('/', (req, res) => {
++ const recent = releases.recentPublic(10);
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ body: html`<h1>Build on OpenVibe</h1>
++<p class="lead">OpenVibe.Codes is the developer portal: create a project and apps in OpenVibe.Network, get scoped credentials, request capability grants, and read reference docs generated from the exact contract and SDK versions the platform runs.</p>
++<div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>. Network owns projects, apps, credentials, grants and quotas; Codes is a portal over its API and never stores a secret.</div>
++<h2>From account to integration</h2>
++<ol class="steps">
++<li><a href="/projects">Create a project</a> (signed in with your OpenVibe account). New projects are sandbox-only; staff enable production.</li>
++<li>Add a <strong>sandbox app</strong>. A confidential app gets a client secret, shown once.</li>
++<li><a href="/docs/capabilities?grantable=1">Pick capabilities</a> and request them for the app. They are approved only inside the project's allowance.</li>
++<li>Install <a href="/docs/sdk">openvibe-sdk</a>; get a token with <code>createServiceTokenClient()</code> (client credentials) or sign people in with <a href="/oauth">authorization code + PKCE</a>.</li>
++<li>Receive events: subscribe, then verify each delivery's signature (<a href="/tools/webhooks">tester</a>).</li>
++<li>Try calls in the app's playground with the app's own credentials: it cannot do anything the app is not granted.</li>
++<li>Publish release metadata with a <a href="/manifests/validate">validated manifest</a>; rotate or revoke credentials any time.</li>
++</ol>
++<h2>Reference</h2>
++<p>Generated from <code>openvibe-contracts v${docs.contractsVersion}</code> and <code>openvibe-sdk v${docs.sdkVersion}</code>: <a href="/docs/contracts">contracts</a> · <a href="/docs/capabilities">capabilities</a> · <a href="/docs/events">events</a> · <a href="/docs/services">services</a> · <a href="/docs/sdk">SDK</a>.</p>
++<h2>Recent releases</h2>
++${table(['App', 'Kind', 'Version', 'Status', 'Trust', 'Published'], recent.map((x) => [
++ html`<a href="/apps/${x.app_id}">${x.name}</a>`, x.kind, html`<a href="/releases/${x.id}">${x.version}</a>`, x.status, x.trust.tier, time(x.published_at),
++ ]), { empty: 'No releases have been published yet.' })}`,
++ });
++ });
++
++ // ── Policy ──────────────────────────────────────────────
++ const adr = (id) => docs.adrs.find((a) => a.id === id);
++ r.get('/policy', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Policy', crumbs: [{ label: 'Policy' }],
++ body: html`<h1>Policy</h1><ul class="cards">
++<li><a href="/policy/rfc"><strong>Proposals and decisions</strong></a><span>how a contract, capability or event changes; the decision record</span></li>
++<li><a href="/policy/compatibility"><strong>Compatibility and deprecation</strong></a><span>ADR-002 and ADR-016, as published</span></li>
++<li><a href="/policy/licensing"><strong>Licensing</strong></a><span>what each package is licensed under</span></li>
++<li><a href="/policy/transparency"><strong>Transparency</strong></a><span>what Codes stores, what it does not, and what works today</span></li></ul>`,
++ }));
++
++ r.get('/policy/rfc', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Proposals and decisions', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Proposals' }],
++ body: html`<h1>Proposals and decisions</h1>
++<p>Changes to the platform's public surface — a contract, a capability, an event type, or a major product policy — are proposed in the open and recorded as an architecture decision record (ADR) in <a href="https://github.com/OpenVibers/OpenVibe.Contracts">OpenVibe.Contracts</a>.</p>
++<ol class="steps">
++<li><strong>Propose.</strong> Open an issue or pull request on OpenVibe.Contracts describing the change, who owns it, and who consumes it. A service that introduces capabilities ships proposals in its own repository (<code>docs/capabilities-proposal/</code>, <code>docs/service-manifest-proposal.json</code>) — this portal's are in <a href="https://github.com/OpenVibers/OpenVibe.Codes/tree/main/docs">its repository</a>.</li>
++<li><strong>Decide.</strong> An accepted proposal becomes an ADR with context, decision, alternatives, migration consequences, rollback and acceptance tests.</li>
++<li><strong>Release.</strong> The contract lands in a tagged openvibe-contracts release; CI in every consuming service checks it (<code>openvibe-contracts-check</code>). The SDK wraps it in the same or the next release, and these docs regenerate from the new pins.</li>
++</ol>
++<h2>Decision record</h2>
++<p class="versions">The ADRs in <code>openvibe-contracts v${docs.contractsVersion}</code>:</p>
++${table(['Decision', 'Status'], docs.adrs.map((a) => [html`<a href="/docs/adr/${a.id}">${a.title}</a>`, a.status]))}`,
++ }));
++
++ r.get('/policy/compatibility', (req, res) => {
++ const a2 = adr('ADR-002');
++ const a16 = adr('ADR-016');
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Compatibility and deprecation', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Compatibility' }],
++ body: html`<h1>Compatibility and deprecation</h1>
++<p class="versions">This page renders two decisions exactly as published in <code>openvibe-contracts v${docs.contractsVersion}</code>, so it cannot say anything they do not.</p>
++<p>Current deprecations in that release: ${docs.contracts.filter((c) => c.deprecation).length ? html`<ul>${docs.contracts.filter((c) => c.deprecation).map((c) => html`<li><a href="/docs/contracts/${c.id}">${c.id}</a> → ${c.deprecation.replacement}, removed after ${c.deprecation.removeAfter}</li>`)}</ul>` : 'none.'}
++Deprecated capabilities: ${docs.capabilities.filter((c) => c.status === 'deprecated').map((c) => c.id).join(', ') || 'none'}.</p>
++${a2 ? html`<article class="prose">${raw(markdown(a2.markdown))}</article>` : notice('ADR-002 is not in this contracts release.', 'warn')}
++${a16 ? html`<article class="prose">${raw(markdown(a16.markdown))}</article>` : notice('ADR-016 is not in this contracts release.', 'warn')}`,
++ });
++ });
++
++ r.get('/policy/licensing', (req, res) => {
++ const pkgs = ['openvibe-contracts', 'openvibe-sdk', 'openvibe-shared'].map((n) => {
++ const p = readJson(path.join(path.dirname(require.resolve(`${n}/package.json`)), 'package.json')) || {};
++ return [code(n), p.version || '—', p.license || 'not declared'];
++ });
++ const own = readJson(path.join(ROOT, 'package.json')) || {};
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Licensing', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Licensing' }],
++ body: html`<h1>Licensing</h1>
++<p>Read from the packages this portal runs, not typed in:</p>
++${table(['Package', 'Version', 'License'], [[code(own.name || 'openvibe-codes'), own.version || '—', own.license || 'not declared'], ...pkgs])}
++<p>The SDK is the library apps outside the network embed; the services and their contracts are open source under the licenses above. Each repository's LICENSE file is authoritative. This page is not legal advice.</p>
++<p>Source: <a href="https://github.com/OpenVibers">github.com/OpenVibers</a>.</p>`,
++ });
++ });
++
++ r.get('/policy/transparency', (req, res) => {
++ const status = readJson(path.join(ROOT, 'STATUS.json')) || {};
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Transparency', crumbs: [{ label: 'Policy', href: '/policy' }, { label: 'Transparency' }],
++ body: html`<h1>Transparency</h1>
++<h2>What Codes stores</h2>
++<p>Its own SQLite database has these tables and nothing else: ${TABLES.map((t, i) => html`${i ? ', ' : ''}<code>${t}</code>`)}, plus the event outbox. That is release metadata keyed to Network app ids, trust tiers (metadata), validated manifests, and a log of playground runs (who ran what, the outcome and the problem code).</p>
++<h2>What Codes does not store</h2>
++<ul><li>Projects, members, apps, credentials, grants and quotas: OpenVibe.Network owns them; Codes shows what Network answers for the signed-in person.</li>
++<li>Client secrets: Network returns a new secret once, Codes shows it in that response and keeps no copy. Network itself stores only a hash.</li>
++<li>Access tokens: your Network session sits in httpOnly cookies in your browser; tokens a playground uses exist only for that request.</li>
++<li>Webhook secrets typed into the tester: used for one computation, then dropped.</li></ul>
++<h2>What Codes does not do</h2>
++<ul><li>It does not enforce quotas: each service that owns a capability does (quotas are shown as recorded limits).</li>
++<li>It does not issue tokens or decide grants: Network does.</li>
++<li>Trust tiers never grant anything: authority comes only from grants.</li></ul>
++<h2>Events it publishes</h2>
++<p>${EVENT_TYPES.map((t, i) => html`${i ? ', ' : ''}<code>${t}</code>`)} when a release is published, deprecated or revoked (through OpenVibe.Events once its relay is configured).</p>
++<h2>Status</h2>
++<p>Stage <strong>${status.stage || 'unknown'}</strong>. ${status.summary || ''}</p>
++${Array.isArray(status.works) ? html`<h3>Works</h3><ul>${status.works.map((w) => html`<li>${w}</li>`)}</ul>` : ''}
++${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w) => html`<li>${w}</li>`)}</ul>` : ''}`,
++ });
++ });
++
++ // ── Public release pages ────────────────────────────────
++ const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++ r.get('/apps/:app', (req, res, next) => {
++ if (!APP_ID_RE.test(req.params.app)) return next();
++ const list = releases.listForApp(req.params.app);
++ const t = trust.get(req.params.app);
++ const hist = trust.history(req.params.app);
++ page(req, res, {
++ index: list.length > 0, cache: PUBLIC_CACHE, title: list[0] ? list[0].name : req.params.app,
++ crumbs: [{ label: 'Apps' }, { label: req.params.app }],
++ body: html`<h1>${list[0] ? list[0].name : 'App'} <small><code>${req.params.app}</code></small></h1>
++<p>Trust tier: ${badge(t.tier)} ${t.note ? html`<span class="muted">— ${t.note}</span>` : ''}</p>
++<p class="muted small">Trust tiers are metadata. They never grant anything: what an app may do comes only from its grants in OpenVibe.Network.</p>
++<h2>Releases</h2>
++${table(['Version', 'Kind', 'Environment', 'Status', 'Published', 'Compatibility'], list.map((x) => [
++ html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, x.environment, statusBadge(x.status), time(x.published_at),
++ Object.entries(x.compatibility).map(([k, v]) => `${k} ${v}`).join('; '),
++ ]), { empty: 'No public releases.' })}
++${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], hist.map((h) => [time(h.set_at), `${h.from_tier} → ${h.to_tier}`, h.note]))}` : ''}`,
++ });
++ });
++
++ r.get('/releases/:id', async (req, res, next) => {
++ const rel = releases.get(req.params.id);
++ if (!rel) return next();
++ if (rel.status === 'draft') {
++ // Drafts are visible to members of the app's project only — as Network reports it.
++ let member = false;
++ if (req.viewer.kind === 'user') {
++ try { await sso.asViewer(req, res, (t) => network.projects.app(t, rel.project_id, rel.app_id)); member = true; } catch { member = false; }
++ }
++ if (!member) return next();
++ }
++ const m = releases.manifestOf(rel.manifest_id);
++ const signedIn = req.viewer.kind === 'user';
++ page(req, res, {
++ index: rel.status === 'published', cache: rel.status === 'draft' ? null : PUBLIC_CACHE, title: `${rel.name} ${rel.version}`,
++ crumbs: [{ label: 'Apps' }, { label: rel.app_id, href: `/apps/${rel.app_id}` }, { label: rel.version }],
++ body: html`<h1>${rel.name} <small>${rel.version}</small> ${statusBadge(rel.status)}</h1>
++${req.query.done ? notice(`Release ${req.query.done}.`, 'ok') : ''}
++<dl class="facts"><dt>Release</dt><dd>${code(rel.id)}</dd><dt>App</dt><dd><a href="/apps/${rel.app_id}">${rel.app_id}</a> (${rel.environment})</dd>
++<dt>Kind</dt><dd>${rel.kind} ${code(rel.subject_id)}</dd><dt>Trust tier</dt><dd>${rel.trust.tier} <span class="muted small">(metadata only)</span></dd>
++<dt>Compatibility</dt><dd>${Object.entries(rel.compatibility).map(([k, v]) => html`<code>${k} ${v}</code> `)}</dd>
++<dt>Created</dt><dd>${time(rel.created_at)}</dd>${rel.published_at ? html`<dt>Published</dt><dd>${time(rel.published_at)}</dd>` : ''}
++${rel.deprecated_at ? html`<dt>Deprecated</dt><dd>${time(rel.deprecated_at)}: ${rel.deprecation_reason}${rel.replacement ? html` — use <a href="/releases/${rel.replacement}">${rel.replacement}</a>` : ''}</dd>` : ''}
++${rel.revoked_at ? html`<dt>Revoked</dt><dd>${time(rel.revoked_at)}: ${rel.revocation_reason}</dd>` : ''}</dl>
++${rel.notes ? html`<h2>Notes</h2><p>${rel.notes}</p>` : ''}
++<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>
++${signedIn ? releaseActions(req, rel) : ''}
++<h2>History</h2>${table(['When', 'Action', 'By'], releases.log(rel.id).map((l) => [time(l.at), l.action, l.actor]))}`,
++ });
++ });
++
++ function releaseActions(req, rel) {
++ const csrf = csrfToken(config, req.viewer);
++ const f = (action, label, extra = '') => html`<form method="post" action="/releases/${rel.id}/${action}" class="stack inline-box">${csrfField(csrf)}${raw(extra)}<button type="submit">${label}</button></form>`;
++ const out = [];
++ if (rel.status === 'draft') out.push(f('publish', 'Publish this release'));
++ if (rel.status === 'published') out.push(f('deprecate', 'Deprecate', '<label>Why (public) <input name="reason" required maxlength="500"></label><label>Replacement release id (optional) <input name="replacement" pattern="rel_[0-9A-HJKMNP-TV-Z]{26}"></label>'));
++ if (rel.status !== 'revoked') out.push(f('revoke', 'Revoke', '<label>Why (public) <input name="reason" required maxlength="500"></label><label><input type="checkbox" name="confirm" value="1" required> Revoking cannot be undone</label>'));
++ return out.length ? html`<h2>Manage</h2><p class="muted small">Network decides whether you may: developer+ for sandbox apps, admin+ for production apps.</p>${out}` : '';
++ }
++
++ // ── Staff: trust tiers (metadata) ───────────────────────
++ r.get('/staff', (req, res) => {
++ if (!req.viewer.staff) return page(req, res, { title: 'Staff', body: html`<h1>Staff only</h1><p>Trust tiers are set by Codes staff.</p>` }, req.viewer.kind === 'user' ? 403 : 401);
++ page(req, res, {
++ title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],
++ body: html`<h1>Trust tiers</h1>${req.query.done ? notice('Saved.', 'ok') : ''}
++<p>Metadata only: a tier never grants, allows or bypasses anything. Grants and allowances are set in Network.</p>
++<form method="post" action="/staff/trust" class="stack">${csrfField(csrfToken(config, req.viewer))}
++<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label>
++<label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>
++<label>Note (public) <input name="note" required maxlength="500"></label><button type="submit">Set tier</button></form>
++<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], trust.listSet().map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,
++ });
++ });
++ r.post('/staff/trust', form, (req, res) => {
++ if (!req.viewer.staff || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
++ try {
++ trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
++ res.redirect(303, '/staff?done=1');
++ } catch (err) {
++ page(req, res, { title: 'Staff', body: problemBox({ status: err.status || 422, code: err.code || 'trust.invalid', detail: err.message }) }, err.status || 422);
++ }
++ });
++
++ // ── Discovery ───────────────────────────────────────────
++ r.get('/robots.txt', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=3600').type('text/plain').send([
++ 'User-agent: *', 'Disallow: /projects', 'Disallow: /auth/', 'Disallow: /oauth/test-callback', 'Disallow: /staff', 'Disallow: /api/',
++ 'Allow: /', `Sitemap: ${config.baseUrl}/sitemap.xml`, '',
++ ].join('\n'));
++ });
++ r.get('/sitemap.xml', (req, res) => {
++ const urls = ['/', '/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/services', '/docs/sdk', '/oauth', '/tools/webhooks', '/manifests/validate',
++ '/policy', '/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency',
++ ...docs.contracts.map((c) => `/docs/contracts/${c.id}`), ...docs.capabilities.map((c) => `/docs/capabilities/${c.id}`),
++ ...docs.sdk.map((m) => `/docs/sdk/${m.slug}`), ...docs.adrs.map((a) => `/docs/adr/${a.id}`)];
++ const x = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
++ res.set('Cache-Control', 'public, max-age=3600').type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => `<url><loc>${x(config.baseUrl + u)}</loc></url>`).join('\n')}\n</urlset>\n`);
++ });
++
++ return r;
++}
++
++const statusBadge = (s) => badge(s, s === 'published' ? 'ok' : (s === 'revoked' ? 'bad' : (s === 'deprecated' ? 'warn' : '')));
++
++module.exports = { createPageRoutes, statusBadge };
ddiff --git a/server/http/portal.js b/server/http/portal.js
nnew file mode 100644
iindex 0000000..5ce5700
---- /dev/null
++++ b/server/http/portal.js
@@@ -0,0 +1,656 @@
++'use strict';
++
++/**
++ * The signed-in portal: projects, members, apps, credentials, grants, quotas and audit — all over
++ * OpenVibe.Network's /api/v1/projects API with the person's own access token (ADR-014: Network owns
++ * every one of these; Codes stores none of them). Plus the Codes-owned parts that hang off an app:
++ * releases (manifest editor, publish/deprecate/revoke) and playgrounds.
++ *
++ * Honesty rules:
++ * - a Network failure is shown as Network answered it (HTTP status, problem code, detail, request
++ * id), with the same status on the page; an unreachable Network is a 502, never an empty list
++ * - a client secret appears only in the response to the create/rotate that produced it, with
++ * Cache-Control: no-store, and is never stored, logged or redirected through a URL
++ * - quotas are "recorded limit — enforced by <service>": Network records, the owner enforces
++ * - the scope editor offers only capabilities apps can be granted (public, or partner when in the
++ * allowance) and refuses to forward anything else
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const Busboy = require('busboy');
++const { html, raw, table, code, badge, time, notice, csrfField, problemBox } = require('../render/html');
++const { send } = require('../render/layout');
++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
++const manifestsLib = require('../domain/manifests');
++const { RANK, ReleaseError } = require('../domain/releases');
++const { validationResult } = require('./tools');
++const { statusBadge } = require('./pages');
++
++const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USR_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CAP_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/;
++const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const atLeast = (role, need) => Boolean(role) && RANK[role] >= RANK[need];
++
++function createPortalRoutes(ctx) {
++ const { config, docs, network, sso, releases, trust, playground, log } = ctx;
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '96kb' });
++
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const csrf = (req) => csrfToken(config, req.viewer);
++
++ function problemPage(req, res, problem, { title = 'Request failed', back = null } = {}) {
++ page(req, res, {
++ title,
++ body: html`<h1>${title}</h1>${problemBox(problem, { title: problem.code && problem.code.startsWith('network.') ? 'OpenVibe.Network could not be reached' : `OpenVibe.Network answered ${problem.status}` })}
++${back ? html`<p><a href="${back}">Back</a></p>` : ''}`,
++ }, problem.status >= 400 && problem.status < 600 ? problem.status : 502);
++ }
++
++ /** Run a Network call as the viewer. → { ok, data } | { ok: false, problem } */
++ async function net(req, res, fn) {
++ try {
++ return { ok: true, data: await sso.asViewer(req, res, fn) };
++ } catch (err) {
++ const p = network.problemOf(err);
++ log.warn(`[Codes] network ${p.status} ${p.code}${p.requestId ? ` (${p.requestId})` : ''}`);
++ return { ok: false, problem: p };
++ }
++ }
++
++ // Everything here needs a signed-in person with a canonical subject.
++ r.use((req, res, next) => {
++ if (req.viewer.kind !== 'user') {
++ return page(req, res, {
++ title: 'Sign in',
++ body: html`<h1>Sign in to manage projects</h1><p>Projects and apps belong to your OpenVibe account and live in OpenVibe.Network.</p>
++<p><a class="button" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
++ }, 401);
++ }
++ if (!req.viewer.subject) return page(req, res, { title: 'Account', body: html`<h1>Your account has no subject id yet</h1><p>Sign out and in again; Network assigns one on sign-in.</p>` }, 409);
++ next();
++ });
++ // Every state change: same-origin and a valid form token.
++ const guard = (req, res, next) => {
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) {
++ return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1><p>Go back, reload the page and try again.</p>` }, 403);
++ }
++ next();
++ };
++ const idParams = (req, res, next) => {
++ const { project, app } = req.params;
++ if ((project && !PRJ_RE.test(project)) || (app && !APP_RE.test(app))) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
++ next();
++ };
++
++ // ── Projects ────────────────────────────────────────────
++ async function projectsPage(req, res, { problem = null, status = 200 } = {}) {
++ const got = await net(req, res, (t) => network.projects.list(t, { all: req.viewer.staff && req.query.all === '1' }));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Your projects' });
++ const list = got.data.projects || [];
++ page(req, res, {
++ title: 'Your projects', crumbs: [{ label: 'Projects' }],
++ body: html`<h1>Your projects</h1>${problem ? problemBox(problem, { title: `OpenVibe.Network answered ${problem.status}` }) : ''}
++${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p) => [
++ html`<a href="/projects/${p.id}">${p.name}</a><br><code class="small">${p.id}</code>${p.archived_at ? html` ${badge('archived', 'bad')}` : ''}`,
++ p.role || (req.viewer.staff ? 'staff' : '—'), (p.environments || []).join(', '), p.counts ? p.counts.apps : '—', time(p.created_at),
++ ]), { empty: 'You are not a member of any project yet.' })}
++<h2>Create a project</h2>
++<form method="post" action="/projects" class="inline-form">${csrfField(csrf(req))}
++<label>Name <input name="name" required maxlength="80"></label><button type="submit">Create</button></form>
++<p class="muted small">New projects are sandbox-only. OpenVibe staff enable production and set which capabilities a project's apps may hold (its allowance).</p>`,
++ }, status);
++ }
++ r.get('/', (req, res) => projectsPage(req, res));
++ r.post('/', form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.create(t, { name: String(req.body.name || '') }));
++ if (!got.ok) return projectsPage(req, res, { problem: got.problem, status: got.problem.status });
++ res.redirect(303, `/projects/${got.data.id}`);
++ });
++
++ /** Project + its role; renders the problem and returns null when Network refuses. */
++ async function loadProject(req, res) {
++ const got = await net(req, res, (t) => network.projects.get(t, req.params.project));
++ if (!got.ok) { problemPage(req, res, got.problem, { title: 'Project', back: '/projects' }); return null; }
++ return got.data;
++ }
++
++ r.get('/:project', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const [members, apps, quotas] = await Promise.all([
++ net(req, res, (t) => network.projects.members(t, project.id)),
++ net(req, res, (t) => network.projects.apps(t, project.id)),
++ net(req, res, (t) => network.projects.quotas(t, project.id)),
++ ]);
++ const role = project.role;
++ const archived = Boolean(project.archived_at);
++ const c = csrf(req);
++ const envs = project.environments || ['sandbox'];
++ const assignable = role === 'owner' ? ['admin', 'developer', 'viewer'] : (role === 'admin' ? ['developer', 'viewer'] : []);
++ page(req, res, {
++ title: project.name, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name }],
++ body: html`<h1>${project.name}</h1>${req.query.done ? notice(String(req.query.done).slice(0, 80), 'ok') : ''}
++${archived ? notice('This project is archived: every app in it is revoked.', 'bad') : ''}
++<dl class="facts"><dt>Project</dt><dd>${code(project.id)}</dd><dt>Your role</dt><dd>${role || (req.viewer.staff ? 'staff (not a member)' : '—')}</dd>
++<dt>Environments</dt><dd>${envs.join(', ')}${project.environment_policy === 'sandbox' ? html` <span class="muted small">(production apps need staff to enable production for this project)</span>` : ''}</dd>
++<dt>Allowance</dt><dd>${(project.allowance || []).length ? html`<ul class="plain">${project.allowance.map((id) => html`<li><a href="/docs/capabilities/${id}"><code>${id}</code></a></li>`)}</ul>` : html`<span class="muted">empty: staff decide which capabilities this project's apps may hold</span>`}</dd>
++<dt>Created</dt><dd>${time(project.created_at)}</dd></dl>
++${atLeast(role, 'admin') || req.viewer.staff ? html`<p><a href="/projects/${project.id}/audit">Audit log</a></p>` : ''}
++
++<h2>Apps</h2>
++${apps.ok ? table(['App', 'Environment', 'Type', 'Grants', 'Created'], (apps.data.apps || []).map((a) => [
++ html`<a href="/projects/${project.id}/apps/${a.id}">${a.name}</a><br><code class="small">${a.id}</code>${a.revoked_at ? html` ${badge('revoked', 'bad')}` : ''}`,
++ a.environment, a.client_type, (a.grants || []).length, time(a.created_at),
++ ]), { empty: 'No apps yet.' }) : problemBox(apps.problem, { title: 'Apps could not be loaded' })}
++${!archived && atLeast(role, 'developer') ? html`<h3>Add an app</h3>
++<form method="post" action="/projects/${project.id}/apps" class="stack">${csrfField(c)}
++<label>Name <input name="name" required maxlength="80"></label>
++<label>Environment <select name="environment">${envs.map((e) => html`<option value="${e}"${e === 'production' && !atLeast(role, 'admin') ? raw(' disabled') : ''}>${e}${e === 'production' && !atLeast(role, 'admin') ? ' (admin+)' : ''}</option>`)}</select></label>
++<label>Type <select name="type"><option value="confidential">confidential — has a client secret (server-side apps)</option><option value="public">public — no secret, authorization code + PKCE only</option></select></label>
++<label>Redirect URIs (one per line; https, or http://localhost for sandbox apps) <textarea name="redirect_uris" rows="3" spellcheck="false" placeholder="${config.baseUrl}/oauth/test-callback"></textarea></label>
++<button type="submit">Create app</button></form>` : ''}
++
++<h2>Quotas</h2>
++${quotas.ok ? html`${table(['Capability', 'Limit', 'Window', 'Unit', 'Enforcement'], (quotas.data.quotas || []).map((q) => [
++ html`<a href="/docs/capabilities/${q.capability}"><code>${q.capability}</code></a>`, String(q.limit), q.window, q.unit,
++ html`recorded limit — enforced by <code>${q.enforced_by || 'the owning service'}</code>`,
++ ]), { empty: 'No quotas recorded for this project.' })}
++<p class="muted small">${quotas.data.note || ''} Codes shows these limits; it does not enforce them, and no usage numbers are collected yet.</p>` : problemBox(quotas.problem, { title: 'Quotas could not be loaded' })}
++
++<h2>Members</h2>
++${members.ok ? table(['Member', 'Role', 'Added', ''], (members.data.members || []).map((m) => [
++ html`${m.display_name || m.username || ''} ${m.username ? html`<span class="muted">@${m.username}</span>` : ''}<br><code class="small">${m.subject.id}</code>`,
++ m.role, time(m.added_at),
++ memberActions(project, role, m, c, req.viewer.subject, assignable),
++ ])) : problemBox(members.problem, { title: 'Members could not be loaded' })}
++${!archived && assignable.length ? html`<h3>Add a member</h3>
++<form method="post" action="/projects/${project.id}/members" class="inline-form">${csrfField(c)}
++<label>Username <input name="username" required maxlength="64"></label>
++<label>Role <select name="role">${assignable.map((x) => html`<option>${x}</option>`)}</select></label><button type="submit">Add</button></form>` : ''}
++${role === 'owner' && !archived ? html`<h2>Archive</h2><form method="post" action="/projects/${project.id}/archive" class="stack">${csrfField(c)}
++<label><input type="checkbox" name="confirm" value="1" required> Archiving cannot be undone and revokes every app in the project</label><button type="submit" class="danger">Archive project</button></form>` : ''}`,
++ });
++ });
++
++ function memberActions(project, role, m, c, me, assignable) {
++ if (project.archived_at || m.role === 'owner') return '';
++ const self = m.subject.id === me;
++ const canChange = assignable.includes(m.role);
++ return html`${canChange ? html`<form method="post" action="/projects/${project.id}/members/${m.subject.id}/role" class="inline-form">${csrfField(c)}
++<select name="role" aria-label="Role">${assignable.map((x) => html`<option${x === m.role ? raw(' selected') : ''}>${x}</option>`)}</select><button type="submit">Change</button></form>` : ''}
++${canChange || self ? html`<form method="post" action="/projects/${project.id}/members/${m.subject.id}/remove" class="inline-form">${csrfField(c)}<button type="submit">${self ? 'Leave' : 'Remove'}</button></form>` : ''}`;
++ }
++
++ const back = (req) => `/projects/${req.params.project}`;
++ const after = (req, res, got, target, done) => {
++ if (!got.ok) return problemPage(req, res, got.problem, { back: target });
++ res.redirect(303, `${target}?done=${encodeURIComponent(done)}`);
++ };
++
++ r.post('/:project/members', idParams, form, guard, async (req, res) => {
++ const username = String(req.body.username || '').trim().replace(/^@/, '');
++ const got = await net(req, res, (t) => network.projects.addMember(t, req.params.project, { username, role: String(req.body.role || 'viewer') }));
++ after(req, res, got, back(req), `Added ${username}`);
++ });
++ r.post('/:project/members/:subject/role', idParams, form, guard, async (req, res) => {
++ if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'codes.invalid', detail: 'not a member subject' }, { back: back(req) });
++ const got = await net(req, res, (t) => network.projects.updateMember(t, req.params.project, req.params.subject, { role: String(req.body.role || '') }));
++ after(req, res, got, back(req), 'Role changed');
++ });
++ r.post('/:project/members/:subject/remove', idParams, form, guard, async (req, res) => {
++ if (!USR_RE.test(req.params.subject)) return problemPage(req, res, { status: 422, code: 'codes.invalid', detail: 'not a member subject' }, { back: back(req) });
++ const self = req.params.subject === req.viewer.subject;
++ const got = await net(req, res, (t) => network.projects.removeMember(t, req.params.project, req.params.subject));
++ if (got.ok && self) return res.redirect(303, '/projects');
++ after(req, res, got, back(req), 'Member removed');
++ });
++ r.post('/:project/archive', idParams, form, guard, async (req, res) => {
++ if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'codes.confirm', detail: 'tick the box to confirm' }, { back: back(req) });
++ const got = await net(req, res, (t) => network.projects.archive(t, req.params.project));
++ after(req, res, got, back(req), 'Project archived');
++ });
++
++ r.get('/:project/audit', idParams, async (req, res) => {
++ const project = await loadProject(req, res);
++ if (!project) return;
++ const before = /^\d+$/.test(String(req.query.before || '')) ? req.query.before : undefined;
++ const got = await net(req, res, (t) => network.projects.audit(t, project.id, { before }));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Audit log', back: back(req) });
++ page(req, res, {
++ title: `Audit · ${project.name}`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: back(req) }, { label: 'Audit' }],
++ body: html`<h1>Audit log</h1><p class="muted">Append-only, kept by OpenVibe.Network. Credential ids and last four characters only; never a secret.</p>
++${table(['When', 'Actor', 'Action', 'Target', 'Detail', 'Event'], (got.data.entries || []).map((e) => [
++ time(e.at), code(e.actor), e.action, e.target ? code(e.target) : '', html`<code class="small">${JSON.stringify(e.detail || {})}</code>`, e.event_type || '',
++ ]))}
++${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=${got.data.next_before}">Older</a></p>` : ''}`,
++ });
++ });
++
++ // ── Apps ────────────────────────────────────────────────
++ const redirectList = (text) => String(text || '').split(/\s+/).map((s) => s.trim()).filter(Boolean).slice(0, 11);
++
++ /** The one page a client secret ever appears on. */
++ function secretPage(req, res, { project, app, credential, rotated = false, previous = [] }) {
++ res.set('Cache-Control', 'no-store');
++ res.set('Pragma', 'no-cache');
++ page(req, res, {
++ title: 'Copy your client secret', scripts: ['js/copy.js'],
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project, href: `/projects/${project}` }, { label: app.name || app.id }],
++ body: html`<h1>${rotated ? 'New client secret' : `${app.name} is ready`}</h1>
++<div class="secret-box">
++<label>client_id <input readonly value="${app.id}" id="client-id" autocomplete="off" spellcheck="false"></label>
++<label>client_secret <input readonly value="${credential.client_secret}" id="client-secret" class="secret" autocomplete="off" spellcheck="false"></label>
++<button type="button" class="copy" data-copy="#client-secret" hidden>Copy secret</button>
++</div>
++<div class="notice warn"><strong>You won't see this secret again.</strong> Copy it into your server's secret store now. Codes does not keep it and OpenVibe.Network stores only a hash (credential <code>${credential.id}</code>, ending <code>…${credential.hint}</code>). If you lose it, rotate the credential. Never put it in a browser or a repository.</div>
++${rotated && previous.length ? html`<p>The previous secret${previous.length > 1 ? 's stay' : ' stays'} valid until ${previous.map((p) => html`${time(p.expires_at)} `)} so you can deploy the new one first.</p>` : ''}
++<h2>Use it</h2>
++<pre><code>const sdk = require('openvibe-sdk');
++const client = sdk.createClient({
++ tokenProvider: sdk.auth.createServiceTokenClient({ clientId: '${app.id}', clientSecret: process.env.OV_CLIENT_SECRET }),
++});</code></pre>
++<p><a class="button" href="/projects/${project}/apps/${app.id}">I have stored it — continue</a></p>`,
++ });
++ }
++
++ r.post('/:project/apps', idParams, form, guard, async (req, res) => {
++ const body = {
++ name: String(req.body.name || ''), environment: String(req.body.environment || 'sandbox'),
++ type: String(req.body.type || 'confidential'), redirect_uris: redirectList(req.body.redirect_uris),
++ };
++ const got = await net(req, res, (t) => network.projects.createApp(t, req.params.project, body));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Create app', back: back(req) });
++ const app = got.data;
++ if (app.credential && app.credential.client_secret) return secretPage(req, res, { project: req.params.project, app, credential: app.credential });
++ res.redirect(303, `/projects/${req.params.project}/apps/${app.id}?done=${encodeURIComponent('App created (public: no secret)')}`);
++ });
++
++ /** Capabilities the scope editor may offer: grantable to apps, per the pinned catalog AND Network. */
++ function offered(catalog, project) {
++ const allowance = new Set(project.allowance || []);
++ return ((catalog && catalog.capabilities) || []).filter((c) => {
++ if (!c || typeof c.id !== 'string' || !CAP_RE.test(c.id)) return false;
++ const pinned = docs.capability(c.id);
++ if (pinned && !pinned.grantable) return false; // never offer what the contracts say is not grantable
++ const vis = pinned ? pinned.visibility : c.visibility;
++ if (vis === 'public') return true;
++ if (vis === 'partner') return allowance.has(c.id); // partner: only when staff put it in this allowance
++ return false;
++ }).map((c) => {
++ const pinned = docs.capability(c.id);
++ return {
++ id: c.id, owner: c.owner || (pinned && pinned.owner), audience: c.audience || `openvibe.${c.owner}`,
++ visibility: pinned ? pinned.visibility : c.visibility, description: c.description || (pinned && pinned.description) || '',
++ inAllowance: allowance.has(c.id),
++ };
++ });
++ }
++
++ async function loadApp(req, res) {
++ const [project, app] = await Promise.all([
++ net(req, res, (t) => network.projects.get(t, req.params.project)),
++ net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),
++ ]);
++ if (!project.ok) { problemPage(req, res, project.problem, { title: 'Project', back: '/projects' }); return null; }
++ if (!app.ok) { problemPage(req, res, app.problem, { title: 'App', back: back(req) }); return null; }
++ return { project: project.data, app: app.data };
++ }
++
++ r.get('/:project/apps/:app', idParams, async (req, res) => {
++ const loaded = await loadApp(req, res);
++ if (!loaded) return;
++ const { project, app } = loaded;
++ const [creds, grants, catalog] = await Promise.all([
++ net(req, res, (t) => network.projects.credentials(t, project.id, app.id)),
++ net(req, res, (t) => network.projects.grants(t, project.id, app.id)),
++ net(req, res, (t) => network.projects.catalog(t)),
++ ]);
++ const role = project.role;
++ const manage = atLeast(role, app.environment === 'production' ? 'admin' : 'developer') && !app.revoked_at && !project.archived_at;
++ const decide = atLeast(role, 'admin') && !app.revoked_at;
++ const c = csrf(req);
++ const base = `/projects/${project.id}/apps/${app.id}`;
++ const grantRows = grants.ok ? (grants.data.grants || []) : [];
++ const byCap = new Map(grantRows.map((g) => [g.capability, g]));
++ const offer = catalog.ok ? offered(catalog.data, project) : [];
++ const t = trust.get(app.id);
++ const rels = releases.listForApp(app.id, { includeDrafts: true });
++ page(req, res, {
++ title: app.name, scripts: ['js/copy.js'],
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name }],
++ body: html`<h1>${app.name} ${app.revoked_at ? badge('revoked', 'bad') : badge(app.environment, app.environment === 'production' ? 'warn' : '')}</h1>
++${req.query.done ? notice(String(req.query.done).slice(0, 120), 'ok') : ''}
++<dl class="facts"><dt>client_id</dt><dd>${code(app.client_id)}</dd><dt>Subject</dt><dd>${code(`app:${app.id}`)}</dd>
++<dt>Type</dt><dd>${app.client_type}</dd><dt>Environment</dt><dd>${app.environment}</dd><dt>Trust tier</dt><dd>${t.tier} <span class="muted small">(metadata only; never grants anything)</span></dd>
++<dt>Created</dt><dd>${time(app.created_at)}</dd>${app.revoked_at ? html`<dt>Revoked</dt><dd>${time(app.revoked_at)}</dd>` : ''}</dl>
++
++<h2>Redirect URIs</h2>
++${manage ? html`<form method="post" action="${base}/redirects" class="stack">${csrfField(c)}
++<textarea name="redirect_uris" rows="3" spellcheck="false">${(app.redirect_uris || []).join('\n')}</textarea>
++<p class="muted small">Exact match. https only (http://localhost, 127.0.0.1 and [::1] for sandbox apps). At most 10. To try sign-in before your callback exists, add <code>${config.baseUrl}/oauth/test-callback</code> (<a href="/oauth?client_id=${app.id}">helper</a>).</p>
++<button type="submit">Save redirect URIs</button></form>` : table(['URI'], (app.redirect_uris || []).map((u) => [code(u)]), { empty: 'None.' })}
++
++<h2>Credentials</h2>
++${app.client_type === 'public' ? html`<p>Public apps have no secret: they use authorization code + PKCE only.</p>` : (creds.ok ? html`
++${table(['Credential', 'Ends with', 'State', 'Created', 'Expires', 'Last used', ''], (creds.data.credentials || []).map((cr) => [
++ code(cr.id), code(`…${cr.hint}`), badge(cr.state, cr.state === 'active' ? 'ok' : (cr.state === 'revoked' || cr.state === 'expired' ? 'bad' : 'warn')),
++ time(cr.created_at), time(cr.expires_at), time(cr.last_used_at),
++ manage && (cr.state === 'active' || cr.state === 'expiring') ? html`<form method="post" action="${base}/credentials/${cr.id}/revoke" class="inline-form">${csrfField(c)}<button type="submit" class="danger">Revoke now</button></form>` : '',
++ ]), { empty: 'No credentials.' })}
++<p class="muted small">Only the last four characters are ever shown: the secret itself was displayed once when it was made. A revoked secret stops working at once; tokens already issued with it end within 5 minutes.</p>
++${manage ? html`<form method="post" action="${base}/credentials/rotate" class="inline-form">${csrfField(c)}
++<label>Keep the previous secret valid for <select name="overlap_seconds"><option value="0">0 (stop at once)</option><option value="3600">1 hour</option><option value="86400" selected>24 hours</option><option value="604800">7 days</option></select></label>
++<button type="submit">Rotate secret</button></form>` : ''}` : problemBox(creds.problem, { title: 'Credentials could not be loaded' }))}
++
++<h2>Grants</h2>
++${grants.ok ? table(['Capability', 'Audience', 'Status', 'Requested', 'Decided', ''], grantRows.map((g) => [
++ html`<a href="/docs/capabilities/${g.capability}"><code>${g.capability}</code></a>`, code(g.audience), badge(g.status, g.status === 'approved' ? 'ok' : (g.status === 'requested' ? 'warn' : 'bad')),
++ time(g.requested_at), time(g.decided_at),
++ decide ? grantActions(base, g, c, project) : '',
++ ]), { empty: 'No grants yet: this app can call nothing.' }) : problemBox(grants.problem, { title: 'Grants could not be loaded' })}
++
++<h3 id="request">Request a capability</h3>
++${catalog.ok ? html`<p class="muted small">Only capabilities apps can be granted are offered: active and <code>public</code>, or <code>partner</code> when staff put one in this project's allowance. A request outside the allowance waits until staff add it; owners and admins get requests inside it approved at once.</p>
++${table(['Capability', 'Owner', 'Visibility', 'Description', 'Allowance', ''], offer.map((o) => {
++ const g = byCap.get(o.id);
++ return [
++ html`<a href="/docs/capabilities/${o.id}"><code>${o.id}</code></a>`, html`${o.owner}<br><code class="small">${o.audience}</code>`, o.visibility, o.description,
++ o.inAllowance ? badge('in allowance', 'ok') : badge('outside allowance'),
++ g && (g.status === 'approved' || g.status === 'requested') ? g.status
++ : (atLeast(role, 'developer') && !app.revoked_at ? html`<form method="post" action="${base}/grants" class="inline-form">${csrfField(c)}<input type="hidden" name="capability" value="${o.id}"><button type="submit">Request</button></form>` : ''),
++ ];
++ }), { cls: 'scope-editor', empty: 'Network offers no grantable capabilities.' })}` : problemBox(catalog.problem, { title: 'The capability catalog could not be loaded' })}
++
++<h2>Playground</h2>
++<p><a href="${base}/playground">Try calls with this app's own credentials</a> — it can do only what this app is granted.</p>
++
++<h2>Releases</h2>
++${table(['Version', 'Kind', 'Status', 'Created', 'Published'], rels.map((x) => [html`<a href="/releases/${x.id}">${x.version}</a>`, x.kind, statusBadge(x.status), time(x.created_at), time(x.published_at)]), { empty: 'No releases yet.' })}
++${manage ? html`<p><a class="button" href="${base}/releases/new">New release</a> <a href="/apps/${app.id}">Public page</a></p>` : html`<p><a href="/apps/${app.id}">Public page</a></p>`}
++
++${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revoke" class="stack">${csrfField(c)}
++<label><input type="checkbox" name="confirm" value="1" required> Revoking stops every credential and pending code at once and cannot be undone</label>
++<button type="submit" class="danger">Revoke app</button></form>` : ''}`,
++ });
++ });
++
++ function grantActions(base, g, c, project) {
++ const f = (action, label, cls = '') => html`<form method="post" action="${base}/grants/${g.capability}/${action}" class="inline-form">${csrfField(c)}<button type="submit" class="${cls}">${label}</button></form>`;
++ if (g.status === 'requested') {
++ const inside = (project.allowance || []).includes(g.capability);
++ return html`${inside ? f('approve', 'Approve') : html`<span class="muted small">outside the allowance</span> `}${f('deny', 'Deny')}`;
++ }
++ if (g.status === 'approved') return f('revoke', 'Revoke', 'danger');
++ return '';
++ }
++
++ const appBase = (req) => `/projects/${req.params.project}/apps/${req.params.app}`;
++ r.post('/:project/apps/:app/redirects', idParams, form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.updateApp(t, req.params.project, req.params.app, { redirect_uris: redirectList(req.body.redirect_uris) }));
++ after(req, res, got, appBase(req), 'Redirect URIs saved');
++ });
++ r.post('/:project/apps/:app/revoke', idParams, form, guard, async (req, res) => {
++ if (req.body.confirm !== '1') return problemPage(req, res, { status: 422, code: 'codes.confirm', detail: 'tick the box to confirm' }, { back: appBase(req) });
++ const got = await net(req, res, (t) => network.projects.revokeApp(t, req.params.project, req.params.app));
++ after(req, res, got, appBase(req), 'App revoked');
++ });
++ r.post('/:project/apps/:app/credentials/rotate', idParams, form, guard, async (req, res) => {
++ const n = Number(req.body.overlap_seconds);
++ const body = Number.isInteger(n) && n >= 0 && n <= 604800 ? { overlap_seconds: n } : {};
++ const got = await net(req, res, (t) => network.projects.rotate(t, req.params.project, req.params.app, body));
++ if (!got.ok) return problemPage(req, res, got.problem, { title: 'Rotate secret', back: appBase(req) });
++ const appInfo = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ secretPage(req, res, {
++ project: req.params.project, app: appInfo.ok ? appInfo.data : { id: req.params.app, name: req.params.app },
++ credential: got.data.credential, rotated: true, previous: got.data.previous || [],
++ });
++ });
++ r.post('/:project/apps/:app/credentials/:credential/revoke', idParams, form, guard, async (req, res) => {
++ if (!CRD_RE.test(req.params.credential)) return problemPage(req, res, { status: 404, code: 'credential.not_found', detail: 'no such credential' }, { back: appBase(req) });
++ const got = await net(req, res, (t) => network.projects.revokeCredential(t, req.params.project, req.params.app, req.params.credential));
++ after(req, res, got, appBase(req), 'Credential revoked');
++ });
++
++ r.post('/:project/apps/:app/grants', idParams, form, guard, async (req, res) => {
++ const capability = String(req.body.capability || '');
++ // Re-derive what the scope editor offers and refuse anything else before Network sees it.
++ const [project, catalog] = await Promise.all([
++ net(req, res, (t) => network.projects.get(t, req.params.project)),
++ net(req, res, (t) => network.projects.catalog(t)),
++ ]);
++ if (!project.ok) return problemPage(req, res, project.problem, { back: appBase(req) });
++ if (!catalog.ok) return problemPage(req, res, catalog.problem, { back: appBase(req) });
++ if (!offered(catalog.data, project.data).some((o) => o.id === capability)) {
++ return problemPage(req, res, { status: 422, code: 'codes.not_grantable', detail: `${capability.slice(0, 80) || 'that'} is not a capability apps can be granted (only public ones, or partner ones in this project's allowance)` }, { title: 'Request a capability', back: appBase(req) });
++ }
++ const got = await net(req, res, (t) => network.projects.requestGrant(t, req.params.project, req.params.app, capability));
++ after(req, res, got, appBase(req), got.ok ? `${capability}: ${got.data.status}` : '');
++ });
++ for (const [action, decision] of [['approve', 'approved'], ['deny', 'denied'], ['revoke', 'revoked']]) {
++ r.post(`/:project/apps/:app/grants/:capability/${action}`, idParams, form, guard, async (req, res) => {
++ if (!CAP_RE.test(req.params.capability)) return problemPage(req, res, { status: 404, code: 'grant.not_found', detail: 'no such grant' }, { back: appBase(req) });
++ const got = await net(req, res, (t) => network.projects.decideGrant(t, req.params.project, req.params.app, req.params.capability, decision));
++ after(req, res, got, appBase(req), `${req.params.capability}: ${decision}`);
++ });
++ }
++
++ // ── Playgrounds ─────────────────────────────────────────
++ async function playgroundPage(req, res, { result = null, kind = null, values = {}, status = 200 } = {}) {
++ const loaded = await loadApp(req, res);
++ if (!loaded) return;
++ const { project, app } = loaded;
++ const base = `/projects/${project.id}/apps/${app.id}`;
++ const c = csrf(req);
++ const events = playground.precheck(app, 'events');
++ const media = playground.precheck(app, 'media');
++ const credFields = html`<fieldset><legend>The app's credential (used for this request only, never stored)</legend>
++<label><input type="radio" name="credential_type" value="client_secret" checked> client secret — Codes asks Network for a 5-minute token with it</label>
++<label><input type="radio" name="credential_type" value="access_token"> access token you minted for this app</label>
++<input type="password" name="credential" value="" autocomplete="off" required aria-label="Credential"></fieldset>`;
++ const pre = (p) => (p.ok ? '' : html`<div class="notice ${p.stage === 'grant' ? 'warn' : 'bad'}"><strong>Cannot run:</strong> ${p.detail}${p.missing && p.grantable ? html` <a href="${base}#request">Request it</a>.` : ''}</div>`);
++ const runs = playground.runsFor(app.id);
++ page(req, res, {
++ title: `Playground · ${app.name}`,
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'Playground' }],
++ body: html`<h1>Playground</h1>
++<p>Calls here run <strong>as the app</strong>, with its own token, against the real services: the playground can do exactly what this app is granted and nothing more. Codes' own credentials are never used. Sandbox apps only.</p>
++${result ? runResult(result) : ''}
++<h2>${playground.KINDS.events.label}</h2>
++<p class="muted small">Needs <code>events.event.publish</code> (audience <code>openvibe.events</code>). Endpoint: <code>${config.playground.eventsUrl}</code>.</p>
++${pre(events)}${events.ok ? html`<form method="post" action="${base}/playground/events" class="stack">${csrfField(c)}
++<label>Event type <input name="event_type" required value="${values.event_type || ''}" placeholder="myapp.thing.happened"></label>
++<label>Source <input name="source" required value="${values.source || ''}" placeholder="myapp"></label>
++<label>Subject type <input name="subject_type" value="${values.subject_type || 'test'}"></label>
++<label>Subject id <input name="subject_id" value="${values.subject_id || 'test-1'}"></label>
++<label>Payload (JSON object) <textarea name="payload" rows="4" spellcheck="false">${values.payload || '{}'}</textarea></label>
++${credFields}<button type="submit">Publish test event</button></form>` : ''}
++<h2>${playground.KINDS.media.label}</h2>
++<p class="muted small">Needs <code>media.object.upload</code> (audience <code>openvibe.media</code>) for the project's namespace <code>${project.id}</code>. Endpoint: <code>${config.playground.mediaUrl}</code>. At most ${config.playground.maxUploadBytes} bytes.</p>
++${pre(media)}${media.ok ? html`<form method="post" action="${base}/playground/media" enctype="multipart/form-data" class="stack">${csrfField(c)}
++<label>File <input type="file" name="file" required></label>${credFields}<button type="submit">Upload</button></form>` : ''}
++<h2>Recent runs</h2>
++${table(['When', 'Playground', 'Credential', 'Outcome', 'Stage', 'HTTP', 'Code', 'Detail', 'Result'], runs.map((x) => [
++ time(x.at), x.kind, x.credential, badge(x.outcome, x.outcome === 'ok' ? 'ok' : (x.outcome === 'failed' ? 'bad' : 'warn')), x.stage, x.http_status || '', x.code ? code(x.code) : '', x.detail, x.ref ? code(x.ref) : '',
++ ]), { empty: 'No runs yet.' })}`,
++ }, status);
++ }
++
++ function runResult(r) {
++ return html`<div class="result ${r.outcome === 'ok' ? 'ok' : 'bad'}" role="status"><p><strong>${r.outcome === 'ok' ? 'Done.' : (r.outcome === 'refused' ? 'Refused.' : 'Failed.')}</strong> ${r.detail}</p>
++<p class="muted small">stage ${r.stage}${r.http_status ? ` · HTTP ${r.http_status}` : ''}${r.code ? html` · <code>${r.code}</code>` : ''}${r.runId ? html` · run <code>${r.runId}</code>` : ''}</p>
++${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>` : ''}</div>`;
++ }
++
++ const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));
++
++ r.get('/:project/apps/:app/playground', idParams, (req, res) => playgroundPage(req, res));
++
++ r.post('/:project/apps/:app/playground/events', idParams, form, guard, async (req, res) => {
++ const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
++ const b = req.body || {};
++ const out = await playground.run({
++ actor: `user:${req.viewer.subject}`, app: got.data, kind: 'events',
++ credential: { type: b.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: typeof b.credential === 'string' ? b.credential.trim() : '' },
++ input: { event_type: b.event_type, source: b.source, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },
++ });
++ // The credential is NOT passed back: the form comes back with it empty.
++ return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, source: b.source, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
++ });
++
++ r.post('/:project/apps/:app/playground/media', idParams, async (req, res) => {
++ if (!sameOrigin(config, req)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
++ let parsed;
++ try { parsed = await readMultipart(req, config.playground.maxUploadBytes); } catch (err) {
++ return page(req, res, { title: 'Upload', body: problemBox({ status: 413, code: 'playground.too_large', detail: err.message }) }, 413);
++ }
++ if (!checkCsrf(config, req.viewer, parsed.fields.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1><p>Reload the page and try again.</p>` }, 403);
++ const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
++ if (!got.ok) return problemPage(req, res, got.problem, { back: appBase(req) });
++ const out = await playground.run({
++ actor: `user:${req.viewer.subject}`, app: got.data, kind: 'media',
++ credential: { type: parsed.fields.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: String(parsed.fields.credential || '').trim() },
++ file: parsed.file,
++ });
++ return playgroundPage(req, res, { result: out, status: statusFor(out) });
++ });
++
++ // ── Releases (Codes-owned) ──────────────────────────────
++ async function editorPage(req, res, { status = 200, kind, text, notes = '', validation = null, parseError = null, problem = null } = {}) {
++ const loaded = await loadApp(req, res);
++ if (!loaded) return;
++ const { project, app } = loaded;
++ const k = kind === 'mod' ? 'mod' : 'app';
++ const base = `/projects/${project.id}/apps/${app.id}`;
++ const starter = JSON.stringify(manifestsLib.template(k, { appId: app.id, projectId: project.id, environment: app.environment, name: app.name, subject: req.viewer.subject }), null, 2);
++ page(req, res, {
++ title: `New release · ${app.name}`,
++ crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'New release' }],
++ body: html`<h1>New release</h1>
++<p>A release is Codes' record of a version of this app (or of a mod it publishes): a validated manifest, compatibility ranges and a status. Versions are immutable. Publishing announces <code>codes.app.published</code>; a manifest's capabilities are only a request — grants in Network stay the authority.</p>
++<p class="muted small">Start from: <a href="${base}/releases/new?kind=app">app manifest</a> · <a href="${base}/releases/new?kind=mod">mod manifest</a> (<code>mods.mod-manifest@1</code>)</p>
++${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? notice(parseError, 'bad') : ''}${validation ? validationResult(validation) : ''}
++<form method="post" action="${base}/releases" class="stack">${csrfField(csrf(req))}
++<input type="hidden" name="kind" value="${k}">
++<label>${k === 'mod' ? 'Mod' : 'App'} manifest (JSON) <textarea name="manifest" rows="22" spellcheck="false">${text != null ? text : starter}</textarea></label>
++<label>Release notes (public) <textarea name="notes" rows="3" maxlength="2000">${notes}</textarea></label>
++<div class="buttons"><button type="submit" name="intent" value="validate">Validate only</button> <button type="submit" name="intent" value="create">Create draft</button></div></form>`,
++ }, status);
++ }
++
++ r.get('/:project/apps/:app/releases/new', idParams, (req, res) => editorPage(req, res, { kind: req.query.kind }));
++ r.post('/:project/apps/:app/releases', idParams, form, guard, async (req, res) => {
++ const b = req.body || {};
++ const kind = b.kind === 'mod' ? 'mod' : 'app';
++ const p = manifestsLib.parse(b.manifest);
++ if (p.error) return editorPage(req, res, { status: 422, kind, text: b.manifest || '', notes: b.notes, parseError: p.error });
++ const [project, app] = await Promise.all([
++ net(req, res, (t) => network.projects.get(t, req.params.project)),
++ net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),
++ ]);
++ if (!project.ok || !app.ok) return problemPage(req, res, (project.ok ? app : project).problem, { back: appBase(req) });
++ if (b.intent !== 'create') {
++ const v = manifestsLib.validate(kind, p.manifest, { app: app.data, viewerSubject: req.viewer.subject, eventTypes: docs.eventTypes });
++ return editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });
++ }
++ try {
++ const out = releases.createDraft({
++ actor: { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.data.role, staff: req.viewer.staff, traceparent: req.ov.traceparent },
++ app: app.data, kind, manifest: p.manifest, notes: b.notes, eventTypes: docs.eventTypes,
++ });
++ res.redirect(303, `/releases/${out.release.id}`);
++ } catch (err) {
++ if (!(err instanceof ReleaseError)) return unexpected(req, res, err);
++ return editorPage(req, res, { status: err.status, kind, text: b.manifest, notes: b.notes, validation: err.validation || null, problem: err.validation ? null : { status: err.status, code: err.code, detail: err.detail } });
++ }
++ });
++
++ return r;
++}
++
++/**
++ * Release actions (/releases/:id/publish|deprecate|revoke): the actor's role comes from Network,
++ * asked with the actor's own token for the release's project and app.
++ */
++function createReleaseActionRoutes(ctx) {
++ const { config, network, sso, releases, log } = ctx;
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '16kb' });
++ const page = (req, res, o, status) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++
++ for (const action of ['publish', 'deprecate', 'revoke']) {
++ r.post(`/:id/${action}`, form, async (req, res) => {
++ if (req.viewer.kind !== 'user' || !req.viewer.subject) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>` }, 401);
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1>` }, 403);
++ if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
++ const rel = releases.get(req.params.id);
++ if (!rel) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
++ let project = null;
++ let app = null;
++ try {
++ [project, app] = await Promise.all([
++ sso.asViewer(req, res, (t) => network.projects.get(t, rel.project_id)),
++ sso.asViewer(req, res, (t) => network.projects.app(t, rel.project_id, rel.app_id)),
++ ]);
++ } catch (err) {
++ const p = network.problemOf(err);
++ // Codes staff may revoke a release of a project they cannot see in Network.
++ if (!(action === 'revoke' && req.viewer.staff && p.status === 404)) {
++ log.warn(`[Codes] network ${p.status} ${p.code}`);
++ return page(req, res, { title: 'Release', body: problemBox(p, { title: `OpenVibe.Network answered ${p.status}` }) }, p.status);
++ }
++ }
++ const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project ? project.role : null, staff: req.viewer.staff, traceparent: req.ov.traceparent };
++ try {
++ if (action === 'publish') releases.publish({ actor, releaseId: rel.id, app });
++ else if (action === 'deprecate') releases.deprecate({ actor, releaseId: rel.id, app, reason: req.body.reason, replacement: req.body.replacement || null });
++ else {
++ if (req.body.confirm !== '1') throw new ReleaseError(422, 'codes.confirm', 'tick the box to confirm');
++ releases.revoke({ actor, releaseId: rel.id, app, reason: req.body.reason });
++ }
++ res.redirect(303, `/releases/${rel.id}?done=${action === 'publish' ? 'published' : (action === 'deprecate' ? 'deprecated' : 'revoked')}`);
++ } catch (err) {
++ if (!(err instanceof ReleaseError)) return unexpected(req, res, err);
++ page(req, res, { title: 'Release', body: html`${problemBox({ status: err.status, code: err.code, detail: err.detail }, { title: 'Not done' })}<p><a href="/releases/${rel.id}">Back</a></p>` }, err.status);
++ }
++ });
++ }
++ return r;
++}
++
++/** An unexpected error in an async handler: logged without the request body, a plain 500 page. */
++function unexpected(req, res, err) {
++ const log = req.app.locals.ctx.log;
++ log.error('[Codes]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', 'private, no-store');
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++}
++
++/** multipart/form-data → { fields, file: { buffer, filename, mimeType } | null }; rejects past the limit. */
++function readMultipart(req, maxBytes) {
++ return new Promise((resolve, reject) => {
++ let bb;
++ try { bb = Busboy({ headers: req.headers, limits: { files: 1, fileSize: maxBytes, fields: 10, fieldSize: 16 * 1024 } }); } catch (err) { reject(new Error('expected multipart/form-data')); return; }
++ const fields = {};
++ let file = null;
++ let tooBig = false;
++ bb.on('field', (name, value) => { fields[name] = value; });
++ bb.on('file', (name, stream, info) => {
++ const chunks = [];
++ stream.on('data', (d) => chunks.push(d));
++ stream.on('limit', () => { tooBig = true; });
++ stream.on('end', () => { if (name === 'file') file = { buffer: Buffer.concat(chunks), filename: String(info.filename || 'file').slice(0, 200), mimeType: info.mimeType || 'application/octet-stream' }; });
++ });
++ bb.on('close', () => (tooBig ? reject(new Error(`playground uploads are at most ${maxBytes} bytes`)) : resolve({ fields, file })));
++ bb.on('error', () => reject(new Error('the upload could not be read')));
++ req.pipe(bb);
++ });
++}
++
++module.exports = { createPortalRoutes, createReleaseActionRoutes };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..088ba89
---- /dev/null
++++ b/server/http/router.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * express.Router() whose get/post handlers may be async: a rejected promise goes to next(err) (the
++ * app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
++ */
++const express = require('express');
++
++function asyncRouter() {
++ const r = express.Router();
++ for (const method of ['get', 'post']) {
++ const orig = r[method].bind(r);
++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
++ try {
++ const out = h(req, res, next);
++ if (out && typeof out.catch === 'function') out.catch(next);
++ } catch (err) { next(err); }
++ })));
++ }
++ return r;
++}
++
++module.exports = { asyncRouter };
ddiff --git a/server/http/tools.js b/server/http/tools.js
nnew file mode 100644
iindex 0000000..9f23f7f
---- /dev/null
++++ b/server/http/tools.js
@@@ -0,0 +1,172 @@
++'use strict';
++
++/**
++ * Developer tools that need no project: the OAuth callback helper, the webhook tester and signed-
++ * event inspector, and the manifest validator. All usable without JavaScript; the webhook page adds
++ * an optional in-browser computation (public/js/webhook.js) so a secret need not leave the browser.
++ *
++ * Secrets typed here are used for one computation and dropped: never stored, logged or rendered
++ * back (forms come back with the secret field empty).
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const crypto = require('crypto');
++const rateLimit = require('express-rate-limit');
++const { html, raw, code, table, notice } = require('../render/html');
++const { send } = require('../render/layout');
++const webhooks = require('../domain/webhooks');
++const manifests = require('../domain/manifests');
++const { pkcePair } = require('../auth/sso');
++
++function createToolRoutes(ctx) {
++ const { config, docs } = ctx;
++ const r = asyncRouter();
++ const form = express.urlencoded({ extended: false, limit: '300kb' });
++ const limiter = rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: true, legacyHeaders: false });
++ const eventTypes = docs.events.map((e) => e.type);
++
++ // ── OAuth callback helper ───────────────────────────────
++ r.get('/oauth', (req, res) => {
++ const clientId = typeof req.query.client_id === 'string' ? req.query.client_id.trim().slice(0, 40) : '';
++ const redirectUri = typeof req.query.redirect_uri === 'string' && req.query.redirect_uri ? req.query.redirect_uri.trim().slice(0, 500) : `${config.baseUrl}/oauth/test-callback`;
++ const scope = typeof req.query.scope === 'string' ? req.query.scope.trim().slice(0, 500) : '';
++ let built = null;
++ if (clientId) {
++ const { verifier, challenge } = pkcePair();
++ const state = crypto.randomBytes(12).toString('hex');
++ const q = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, state, code_challenge: challenge, code_challenge_method: 'S256' });
++ if (scope) q.set('scope', scope);
++ built = { url: `${config.networkUrl}/oauth/authorize?${q}`, verifier, challenge, state };
++ }
++ send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl, title: 'OAuth callback helper', index: !clientId,
++ crumbs: [{ label: 'OAuth' }],
++ body: html`<h1>Sign people in to your app (authorization code + PKCE)</h1>
++<ol class="steps">
++<li><strong>Register a redirect URI</strong> on your app (project → app → redirect URIs). It must match exactly. https is required; <code>http://localhost</code>, <code>127.0.0.1</code> and <code>[::1]</code> are allowed for sandbox apps only. To try the flow before your own callback exists, register <code>${config.baseUrl}/oauth/test-callback</code>: it shows what Network sent and never uses it.</li>
++<li><strong>Make a PKCE pair.</strong> A random <code>code_verifier</code> (43–128 characters) and <code>code_challenge = BASE64URL(SHA-256(code_verifier))</code>. Network requires <code>code_challenge_method=S256</code> for every app, public or confidential. The SDK does it: <code>createPkcePair()</code>, <code>startAuthorization()</code> in <a href="/docs/sdk/auth-browser"><code>openvibe-sdk/auth</code></a>.</li>
++<li><strong>Send the browser to</strong> <code>${config.networkUrl}/oauth/authorize</code> with <code>response_type=code</code>, <code>client_id</code> (your app id), <code>redirect_uri</code>, <code>state</code>, the challenge, and optionally <code>scope</code> (capability ids, which limit what the code can yield). <code>prompt=none</code> is refused for apps: a person always chooses to continue. A sandbox app can be authorized only by members of its project.</li>
++<li><strong>Network redirects back</strong> with <code>code</code> and your <code>state</code> (or <code>error</code>). Check the state. Codes are single use and expire after 5 minutes; a failed PKCE check burns the code.</li>
++<li><strong>Exchange on your server</strong>: <code>POST ${config.networkUrl}/oauth/token</code> with <code>grant_type=authorization_code</code>, <code>client_id</code>, <code>code</code>, <code>redirect_uri</code>, <code>code_verifier</code>, <code>audience</code> (the service you will call, e.g. <code>openvibe.media</code>) and, for a confidential app, <code>client_secret</code>. The token lasts 5 minutes and carries <code>on_behalf_of</code> (the person) and <code>cap</code> (your approved grants for that audience). Apps get no refresh token.</li>
++</ol>
++<h2>Build a test authorization URL</h2>
++<form method="get" action="/oauth" class="stack">
++<label>Your app's client id <input name="client_id" value="${clientId}" placeholder="app_01…" pattern="app_[0-9A-HJKMNP-TV-Z]{26}" required></label>
++<label>Redirect URI <input name="redirect_uri" value="${redirectUri}" size="60"></label>
++<label>Scope (optional, capability ids separated by spaces) <input name="scope" value="${scope}" size="60"></label>
++<button type="submit">Build URL</button></form>
++${built ? html`<div class="result"><p>Open this URL to start the flow (it goes to OpenVibe.Network; Codes is not involved in the exchange):</p>
++<p><a href="${built.url}" rel="noreferrer">${built.url}</a></p>
++<dl class="facts"><dt>state</dt><dd>${code(built.state)}</dd><dt>code_challenge</dt><dd>${code(built.challenge)}</dd>
++<dt>code_verifier</dt><dd>${code(built.verifier)} <span class="muted small">made just now for this test and not kept anywhere; you need it for the exchange</span></dd></dl></div>` : ''}`,
++ });
++ });
++
++ r.get('/oauth/test-callback', (req, res) => {
++ const q = (k) => (typeof req.query[k] === 'string' ? req.query[k].slice(0, 2000) : null);
++ const got = { code: q('code'), state: q('state'), error: q('error'), error_description: q('error_description') };
++ res.set('Cache-Control', 'no-store');
++ res.set('Referrer-Policy', 'no-referrer');
++ send(res, got.error ? 400 : 200, {
++ viewer: req.viewer, config, path: '/oauth/test-callback', title: 'OAuth test callback', noReferrer: true,
++ crumbs: [{ label: 'OAuth', href: '/oauth' }, { label: 'Test callback' }],
++ body: html`<h1>What Network sent back</h1>
++<p>Codes shows this and does nothing else with it: it <strong>never exchanges the code</strong> and keeps no copy. Exchange it on your own server within 5 minutes, once.</p>
++${table(['Parameter', 'Value'], Object.entries(got).filter(([, v]) => v !== null).map(([k, v]) => [code(k), code(v)]), { empty: 'No code, state or error in this request. Network redirects here with ?code=…&state=… after a person continues.' })}
++${got.error ? notice(html`Network returned <code>${got.error}</code>${got.error_description ? html`: ${got.error_description}` : ''}. <code>access_denied</code> means the person declined; <code>interaction_required</code> means <code>prompt=none</code> was sent (apps must not).`, 'warn') : ''}
++${got.code ? html`<h2>Exchange it (on your server)</h2>
++<p>Send <code>client_secret</code> only for a confidential app, and only from a server — never from a browser.</p>
++<pre><code>curl -X POST ${config.networkUrl}/oauth/token \\
++ -d grant_type=authorization_code \\
++ -d client_id="$CLIENT_ID" \\
++ -d client_secret="$CLIENT_SECRET" \\
++ -d code=${got.code} \\
++ -d redirect_uri=${config.baseUrl}/oauth/test-callback \\
++ -d code_verifier="$CODE_VERIFIER" \\
++ -d audience=openvibe.media</code></pre>` : ''}`,
++ });
++ });
++
++ // ── Webhook tester and signed-event inspector ───────────
++ const webhookPage = (req, res, { status = 200, verify = null, sample = null, values = {} } = {}) => send(res, status, {
++ viewer: req.viewer, config, path: '/tools/webhooks', title: 'Webhook tester', index: true, scripts: ['js/webhook.js'],
++ crumbs: [{ label: 'Webhooks' }],
++ body: html`<h1>Webhook tester and signed-event inspector</h1>
++<p>OpenVibe.Events delivers each event as <code>POST</code> with the body <code>{"event": &lt;envelope&gt;, "seq": &lt;n&gt;}</code> and <code>X-OpenVibe-Signature: sha256=&lt;hex HMAC-SHA256 of the raw body under your subscription secret&gt;</code>. Verify against the <strong>raw bytes</strong>, before parsing, with a constant-time comparison; <code>verifyDelivery()</code> in <a href="/docs/sdk/events"><code>openvibe-sdk/events</code></a> does exactly that.</p>
++<h2>Verify a delivery</h2>
++<form method="post" action="/tools/webhooks/verify" class="stack" id="verify-form">
++<label>Raw body <textarea name="body" rows="8" required spellcheck="false">${values.body || ''}</textarea></label>
++<label>X-OpenVibe-Signature <input name="signature" value="${values.signature || ''}" size="80" spellcheck="false" autocomplete="off"></label>
++<label>Subscription secret <input type="password" name="secret" value="" autocomplete="off" required></label>
++<p class="muted small">Without JavaScript the form is sent to Codes, which computes the HMAC and forgets the secret. With JavaScript it is computed in your browser and nothing is sent.</p>
++<button type="submit">Verify</button></form>
++<div id="verify-result">${verify ? verifyResult(verify) : ''}</div>
++<h2>Generate a sample delivery</h2>
++<form method="post" action="/tools/webhooks/sample" class="stack">
++<label>Event type <select name="event_type">${eventTypes.map((t) => html`<option${t === values.event_type ? raw(' selected') : ''}>${t}</option>`)}</select></label>
++<label>Secret to sign with <input type="password" name="secret" value="" autocomplete="off" required></label>
++<button type="submit">Generate</button></form>
++${sample ? html`<div class="result"><p>A delivery exactly as Events would send it (event types from <code>openvibe-contracts v${docs.contractsVersion}</code>). The envelope ${sample.envelopeValid ? 'validates' : 'does NOT validate'} as events.event-envelope@1. The payload is empty: contracts define no payload schema for this event type yet, and Codes does not invent one.</p>
++${table(['Header', 'Value'], Object.entries(sample.headers).map(([k, v]) => [code(k), code(v)]))}
++<p>Body (byte for byte):</p><pre><code>${sample.body}</code></pre>
++<p>Replay it against your own endpoint (Codes never sends requests to your URLs):</p><pre><code>${webhooks.curlFor(sample.headers, sample.body)}</code></pre></div>` : ''}`,
++ });
++
++ function verifyResult(v) {
++ return html`<div class="result ${v.valid ? 'ok' : 'bad'}" role="status"><p><strong>${v.valid ? 'Signature valid.' : 'Signature NOT valid.'}</strong> ${v.reason || ''}</p>
++${v.expected ? html`<dl class="facts"><dt>Expected</dt><dd>${code(v.expected)}</dd><dt>Given</dt><dd>${code(v.given || '(none)')}</dd><dt>Body</dt><dd>${v.bodyBytes} bytes</dd>
++${v.parsed && v.parsed.event_type ? html`<dt>Event</dt><dd>${code(v.parsed.event_type)} ${code(v.parsed.event_id || '')} seq ${v.parsed.seq}; envelope ${v.parsed.envelopeValid ? 'validates' : html`does not validate: ${v.parsed.envelopeErrors.map((e) => `${e.path} ${e.message}`).join('; ')}`}</dd>` : ''}
++${v.parsed && v.parsed.note ? html`<dt>Body</dt><dd>${v.parsed.note}</dd>` : ''}</dl>` : ''}</div>`;
++ }
++
++ r.get('/tools/webhooks', (req, res) => webhookPage(req, res));
++ r.post('/tools/webhooks/verify', limiter, form, (req, res) => {
++ const b = req.body || {};
++ const v = webhooks.inspect({ rawBody: typeof b.body === 'string' ? b.body.replace(/\r\n/g, '\n') : '', signature: b.signature, secret: typeof b.secret === 'string' ? b.secret : '' });
++ webhookPage(req, res, { status: v.valid ? 200 : 422, verify: v, values: { body: b.body, signature: b.signature } });
++ });
++ r.post('/tools/webhooks/sample', limiter, form, (req, res) => {
++ const b = req.body || {};
++ const type = eventTypes.includes(b.event_type) ? b.event_type : null;
++ if (!type || !b.secret) return webhookPage(req, res, { status: 422, values: { event_type: b.event_type } });
++ const e = docs.events.find((x) => x.type === type);
++ webhookPage(req, res, { sample: webhooks.sample({ eventType: type, secret: String(b.secret), producer: e && e.producers[0] }), values: { event_type: type } });
++ });
++
++ // ── Manifest validator ──────────────────────────────────
++ const info = manifests.appSchemaInfo();
++ const validatorPage = (req, res, { status = 200, kind = 'app', text = null, result = null, parseError = null } = {}) => send(res, status, {
++ viewer: req.viewer, config, path: '/manifests/validate', title: 'Manifest validator', index: true,
++ crumbs: [{ label: 'Manifests' }],
++ body: html`<h1>Manifest validator</h1>
++<p>Checks an app manifest or a mod manifest with <code>openvibe-contracts v${docs.contractsVersion}</code>: the JSON Schema first, then that every requested capability exists and can be granted to apps, that version ranges parse, and that consumed events are produced by someone. Nothing is stored. To publish a release, open your app's page.</p>
++<ul class="plain small muted"><li>mod: <a href="/docs/contracts/mods.mod-manifest"><code>mods.mod-manifest@1</code></a> (released)</li>
++<li>app: <code>${info.id}@1</code> ${info.released ? '(released)' : html`— a proposal Codes validates with until Contracts releases it (<a href="https://github.com/OpenVibers/OpenVibe.Codes/blob/main/docs/contracts-proposal/codes/app-manifest.v1.json">schema</a>)`}</li></ul>
++<form method="post" action="/manifests/validate" class="stack">
++<label>Kind <select name="kind"><option value="app"${kind === 'app' ? raw(' selected') : ''}>app</option><option value="mod"${kind === 'mod' ? raw(' selected') : ''}>mod</option></select></label>
++<label>Manifest (JSON) <textarea name="manifest" rows="18" spellcheck="false">${text != null ? text : JSON.stringify(manifests.template(kind), null, 2)}</textarea></label>
++<button type="submit">Validate</button></form>
++${parseError ? notice(parseError, 'bad') : ''}
++${result ? validationResult(result) : ''}`,
++ });
++
++ r.get('/manifests/validate', (req, res) => validatorPage(req, res, { kind: req.query.kind === 'mod' ? 'mod' : 'app' }));
++ r.post('/manifests/validate', limiter, form, (req, res) => {
++ const b = req.body || {};
++ const kind = b.kind === 'mod' ? 'mod' : 'app';
++ const p = manifests.parse(b.manifest);
++ if (p.error) return validatorPage(req, res, { status: 422, kind, text: b.manifest || '', parseError: p.error });
++ const result = manifests.validate(kind, p.manifest, { eventTypes: docs.eventTypes });
++ validatorPage(req, res, { status: result.valid ? 200 : 422, kind, text: b.manifest, result });
++ });
++
++ return r;
++}
++
++function validationResult(v) {
++ return html`<div class="result ${v.valid ? 'ok' : 'bad'}" role="status"><p><strong>${v.valid ? 'Valid.' : `${v.errors.length} error${v.errors.length === 1 ? '' : 's'}.`}</strong>${v.schema ? html` Checked with <code>${v.schema.id}</code> ${v.schema.version}.` : ''}</p>
++${v.errors.length ? html`<h3>Errors</h3><ul>${v.errors.map((e) => html`<li><code>${e.path}</code> ${e.message}</li>`)}</ul>` : ''}
++${v.warnings.length ? html`<h3>Warnings</h3><ul>${v.warnings.map((e) => html`<li><code>${e.path}</code> ${e.message}</li>`)}</ul>` : ''}</div>`;
++}
++
++module.exports = { createToolRoutes, validationResult };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..f9d100d
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,31 @@
++'use strict';
++
++/**
++ * OpenVibe.Codes — process entry. `node server/index.js`
++ * Listens on PORT (4900) behind nginx (deploy/). Starts the outbox relay when EVENTS_URL and the
++ * client secret are set.
++ */
++const { createApp } = require('./app');
++
++const { app, ctx } = createApp();
++const { config } = ctx;
++
++const server = app.listen(config.port, config.host, () => {
++ console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
++ console.log(`[Codes] docs from openvibe-contracts v${ctx.docs.contractsVersion} and openvibe-sdk v${ctx.docs.sdkVersion}; events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}`);
++});
++server.keepAliveTimeout = 65_000;
++ctx.outbox.start();
++ctx.keys.ensure().catch(() => {});
++
++function shutdown(signal) {
++ console.log(`[Codes] ${signal}: closing`);
++ server.close(async () => {
++ try { await ctx.outbox.stop(); } catch { /* best effort */ }
++ try { ctx.store.close(); } catch { /* already closed */ }
++ process.exit(0);
++ });
++ setTimeout(() => process.exit(0), 5000).unref();
++}
++process.on('SIGTERM', () => shutdown('SIGTERM'));
++process.on('SIGINT', () => shutdown('SIGINT'));
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..3f048e2
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
++ *
++ * db required a real query on Codes' SQLite: every Codes table answers
++ * docs required the reference was generated at boot from the pinned packages
++ * network_jwks optional the Network signing key is loaded; without it nobody can sign in and
++ * app tokens cannot be verified (docs and tools still serve)
++ * network optional the Network answers (its /.well-known/openvibe descriptor); without
++ * it the portal shows Network's failure on every project page
++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in and the events relay need it)
++ * events_relay optional the outbox relay is configured and has no rejected rows
++ */
++const { createReadiness } = require('openvibe-shared/ready');
++const { TABLES } = require('./db');
++
++function createCodesReadiness({ store, keys, network, outbox, docs, config, release = null }) {
++ const { db } = store;
++ return createReadiness({
++ service: 'codes',
++ release,
++ checks: [
++ {
++ name: 'db', required: true,
++ check: () => {
++ const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ const missing = TABLES.filter((t) => !names.has(t));
++ return missing.length ? `missing ${missing.join(', ')}` : true;
++ },
++ },
++ {
++ name: 'docs', required: true,
++ check: () => (docs && docs.contracts.length && docs.capabilities.length
++ ? { ok: true, detail: { contracts: docs.contractsVersion, sdk: docs.sdkVersion } }
++ : 'reference docs were not generated'),
++ },
++ {
++ name: 'network_jwks', required: false,
++ check: () => {
++ if (keys.loaded()) return true;
++ keys.ensure().catch(() => {});
++ return 'Network signing key not loaded yet: sign-in and token checks are unavailable';
++ },
++ },
++ {
++ name: 'network', required: false, cacheMs: 30_000,
++ check: async () => {
++ try { await network.registry.descriptor(); return true; } catch (err) { return `OpenVibe.Network did not answer: ${network.problemOf(err).code}`; }
++ },
++ },
++ {
++ name: 'oauth_client', required: false,
++ check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete'),
++ },
++ {
++ name: 'events_relay', required: false,
++ check: () => {
++ const s = outbox.status();
++ if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
++ if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
++ return { ok: true, detail: { pending: s.pending } };
++ },
++ },
++ ],
++ });
++}
++
++module.exports = { createCodesReadiness };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..c0f69d9
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,45 @@
++'use strict';
++
++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */
++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
++const raw = (s) => new Raw(s);
++function html(strings, ...values) {
++ let out = strings[0];
++ values.forEach((v, i) => {
++ out += render(v) + strings[i + 1];
++ });
++ return raw(out);
++}
++function render(v) {
++ if (v == null || v === false) return '';
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ return esc(v);
++}
++
++const code = (s) => html`<code>${s}</code>`;
++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
++
++/** A <table> from headers and rows of cells (cells are already html or plain values). */
++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
++ if (!rows.length) return html`<p class="muted">${empty}</p>`;
++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
++}
++
++/** A problem as returned by Network, Events or Media — shown as it came, never softened. */
++function problemBox(p, { title = 'The request failed' } = {}) {
++ if (!p) return '';
++ return html`<div class="problem" role="alert"><strong>${title}</strong>
++<p>${p.detail || 'No detail was given.'}</p>
++<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;
++}
++
++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
++const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
++
++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..60e0c06
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,114 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through this and is complete without JavaScript:
++ * - <head>: title, description, canonical, robots (portal pages are noindex), the shared app icon
++ * - the shared chrome: navbar.js and theme-loader.js from the Network (progressive), a <noscript>
++ * navigation bar and the server-rendered shared footer (openvibe-shared)
++ * - Codes' own script only where a page offers an optional in-browser convenience (webhook
++ * signature computed locally, copy buttons); every form works without it
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const chrome = require('openvibe-shared/chrome-ssr');
++const { html, raw, esc } = require('./html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Codes';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Projects', href: '/projects' },
++ { label: 'Docs', href: '/docs' },
++ { label: 'OAuth', href: '/oauth' },
++ { label: 'Webhooks', href: '/tools/webhooks' },
++ { label: 'Manifests', href: '/manifests/validate' },
++ { label: 'Policy', href: '/policy' },
++];
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++/**
++ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
++ * crumbs [{ label, href }]
++ */
++function renderPage(o) {
++ const viewer = o.viewer || { kind: 'anonymous' };
++ const signedIn = viewer.kind === 'user';
++ const pathNow = o.path || '/';
++ const loginNext = encodeURIComponent(pathNow);
++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
++ const nav = {
++ service: 'codes',
++ apiBase: NETWORK_URL,
++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: `/auth/login?next=${loginNext}`,
++ };
++ const account = signedIn
++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong>${viewer.staff ? html` · <a href="/staff">Staff</a>` : ''} · <a href="/projects">Your projects</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a> to manage projects and apps`;
++ const crumbs = (o.crumbs || []).length
++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
++ : '';
++ const title = o.title ? `${o.title} · ${SITE_NAME}` : `${SITE_NAME}: the OpenVibe developer portal`;
++ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>${esc(title)}</title>
++<meta name="description" content="${esc(o.description || 'Build on OpenVibe: projects, apps and scoped credentials, generated contract and SDK docs, webhook and OAuth tools, and manifest validation.')}">
++<meta name="robots" content="${o.index ? 'index, follow' : 'noindex, nofollow'}">
++<link rel="canonical" href="${esc(canonical)}">
++<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
++${appIcon.headTags({ site: 'codes' })}
++<link rel="stylesheet" href="${asset('css/codes.css')}">
++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
++${render(scripts)}
++</head>
++<body>
++<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++${chrome.noscriptNav({ name: SITE_NAME, home: '/', links: NAV })}
++<div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div>
++<main id="main" class="page">
++${render(crumbs)}
++${render(o.body)}
++</main>
++${chrome.footer({ service: 'codes', variant: 'full' })}
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the chrome is optional */ }
++});
++</script>
++</body>
++</html>`;
++}
++
++function render(v) { return require('./html').render(v); }
++
++/** Send a page; portal pages are private and never cached. */
++function send(res, status, o) {
++ // A page rendered for a signed-in person names them in the account bar: never shared caches.
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : 'private, no-store');
++ res.set('Vary', 'Cookie');
++ res.status(status).type('html').send(renderPage(o));
++}
++
++module.exports = { renderPage, send, asset, assetVersion, SITE_NAME, NETWORK_URL, NAV, raw, html };
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..076977b
---- /dev/null
++++ b/server/render/markdown.js
@@@ -0,0 +1,62 @@
++'use strict';
++
++/**
++ * A deliberately small Markdown renderer for the ADRs shipped inside openvibe-contracts (the policy
++ * pages render them verbatim, so the policy cannot drift from the decision record). Everything is
++ * escaped first; only headings, paragraphs, lists, fenced code, bold, inline code and links are
++ * recognised. Links are kept only when http(s) or relative.
++ */
++const { esc } = require('./html');
++
++function inline(s) {
++ let out = esc(s);
++ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
++ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
++ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {
++ const h = href.replace(/&amp;/g, '&');
++ if (/^https?:\/\//.test(h) || /^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;
++ return text;
++ });
++ return out;
++}
++
++function markdown(src, { headingOffset = 1 } = {}) {
++ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
++ const out = [];
++ let para = [];
++ let list = null; // { type: 'ul' | 'ol', items: [] }
++ let fence = null;
++ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
++ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
++ for (const line of lines) {
++ if (fence) {
++ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
++ continue;
++ }
++ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
++ const h = line.match(/^(#{1,6})\s+(.*)$/);
++ if (h) {
++ flushPara(); flushList();
++ const level = Math.min(6, h[1].length + headingOffset);
++ out.push(`<h${level}>${inline(h[2])}</h${level}>`);
++ continue;
++ }
++ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
++ if (li) {
++ flushPara();
++ const type = li[1] ? 'ol' : 'ul';
++ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
++ list.items.push(li[2]);
++ continue;
++ }
++ if (!line.trim()) { flushPara(); flushList(); continue; }
++ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
++ flushList();
++ para.push(line.trim());
++ }
++ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
++ flushPara(); flushList();
++ return out.join('\n');
++}
++
++module.exports = { markdown };
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
nnew file mode 100644
iindex 0000000..e9e346b
---- /dev/null
++++ b/test/auth-ops.test.js
@@@ -0,0 +1,156 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256, the outbox relay with Codes' own service token, truthful readiness,
++ * /release.json, loopback-only /metrics, pages useful without JavaScript, and form protection.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++
++ await check('login redirects to Network with state and an S256 code challenge', async () => {
++ const r = await t.get('/auth/login?next=/projects');
++ assert.strictEqual(r.status, 302);
++ const loc = new URL(r.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
++ assert.strictEqual(loc.searchParams.get('client_id'), 'codes');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
++ assert.match(r.headers.get('set-cookie'), /codes_oauth=.*Path=\/auth.*HttpOnly/i);
++ });
++
++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
++ const login = await t.get('/auth/login?next=/projects');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(r.status, 302, r.text);
++ assert.strictEqual(r.headers.get('location'), '/projects');
++ const set = r.headers.get('set-cookie');
++ assert.match(set, /codes_at=[^;]+;.*HttpOnly/i);
++ assert.match(set, /codes_rt=[^;]+;.*HttpOnly/i);
++ const at = set.match(/codes_at=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `codes_at=${at}` });
++ assert.strictEqual(me.json().user.username, 'kim');
++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
++ });
++
++ await check('a wrong state or a verifier that does not match is refused', async () => {
++ const login = await t.get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
++ assert.strictEqual(bad.status, 400);
++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(mismatch.status, 400, 'Network refuses the PKCE check; Codes reports it');
++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
++ assert.strictEqual(noCookie.status, 400);
++ });
++
++ await check('next= only accepts same-site paths', async () => {
++ const r = await t.get('/auth/login?next=//evil.example/x');
++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/codes_oauth=([^;]+)/)[1]);
++ assert.strictEqual(JSON.parse(flow).next, '/');
++ });
++
++ await check('forms need the form token and a same-site Origin', async () => {
++ const noToken = await t.get('/projects', { as: user, form: { name: 'x', csrf: 'nope' } });
++ assert.strictEqual(noToken.status, 403);
++ const crossSite = await t.get('/projects', { as: user, form: { name: 'x' }, headers: { origin: 'https://evil.example' } });
++ assert.strictEqual(crossSite.status, 403);
++ const ok = await t.get('/projects', { as: user, form: { name: 'x' }, headers: { origin: 'https://openvibe.codes' } });
++ assert.strictEqual(ok.status, 303);
++ });
++
++ await check('readiness is truthful: required db + docs; Network, relay and client are optional', async () => {
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(b.checks.db.status, 'ok');
++ assert.strictEqual(b.checks.docs.status, 'ok');
++ assert.strictEqual(b.checks.network_jwks.status, 'ok');
++ assert.strictEqual(b.checks.network.status, 'ok');
++ assert.strictEqual(b.checks.events_relay.status, 'fail', 'EVENTS_URL is unset in this boot, and it says so');
++ assert.ok(b.degraded.includes('events_relay'));
++ });
++
++ await check('/release.json and /metrics (loopback only)', async () => {
++ const rel = await t.get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ assert.strictEqual(rel.json().service, 'codes');
++ const m = await t.get('/metrics');
++ assert.strictEqual(m.status, 200);
++ assert.match(m.text, /http_requests_total|release_info/);
++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
++ });
++
++ await check('public pages are complete without JavaScript (forms, noscript nav, SSR footer)', async () => {
++ for (const p of ['/', '/docs', '/oauth', '/tools/webhooks', '/manifests/validate', '/policy/transparency']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.text, /<noscript><nav/);
++ assert.match(r.text, /<footer id="ov-footer"/);
++ assert.match(r.text, /<main id="main"/);
++ }
++ const hook = await t.get('/tools/webhooks');
++ assert.match(hook.text, /<form method="post" action="\/tools\/webhooks\/verify"/);
++ });
++
++ await check('the OAuth test callback shows what came back and never exchanges it', async () => {
++ const before = t.network.tokenRequests.length;
++ const r = await t.get('/oauth/test-callback?code=abc123&state=s1');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<code>abc123<\/code>/);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');
++ assert.match(r.text, /noindex/);
++ assert.strictEqual(t.network.tokenRequests.length, before, 'no token request');
++ const err = await t.get('/oauth/test-callback?error=access_denied&state=s1');
++ assert.strictEqual(err.status, 400);
++ assert.match(err.text, /access_denied/);
++ const xss = await t.get('/oauth/test-callback?code=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
++ assert.ok(!xss.text.includes('<script>alert(1)</script>'));
++ });
++
++ await check('the OAuth helper builds a PKCE authorize URL for the developer\'s app', async () => {
++ const r = await t.get('/oauth?client_id=app_01JABCDEFGHJKMNPQRSTVWXYZ0&scope=media.object.upload');
++ const url = new URL(r.text.match(/<a href="([^"]+oauth\/authorize[^"]+)"/)[1].replace(/&amp;/g, '&'));
++ assert.strictEqual(url.searchParams.get('code_challenge_method'), 'S256');
++ const verifier = r.text.match(/code_verifier<\/dt><dd><code>([A-Za-z0-9_-]{43})<\/code>/)[1];
++ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), url.searchParams.get('code_challenge'));
++ assert.strictEqual(url.searchParams.get('redirect_uri'), 'https://openvibe.codes/oauth/test-callback');
++ });
++
++ await t.close();
++
++ // ── Outbox relay: Codes' own token, events.event.publish, to Events ──
++ const t2 = await boot({ relay: true });
++ await check('with EVENTS_URL set, the relay publishes codes.app.* with Codes\' own service token', async () => {
++ const owner = t2.network.addUser('rel');
++ const p = await t2.project(owner);
++ const a = await t2.app(owner, p);
++ const manifest = JSON.stringify(require('../server/domain/manifests').template('app', { appId: a.id, projectId: p }));
++ const c = await t2.get(`/projects/${p}/apps/${a.id}/releases`, { as: owner, form: { kind: 'app', manifest, intent: 'create' } });
++ const id = c.headers.get('location').split('/').pop();
++ await t2.get(`/releases/${id}/publish`, { as: owner, form: {} });
++ for (let i = 0; i < 60 && !t2.events.published.length; i++) await new Promise((r) => setTimeout(r, 50));
++ assert.strictEqual(t2.events.published.length, 1);
++ assert.strictEqual(t2.events.published[0].sub, 'svc:codes');
++ assert.strictEqual(t2.events.published[0].event.event_type, 'codes.app.published');
++ const tok = t2.network.tokenRequests.find((x) => x.client_id === 'codes' && x.grant_type === 'client_credentials');
++ assert.strictEqual(tok.audience, 'openvibe.events');
++ assert.strictEqual(tok.scope, 'events.event.publish');
++ const ready = (await t2.get('/api/ready')).json();
++ assert.strictEqual(ready.checks.events_relay.status, 'ok');
++ });
++ await t2.close();
++ done();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..e20d05e
---- /dev/null
++++ b/test/contracts.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * The proposals Codes ships validate against the released contract schemas, and they describe what
++ * the code actually does: the events the outbox can produce and the capability the API checks.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { EVENT_TYPES } = require('../server/events/outbox');
++const { MANAGE } = require('../server/http/api');
++const { check, done } = require('./helpers/boot');
++
++const DOCS = path.join(__dirname, '..', 'docs');
++
++(async () => {
++ const caps = fs.readdirSync(path.join(DOCS, 'capabilities-proposal')).map((f) => JSON.parse(fs.readFileSync(path.join(DOCS, 'capabilities-proposal', f), 'utf8')));
++ const service = JSON.parse(fs.readFileSync(path.join(DOCS, 'service-manifest-proposal.json'), 'utf8'));
++
++ await check('capability proposals validate as capabilities.capability@1 and are owned by codes', async () => {
++ assert.ok(caps.length >= 2);
++ for (const c of caps) {
++ const v = contracts.validate('capabilities.capability@1', c);
++ assert.ok(v.valid, `${c.id}: ${JSON.stringify(v.errors)}`);
++ assert.strictEqual(c.owner, 'codes');
++ assert.ok(c.id.startsWith('codes.'));
++ assert.ok(!contracts.capabilities.get(c.id), `${c.id} is already released; drop the proposal`);
++ }
++ });
++
++ await check('the service manifest proposal validates and matches the code', async () => {
++ const v = contracts.validate('registry.service-manifest@1', service);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.strictEqual(service.id, 'codes');
++ assert.deepStrictEqual([...service.eventsProduced].sort(), [...EVENT_TYPES].sort());
++ assert.deepStrictEqual([...service.capabilities].sort(), caps.map((c) => c.id).sort());
++ assert.ok(service.capabilities.includes(MANAGE));
++ for (const e of service.eventsProduced) assert.strictEqual(e.split('.').length, 3, `${e} has three segments`);
++ const range = service.contractRanges['openvibe-contracts'];
++ assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
++ });
++
++ await check('the app manifest proposal is a valid JSON Schema the contracts validator compiles', async () => {
++ const schema = JSON.parse(fs.readFileSync(path.join(DOCS, 'contracts-proposal', 'codes', 'app-manifest.v1.json'), 'utf8'));
++ assert.strictEqual(schema.$id, 'https://openvibe.network/contracts/codes/app-manifest.v1.json');
++ const info = require('../server/domain/manifests').appSchemaInfo();
++ assert.strictEqual(info.id, 'codes.app-manifest');
++ });
++
++ done();
++})();
ddiff --git a/test/docs.test.js b/test/docs.test.js
nnew file mode 100644
iindex 0000000..26436c6
---- /dev/null
++++ b/test/docs.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * Generated docs match the pinned contracts and SDK versions: the tag pinned in package.json, the
++ * installed package and what every page states are the same version, and every contract,
++ * capability, event type and SDK module in those packages has its page — nothing more, nothing less.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const pkg = require('../package.json');
++const contractsVersion = require('openvibe-contracts/package.json').version;
++const sdkPkg = require('openvibe-sdk/package.json');
++
++(async () => {
++ const t = await boot();
++
++ await check('package.json pins exactly the installed contracts, SDK and shared releases', async () => {
++ const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/v([0-9.]+)$/) || [])[1];
++ assert.strictEqual(tag('openvibe-contracts'), contractsVersion);
++ assert.strictEqual(tag('openvibe-sdk'), sdkPkg.version);
++ assert.strictEqual(tag('openvibe-shared'), require('openvibe-shared/package.json').version);
++ assert.strictEqual(contractsVersion, '0.26.0');
++ assert.strictEqual(sdkPkg.version, '0.2.2');
++ });
++
++ await check('every docs page states the versions it was generated from', async () => {
++ for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the contracts version`);
++ assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
++ }
++ const v = (await t.get('/api/v1/docs/versions')).json();
++ assert.strictEqual(v.contracts, contractsVersion);
++ assert.strictEqual(v.sdk, sdkPkg.version);
++ });
++
++ await check('one page per contract in the catalog, with its version, fields and fixtures', async () => {
++ const index = (await t.get('/docs/contracts')).text;
++ for (const c of contracts.catalog) {
++ assert.ok(index.includes(`/docs/contracts/${c.id}"`), `${c.id} listed`);
++ const r = await t.get(`/docs/contracts/${c.id}`);
++ assert.strictEqual(r.status, 200, c.id);
++ assert.ok(r.text.includes(`<dd>${c.version}</dd>`), `${c.id} version`);
++ const schema = contracts.schema(c.id);
++ for (const field of Object.keys(schema.properties || {})) assert.ok(r.text.includes(`<code>${field}</code>`), `${c.id} field ${field}`);
++ const fixtureDir = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', c.id, 'valid');
++ for (const f of fs.existsSync(fixtureDir) ? fs.readdirSync(fixtureDir) : []) assert.ok(r.text.includes(f.replace(/\.json$/, '')), `${c.id} fixture ${f}`);
++ const raw = await t.get(`/docs/contracts/${c.id}.json`);
++ assert.deepStrictEqual(JSON.parse(raw.text), schema);
++ }
++ assert.strictEqual((await t.get('/docs/contracts/no.such-contract')).status, 404);
++ });
++
++ await check('the capability catalog lists every capability manifest exactly once', async () => {
++ const r = await t.get('/docs/capabilities');
++ const listed = [...r.text.matchAll(/<a href="\/docs\/capabilities\/([^"]+)"/g)].map((m) => m[1]);
++ assert.deepStrictEqual([...listed].sort(), contracts.capabilities.manifests.map((c) => c.id).sort());
++ });
++
++ await check('event types are exactly the service manifests\' eventsProduced', async () => {
++ const r = await t.get('/docs/events');
++ const expected = new Set(contracts.services.manifests.flatMap((m) => m.eventsProduced || []));
++ for (const e of expected) assert.ok(r.text.includes(`<code>${e}</code>`), e);
++ assert.strictEqual([...r.text.matchAll(/<tr><td><code>/g)].length, expected.size);
++ });
++
++ await check('the SDK reference has one page per exported types entry, declarations verbatim', async () => {
++ const typed = Object.entries(sdkPkg.exports).filter(([, v]) => v && typeof v === 'object' && v.types);
++ const r = await t.get('/docs/sdk');
++ for (const [sub] of typed) {
++ const slug = sub === '.' ? 'index' : sub.replace(/^\.\//, '').replace(/\//g, '-');
++ assert.ok(r.text.includes(`/docs/sdk/${slug}"`), `module ${sub}`);
++ }
++ const media = await t.get('/docs/sdk/media');
++ assert.ok(media.text.includes('export declare function createMediaClient'));
++ const events = await t.get('/docs/sdk/events');
++ assert.ok(events.text.includes('export declare function verifyDelivery'));
++ });
++
++ await check('policy pages render the published ADRs, not a paraphrase', async () => {
++ const r = await t.get('/policy/compatibility');
++ const adr = fs.readFileSync(path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'docs', 'adr', 'ADR-016-active-client-updates.md'), 'utf8');
++ assert.ok(r.text.includes('Mixed-version window'));
++ assert.ok(adr.includes('Mixed-version window'));
++ assert.ok(r.text.includes('ADR-002: Contract repository and compatibility policy'));
++ const rfc = await t.get('/policy/rfc');
++ const adrs = fs.readdirSync(path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'docs', 'adr')).filter((f) => /^ADR-\d+/.test(f));
++ for (const f of adrs) assert.ok(rfc.text.includes(`/docs/adr/${f.match(/^(ADR-\d+)/)[1]}"`), f);
++ const lic = await t.get('/policy/licensing');
++ assert.ok(lic.text.includes(sdkPkg.license) && lic.text.includes(require('openvibe-contracts/package.json').license));
++ });
++
++ await check('public docs are crawlable and cacheable; the sitemap lists them', async () => {
++ const r = await t.get('/docs/capabilities');
++ assert.match(r.text, /<meta name="robots" content="index, follow">/);
++ assert.match(r.headers.get('cache-control'), /public/);
++ const sm = await t.get('/sitemap.xml');
++ assert.ok(sm.text.includes('/docs/contracts/mods.mod-manifest</loc>'));
++ });
++
++ await check('pages rendered for a signed-in person are never publicly cacheable', async () => {
++ const u = t.network.addUser('pat');
++ const r = await t.get('/docs', { as: u });
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..19cbb3f
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * Boots Codes on a temp database with mocks of Network, Events and Media, captures every log line
++ * (the app's logger AND console), and returns a small HTTP client. Every test file gets its own.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, form, json, multipart: { fields, file }, headers, method })
++ * t.signIn(user) → cookie header value (codes_at = a Network user token, as after /auth/callback)
++ * t.csrf(user), t.logs(), t.dbDump(), t.project(user, name), t.app(user, projectId, body)
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const { startNetwork, startEvents, startMedia } = require('./mocks');
++
++const captured = [];
++for (const m of ['log', 'info', 'warn', 'error']) {
++ const orig = console[m].bind(console);
++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
++}
++
++async function boot(opts = {}) {
++ const network = await startNetwork(opts.network || {});
++ const events = await startEvents(network);
++ const media = await startMedia(network);
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-test-'));
++ const dbPath = path.join(dir, 'codes.db');
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.codes', TRUST_PROXY: '1',
++ CODES_DB_PATH: dbPath,
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'codes', OV_OAUTH_CLIENT_SECRET: 'codes-secret', COOKIE_SECURE: 'false',
++ CODES_FORM_SECRET: 'test-form-secret',
++ CODES_PLAYGROUND_EVENTS_URL: events.url, CODES_PLAYGROUND_MEDIA_URL: media.url,
++ CODES_REGISTRY_TTL_MS: '0',
++ ...(opts.relay ? { EVENTS_URL: events.url, EVENTS_RELAY_INTERVAL_MS: '50' } : {}),
++ ...(opts.env || {}),
++ };
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
++
++ const config = configLib.load(env);
++ const built = createApp({ config, log });
++ await built.ctx.keys.ensure();
++ if (opts.relay) built.ctx.outbox.start();
++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const signIn = (user) => `codes_at=${network.userToken(user)}`;
++ const csrf = (user) => require('../../server/auth/forms').csrfToken({ formSecret: env.CODES_FORM_SECRET }, user);
++
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
++ if (o.cookie) headers.cookie = o.cookie;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) {
++ const f = { ...o.form };
++ if (o.as && f.csrf === undefined) f.csrf = csrf(o.as);
++ body = new URLSearchParams(f).toString();
++ headers['content-type'] = 'application/x-www-form-urlencoded';
++ }
++ if (o.multipart) {
++ const fd = new FormData();
++ const fields = { ...(o.multipart.fields || {}) };
++ if (o.as && fields.csrf === undefined) fields.csrf = csrf(o.as);
++ for (const [k, v] of Object.entries(fields)) fd.append(k, v);
++ if (o.multipart.file) fd.append('file', new Blob([o.multipart.file.content], { type: o.multipart.file.type || 'text/plain' }), o.multipart.file.name || 'f.txt');
++ body = fd;
++ }
++ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
++ const text = await res.text();
++ return { status: res.status, headers: res.headers, text, json() { return JSON.parse(text); } };
++ }
++
++ /** Every value in every table of Codes' database, as one string. */
++ function dbDump() {
++ const db = built.ctx.store.db;
++ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);
++ return tables.map((tname) => JSON.stringify(db.prepare(`SELECT * FROM "${tname}"`).all())).join('\n');
++ }
++
++ async function project(user, name = 'Test project') {
++ const r = await get('/projects', { as: user, form: { name } });
++ if (r.status !== 303) throw new Error(`project: ${r.status} ${r.text.slice(0, 300)}`);
++ return r.headers.get('location').split('/').pop();
++ }
++ /** Create an app through the portal; returns { id, secret, page }. */
++ async function app(user, projectId, body = {}) {
++ const r = await get(`/projects/${projectId}/apps`, { as: user, form: { name: 'Test app', environment: 'sandbox', type: 'confidential', redirect_uris: '', ...body } });
++ if (r.status === 303) return { id: r.headers.get('location').split('/').pop().split('?')[0], secret: null, page: r };
++ if (r.status !== 200) throw new Error(`app: ${r.status} ${r.text.slice(0, 300)}`);
++ const secret = (r.text.match(/ovsec_[A-Za-z0-9_-]{43}/) || [null])[0];
++ const id = (r.text.match(/app_[0-9A-HJKMNP-TV-Z]{26}/) || [null])[0];
++ return { id, secret, page: r };
++ }
++ /** Approve a grant the way Network would after staff put it in the allowance. */
++ async function grant(owner, projectId, appId, capability) {
++ network.setAllowance(projectId, [...new Set([...network.state.projects.get(projectId).allowance, capability])]);
++ const r = await get(`/projects/${projectId}/apps/${appId}/grants`, { as: owner, form: { capability } });
++ if (r.status !== 303) throw new Error(`grant: ${r.status} ${r.text.slice(0, 300)}`);
++ }
++
++ const t = {
++ base, network, events, media, config, ctx: built.ctx, dbPath, get, signIn, csrf, dbDump, project, app, grant,
++ logs: () => captured.join('\n'),
++ async close() {
++ await new Promise((r) => server.close(r));
++ await built.ctx.outbox.stop();
++ built.ctx.store.close();
++ await network.close(); await events.close(); await media.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ },
++ };
++ return t;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..4f65e75
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,346 @@
++'use strict';
++/**
++ * In-process stand-ins for Codes' neighbours, with a real RS256 key pair.
++ *
++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `codes`, refresh_token,
++ * client_credentials for `codes` and for developer apps with Network's app rules:
++ * hashed secrets, sandbox audiences, cap = approved grants ∩ allowance for the audience);
++ * /oauth/revoke; /.well-known/openvibe; /api/v1/registry/services; and /api/v1/projects…
++ * with the same shapes, roles and problem codes as OpenVibe.Network's developer API
++ * (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
++ * connections-equivalent (503) and drop() closes the socket.
++ * Events POST /api/v1/events: verifies the app token (openvibe-contracts), needs the capability
++ * Media POST /api/v1/:app/files: verifies the token, capability and namespace
++ * Every request is recorded in `.requests` so tests can assert what was (not) called.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const Busboy = require('busboy');
++const { serviceAuth, ids, capabilities } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({
++ server, url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
++ }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
++
++async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.media'] } = {}) {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const st = {
++ users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
++ grants: new Map(), quotas: new Map(), audit: [], codes: new Map(), refresh: new Map(),
++ catalog: null, down: false, requests: [], tokenRequests: [],
++ };
++ let issuer = null;
++ let nextUserId = 1;
++
++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
++
++ function addUser(username, { role = 'user' } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
++ st.users.set(u.id, u); st.byUsername.set(username, u);
++ return u;
++ }
++ function userToken(u, { ttl = 3600, iat = null } = {}) {
++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
++ }
++ function userFrom(req) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const parts = h.slice(7).split('.');
++ if (parts.length !== 3) return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (c.exp * 1000 < Date.now()) return 'expired';
++ return st.users.get(c.sub) || null;
++ }
++
++ // ── Projects model ──
++ const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
++ const projectView = (p, role) => ({
++ id: p.id, name: p.name, owner: { type: 'user', id: p.owner }, role: role || null, environment_policy: p.environment_policy,
++ environments: p.environment_policy === 'sandbox' ? ['sandbox'] : ['sandbox', 'production'], allowance: [...p.allowance],
++ created_at: p.created_at, archived_at: p.archived_at, counts: { members: (st.members.get(p.id) || new Map()).size, apps: [...st.apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },
++ });
++ const appView = (a) => ({
++ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,
++ client_type: a.client_type, redirect_uris: a.redirect_uris, created_at: a.created_at, revoked_at: a.revoked_at,
++ grants: [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved').map((g) => g.capability).sort(),
++ });
++ function newCredential(app) {
++ const secret = `ovsec_${crypto.randomBytes(32).toString('base64url')}`;
++ const c = { id: ids.newId('project').replace(/^prj_/, 'crd_'), app_id: app.id, hash: sha256(secret), hint: secret.slice(-4), created_at: new Date().toISOString(), expires_at: null, revoked_at: null };
++ st.creds.set(c.id, c);
++ return { c, secret };
++ }
++ const credState = (c) => (c.revoked_at ? 'revoked' : (c.expires_at ? (Date.parse(c.expires_at) < Date.now() ? 'expired' : 'expiring') : 'active'));
++ const grantable = (id) => { const c = capabilities.get(id); return c && c.status === 'active' && ['public', 'partner'].includes(c.visibility); };
++ const defaultCatalog = () => capabilities.manifests.filter((c) => grantable(c.id)).map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));
++
++ async function projectsApi(req, raw, json, u, parts, q) {
++ let body = {};
++ if (raw.length) { try { body = JSON.parse(raw.toString('utf8')); } catch { return problem(json, 400, 'request.malformed_json', 'body is not valid JSON'); } }
++ const m = req.method;
++ if (parts.length === 1 && parts[0] === 'catalog' && m === 'GET') return json(200, { capabilities: st.catalog || defaultCatalog(), rule: 'only active public capabilities' });
++ if (!parts.length && m === 'GET') {
++ const list = [...st.projects.values()].filter((p) => roleOf(p, u) || (q.get('all') === '1' && u.role === 'admin')).map((p) => projectView(p, roleOf(p, u)));
++ return json(200, { projects: list });
++ }
++ if (!parts.length && m === 'POST') {
++ const name = String(body.name || '').trim();
++ if (!name || name.length > 80) return problem(json, 422, 'project.invalid', 'name must be 1-80 characters');
++ const p = { id: ids.newId('project'), name, owner: u.subject, environment_policy: 'sandbox', allowance: [], created_at: new Date().toISOString(), archived_at: null };
++ st.projects.set(p.id, p); st.members.set(p.id, new Map([[u.subject, 'owner']]));
++ return json(201, projectView(p, 'owner'));
++ }
++ const p = st.projects.get(parts[0]);
++ const role = p ? roleOf(p, u) : null;
++ const staff = u.role === 'admin';
++ if (!p || (!role && !staff)) return problem(json, 404, 'project.not_found', 'no such project');
++ const need = (r) => { if (!role || RANK[role] < RANK[r]) { problem(json, 403, 'project.forbidden', `requires ${r} role`); return false; } return true; };
++ const rest = parts.slice(1);
++ if (!rest.length && m === 'GET') return json(200, projectView(p, role));
++ if (rest[0] === 'archive' && m === 'POST') { if (role !== 'owner' && !staff) return problem(json, 403, 'project.forbidden', 'owner only'); p.archived_at = new Date().toISOString(); for (const a of st.apps.values()) if (a.project_id === p.id) a.revoked_at = a.revoked_at || p.archived_at; return json(200, projectView(p, role)); }
++ if (rest[0] === 'members') {
++ const mem = st.members.get(p.id);
++ if (rest.length === 1 && m === 'GET') return json(200, { members: [...mem.entries()].map(([s, r]) => { const x = [...st.users.values()].find((y) => y.subject === s); return { subject: { type: 'user', id: s }, username: x && x.username, display_name: x && x.display_name, role: r, added_at: p.created_at }; }) });
++ if (rest.length === 1 && m === 'POST') {
++ if (!need('admin')) return;
++ const target = st.byUsername.get(String(body.username || ''));
++ if (!target) return problem(json, 404, 'member.user_not_found', 'no such account');
++ if (!['admin', 'developer', 'viewer'].includes(body.role)) return problem(json, 422, 'member.invalid', 'role is one of admin, developer, viewer');
++ if (body.role === 'admin' && role !== 'owner') return problem(json, 403, 'member.forbidden', `a ${role} cannot assign admin`);
++ if (mem.has(target.subject)) return problem(json, 409, 'member.exists', 'already a member');
++ mem.set(target.subject, body.role);
++ return json(201, { subject: { type: 'user', id: target.subject }, role: body.role });
++ }
++ const s = rest[1];
++ if (!mem.has(s)) return problem(json, 404, 'member.not_found', 'not a member');
++ if (m === 'PATCH') { if (!need('admin')) return; mem.set(s, body.role); return json(200, { role: body.role }); }
++ if (m === 'DELETE') { if (s !== u.subject && !need('admin')) return; if (mem.get(s) === 'owner') return problem(json, 403, 'member.forbidden', 'the owner cannot be removed'); mem.delete(s); res204(json); return; }
++ }
++ if (rest[0] === 'quotas' && m === 'GET') return json(200, { quotas: [...st.quotas.values()].filter((x) => x.project_id === p.id).map((x) => ({ capability: x.capability, limit: x.limit, window: x.window, unit: x.unit, enforced_by: `openvibe.${capabilities.get(x.capability).owner}`, updated_at: x.updated_at })), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });
++ if (rest[0] === 'audit' && m === 'GET') { if (!role || RANK[role] < RANK.admin) { if (!staff) return problem(json, 403, 'project.forbidden', 'requires admin role'); } return json(200, { entries: st.audit.filter((e) => e.project_id === p.id).slice().reverse(), next_before: null }); }
++ if (rest[0] === 'apps') {
++ if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });
++ if (rest.length === 1 && m === 'POST') {
++ const env = String(body.environment || 'sandbox');
++ if (!['sandbox', 'production'].includes(env)) return problem(json, 422, 'app.invalid', 'environment is sandbox or production');
++ if (!need(env === 'production' ? 'admin' : 'developer')) return;
++ if (env === 'production' && p.environment_policy === 'sandbox') return problem(json, 403, 'app.environment_not_allowed', 'this project may only have sandbox apps (staff enable production)');
++ const type = String(body.type || 'confidential');
++ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
++ for (const x of redirects) { try { const url = new URL(x); if (url.protocol !== 'https:' && url.hostname !== 'localhost') return problem(json, 422, 'app.invalid', 'redirect URIs must be https (http only for loopback)'); } catch { return problem(json, 422, 'app.invalid', `bad redirect URI ${x}`); } }
++ if (type === 'public' && !redirects.length) return problem(json, 422, 'app.invalid', 'a public app needs at least one redirect URI');
++ const a = { id: ids.newId('app'), project_id: p.id, name: String(body.name || '').trim() || 'app', environment: env, client_type: type, redirect_uris: redirects, created_at: new Date().toISOString(), revoked_at: null };
++ st.apps.set(a.id, a);
++ const out = appView(a);
++ if (type === 'confidential') { const { c, secret } = newCredential(a); out.credential = { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }; }
++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'app.created', target: `app:${a.id}`, detail: { name: a.name }, event_type: 'network.app.created' });
++ return json(201, out);
++ }
++ const a = st.apps.get(rest[1]);
++ if (!a || a.project_id !== p.id) return problem(json, 404, 'app.not_found', 'no such app');
++ const manage = a.environment === 'production' ? 'admin' : 'developer';
++ if (rest.length === 2 && m === 'GET') return json(200, appView(a));
++ if (rest.length === 2 && m === 'PATCH') { if (!need(manage)) return; if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris; return json(200, appView(a)); }
++ if (rest.length === 2 && m === 'DELETE') { if (!staff && !need(manage)) return; a.revoked_at = new Date().toISOString(); return json(200, appView(a)); }
++ if (rest[2] === 'credentials') {
++ if (rest.length === 3 && m === 'GET') return json(200, { credentials: [...st.creds.values()].filter((c) => c.app_id === a.id).map((c) => ({ id: c.id, hint: c.hint, state: credState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at, last_used_at: null })) });
++ if (rest[3] === 'rotate' && m === 'POST') {
++ if (!need(manage)) return;
++ if (a.revoked_at) return problem(json, 409, 'app.revoked', 'app is revoked');
++ if (a.client_type !== 'confidential') return problem(json, 409, 'credential.public_client', 'public apps have no client secret');
++ const overlap = body.overlap_seconds === undefined ? 86400 : body.overlap_seconds;
++ const until = new Date(Date.now() + overlap * 1000).toISOString();
++ const prev = [];
++ for (const c of st.creds.values()) if (c.app_id === a.id && !c.revoked_at) { c.expires_at = until; prev.push({ id: c.id, expires_at: until }); }
++ const { c, secret } = newCredential(a);
++ return json(201, { credential: { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }, previous: prev });
++ }
++ if (rest[4] === 'revoke' && m === 'POST') { if (!need(manage)) return; const c = st.creds.get(rest[3]); if (!c || c.app_id !== a.id) return problem(json, 404, 'credential.not_found', 'no such credential'); c.revoked_at = new Date().toISOString(); return json(200, { id: c.id, state: 'revoked' }); }
++ }
++ if (rest[2] === 'grants') {
++ const key = (cap) => `${a.id} ${cap}`;
++ const gv = (g) => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null });
++ if (rest.length === 3 && m === 'GET') return json(200, { grants: [...st.grants.values()].filter((g) => g.app_id === a.id).map(gv) });
++ if (rest.length === 3 && m === 'POST') {
++ if (!need('developer')) return;
++ const cap = String(body.capability || '');
++ const c = capabilities.get(cap);
++ if (!c) return problem(json, 422, 'grant.unknown_capability', `no capability ${cap} in the catalog`);
++ if (!grantable(cap)) return problem(json, 403, 'grant.not_grantable', `${cap} is ${c.visibility}; only public capabilities are granted to apps`);
++ const auto = RANK[role] >= RANK.admin && p.allowance.includes(cap);
++ const g = { app_id: a.id, capability: cap, audience: `openvibe.${c.owner}`, status: auto ? 'approved' : 'requested', requested_by: `user:${u.subject}`, requested_at: new Date().toISOString(), decided_at: auto ? new Date().toISOString() : null };
++ st.grants.set(key(cap), g);
++ return json(201, gv(g));
++ }
++ const g = st.grants.get(key(rest[3]));
++ if (!g) return problem(json, 404, 'grant.not_found', 'no such grant');
++ if (m === 'DELETE') { if (!staff && !need('admin')) return; g.status = 'revoked'; return json(200, gv(g)); }
++ if (rest[4] === 'approve') { if (!need('admin')) return; if (!p.allowance.includes(g.capability)) return problem(json, 403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`); g.status = 'approved'; g.decided_at = new Date().toISOString(); return json(200, gv(g)); }
++ if (rest[4] === 'deny') { if (!need('admin')) return; g.status = 'denied'; return json(200, gv(g)); }
++ }
++ }
++ return problem(json, 404, 'route.not_found', 'no such route');
++ }
++ const res204 = (json) => json(204, {});
++
++ function mintApp(a, audience, cap) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
++ }
++
++ const srv = await listen(async (req, raw, json, res) => {
++ const url = new URL(req.url, 'http://x');
++ st.requests.push({ method: req.method, path: url.pathname, headers: req.headers });
++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
++ if (url.pathname === '/api/v1/registry/services') {
++ return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }, { id: 'codes', name: 'OpenVibe.Codes', status: 'placeholder', domains: [], capabilities: [], runtime: { status: 'not-running', reason: 'placeholder', checked_at: new Date().toISOString() } }] });
++ }
++ if (url.pathname === '/oauth/revoke') return json(200, {});
++ if (url.pathname === '/oauth/token' && req.method === 'POST') {
++ const ct = String(req.headers['content-type'] || '');
++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
++ if (body.client_id === 'codes') {
++ if (body.client_secret !== 'codes-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (body.grant_type === 'authorization_code') {
++ const c = st.codes.get(body.code);
++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
++ st.codes.delete(body.code);
++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, c.user);
++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'refresh_token') {
++ const user = st.refresh.get(body.refresh_token);
++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
++ st.refresh.delete(body.refresh_token);
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, user);
++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'client_credentials') {
++ const now = Math.floor(Date.now() / 1000);
++ return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:codes', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });
++ }
++ }
++ const a = st.apps.get(body.client_id);
++ if (!a || a.revoked_at) return json(401, { error: 'invalid_client', error_description: 'unknown or revoked app' });
++ const cred = [...st.creds.values()].find((c) => c.app_id === a.id && c.hash === sha256(body.client_secret) && credState(c) !== 'revoked' && credState(c) !== 'expired');
++ if (!cred) return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (a.environment === 'sandbox' && !sandboxAudiences.includes(body.audience)) return json(400, { error: 'invalid_target', error_description: `${body.audience} does not accept sandbox tokens` });
++ const p = st.projects.get(a.project_id);
++ const held = [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved' && g.audience === body.audience && p.allowance.includes(g.capability)).map((g) => g.capability);
++ const wanted = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : held;
++ const missing = wanted.filter((w) => !held.includes(w));
++ if (missing.length) return json(400, { error: 'invalid_scope', error_description: `not granted: ${missing.join(' ')}` });
++ if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
++ return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
++ }
++ if (url.pathname.startsWith('/api/v1/projects')) {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');
++ const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
++ return projectsApi(req, raw, json, u, parts, url.searchParams);
++ }
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++
++ return {
++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
++ addUser, userToken,
++ /** An authorization code the mock will exchange for this user if the verifier matches. */
++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
++ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
++ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
++ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
++ setCatalog(list) { st.catalog = list; },
++ setDown(v) { st.down = v; },
++ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
++ close: srv.close,
++ };
++}
++
++/** Events: POST /api/v1/events with an app or service token carrying events.event.publish. */
++async function startEvents(network) {
++ const published = [];
++ const requests = [];
++ let seq = 0;
++ const srv = await listen((req, raw, json) => {
++ requests.push({ method: req.method, path: req.url });
++ if (req.url === '/api/v1/events' && req.method === 'POST') {
++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.events', acceptSandbox: true });
++ if (!v.ok) return json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason });
++ if (!v.claims.cap.includes('events.event.publish')) return json(403, { type: 'x', title: 'Forbidden', status: 403, code: 'capability.denied', detail: 'events.event.publish not granted' });
++ const body = JSON.parse(raw.toString('utf8'));
++ const events = body.events || [body];
++ const results = events.map((e) => { published.push({ event: e, sub: v.claims.sub }); return { event_id: e.event_id, seq: ++seq, duplicate: false }; });
++ return json(201, body.events ? { results } : results[0]);
++ }
++ return json(404, { error: 'not found' });
++ });
++ return { url: srv.url, published, requests, close: srv.close };
++}
++
++/** Media: POST /api/v1/:app/files with a token carrying media.object.upload for that namespace. */
++async function startMedia(network) {
++ const uploads = [];
++ const requests = [];
++ const srv = await listen((req, raw, json) => new Promise((resolve) => {
++ requests.push({ method: req.method, path: req.url });
++ const m = req.url.match(/^\/api\/v1\/([^/]+)\/files$/);
++ if (!m || req.method !== 'POST') { json(404, { error: 'not found' }); return resolve(); }
++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.media', acceptSandbox: true });
++ if (!v.ok) { json(401, { type: 'x', title: 'Unauthorized', status: 401, code: v.code, detail: v.reason }); return resolve(); }
++ const c = capabilities.check(v.claims, 'media.object.upload', { namespace: decodeURIComponent(m[1]) });
++ if (!c.allowed) { json(403, { type: 'x', title: 'Forbidden', status: 403, code: c.code, detail: c.reason }); return resolve(); }
++ const bb = Busboy({ headers: req.headers });
++ let file = null;
++ bb.on('file', (_n, stream, info) => { const chunks = []; stream.on('data', (d) => chunks.push(d)); stream.on('end', () => { file = { buf: Buffer.concat(chunks), info }; }); });
++ bb.on('close', () => {
++ const key = crypto.createHash('sha256').update(file.buf).digest('hex').slice(0, 16);
++ uploads.push({ app: m[1], key, size: file.buf.length });
++ json(201, { key, app_id: m[1], user_id: null, original_name: file.info.filename, size: file.buf.length, mime: file.info.mimeType, sha256: crypto.createHash('sha256').update(file.buf).digest('hex'), url: `/f/${key}`, created_at: new Date().toISOString() });
++ resolve();
++ });
++ bb.end(raw);
++ }));
++ return { url: srv.url, uploads, requests, close: srv.close };
++}
++
++module.exports = { startNetwork, startEvents, startMedia, signJwt };
ddiff --git a/test/manifests.test.js b/test/manifests.test.js
nnew file mode 100644
iindex 0000000..1332d3a
---- /dev/null
++++ b/test/manifests.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * Manifest validation with openvibe-contracts: mods.mod-manifest@1 (released) and the app manifest
++ * proposal compiled into the contracts validator; semantic checks against the pinned catalog.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++const FIX = path.join(path.dirname(require.resolve('openvibe-contracts/package.json')), 'fixtures', 'mods.mod-manifest');
++const read = (p) => JSON.parse(fs.readFileSync(p, 'utf8'));
++const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
++const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
++
++(async () => {
++ await check('the contracts\' own mod fixtures: valid ones pass, invalid ones fail', async () => {
++ for (const f of fs.readdirSync(path.join(FIX, 'valid'))) assert.strictEqual(manifests.validate('mod', read(path.join(FIX, 'valid', f))).valid, true, f);
++ for (const f of fs.readdirSync(path.join(FIX, 'invalid'))) assert.strictEqual(manifests.validate('mod', read(path.join(FIX, 'invalid', f))).valid, false, f);
++ });
++
++ await check('schema errors come from openvibe-contracts with JSON pointers', async () => {
++ const m = read(path.join(FIX, 'valid', fs.readdirSync(path.join(FIX, 'valid'))[0]));
++ const v = manifests.validate('mod', { ...m, version: 'one' });
++ assert.strictEqual(v.valid, false);
++ assert.ok(v.errors.some((e) => e.path === '/version'));
++ assert.strictEqual(v.schema.id, 'mods.mod-manifest');
++ });
++
++ await check('the starter templates validate', async () => {
++ assert.deepStrictEqual(manifests.validate('app', manifests.template('app')).errors, []);
++ assert.deepStrictEqual(manifests.validate('mod', manifests.template('mod')).errors, []);
++ });
++
++ await check('app manifests: unknown and non-grantable capabilities are errors', async () => {
++ const internal = contracts.capabilities.manifests.find((c) => c.visibility === 'internal').id;
++ const m = { ...manifests.template('app'), capabilities: ['media.object.upload', 'nope.not.real', internal] };
++ const v = manifests.validate('app', m);
++ assert.strictEqual(v.valid, false);
++ assert.ok(v.errors.some((e) => e.path === '/capabilities/1' && /not in openvibe-contracts/.test(e.message)));
++ assert.ok(v.errors.some((e) => e.path === '/capabilities/2' && /never granted/.test(e.message)));
++ assert.ok(!v.errors.some((e) => e.path === '/capabilities/0'));
++ });
++
++ await check('ranges must parse; out-of-range pins are warnings', async () => {
++ const bad = manifests.validate('app', { ...manifests.template('app'), compatibility: { contracts: 'lots' } });
++ assert.ok(bad.errors.some((e) => e.path === '/compatibility/contracts'));
++ const old = manifests.validate('app', { ...manifests.template('app'), compatibility: { contracts: '^0.5.0' } });
++ assert.strictEqual(old.valid, true);
++ assert.ok(old.warnings.some((w) => /outside \^0\.5\.0/.test(w.message)));
++ });
++
++ await check('unknown consumed events are warnings, not errors', async () => {
++ const types = new Set(contracts.services.manifests.flatMap((m) => m.eventsProduced || []));
++ const v = manifests.validate('app', { ...manifests.template('app'), events: { consumes: ['network.app.created', 'made.up.thing', 'network.*'] } }, { eventTypes: types });
++ assert.strictEqual(v.valid, true);
++ assert.strictEqual(v.warnings.filter((w) => /made\.up\.thing/.test(w.message)).length, 1);
++ assert.ok(!v.warnings.some((w) => /network\.\*/.test(w.message)));
++ });
++
++ await check('for a release, the manifest must describe that app (id, project, environment, publisher)', async () => {
++ const app = { id: APP, project_id: PRJ, environment: 'sandbox', grants: [] };
++ const ok = manifests.validate('app', manifests.template('app', { appId: APP, projectId: PRJ }), { app });
++ assert.strictEqual(ok.valid, true, JSON.stringify(ok.errors));
++ const other = manifests.validate('app', { ...manifests.template('app', { appId: APP, projectId: PRJ }), id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ', environment: 'production', publisher: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }, { app, viewerSubject: 'usr_01JBBBBBBBBBBBBBBBBBBBBBBB' });
++ assert.ok(other.errors.some((e) => e.path === '/id'));
++ assert.ok(other.errors.some((e) => e.path === '/environment'));
++ assert.ok(other.errors.some((e) => e.path === '/publisher'));
++ const ungranted = manifests.validate('app', { ...manifests.template('app', { appId: APP, projectId: PRJ }), capabilities: ['media.object.upload'] }, { app });
++ assert.ok(ungranted.warnings.some((w) => /not granted to this app yet/.test(w.message)));
++ });
++
++ await check('the app manifest schema is loaded into openvibe-contracts\' validator (proposal until released)', async () => {
++ const info = manifests.appSchemaInfo();
++ assert.strictEqual(info.id, 'codes.app-manifest');
++ assert.strictEqual(info.released, false);
++ const v = manifests.validate('app', { ...manifests.template('app'), publisher: { type: 'nobody', id: 'x' } });
++ assert.ok(v.errors.some((e) => e.path.startsWith('/publisher')), 'the $ref to identity.subject-ref resolves');
++ });
++
++ const t = await boot();
++ await check('the validator page and API work without signing in and store nothing', async () => {
++ const r = await t.get('/manifests/validate', { form: { kind: 'mod', manifest: JSON.stringify(manifests.template('mod')) } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Valid\./);
++ const bad = await t.get('/manifests/validate', { form: { kind: 'app', manifest: '{not json' } });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.text, /not valid JSON/);
++ const api = await t.get('/api/v1/manifests/validate', { json: { kind: 'app', manifest: { ...manifests.template('app'), capabilities: ['nope.not.real'] } } });
++ assert.strictEqual(api.status, 422);
++ assert.strictEqual(api.json().valid, false);
++ assert.strictEqual(api.json().contracts_version, require('openvibe-contracts/package.json').version);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM manifests').get().n, 0);
++ });
++ await t.close();
++ done();
++})();
ddiff --git a/test/network-errors.test.js b/test/network-errors.test.js
nnew file mode 100644
iindex 0000000..cfe14aa
---- /dev/null
++++ b/test/network-errors.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++/**
++ * Network API errors surface honestly: the page carries Network's HTTP status, problem code, detail
++ * and request id; an unreachable Network is a 502 that says so, never an empty list; an expired
++ * session is refreshed once and the call retried.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('grace');
++ const viewer = t.network.addUser('vera');
++ const stranger = t.network.addUser('sam');
++ const projectId = await t.project(owner, 'Errors');
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vera', role: 'viewer' } });
++
++ await check('a project the person cannot see: 404 with project.not_found', async () => {
++ const r = await t.get(`/projects/${projectId}`, { as: stranger });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ assert.match(r.text, /no such project/);
++ assert.match(r.text, /request <code>req_/);
++ });
++
++ await check('a role Network refuses: 403 with project.forbidden and its detail', async () => {
++ const r = await t.get(`/projects/${projectId}/apps`, { as: viewer, form: { name: 'x', environment: 'sandbox', type: 'confidential' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /project\.forbidden/);
++ assert.match(r.text, /requires developer role/);
++ });
++
++ await check('a validation failure: 422 with Network\'s own detail', async () => {
++ const r = await t.get(`/projects/${projectId}/apps`, { as: owner, form: { name: 'pub', environment: 'sandbox', type: 'public', redirect_uris: '' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /app\.invalid/);
++ assert.match(r.text, /public app needs at least one redirect URI/);
++ });
++
++ await check('production on a sandbox-only project: 403 app.environment_not_allowed', async () => {
++ const r = await t.get(`/projects/${projectId}/apps`, { as: owner, form: { name: 'prod', environment: 'production', type: 'confidential' } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /app\.environment_not_allowed/);
++ });
++
++ await check('a conflict: 409 member.exists', async () => {
++ const r = await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vera', role: 'viewer' } });
++ assert.strictEqual(r.status, 409);
++ assert.match(r.text, /member\.exists/);
++ });
++
++ await check('approving outside the allowance: 403 grant.beyond_allowance', async () => {
++ const app = await t.app(owner, projectId);
++ const req = await t.get(`/projects/${projectId}/apps/${app.id}/grants`, { as: owner, form: { capability: 'media.object.read' } });
++ assert.strictEqual(req.status, 303);
++ const r = await t.get(`/projects/${projectId}/apps/${app.id}/grants/media.object.read/approve`, { as: owner, form: {} });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /grant\.beyond_allowance/);
++ });
++
++ await check('Network answering 503: the page is 503 with Network\'s code, not an empty list', async () => {
++ t.network.setDown(true);
++ const r = await t.get('/projects', { as: owner });
++ t.network.setDown(false);
++ assert.strictEqual(r.status, 503);
++ assert.match(r.text, /network\.down/);
++ assert.ok(!/not a member of any project/.test(r.text));
++ });
++
++ await check('Network unreachable: 502 "did not answer", and the registry page says so too', async () => {
++ // A separate instance whose Network mock is shut down.
++ const t2 = await boot();
++ const u = t2.network.addUser('ivy');
++ await t2.network.close();
++ const r = await t2.get('/projects', { as: u });
++ assert.strictEqual(r.status, 502);
++ assert.match(r.text, /OpenVibe\.Network could not be reached/);
++ assert.match(r.text, /did not answer/);
++ const s = await t2.get('/docs/services');
++ assert.strictEqual(s.status, 502);
++ assert.match(s.text, /The registry could not be read/);
++ assert.match(s.text, /without health/);
++ const ready = await t2.get('/api/ready');
++ assert.strictEqual(ready.status, 200);
++ const body = ready.json();
++ assert.ok(body.degraded.includes('network'), 'readiness reports the Network as degraded');
++ await t2.events.close(); await t2.media.close();
++ });
++
++ await check('an expired access token is refreshed once and the call retried', async () => {
++ const expired = t.network.userToken(owner, { iat: Math.floor(Date.now() / 1000) - 7200, ttl: 3600 });
++ // Hand the mock a refresh token for this user.
++ t.network.state.refresh.set('rt_test', owner);
++ const r = await t.get('/projects', { cookie: `codes_at=${expired}; codes_rt=rt_test` });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.text, /Errors/);
++ const set = r.headers.get('set-cookie') || '';
++ assert.match(set, /codes_at=/);
++ assert.match(set, /HttpOnly/i);
++ });
++
++ await check('an expired token with no refresh token reads as signed out (401), not as an error', async () => {
++ const expired = t.network.userToken(owner, { iat: Math.floor(Date.now() / 1000) - 7200, ttl: 3600 });
++ const r = await t.get('/projects', { cookie: `codes_at=${expired}` });
++ assert.strictEqual(r.status, 401);
++ assert.match(r.text, /Sign in/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/no-internal-key.test.js b/test/no-internal-key.test.js
nnew file mode 100644
iindex 0000000..e923994
---- /dev/null
++++ b/test/no-internal-key.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").
++ * Code, client scripts, deploy files, the environment template and the manifest proposals must not
++ * mention it; the running portal never sends it to Network (the Network mock refuses any request
++ * that carries one), and a request that presents one gets no special treatment.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++const ROOT = path.join(__dirname, '..');
++const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;
++
++function files(dir) {
++ const out = [];
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) out.push(...files(p)); else out.push(p);
++ }
++ return out;
++}
++
++(async () => {
++ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
++ const targets = [
++ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
++ ...files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')),
++ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
++ ].filter((f) => fs.existsSync(f));
++ assert.ok(targets.length > 20);
++ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));
++ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);
++ });
++
++ const t = await boot();
++ await check('a whole portal session never sends an internal key to Network', async () => {
++ const u = t.network.addUser('nia');
++ const p = await t.project(u);
++ const a = await t.app(u, p);
++ await t.grant(u, p, a.id, 'media.object.upload');
++ await t.get(`/projects/${p}`, { as: u });
++ await t.get(`/projects/${p}/apps/${a.id}`, { as: u });
++ await t.get(`/projects/${p}/apps/${a.id}/playground/media`, { as: u, multipart: { fields: { credential_type: 'client_secret', credential: a.secret }, file: { name: 'x', content: 'x' } } });
++ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
++ assert.strictEqual(sent.length, 0);
++ assert.ok(t.network.requests.length > 10);
++ });
++
++ await check('presenting X-Internal-Key gets nothing: the API still wants an app token', async () => {
++ const r = await t.get('/api/v1/apps/app_01JABCDEFGHJKMNPQRSTVWXYZ0/releases', { method: 'POST', headers: { 'x-internal-key': 'anything', 'content-type': 'application/json' }, body: '{}' });
++ assert.strictEqual(r.status, 401);
++ const page = await t.get('/projects', { headers: { 'x-internal-key': 'anything' } });
++ assert.strictEqual(page.status, 401);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/playground.test.js b/test/playground.test.js
nnew file mode 100644
iindex 0000000..d352b47
---- /dev/null
++++ b/test/playground.test.js
@@@ -0,0 +1,170 @@
++'use strict';
++/**
++ * Playgrounds cannot exceed the project's grants.
++ * - without the grant: refused at the grant step, the missing grant is named, and NOTHING else
++ * happens (no token request to Network, no call to Events or Media)
++ * - production apps and revoked apps are refused
++ * - with the grant: the app's own token (from its secret, or pasted) is used through the SDK
++ * - a pasted token for another app, another audience or without the capability is refused
++ * - the Network refusing the token request surfaces as Network said it
++ * - events.event.publish is internal in contracts v0.26.0: the page says it can never be granted
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('mia');
++ const projectId = await t.project(owner, 'Play');
++ const app = await t.app(owner, projectId);
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const media = (fields, file = { name: 'a.txt', content: 'hello' }) => t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });
++ const outside = () => ({
++ token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,
++ media: t.media.requests.length,
++ events: t.events.requests.length,
++ });
++
++ await check('without media.object.upload: refused at the grant step, nothing requested or called', async () => {
++ const before = outside();
++ const r = await media({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /does not hold media\.object\.upload/);
++ assert.match(r.text, /Request media\.object\.upload on the app page/);
++ assert.deepStrictEqual(outside(), before);
++ const page = await t.get(`${base}/playground`, { as: owner });
++ assert.match(page.text, /Cannot run:/);
++ assert.ok(!page.text.includes(`action="${base}/playground/media"`), 'no upload form without the grant');
++ });
++
++ await check('the events playground explains that events.event.publish is never granted to apps', async () => {
++ const before = outside();
++ const r = await t.get(`${base}/playground/events`, { as: owner, form: { event_type: 'play.thing.done', source: 'play', credential_type: 'client_secret', credential: app.secret } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /events\.event\.publish is internal in openvibe-contracts 0\.26\.0/);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('a requested (not approved) grant still refuses', async () => {
++ await t.get(`${base}/grants`, { as: owner, form: { capability: 'media.object.upload' } }); // outside the allowance → requested
++ const before = outside();
++ const r = await media({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('with the grant and the client secret: uploads through the SDK with the app token', async () => {
++ await t.grant(owner, projectId, app.id, 'media.object.upload');
++ const r = await media({ credential_type: 'client_secret', credential: app.secret }, { name: 'hello.txt', content: 'hello playground' });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 400));
++ assert.match(r.text, /Done\./);
++ assert.strictEqual(t.media.uploads.length, 1);
++ assert.strictEqual(t.media.uploads[0].app, projectId, 'the namespace is the project id');
++ const tok = t.network.tokenRequests.filter((x) => x.client_id === app.id).pop();
++ assert.strictEqual(tok.audience, 'openvibe.media');
++ assert.strictEqual(tok.scope, 'media.object.upload');
++ });
++
++ await check('a pasted token for another app is refused before any call', async () => {
++ const other = await t.app(owner, projectId, { name: 'Other' });
++ await t.grant(owner, projectId, other.id, 'media.object.upload');
++ const foreign = t.network.mintApp(other.id, 'openvibe.media', ['media.object.upload']);
++ const before = outside();
++ const r = await media({ credential_type: 'access_token', credential: foreign });
++ assert.strictEqual(r.status, 401);
++ assert.match(r.text, /not app:/);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('a pasted token without the capability, or for another audience, is refused', async () => {
++ const noCap = t.network.mintApp(app.id, 'openvibe.media', ['media.object.read']);
++ const r1 = await media({ credential_type: 'access_token', credential: noCap });
++ assert.strictEqual(r1.status, 401);
++ assert.match(r1.text, /does not carry media\.object\.upload/);
++ const wrongAud = t.network.mintApp(app.id, 'openvibe.events', ['media.object.upload']);
++ const r2 = await media({ credential_type: 'access_token', credential: wrongAud });
++ assert.strictEqual(r2.status, 401);
++ assert.match(r2.text, /not for openvibe\.media/);
++ const prod = t.network.mintApp(app.id, 'openvibe.media', ['media.object.upload'], { env: 'production' });
++ const r3 = await media({ credential_type: 'access_token', credential: prod });
++ assert.strictEqual(r3.status, 401);
++ assert.match(r3.text, /not sandbox/);
++ });
++
++ await check('Network refusing the token request is shown as Network said it', async () => {
++ const r = await media({ credential_type: 'client_secret', credential: 'ovsec_wrong' });
++ assert.strictEqual(r.status, 401);
++ assert.match(r.text, /invalid_client/);
++ assert.match(r.text, /Network refused the token request/);
++ });
++
++ await check('a sandbox audience Network does not allow: invalid_target surfaces', async () => {
++ const t2 = await boot({ network: { sandboxAudiences: [] } });
++ const o = t2.network.addUser('ned');
++ const p = await t2.project(o);
++ const a = await t2.app(o, p);
++ await t2.grant(o, p, a.id, 'media.object.upload');
++ const r = await t2.get(`/projects/${p}/apps/${a.id}/playground/media`, { as: o, multipart: { fields: { credential_type: 'client_secret', credential: a.secret }, file: { name: 'x', content: 'x' } } });
++ assert.strictEqual(r.status, 400);
++ assert.match(r.text, /invalid_target/);
++ assert.match(r.text, /does not accept sandbox tokens/);
++ assert.strictEqual(t2.media.requests.length, 0);
++ await t2.close();
++ });
++
++ await check('production apps are refused', async () => {
++ t.network.setEnvironmentPolicy(projectId, 'sandbox+production');
++ const prod = await t.app(owner, projectId, { name: 'Prod', environment: 'production' });
++ await t.grant(owner, projectId, prod.id, 'media.object.upload');
++ const r = await t.get(`/projects/${projectId}/apps/${prod.id}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: prod.secret }, file: { name: 'x', content: 'x' } } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /playgrounds run only sandbox apps/);
++ });
++
++ await check('revoking the grant in Network stops the playground at once', async () => {
++ await t.get(`${base}/grants/media.object.upload/revoke`, { as: owner, form: {} });
++ const before = outside();
++ const r = await media({ credential_type: 'client_secret', credential: app.secret });
++ assert.strictEqual(r.status, 403);
++ assert.deepStrictEqual(outside(), before);
++ });
++
++ await check('the run log records outcomes and codes, never the credential', async () => {
++ const rows = t.ctx.store.db.prepare('SELECT * FROM playground_runs WHERE app_id = ? ORDER BY at').all(app.id);
++ assert.ok(rows.length >= 6);
++ assert.ok(rows.some((x) => x.outcome === 'ok' && x.ref));
++ assert.ok(rows.some((x) => x.stage === 'grant' && x.code === 'playground.grant_missing'));
++ const dump = JSON.stringify(rows);
++ assert.ok(!dump.includes(app.secret));
++ assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\./.test(dump), 'no JWT in the run log');
++ });
++
++ await check('a non-member cannot open the playground of another project\'s app', async () => {
++ const stranger = t.network.addUser('zed');
++ const r = await t.get(`${base}/playground`, { as: stranger });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.text, /project\.not_found/);
++ });
++
++ await check('the events path publishes through the SDK when an app does hold the grant (future public capability)', async () => {
++ // No app can hold events.event.publish today (internal); exercise the path with an app view
++ // as Network would report it once a publish capability is grantable.
++ const view = { id: app.id, project_id: projectId, environment: 'sandbox', grants: ['events.event.publish'], revoked_at: null };
++ const token = t.network.mintApp(app.id, 'openvibe.events', ['events.event.publish']);
++ const out = await t.ctx.playground.run({
++ actor: `user:${owner.subject}`, app: view, kind: 'events', credential: { type: 'access_token', value: token },
++ input: { event_type: 'play.thing.done', source: 'play', payload: '{"n":1}' },
++ });
++ assert.strictEqual(out.outcome, 'ok', JSON.stringify(out));
++ assert.strictEqual(t.events.published.length, 1);
++ assert.strictEqual(t.events.published[0].sub, `app:${app.id}`);
++ assert.strictEqual(t.events.published[0].event.event_type, 'play.thing.done');
++ const bad = await t.ctx.playground.run({ actor: `user:${owner.subject}`, app: view, kind: 'events', credential: { type: 'access_token', value: token }, input: { event_type: 'two.segments', source: 'play' } });
++ assert.strictEqual(bad.outcome, 'refused');
++ assert.strictEqual(bad.stage, 'input');
++ assert.strictEqual(t.events.published.length, 1);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/releases.test.js b/test/releases.test.js
nnew file mode 100644
iindex 0000000..bf7a146
---- /dev/null
++++ b/test/releases.test.js
@@@ -0,0 +1,181 @@
++'use strict';
++/**
++ * Release metadata (Codes-owned, keyed to Network app ids) and its events.
++ * - draft → published → deprecated → revoked, with Network's role rules (developer+ sandbox)
++ * - each public transition writes codes.app.published|deprecated|revoked to the SDK outbox in the
++ * same transaction, as a valid events.event-envelope@1
++ * - trust tiers are staff-set metadata and change no permission
++ * - an app manages its own releases with an app token carrying codes.release.manage
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('rosa');
++ const viewer = t.network.addUser('vic');
++ const staff = t.network.addUser('root', { role: 'admin' });
++ const projectId = await t.project(owner, 'Releases');
++ await t.get(`/projects/${projectId}/members`, { as: owner, form: { username: 'vic', role: 'viewer' } });
++ const app = await t.app(owner, projectId, { name: 'Releaser' });
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const manifest = (version, extra = {}) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Releaser' }), version, ...extra });
++ const outbox = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
++ let rel1;
++ let rel2;
++
++ await check('the editor validates without creating anything', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'validate' } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Valid\./);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get().n, 0);
++ });
++
++ await check('an invalid manifest creates nothing and shows the errors', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0', { id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ' }), intent: 'create' } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /must be this app&#39;s id/);
++ assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get().n, 0);
++ });
++
++ await check('a draft is created, visible to members only, and emits nothing', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'create', notes: 'First.' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 400));
++ rel1 = r.headers.get('location').split('/').pop();
++ assert.match(rel1, /^rel_/);
++ assert.strictEqual((await t.get(`/releases/${rel1}`)).status, 404, 'anonymous cannot see a draft');
++ assert.strictEqual((await t.get(`/releases/${rel1}`, { as: owner })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/releases/${rel1}`)).status, 404);
++ assert.strictEqual(outbox().length, 0);
++ });
++
++ await check('versions are immutable: the same version again is 409', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'create' } });
++ assert.strictEqual(r.status, 409);
++ assert.match(r.text, /release\.exists/);
++ });
++
++ await check('a viewer cannot publish (Network reports the role; Codes enforces its own records)', async () => {
++ const r = await t.get(`/releases/${rel1}/publish`, { as: viewer, form: {} });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.text, /needs the developer role/);
++ });
++
++ await check('publishing emits codes.app.published (valid envelope, 3 segments, public)', async () => {
++ const r = await t.get(`/releases/${rel1}/publish`, { as: owner, form: {} });
++ assert.strictEqual(r.status, 303);
++ const ev = outbox();
++ assert.strictEqual(ev.length, 1);
++ const e = ev[0];
++ assert.strictEqual(e.event_type, 'codes.app.published');
++ assert.strictEqual(e.event_type.split('.').length, 3);
++ assert.strictEqual(e.source, 'codes');
++ assert.deepStrictEqual(e.subject, { type: 'app', id: app.id });
++ assert.deepStrictEqual(e.actor, { type: 'user', id: owner.subject });
++ assert.strictEqual(e.payload.release_id, rel1);
++ assert.strictEqual(e.payload.project_id, projectId);
++ assert.strictEqual(e.payload.trust_tier, 'untrusted');
++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true);
++ const pub = await t.get(`/api/v1/apps/${app.id}/releases`);
++ assert.strictEqual(pub.json().releases[0].status, 'published');
++ const pageAnon = await t.get(`/releases/${rel1}`);
++ assert.strictEqual(pageAnon.status, 200);
++ });
++
++ await check('deprecating needs a reason and a published replacement of the same app', async () => {
++ const r2 = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.1.0'), intent: 'create' } });
++ rel2 = r2.headers.get('location').split('/').pop();
++ const noReason = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: '' } });
++ assert.strictEqual(noReason.status, 422);
++ const draftRepl = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'old', replacement: rel2 } });
++ assert.strictEqual(draftRepl.status, 422, 'a draft cannot be the replacement');
++ await t.get(`/releases/${rel2}/publish`, { as: owner, form: {} });
++ const ok = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'Use 1.1.0', replacement: rel2 } });
++ assert.strictEqual(ok.status, 303);
++ const e = outbox().pop();
++ assert.strictEqual(e.event_type, 'codes.app.deprecated');
++ assert.strictEqual(e.payload.replacement, rel2);
++ });
++
++ await check('revoking needs confirmation and a reason; emits codes.app.revoked; is terminal', async () => {
++ const unconfirmed = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'bad' } });
++ assert.strictEqual(unconfirmed.status, 422);
++ const ok = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'Security issue', confirm: '1' } });
++ assert.strictEqual(ok.status, 303);
++ assert.strictEqual(outbox().pop().event_type, 'codes.app.revoked');
++ const again = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'x', confirm: '1' } });
++ assert.strictEqual(again.status, 409);
++ });
++
++ await check('revoking a draft announces nothing (it was never public)', async () => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('2.0.0-rc.1'), intent: 'create' } });
++ const id = r.headers.get('location').split('/').pop();
++ const n = outbox().length;
++ await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'abandoned', confirm: '1' } });
++ assert.strictEqual(outbox().length, n);
++ });
++
++ await check('only the three codes.app.* types are ever produced', async () => {
++ const types = new Set(outbox().map((e) => e.event_type));
++ for (const x of types) assert.ok(['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked'].includes(x), x);
++ });
++
++ await check('trust tiers: staff only, noted, recorded, and they change no permission', async () => {
++ const denied = await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'trusted', note: 'me' } });
++ assert.strictEqual(denied.status, 403);
++ const ok = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'verified', note: 'Reviewed the source' } });
++ assert.strictEqual(ok.status, 303);
++ const trust = (await t.get(`/api/v1/apps/${app.id}/trust`)).json();
++ assert.strictEqual(trust.tier, 'verified');
++ assert.match(trust.note_on_authority, /metadata only/);
++ // A verified app is still refused by the playground without the grant.
++ const r = await t.get(`${base}/playground/media`, { as: owner, multipart: { fields: { credential_type: 'client_secret', credential: app.secret }, file: { name: 'x', content: 'x' } } });
++ assert.strictEqual(r.status, 403);
++ // And a viewer still cannot publish.
++ const r3 = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('3.0.0'), intent: 'create' } });
++ const id = r3.headers.get('location').split('/').pop();
++ assert.strictEqual((await t.get(`/releases/${id}/publish`, { as: viewer, form: {} })).status, 403);
++ });
++
++ await check('mod releases validate as mods.mod-manifest@1', async () => {
++ const mod = { ...manifests.template('mod', { appId: app.id }), id: contracts.ids.newId('mod'), version: '0.1.0' };
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'mod', manifest: JSON.stringify(mod), intent: 'create' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const rel = t.ctx.releases.get(r.headers.get('location').split('/').pop());
++ assert.strictEqual(rel.kind, 'mod');
++ assert.strictEqual(rel.subject_id, mod.id);
++ });
++
++ // ── App token API ──
++ await check('an app token with codes.release.manage creates and publishes its own release', async () => {
++ const token = t.network.mintApp(app.id, 'openvibe.codes', ['codes.release.manage']);
++ const r = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${token}` }, json: { kind: 'app', manifest: JSON.parse(manifest('4.0.0', { publisher: { type: 'app', id: app.id } })), publish: true } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().release.status, 'published');
++ const e = outbox().pop();
++ assert.deepStrictEqual(e.actor, { type: 'app', id: app.id });
++ });
++
++ await check('the API refuses: no token, wrong audience, no capability, another app', async () => {
++ const body = { kind: 'app', manifest: JSON.parse(manifest('5.0.0')) };
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { json: body })).status, 401);
++ const wrongAud = t.network.mintApp(app.id, 'openvibe.media', ['codes.release.manage']);
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${wrongAud}` }, json: body })).status, 401);
++ const noCap = t.network.mintApp(app.id, 'openvibe.codes', ['media.object.upload']);
++ const r3 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${noCap}` }, json: body });
++ assert.strictEqual(r3.status, 403);
++ assert.strictEqual(r3.json().code, 'capability.denied');
++ const other = await t.app(owner, projectId, { name: 'Other' });
++ const otherTok = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.manage']);
++ const r4 = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${otherTok}` }, json: body });
++ assert.strictEqual(r4.status, 403);
++ assert.strictEqual(r4.headers.get('content-type'), 'application/problem+json');
++ const userTok = t.network.userToken(owner);
++ assert.strictEqual((await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${userTok}` }, json: body })).status, 401);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..faf8276
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp SQLite
++ * databases and in-process mocks of OpenVibe.Network, Events and Media; none needs the
++ * network or a running site.
++ *
++ * npm test # everything
++ * npm test -- schedule # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], { cwd: path.join(__dirname, '..'), env: { ...process.env, NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'] });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => { clearTimeout(timer); resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output }); });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(34)} ${String(r.ms).padStart(6)}ms`);
++ if (process.env.VERBOSE) console.log(r.output);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) { console.log(`\n── ${r.file} (exit ${r.code}) ──`); console.log(r.output.split('\n').slice(-80).join('\n')); }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/scope-editor.test.js b/test/scope-editor.test.js
nnew file mode 100644
iindex 0000000..76a35c1
---- /dev/null
++++ b/test/scope-editor.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++/**
++ * The scope editor offers only capabilities apps can be granted: active + public, or partner when
++ * staff put it in this project's allowance. Even if Network's catalog listed something the pinned
++ * contracts call first-party or internal, Codes would not offer it and would refuse to forward a
++ * forged request for it (Network is never asked).
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('lin');
++ const projectId = await t.project(owner, 'Scopes');
++ const app = await t.app(owner, projectId);
++ const base = `/projects/${projectId}/apps/${app.id}`;
++
++ const pub = contracts.capabilities.manifests.find((c) => c.visibility === 'public' && c.status === 'active');
++ const internal = contracts.capabilities.manifests.find((c) => c.visibility === 'internal');
++ const firstParty = contracts.capabilities.manifests.find((c) => c.visibility === 'first-party');
++ const planned = contracts.capabilities.manifests.find((c) => c.visibility === 'public' && c.status !== 'active');
++ // Partner capabilities do not exist in v0.26.0's manifests; the catalog Network serves can still
++ // carry one (Network accepts the proposed visibility), so the mock lists two that the pinned
++ // contracts do not know.
++ const entry = (c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '' });
++ t.network.setCatalog([
++ entry(pub),
++ entry(internal), // a buggy Network must not make Codes offer this
++ entry(firstParty),
++ ...(planned ? [entry(planned)] : []),
++ { id: 'partnerx.thing.do', owner: 'partnerx', audience: 'openvibe.partnerx', visibility: 'partner', description: 'Partner thing A' },
++ { id: 'partnerx.other.do', owner: 'partnerx', audience: 'openvibe.partnerx', visibility: 'partner', description: 'Partner thing B' },
++ ]);
++ t.network.setAllowance(projectId, ['partnerx.thing.do']);
++
++ const offeredOn = (html) => {
++ const table = (html.split('class="scope-editor"')[1] || '').split('</table>')[0];
++ return [...table.matchAll(/<a href="\/docs\/capabilities\/([^"]+)"/g)].map((m) => m[1]);
++ };
++
++ await check('offers public capabilities with description, owner and visibility', async () => {
++ const r = await t.get(base, { as: owner });
++ assert.strictEqual(r.status, 200);
++ const offered = offeredOn(r.text);
++ assert.ok(offered.includes(pub.id));
++ const row = r.text.split(`/docs/capabilities/${pub.id}"`)[1].split('</tr>')[0];
++ assert.ok(row.includes(pub.owner) && row.includes('public'));
++ if (pub.description) assert.ok(row.includes(pub.description.slice(0, 20).replace(/"/g, '&quot;').replace(/'/g, '&#39;')));
++ });
++
++ await check('never offers internal, first-party or non-active capabilities', async () => {
++ const offered = offeredOn((await t.get(base, { as: owner })).text);
++ assert.ok(!offered.includes(internal.id), `${internal.id} offered`);
++ assert.ok(!offered.includes(firstParty.id), `${firstParty.id} offered`);
++ if (planned) assert.ok(!offered.includes(planned.id), `${planned.id} offered`);
++ });
++
++ await check('offers a partner capability only when it is in this project\'s allowance', async () => {
++ const offered = offeredOn((await t.get(base, { as: owner })).text);
++ assert.ok(offered.includes('partnerx.thing.do'));
++ assert.ok(!offered.includes('partnerx.other.do'));
++ });
++
++ await check('a forged request for an internal capability is refused before Network sees it', async () => {
++ const before = t.network.requests.filter((x) => x.method === 'POST' && x.path.endsWith('/grants')).length;
++ const r = await t.get(`${base}/grants`, { as: owner, form: { capability: internal.id } });
++ assert.strictEqual(r.status, 422);
++ assert.match(r.text, /codes\.not_grantable/);
++ const after = t.network.requests.filter((x) => x.method === 'POST' && x.path.endsWith('/grants')).length;
++ assert.strictEqual(after, before, 'no grant request reached Network');
++ });
++
++ await check('a forged request for a partner capability outside the allowance is refused too', async () => {
++ const r = await t.get(`${base}/grants`, { as: owner, form: { capability: 'partnerx.other.do' } });
++ assert.strictEqual(r.status, 422);
++ });
++
++ await check('an offered public capability is forwarded to Network and its answer shown', async () => {
++ const r = await t.get(`${base}/grants`, { as: owner, form: { capability: pub.id } });
++ assert.strictEqual(r.status, 303);
++ assert.match(decodeURIComponent(r.headers.get('location')), new RegExp(`${pub.id.replace(/\./g, '\\.')}: requested`));
++ const page = await t.get(base, { as: owner });
++ assert.match(page.text, /outside the allowance/, 'a requested grant outside the allowance cannot be approved from Codes');
++ });
++
++ await check('the capability docs highlight exactly the grantable ones', async () => {
++ const r = await t.get('/docs/capabilities?grantable=1');
++ const listed = [...r.text.matchAll(/<a href="\/docs\/capabilities\/([^"]+)" class="grantable">/g)].map((m) => m[1]).sort();
++ const expected = contracts.capabilities.manifests.filter((c) => c.status === 'active' && ['public', 'partner'].includes(c.visibility)).map((c) => c.id).sort();
++ assert.deepStrictEqual(listed, expected);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/secrets.test.js b/test/secrets.test.js
nnew file mode 100644
iindex 0000000..e6e3bea
---- /dev/null
++++ b/test/secrets.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * The portal never persists or re-displays a secret.
++ *
++ * Every client secret Network hands out (app creation, rotation), every secret or token typed into a
++ * playground, and every webhook secret typed into the tester is searched for in: every table of
++ * Codes' database, every log line (app logger and console), and every later page and API response.
++ * The only response allowed to contain a client secret is the one to the create/rotate that made it,
++ * and that response is Cache-Control: no-store.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('ada');
++ const secrets = [];
++ const seen = []; // every response body after the one-time display
++ const record = (r) => { seen.push(r.text); return r; };
++
++ const projectId = await t.project(owner, 'Secrets');
++ let appId;
++
++ await check('creating a confidential app shows the secret once, with no-store, and a clear warning', async () => {
++ const made = await t.app(owner, projectId, { name: 'Server app' });
++ assert.ok(made.secret, 'secret on the creation page');
++ assert.ok(made.id);
++ appId = made.id;
++ secrets.push(made.secret);
++ assert.strictEqual(made.page.headers.get('cache-control'), 'no-store');
++ assert.match(made.page.text, /You won't see this secret again/);
++ assert.match(made.page.text, /Codes does not keep it/);
++ assert.ok(!made.page.headers.get('location'), 'no redirect through a URL');
++ });
++
++ await check('the app page afterwards shows only the last four characters', async () => {
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}`, { as: owner }));
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.ok(!r.text.includes(secrets[0]));
++ assert.ok(r.text.includes(`…${secrets[0].slice(-4)}`));
++ });
++
++ await check('rotating shows the new secret once; neither secret appears again', async () => {
++ const r = await t.get(`/projects/${projectId}/apps/${appId}/credentials/rotate`, { as: owner, form: { overlap_seconds: '3600' } });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const s = (r.text.match(/ovsec_[A-Za-z0-9_-]{43}/) || [])[0];
++ assert.ok(s && s !== secrets[0]);
++ assert.ok(!r.text.includes(secrets[0]), 'the old secret is not shown with the new one');
++ secrets.push(s);
++ const again = record(await t.get(`/projects/${projectId}/apps/${appId}`, { as: owner }));
++ for (const x of secrets) assert.ok(!again.text.includes(x));
++ });
++
++ await check('a playground run with the client secret neither stores nor echoes it', async () => {
++ await t.grant(owner, projectId, appId, 'media.object.upload');
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}/playground/media`, {
++ as: owner, multipart: { fields: { credential_type: 'client_secret', credential: secrets[1] }, file: { name: 'hello.txt', content: 'hello world' } },
++ }));
++ assert.strictEqual(r.status, 200, r.text.slice(0, 500));
++ assert.match(r.text, /Done\./);
++ assert.ok(!r.text.includes(secrets[1]), 'the form comes back empty');
++ });
++
++ await check('a playground run with a pasted access token neither stores nor echoes it', async () => {
++ const token = t.network.mintApp(appId, 'openvibe.media', ['media.object.upload']);
++ secrets.push(token);
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}/playground/media`, {
++ as: owner, multipart: { fields: { credential_type: 'access_token', credential: token }, file: { name: 'b.txt', content: 'bytes' } },
++ }));
++ assert.strictEqual(r.status, 200, r.text.slice(0, 500));
++ assert.ok(!r.text.includes(token));
++ });
++
++ await check('a refused playground run (wrong secret) does not echo it either', async () => {
++ const wrong = 'ovsec_' + 'x'.repeat(43);
++ secrets.push(wrong);
++ const r = record(await t.get(`/projects/${projectId}/apps/${appId}/playground/media`, {
++ as: owner, multipart: { fields: { credential_type: 'client_secret', credential: wrong }, file: { name: 'c.txt', content: 'c' } },
++ }));
++ assert.strictEqual(r.status, 401, r.text.slice(0, 500));
++ assert.match(r.text, /invalid_client/);
++ assert.ok(!r.text.includes(wrong));
++ });
++
++ await check('webhook tester secrets are not echoed', async () => {
++ const hook = 'whsec_test_' + 'y'.repeat(40);
++ secrets.push(hook);
++ const r1 = record(await t.get('/tools/webhooks/verify', { form: { body: '{"event":{},"seq":1}', signature: 'sha256=00', secret: hook } }));
++ assert.strictEqual(r1.status, 422);
++ const r2 = record(await t.get('/tools/webhooks/sample', { form: { event_type: 'network.app.created', secret: hook } }));
++ assert.strictEqual(r2.status, 200);
++ assert.ok(!r1.text.includes(hook) && !r2.text.includes(hook));
++ });
++
++ await check('the release flow, audit and project pages carry no secret', async () => {
++ for (const p of [`/projects/${projectId}`, `/projects/${projectId}/audit`, `/projects/${projectId}/apps/${appId}/playground`, '/projects', `/apps/${appId}`, `/api/v1/apps/${appId}/releases`]) {
++ record(await t.get(p, { as: owner }));
++ }
++ });
++
++ await check('no secret or token anywhere: database, logs, later responses', async () => {
++ const dump = t.dbDump();
++ const logs = t.logs();
++ assert.ok(secrets.length >= 5);
++ for (const s of secrets) {
++ assert.ok(!dump.includes(s), `secret ${s.slice(0, 10)}… found in the database`);
++ assert.ok(!logs.includes(s), `secret ${s.slice(0, 10)}… found in the logs`);
++ for (const body of seen) assert.ok(!body.includes(s), `secret ${s.slice(0, 10)}… re-displayed`);
++ }
++ assert.ok(!/ovsec_[A-Za-z0-9_-]{20,}/.test(dump), 'no ovsec_ value of any kind in the database');
++ // The user's own Network access token is never rendered either.
++ const userToken = t.network.userToken(owner);
++ assert.ok(!seen.some((b) => b.includes(userToken.split('.')[2])));
++ });
++
++ await check('session cookies are httpOnly (no script can read the Network token)', async () => {
++ const code = t.network.issueCode(owner, 'x');
++ void code;
++ const login = await t.get('/auth/login?next=/projects');
++ const flow = login.headers.get('set-cookie');
++ assert.match(flow, /codes_oauth=.*HttpOnly/i);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
nnew file mode 100644
iindex 0000000..1b4edbb
---- /dev/null
++++ b/test/webhooks.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * Webhook signature verification: correct against an independent HMAC, rejects tampering, wrong
++ * secrets, prefixes and lengths, compares in constant time (crypto.timingSafeEqual), and sample
++ * deliveries verify with the SDK receivers use. The no-JS path works through the server.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { verifyDelivery, parseDelivery } = require('openvibe-sdk/events');
++const webhooks = require('../server/domain/webhooks');
++const { boot, check, done } = require('./helpers/boot');
++
++const hmac = (body, secret) => `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
++
++(async () => {
++ const secret = 'whsec_' + crypto.randomBytes(16).toString('hex');
++ const body = JSON.stringify({ event: { event_id: contracts.ids.newId('event'), event_type: 'network.app.created', version: 1, source: 'network', actor: { type: 'system', id: 'network' }, timestamp: new Date().toISOString(), subject: { type: 'app', id: 'app_1' }, payload: {} }, seq: 7 });
++
++ await check('a correct signature verifies (independent HMAC-SHA256 of the raw body)', async () => {
++ const v = webhooks.inspect({ rawBody: body, signature: hmac(body, secret), secret });
++ assert.strictEqual(v.valid, true);
++ assert.strictEqual(v.expected, hmac(body, secret));
++ assert.strictEqual(v.parsed.event_type, 'network.app.created');
++ assert.strictEqual(v.parsed.seq, 7);
++ });
++
++ await check('tampered body, wrong secret, missing prefix, wrong length, uppercase hex: all rejected', async () => {
++ const good = hmac(body, secret);
++ assert.strictEqual(webhooks.inspect({ rawBody: body.replace('"seq":7', '"seq":8'), signature: good, secret }).valid, false);
++ assert.strictEqual(webhooks.inspect({ rawBody: body, signature: good, secret: secret + 'x' }).valid, false);
++ const noPrefix = webhooks.inspect({ rawBody: body, signature: good.slice(7), secret });
++ assert.strictEqual(noPrefix.valid, false);
++ assert.match(noPrefix.reason, /sha256=/);
++ const short = webhooks.inspect({ rawBody: body, signature: good.slice(0, -2), secret });
++ assert.strictEqual(short.valid, false);
++ assert.match(short.reason, /wrong length/);
++ assert.strictEqual(webhooks.inspect({ rawBody: body, signature: good.toUpperCase().replace('SHA256=', 'sha256='), secret }).valid, false);
++ // Re-serialised JSON (same data, different bytes) must fail: verification is over raw bytes.
++ assert.strictEqual(webhooks.inspect({ rawBody: JSON.stringify(JSON.parse(body), null, 1), signature: good, secret }).valid, false);
++ });
++
++ await check('the comparison is constant-time (crypto.timingSafeEqual on equal-length buffers)', async () => {
++ const orig = crypto.timingSafeEqual;
++ let calls = 0;
++ crypto.timingSafeEqual = (a, b) => { calls++; assert.strictEqual(a.length, b.length); return orig(a, b); };
++ try {
++ webhooks.inspect({ rawBody: body, signature: hmac(body, secret), secret });
++ const wrong = hmac(body, 'other');
++ webhooks.inspect({ rawBody: body, signature: wrong, secret });
++ } finally { crypto.timingSafeEqual = orig; }
++ assert.strictEqual(calls, 2, 'every equal-length comparison goes through timingSafeEqual');
++ });
++
++ await check('a generated sample is a valid envelope, signed so the SDK receiver accepts it', async () => {
++ const s = webhooks.sample({ eventType: 'media.object.ready', secret, producer: 'media' });
++ assert.strictEqual(s.envelopeValid, true, JSON.stringify(s.envelopeErrors));
++ const headers = Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [k.toLowerCase(), v]));
++ assert.strictEqual(verifyDelivery(s.body, headers['x-openvibe-signature'], secret), true);
++ const parsed = parseDelivery(s.body, headers, secret);
++ assert.strictEqual(parsed.event.event_type, 'media.object.ready');
++ assert.strictEqual(parsed.seq, 1);
++ assert.strictEqual(contracts.validate('events.event-envelope@1', parsed.event).valid, true);
++ assert.strictEqual(verifyDelivery(s.body, headers['x-openvibe-signature'], 'wrong'), false);
++ assert.deepStrictEqual(JSON.parse(s.body).event.payload, {}, 'no invented payload');
++ });
++
++ const t = await boot();
++
++ await check('no-JS path: the server verifies and says why a signature fails', async () => {
++ const ok = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, secret), secret } });
++ assert.strictEqual(ok.status, 200);
++ assert.match(ok.text, /Signature valid\./);
++ const bad = await t.get('/tools/webhooks/verify', { form: { body, signature: hmac(body, 'nope'), secret } });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.text, /Signature NOT valid\./);
++ assert.ok(!ok.text.includes(secret) && !bad.text.includes(secret));
++ assert.match(ok.text, /<input type="password" name="secret" value=""/);
++ });
++
++ await check('no-JS path: sample generation for an event type from the contracts catalog', async () => {
++ const r = await t.get('/tools/webhooks/sample', { form: { event_type: 'network.credential.rotated', secret } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /X-OpenVibe-Signature/);
++ assert.match(r.text, /The envelope validates/);
++ const unknown = await t.get('/tools/webhooks/sample', { form: { event_type: 'made.up.event', secret } });
++ assert.strictEqual(unknown.status, 422);
++ });
++
++ await check('the page offers only event types some service manifest produces', async () => {
++ const r = await t.get('/tools/webhooks');
++ const offered = [...r.text.matchAll(/<option(?: selected)?>([^<]+)<\/option>/g)].map((m) => m[1]).sort();
++ const expected = [...new Set(contracts.services.manifests.flatMap((m) => m.eventsProduced || []))].sort();
++ assert.deepStrictEqual(offered, expected);
++ });
++
++ await t.close();
++ done();
++})();