openvibers/openrestream Commits
OpenRe.Stream runtime: stream definitions with hashed ingest keys, sessions with a state machine and leases, transport workers with generations (RTMP ingest on node-media-server, restream on ffmpeg), session coordinator, Media recording requests, events through the openvibe-sdk outbox, API and standalone UI
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -0,0 +1,1675 @@++{++ "name": "openre-stream",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openre-stream",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "node-media-server": "2.7.4",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.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.0.0"++ },++ "engines": {++ "node": ">=22.12.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/ansi-styles": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",++ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",++ "license": "MIT",++ "dependencies": {++ "color-convert": "^2.0.1"++ },++ "engines": {++ "node": ">=8"++ },++ "funding": {++ "url": "https://github.com/chalk/ansi-styles?sponsor=1"++ }++ },++ "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/basic-auth-connect": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.1.0.tgz",++ "integrity": "sha512-rKcWjfiRZ3p5WS9e5q6msXa07s6DaFAMXoyowV+mb2xQG+oYdw2QEUyKi0Xp95JvXzShlM+oGy5QuqSK6TfC1Q==",++ "license": "MIT",++ "dependencies": {++ "tsscmp": "^1.0.6"++ }++ },++ "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/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/chalk": {++ "version": "4.1.2",++ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",++ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",++ "license": "MIT",++ "dependencies": {++ "ansi-styles": "^4.1.0",++ "supports-color": "^7.1.0"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/chalk/chalk?sponsor=1"++ }++ },++ "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/color-convert": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",++ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",++ "license": "MIT",++ "dependencies": {++ "color-name": "~1.1.4"++ },++ "engines": {++ "node": ">=7.0.0"++ }++ },++ "node_modules/color-name": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",++ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",++ "license": "MIT"++ },++ "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/dateformat": {++ "version": "4.6.3",++ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",++ "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",++ "license": "MIT",++ "engines": {++ "node": "*"++ }++ },++ "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/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-flag": {++ "version": "4.0.0",++ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",++ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",++ "license": "MIT",++ "engines": {++ "node": ">=8"++ }++ },++ "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/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/http2-express": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/http2-express/-/http2-express-1.1.1.tgz",++ "integrity": "sha512-iROf3EIQdJZLNk+/LTkENZp1WB8B+IrYT+z/TPP3/RyGk6w9P9QZNDvs/lo5zAOozNuNYdHewIyWVXXAkBGQ7g==",++ "license": "MIT",++ "engines": {++ "node": ">= 20.0.0"++ },++ "peerDependencies": {++ "express": ">=4.0.0"++ }++ },++ "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": {++ "version": "4.18.1",++ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",++ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",++ "license": "MIT"++ },++ "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": {++ "version": "2.1.6",++ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz",++ "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==",++ "license": "MIT",++ "bin": {++ "mkdirp": "dist/cjs/src/bin.js"++ },++ "engines": {++ "node": ">=10"++ },++ "funding": {++ "url": "https://github.com/sponsors/isaacs"++ }++ },++ "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/node-media-server": {++ "version": "2.7.4",++ "resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.7.4.tgz",++ "integrity": "sha512-4nsqfukfnI6tY7d1deqBQSj3KywOiNVlPQkY0tLIjF62rzXo1SDXJXgUU+cVs5e06uO4x/55O4SiAaRH3li/Vg==",++ "license": "MIT",++ "dependencies": {++ "basic-auth-connect": "^1.1.0",++ "chalk": "^4.1.2",++ "dateformat": "^4.6.3",++ "express": "^4.21.1",++ "http2-express": "^1.0.0",++ "lodash": "^4.17.21",++ "minimist": "^1.2.8",++ "mkdirp": "^2.1.6",++ "ws": "^8.18.0"++ },++ "bin": {++ "node-media-server": "bin/app.js"++ },++ "engines": {++ "node": ">=8.0.0"++ }++ },++ "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.8.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",++ "integrity": "sha512-2Msdb+k+fJZf9kQTPS2jdY8+o9FhL9tBuu3FzArHs3JcBcc06ReHpOi2sgnSBgQcSvZSaSsWT2YVbmhjnEplqQ==",++ "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.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",++ "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/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/supports-color": {++ "version": "7.2.0",++ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",++ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",++ "license": "MIT",++ "dependencies": {++ "has-flag": "^4.0.0"++ },++ "engines": {++ "node": ">=8"++ }++ },++ "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/tsscmp": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",++ "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6.x"++ }++ },++ "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"++ },++ "node_modules/ws": {++ "version": "8.21.3",++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",++ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",++ "license": "MIT",++ "engines": {++ "node": ">=10.0.0"++ },++ "peerDependencies": {++ "bufferutil": "^4.0.1",++ "utf-8-validate": ">=5.0.2"++ },++ "peerDependenciesMeta": {++ "bufferutil": {++ "optional": true++ },++ "utf-8-validate": {++ "optional": true++ }++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,33 @@++{++ "name": "openre-stream",++ "version": "0.1.0",++ "private": true,++ "description": "OpenRe.Stream: ingest and restream authority for the OpenVibe network. Stream definitions, hashed ingest keys, ingest sessions, transport workers with generations and leases, restream outputs with health and logs, recording requests to OpenVibe.Media.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "coordinator": "node workers/coordinator.js",++ "rtmp-ingest": "node workers/rtmp-ingest.js",++ "restream-worker": "node workers/restream-worker.js",++ "migrate-from-live": "node scripts/migrate-from-live.js",++ "test": "node test/run.js"++ },++ "keywords": ["openvibe", "openre", "rtmp", "restream", "ingest"],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenRe.Stream",++ "dependencies": {++ "better-sqlite3": "^11.10.0",++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "node-media-server": "2.7.4",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.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.0.0"++ },++ "engines": {++ "node": ">=22.12.0"++ }++}nnew file mode 100644@@@ -0,0 +1,320 @@++'use strict';++/**++ * OpenRe API v1 (openre-api). Every route checks one capability for service callers; owners++ * (Network user JWT) act on their own streams. Errors are RFC 9457 problem+json.++ *++ * GET /api/v1/streams openre.stream.read list (owner, ?external_ref=live:managed_stream:12)++ * POST /api/v1/streams openre.stream.write create → first ingest key, shown once++ * GET /api/v1/streams/:id openre.stream.read definition + ingest URLs + key hints++ * PATCH /api/v1/streams/:id openre.stream.write++ * DELETE /api/v1/streams/:id openre.stream.write archive (refused while live)++ * GET /api/v1/streams/:id/keys openre.stream.read key metadata (never a key)++ * POST /api/v1/streams/:id/keys/rotate openre.key.rotate { grace_seconds, end_sessions } → new key, shown once++ * GET /api/v1/streams/:id/destinations openre.output.read++ * POST /api/v1/streams/:id/destinations openre.output.write++ * PATCH /api/v1/destinations/:id openre.output.write stream_key / srt_passphrase are write-only++ * DELETE /api/v1/destinations/:id openre.output.write++ * POST /api/v1/destinations/:id/test openre.output.write URL rules + DNS + TCP reachability++ * POST /api/v1/destinations/:id/start|stop openre.output.write for the stream's live session++ * GET /api/v1/destinations/:id/logs openre.output.read++ * GET /api/v1/sessions openre.session.read ?stream_id=&state=open|live|ended|failed++ * GET /api/v1/sessions/:id openre.session.read session + transitions + outputs + recording++ * GET /api/v1/sessions/:id/playback openre.session.read playback descriptor++ * POST /api/v1/sessions/:id/end openre.session.end the owning worker disconnects the encoder++ * GET /api/v1/sessions/:id/outputs openre.output.read outputs with health++ * GET /api/v1/outputs/:id/logs openre.output.read++ * GET /api/v1/workers openre.session.read worker generations (staff/services)++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { StoreError } = require('../store/definitions');++const { testDestination } = require('../destination-test');++++function iso(ms) { return ms ? new Date(ms).toISOString() : null; }++++function createV1Router({ rt, auth }) {++ const { store, config } = rt;++ const router = express.Router();++ const guard = auth.guard;++++ const fail = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const handle = (fn) => async (req, res, next) => {++ try { await fn(req, res, next); } catch (err) {++ if (err instanceof StoreError) return fail(req, res, err.status, err.code, err.message);++ if (err && err.code === 'openre.secrets_unavailable') return fail(req, res, 503, err.code, err.message);++ return next(err);++ }++ };++++ function publicDefinition(d, { withSessions = true } = {}) {++ const keys = store.definitions.keys(d.id).filter(k => k.status !== 'revoked');++ const open = withSessions ? store.sessions.openFor(d.id)[0] || null : null;++ return {++ id: d.id,++ title: d.title,++ description: d.description,++ owner: { type: 'user', id: d.owner_subject },++ protocols: d.protocols,++ recording_mode: d.recording_mode,++ recording_visibility: d.recording_visibility,++ playback_visibility: d.playback_visibility,++ mirror_to_live: d.mirror_to_live,++ state: d.state,++ revision: d.revision,++ external_refs: d.external_refs,++ ingest: store.definitions.ingestEndpoints(d),++ keys: keys.map(k => ({ id: k.id, hint: k.hint, status: k.status, grace_until: iso(k.grace_until), created_at: iso(k.created_at), last_used_at: iso(k.last_used_at) })),++ session: open ? { id: open.id, state: open.state, protocol: open.protocol, live_at: iso(open.live_at) } : null,++ created_at: iso(d.created_at),++ updated_at: iso(d.updated_at),++ };++ }++++ function publicSession(s, { detail = false } = {}) {++ const out = {++ id: s.id,++ stream_id: s.definition_id,++ protocol: s.protocol,++ state: s.state,++ desired_state: s.desired_state,++ worker: s.worker_id ? { id: s.worker_id, kind: s.worker_kind, generation: s.worker_generation } : null,++ lease_expires_at: iso(s.lease_expires_at),++ key_id: s.key_id,++ end_reason: s.end_reason,++ failure_reason: s.failure_reason,++ media_info: s.media_info,++ revision: s.revision,++ created_at: iso(s.created_at),++ live_at: iso(s.live_at),++ ended_at: iso(s.ended_at),++ duration_seconds: s.live_at ? Math.round(((s.ended_at || Date.now()) - s.live_at) / 1000) : 0,++ };++ if (detail) {++ out.transitions = store.sessions.transitions(s.id).map(t => ({ ...t, at: iso(t.at) }));++ out.outputs = store.outputs.outputsOfSession(s.id);++ const rec = store.recordings.bySession(s.id);++ out.recording = rec ? { id: rec.id, mode: rec.mode, state: rec.state, media: rec.media_vod_id ? { app: rec.media_app, vod_id: rec.media_vod_id } : null, last_error: rec.last_error } : null;++ out.playback = store.sessions.playback(s);++ }++ return out;++ }++++ function loadDefinition(req, res, id) {++ const d = store.definitions.get(id);++ if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {++ fail(req, res, 404, 'openre.stream_not_found', 'no such stream definition');++ return null;++ }++ return d;++ }++++ function loadDestination(req, res, id) {++ const dest = store.outputs.destinationRow(id);++ const d = dest ? store.definitions.get(dest.definition_id) : null;++ if (!dest || !d || !auth.canAccess(req.caller, d.owner_subject)) {++ fail(req, res, 404, 'openre.destination_not_found', 'no such destination');++ return null;++ }++ return dest;++ }++++ function loadSession(req, res, id) {++ const s = store.sessions.get(id);++ const d = s ? store.definitions.row(s.definition_id) : null;++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) {++ fail(req, res, 404, 'openre.session_not_found', 'no such session');++ return null;++ }++ return s;++ }++++ // ── Streams ───────────────────────────────────────────────++++ router.get('/streams', guard('openre.stream.read'), handle((req, res) => {++ const ref = String(req.query.external_ref || '');++ if (ref) {++ const [service, type, ...rest] = ref.split(':');++ const d = store.definitions.findByRef(service, type, rest.join(':'));++ const visible = d && d.state !== 'archived' && auth.canAccess(req.caller, d.owner_subject);++ return res.json({ streams: visible ? [publicDefinition(d)] : [] });++ }++ const c = req.caller;++ let owner = c.subject || null;++ if (c.kind === 'user' && c.staff && req.query.all === '1') owner = null;++ const list = store.definitions.list({ owner_subject: owner || undefined, limit: Number(req.query.limit) || 100 });++ return res.json({ streams: list.map(d => publicDefinition(d)) });++ }));++++ router.post('/streams', guard('openre.stream.write'), handle((req, res) => {++ const owner = req.caller.kind === 'user' ? req.caller.subject : (req.caller.subject || (req.body && req.body.owner_subject));++ if (!owner) return fail(req, res, 400, 'openre.invalid_owner', 'name the owner (X-OV-Subject)');++ if (req.caller.kind === 'service' && req.caller.subject && req.body && req.body.owner_subject && req.body.owner_subject !== req.caller.subject) {++ return fail(req, res, 400, 'openre.invalid_owner', 'owner_subject differs from X-OV-Subject');++ }++ const b = req.body || {};++ const { definition, key } = store.definitions.create({++ owner_subject: owner,++ title: b.title, description: b.description, protocols: b.protocols,++ recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility,++ mirror_to_live: b.mirror_to_live, external_refs: b.external_refs,++ created_by: auth.actorOf(req.caller),++ });++ res.set('Cache-Control', 'no-store');++ return res.status(201).json({ stream: publicDefinition(definition), key: { id: key.id, key: key.key, hint: key.hint, shown_once: true } });++ }));++++ router.get('/streams/:id', guard('openre.stream.read'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (d) res.json({ stream: publicDefinition(d) });++ }));++++ router.patch('/streams/:id', guard('openre.stream.write'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (!d) return;++ const b = req.body || {};++ const updated = store.definitions.update(d.id, {++ title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,++ recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, mirror_to_live: b.mirror_to_live, state: b.state,++ });++ res.json({ stream: publicDefinition(updated) });++ }));++++ router.delete('/streams/:id', guard('openre.stream.write'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (!d) return;++ store.definitions.archive(d.id);++ res.status(204).end();++ }));++++ router.get('/streams/:id/keys', guard('openre.stream.read'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (d) res.json({ keys: store.definitions.keys(d.id).map(k => ({ ...k, created_at: iso(k.created_at), grace_until: iso(k.grace_until), revoked_at: iso(k.revoked_at), last_used_at: iso(k.last_used_at) })) });++ }));++++ router.post('/streams/:id/keys/rotate', guard('openre.key.rotate'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (!d) return;++ const b = req.body || {};++ const r = store.definitions.rotateKey(d.id, { grace_seconds: b.grace_seconds, rotated_by: auth.actorOf(req.caller), reason: 'rotated' });++ let ended = 0;++ if (b.end_sessions) {++ for (const s of store.sessions.openFor(d.id)) if (store.sessions.requestEnd(s.id, `key_rotation:${auth.actorOf(req.caller)}`)) ended++;++ }++ res.set('Cache-Control', 'no-store');++ res.json({++ key: { id: r.key.id, key: r.key.key, hint: r.key.hint, shown_once: true },++ retired: r.retired,++ grace_until: iso(r.grace_until),++ sessions_ending: ended,++ ingest: store.definitions.ingestEndpoints(store.definitions.get(d.id)),++ });++ }));++++ // ── Destinations ──────────────────────────────────────────++++ router.get('/streams/:id/destinations', guard('openre.output.read'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (d) res.json({ destinations: store.outputs.destinations(d.id) });++ }));++++ router.post('/streams/:id/destinations', guard('openre.output.write'), handle((req, res) => {++ const d = loadDefinition(req, res, req.params.id);++ if (d) res.status(201).json({ destination: store.outputs.createDestination(d.id, req.body || {}) });++ }));++++ router.patch('/destinations/:id', guard('openre.output.write'), handle((req, res) => {++ const dest = loadDestination(req, res, req.params.id);++ if (dest) res.json({ destination: store.outputs.updateDestination(dest.id, req.body || {}) });++ }));++++ router.delete('/destinations/:id', guard('openre.output.write'), handle((req, res) => {++ const dest = loadDestination(req, res, req.params.id);++ if (!dest) return;++ store.outputs.deleteDestination(dest.id);++ res.status(204).end();++ }));++++ router.post('/destinations/:id/test', guard('openre.output.write'), handle(async (req, res) => {++ const dest = loadDestination(req, res, req.params.id);++ if (!dest) return;++ const result = await testDestination(dest, { allowPrivate: config.outputs.allowPrivateHosts });++ store.outputs.log(null, dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);++ res.json(result);++ }));++++ router.post('/destinations/:id/start', guard('openre.output.write'), handle((req, res) => {++ const dest = loadDestination(req, res, req.params.id);++ if (dest) res.json({ output: store.outputs.startDestination(dest.id) });++ }));++++ router.post('/destinations/:id/stop', guard('openre.output.write'), handle((req, res) => {++ const dest = loadDestination(req, res, req.params.id);++ if (dest) res.json({ stopping: store.outputs.stopDestination(dest.id) });++ }));++++ router.get('/destinations/:id/logs', guard('openre.output.read'), handle((req, res) => {++ const dest = loadDestination(req, res, req.params.id);++ if (dest) res.json({ logs: store.outputs.logsOfDestination(dest.id, Math.min(Number(req.query.limit) || 100, 500)).map(l => ({ ...l, at: iso(l.at) })) });++ }));++++ // ── Sessions ──────────────────────────────────────────────++++ router.get('/sessions', guard('openre.session.read'), handle((req, res) => {++ const q = req.query;++ if (q.stream_id) {++ const d = loadDefinition(req, res, String(q.stream_id));++ if (!d) return;++ }++ const c = req.caller;++ // Owners see their own sessions; staff may ask for all (?all=1); a service sees the++ // sessions of the subject it acts for, or every session without X-OV-Subject.++ let owner = c.subject || null;++ if (c.kind === 'user' && c.staff && q.all === '1') owner = null;++ const list = store.sessions.list({++ definition_id: q.stream_id ? String(q.stream_id) : undefined,++ owner_subject: q.stream_id ? undefined : (owner || undefined),++ state: q.state ? String(q.state) : undefined,++ limit: q.limit,++ before: q.before,++ });++ res.json({ sessions: list.map(s => publicSession(s)) });++ }));++++ router.get('/sessions/:id', guard('openre.session.read'), handle((req, res) => {++ const s = loadSession(req, res, req.params.id);++ if (s) res.json({ session: publicSession(s, { detail: true }) });++ }));++++ router.get('/sessions/:id/playback', guard('openre.session.read'), handle((req, res) => {++ const s = loadSession(req, res, req.params.id);++ if (s) res.json({ playback: store.sessions.playback(s) });++ }));++++ router.post('/sessions/:id/end', guard('openre.session.end'), handle((req, res) => {++ const s = loadSession(req, res, req.params.id);++ if (!s) return;++ const requested = store.sessions.requestEnd(s.id, auth.actorOf(req.caller));++ res.status(requested ? 202 : 409).json({ requested, state: store.sessions.get(s.id).state });++ }));++++ router.get('/sessions/:id/outputs', guard('openre.output.read'), handle((req, res) => {++ const s = loadSession(req, res, req.params.id);++ if (s) res.json({ outputs: store.outputs.outputsOfSession(s.id) });++ }));++++ router.get('/outputs/:id/logs', guard('openre.output.read'), handle((req, res) => {++ const o = store.outputs.outputRow(req.params.id);++ const s = o ? loadSession(req, res, o.session_id) : null;++ if (!o) return fail(req, res, 404, 'openre.output_not_found', 'no such output');++ if (s) res.json({ logs: store.outputs.logsOfOutput(o.id, Math.min(Number(req.query.limit) || 100, 500)).map(l => ({ ...l, at: iso(l.at) })) });++ }));++++ router.get('/workers', guard('openre.session.read'), handle((req, res) => {++ if (req.caller.kind === 'user' && !req.caller.staff) return fail(req, res, 403, 'capability.denied', 'staff only');++ res.json({ workers: store.workers.recent(100).map(w => ({ ...w, started_at: iso(w.started_at), ready_at: iso(w.ready_at), heartbeat_at: iso(w.heartbeat_at), drain_started_at: iso(w.drain_started_at), drain_deadline: iso(w.drain_deadline), stopped_at: iso(w.stopped_at) })) });++ }));++++ return { router, publicDefinition, publicSession };++}++++module.exports = { createV1Router };nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/** openre-api Express app: request context, health/readiness, API v1, playback proxy, SSO, UI. */++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const contracts = require('openvibe-contracts');++const { createV1Router } = require('./api/v1');++const { createSsoRoutes } = require('./auth/sso');++const { createUiRouter } = require('./ui/routes');++const pkg = require('../package.json');++++const SESSION_FLV_RE = /^(ses_[0-9A-HJKMNP-TV-Z]{26})\.flv$/;++++function createApp({ rt, auth, keys, log = console, fetchImpl }) {++ const { config, store, events, db } = rt;++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ app.use(contracts.http.middleware());++ app.use((req, res, next) => {++ res.setHeader('X-Content-Type-Options', 'nosniff');++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');++ next();++ });++ app.use(cookieParser());++++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openre-api', version: pkg.version, release: config.release }));++++ // Ready = the database answers and the Network key is loaded. Worker and coordinator state is++ // reported, not required: the API serves reads while a worker generation rolls over.++ app.get('/api/ready', (_req, res) => {++ let dbOk = false;++ try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }++ const checks = { db: dbOk, key: keys.loaded() };++ const ready = Object.values(checks).every(Boolean);++ let workers = [];++ let coordinator = null;++ try {++ workers = store.workers.alive().map(w => ({ kind: w.kind, generation: w.generation, state: w.state, heartbeat_age_ms: Date.now() - w.heartbeat_at }));++ const l = db.prepare("SELECT holder, expires_at FROM leases WHERE name = 'coordinator'").get();++ coordinator = l ? { holder: l.holder, lease_valid: l.expires_at > Date.now() } : null;++ } catch { /* reported as empty */ }++ res.status(ready ? 200 : 503).json({++ status: ready ? 'ready' : 'not_ready',++ checks,++ store: { engine: 'sqlite', path_configured: Boolean(config.dbPath) },++ workers,++ coordinator,++ events: dbOk ? events.status() : null,++ });++ });++++ const apiJson = express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] });++ const v1 = createV1Router({ rt, auth });++ app.use('/api/v1', apiJson, auth.middleware({ services: true }), v1.router);++++ // Public HTTP-FLV playback of a live session, proxied from the worker that holds it. A viewer++ // deploy of this API interrupts viewers of this URL (they reconnect), never the ingest.++ app.get('/play/:file', (req, res) => {++ const m = SESSION_FLV_RE.exec(req.params.file);++ const s = m ? store.sessions.get(m[1]) : null;++ const d = s ? store.definitions.row(s.definition_id) : null;++ if (!s || !d || s.state !== 'live' || d.playback_visibility === 'private') return res.status(404).end();++ const pb = store.sessions.playback(s);++ if (!pb || !pb.flv) return res.status(404).end();++ const upstream = http.get(pb.flv.internal_url, (up) => {++ if (up.statusCode !== 200) { res.status(502).end(); up.resume(); return; }++ res.writeHead(200, { 'Content-Type': 'video/x-flv', 'Cache-Control': 'no-cache, no-store', 'Access-Control-Allow-Origin': '*' });++ up.pipe(res);++ });++ upstream.on('error', () => { if (!res.headersSent) res.status(502).end(); else res.end(); });++ req.on('close', () => upstream.destroy());++ return undefined;++ });++++ app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));++ app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /$\nDisallow: /streams\nDisallow: /sessions\nDisallow: /destinations\nDisallow: /api/\nDisallow: /play/\n'));++ app.use('/', createUiRouter({ rt, auth }));++++ app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ if (err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'openre.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });++ if (err.type === 'entity.too.large') return contracts.http.sendProblem(res, 413, 'openre.too_large', { detail: 'request body too large', ctx: req.ov });++ log.error(`[app] ${req.method} ${req.path}: ${err.stack || err}`);++ if (res.headersSent) return res.end();++ return contracts.http.sendProblem(res, 500, 'openre.internal', { detail: 'internal error', ctx: req.ov });++ });++ return app;++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,236 @@++'use strict';++/**++ * Who is calling openre-api, resolved once per request into req.caller:++ *++ * { kind: 'service', sub: 'svc:live', claims, subject: 'usr_…'|null }++ * An OpenVibe.Network client-credentials token (RS256, audience openvibe.openre), verified++ * offline. Each route checks ONE capability (guard('openre.<noun>.<verb>')). A service may++ * name the person it acts for in X-OV-Subject; it is then limited to that owner's streams.++ * Without it the grant applies to every stream (first-party integrations such as Live's++ * mirror read sessions for any channel that opted in).++ * { kind: 'user', subject: 'usr_…', staff, claims }++ * A browser/owner with the Network user JWT (ov_token cookie or Bearer). Owners act on++ * their own streams; Network role admin is staff (read everything, end sessions).++ * { kind: 'anonymous' }++ *++ * The capability ids are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts++ * yet. Until the release that defines them, allows() grants them with the contracts rule (exact id++ * or a `family.*` grant) and hands the decision to capabilities.check() once contracts know the id++ * (the same bridge OpenVibe.Events used).++ */++const crypto = require('crypto');++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod):/;++const STAFF_ROLES = new Set(['admin']);++++// ── Network public key ─────────────────────────────────────────++++function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {++ let key = pem ? toPem(pem) : null;++ let retryTimer = null;++ let refreshTimer = null;++++ function toPem(value) { return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' }); }++++ async function fetchOnce() {++ for (const base of urls) {++ if (!base) continue;++ const url = `${base}/api/.well-known/jwks`;++ try {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`HTTP ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find(k => k.kty === 'RSA');++ if (jwk) key = toPem({ key: jwk, format: 'jwk' });++ else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);++ else throw new Error('no RSA key in response');++ log.log(`[auth] Network public key loaded from ${base}`);++ return key;++ } catch (err) {++ log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);++ }++ }++ return null;++ }++++ function start() {++ if (pem) return Promise.resolve(key);++ const attempt = async () => {++ const k = await fetchOnce();++ if (!k && !key) { retryTimer = setTimeout(attempt, 30000); retryTimer.unref?.(); }++ return k;++ };++ refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);++ refreshTimer.unref?.();++ return attempt();++ }++++ return { get: () => key, loaded: () => Boolean(key), start, stop() { clearTimeout(retryTimer); clearInterval(refreshTimer); }, fetchOnce };++}++++// ── Capabilities ───────────────────────────────────────────────++++function hasCap(claims, id) {++ const granted = claims && Array.isArray(claims.cap) ? claims.cap : [];++ return granted.some(g => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));++}++++function allows(claims, id) {++ if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };++ const c = capabilities.check(claims, id);++ if (c.code === 'capability.unknown' && !capabilities.get(id)) return { allowed: true, code: null, reason: null };++ return c;++}++++// ── Tokens ─────────────────────────────────────────────────────++++const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));++++function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {++ if (!publicKey || typeof token !== 'string') return null;++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ try {++ const header = b64json(parts[0]);++ if (header.alg !== 'RS256') return null;++ if (!crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return null;++ const claims = b64json(parts[1]);++ const t = Math.floor(now / 1000);++ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;++ if (issuer && claims.iss !== issuer) return null;++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];++ if (!aud.some(a => audiences.includes(a))) return null;++ if (claims.actor_type || PRINCIPAL_SUB.test(String(claims.sub))) return null;++ return claims;++ } catch {++ return null;++ }++}++++function bearer(req) {++ const h = String(req.headers.authorization || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;++}++++function cookie(req, name) {++ if (req.cookies && req.cookies[name]) return req.cookies[name];++ const raw = String(req.headers.cookie || '');++ for (const part of raw.split(';')) {++ const i = part.indexOf('=');++ if (i > 0 && part.slice(0, i).trim() === name) {++ try { return decodeURIComponent(part.slice(i + 1).trim()); } catch { return null; }++ }++ }++ return null;++}++++function decodePayload(token) {++ try { return b64json(String(token).split('.')[1]); } catch { return null; }++}++++class AuthError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function createAuth({ config, keys }) {++ function verifyService(token) {++ const publicKey = keys.get();++ if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };++ return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });++ }++++ function verifyUser(token) {++ return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });++ }++++ function userCaller(claims, token) {++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), claims, token, name: claims.display_name || claims.username || null };++ }++++ /**++ * resolve(req, { services }) — services: false for server-rendered pages (a service token is++ * no identity there). A presented service token must verify: it is never downgraded.++ */++ function resolve(req, { services = true } = {}) {++ const token = bearer(req);++ if (token) {++ const payload = decodePayload(token);++ if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {++ if (!services) return { kind: 'anonymous' };++ const r = verifyService(token);++ if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);++ const subjectHeader = req.get('x-ov-subject');++ let subject = null;++ if (subjectHeader) {++ if (!ids.isSubjectId('user', subjectHeader)) throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');++ subject = subjectHeader;++ }++ return { kind: 'service', sub: r.claims.sub, claims: r.claims, subject };++ }++ const claims = verifyUser(token);++ if (claims) return userCaller(claims, token);++ if (!keys.loaded()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');++ throw new AuthError(401, 'token.invalid', 'token does not verify');++ }++ const fromCookie = cookie(req, 'ov_token');++ const claims = fromCookie ? verifyUser(fromCookie) : null;++ if (claims) return userCaller(claims, fromCookie);++ return { kind: 'anonymous' };++ }++++ function middleware(opts) {++ return (req, res, next) => {++ try {++ req.caller = resolve(req, opts);++ next();++ } catch (err) {++ if (!(err instanceof AuthError)) return next(err);++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });++ }++ };++ }++++ /**++ * API guard: a service needs the capability; a signed-in owner may act on their own streams++ * (the route checks ownership with canAccess). Anonymous callers are refused.++ */++ function guard(capabilityId) {++ return function capGuard(req, res, next) {++ const c = req.caller || { kind: 'anonymous' };++ if (c.kind === 'service') {++ const r = allows(c.claims, capabilityId);++ if (!r.allowed) return http.sendProblem(res, 403, r.code, { detail: r.reason, ctx: req.ov });++ req.capability = capabilityId;++ return next();++ }++ if (c.kind === 'user') {++ if (!c.subject) return http.sendProblem(res, 403, 'subject.missing', { detail: 'this account has no canonical subject yet; sign in again', ctx: req.ov });++ req.capability = capabilityId;++ return next();++ }++ return http.sendProblem(res, 401, 'token.missing', { detail: 'sign in, or call with a service token', ctx: req.ov });++ };++ }++++ /** May this caller act on a stream owned by ownerSubject? */++ function canAccess(caller, ownerSubject, { staffAllowed = true } = {}) {++ if (!caller) return false;++ if (caller.kind === 'service') return !caller.subject || caller.subject === ownerSubject;++ if (caller.kind === 'user') return caller.subject === ownerSubject || (staffAllowed && caller.staff);++ return false;++ }++++ /** The subject recorded as the actor of a change. */++ function actorOf(caller) {++ if (!caller) return null;++ if (caller.kind === 'user') return caller.subject;++ if (caller.kind === 'service') return caller.subject || caller.sub;++ return null;++ }++++ return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };++}++++module.exports = { createKeyStore, createAuth, allows, hasCap, verifyUserJwt, bearer, cookie, AuthError };nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * Browser sign-in for the standalone UI: OAuth2 authorization-code client of OpenVibe.Network++ * (client `openre`, redirect https://openre.stream/auth/callback). The same shape as++ * OpenVibe.Community's session layer, without FedCM:++ *++ * GET /auth/login → Network /oauth/authorize (state cookie; ?next= same-site path)++ * GET /auth/callback → code exchange server-side, sets ov_token (+ ov_refresh)++ * GET /auth/logout → clears cookies++ * GET /auth/me → the signed-in user (offline JWT verification)++ * POST /auth/refresh → refresh_token grant (the shared navbar calls it)++ */++const crypto = require('crypto');++const express = require('express');++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++++const ACCESS = 'ov_token';++const REFRESH = 'ov_refresh';++const HINT = 'ov_sso_hint';++const STATE = 'ov_oauth_state';++const NEXT = 'ov_oauth_next';++++function sanitizeNext(next) {++ if (typeof next === 'string' && /^\/(?!\/|\\)/.test(next)) return next;++ return '/';++}++++function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {++ const router = express.Router();++ const client = new OpenVibeAuthClient({++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, redirectUri: config.oauth.redirectUri,++ publicKey: null, authBase: config.networkUrl, internalBase: config.networkInternalUrl,++ });++ const base = { sameSite: 'lax', secure: config.cookies.secure };++ const flow = { ...base, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 };++++ async function tokenGrant(body) {++ let lastErr = null;++ for (const b of [config.networkInternalUrl, config.networkUrl]) {++ try {++ const res = await fetchImpl(`${b}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),++ signal: AbortSignal.timeout(10000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status });++ return data;++ } catch (err) {++ lastErr = err;++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ function setSession(res, access, refresh) {++ res.cookie(ACCESS, access, { ...base, httpOnly: false, path: '/', maxAge: 24 * 60 * 60 * 1000 });++ if (refresh) res.cookie(REFRESH, refresh, { ...base, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT, 'account', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++++ router.get('/login', (req, res) => {++ const { url, state } = client.getAuthorizationUrl(config.oauth.scope);++ res.cookie(STATE, state, flow);++ res.cookie(NEXT, sanitizeNext(req.query.next), flow);++ res.redirect(url);++ });++++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies && req.cookies[NEXT]);++ const expected = req.cookies && req.cookies[STATE];++ res.clearCookie(STATE, { path: '/auth' });++ res.clearCookie(NEXT, { path: '/auth' });++ if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');++ if (!expected || !state || !crypto.timingSafeEqual(++ Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)),++ )) return res.status(400).type('text/plain').send('OAuth state mismatch, please sign in again.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ setSession(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);++ }++ });++++ router.get('/logout', (req, res) => {++ res.clearCookie(ACCESS, { path: '/' });++ res.clearCookie(REFRESH, { path: '/auth' });++ res.cookie(HINT, 'guest', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(sanitizeNext(req.query.next));++ });++++ router.get('/me', (req, res) => {++ const token = (req.cookies && req.cookies[ACCESS]) || null;++ const claims = token ? auth.verifyUser(token) : null;++ if (!claims) return res.status(401).json({ error: 'Not authenticated' });++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;++ return res.json({ user, expires_at: exp ? exp * 1000 : null });++ });++++ router.post('/refresh', async (req, res) => {++ const refresh = req.cookies && req.cookies[REFRESH];++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ setSession(res, data.access_token, data.refresh_token);++ return res.json({ token: data.access_token });++ } catch (err) {++ if (err.status && err.status < 500) return res.status(401).json({ error: 'Refresh token rejected, please sign in again' });++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++module.exports = { createSsoRoutes, sanitizeNext };nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++/**++ * OpenRe.Stream configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/openre.env in production); .env.example documents every name.++ *++ * load(env) is pure so tests (and every worker process) can build a config without touching++ * process.env. All processes of the service (openre-api, openre-session-coordinator and the++ * transport workers) read the same file and share one SQLite database (ADR-007: one service,++ * one store; the processes are the reason this service is the first candidate for PostgreSQL).++ */++++const int = (v, d) => {++ const n = parseInt(v, 10);++ return Number.isFinite(n) ? n : d;++};++const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(String(v)));++const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));++const trim = (v) => String(v || '').replace(/\/+$/, '');++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4500);++ const rtmpPort = int(env.OPENRE_RTMP_PORT, 1936);++ return {++ service: 'openre',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl: trim(env.BASE_URL || (isProduction ? 'https://openre.stream' : `http://localhost:${port}`)),++ // A release label for logs and worker generations (deploy.sh sets the git sha).++ release: env.OPENRE_RELEASE || 'dev',++++ // ── Identity: OpenVibe.Network ──────────────────────────++ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ issuer: trim(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),++ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,++ audience: 'openvibe.openre',++ // A browser's Network user JWT is accepted when its aud contains one of these.++ userAudiences: list(env.OPENRE_USER_AUDIENCES, ['openvibe.openre', 'openvibe.network']),++ // OAuth client `openre` (browser sign-in for the standalone UI, and this service's own++ // client-credentials tokens for Events and Media).++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'openre',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || (isProduction ? 'https://openre.stream/auth/callback' : `http://localhost:${port}/auth/callback`),++ scope: 'profile',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++++ // ── Store ───────────────────────────────────────────────++ dbPath: env.OPENRE_DB_PATH || './data/openre.db',++++ // ── Secrets ─────────────────────────────────────────────++ // 32-byte key (hex or base64) that encrypts destination stream keys and SRT passphrases++ // at rest (AES-256-GCM). OPENRE_SECRETS_KEY_PREVIOUS is tried for decryption only, so the++ // key can be rotated (scripts/reencrypt is not needed: rows are re-encrypted on write).++ secretsKey: env.OPENRE_SECRETS_KEY || '',++ secretsKeyPrevious: env.OPENRE_SECRETS_KEY_PREVIOUS || '',++++ // ── Ingest (RTMP) ───────────────────────────────────────++ rtmp: {++ // Public ingest port. Live's in-process ingest owns 1935 until the RTMP cutover++ // (README "Port plan"); OpenRe listens on 1936 until then.++ port: rtmpPort,++ bindHost: env.OPENRE_RTMP_BIND || '0.0.0.0',++ // What streamers paste into OBS: rtmp://<publicHost>[:port]/live++ publicHost: env.OPENRE_RTMP_PUBLIC_HOST || (isProduction ? 'ingest.openre.stream' : '127.0.0.1'),++ publicPort: int(env.OPENRE_RTMP_PUBLIC_PORT, rtmpPort),++ // Loopback ports a worker takes for its internal RTMP play and HTTP-FLV endpoints (the++ // restream worker, Media's recorder and the playback proxy pull from these). Each++ // worker generation takes the first free pair from this range, so two generations can++ // run side by side during a drain.++ internalPortMin: int(env.OPENRE_RTMP_INTERNAL_PORT_MIN, 19360),++ internalPortMax: int(env.OPENRE_RTMP_INTERNAL_PORT_MAX, 19399),++ chunkSize: int(env.OPENRE_RTMP_CHUNK_SIZE, 60000),++ maxPublishersPerWorker: int(env.OPENRE_RTMP_MAX_PUBLISHERS, 64),++ },++++ // ── Workers, leases, generations ────────────────────────++ workers: {++ heartbeatMs: int(env.OPENRE_WORKER_HEARTBEAT_MS, 2000),++ // A worker (and every session it owns) is lost when it has not heartbeated this long.++ leaseMs: int(env.OPENRE_WORKER_LEASE_MS, 15000),++ // A draining worker keeps its sessions at most this long, then ends them (the encoder++ // reconnects and lands on the newest generation). Plan §15.10 "explicit maximum-lifetime".++ drainMaxMs: int(env.OPENRE_DRAIN_MAX_MS, 24 * 60 * 60 * 1000),++ coordinatorIntervalMs: int(env.OPENRE_COORDINATOR_INTERVAL_MS, 1000),++ restreamPollMs: int(env.OPENRE_RESTREAM_POLL_MS, 1000),++ },++++ // ── Restream outputs ────────────────────────────────────++ outputs: {++ ffmpegPath: env.OPENRE_FFMPEG_PATH || 'ffmpeg',++ // Static OpenSSL ffmpeg for rtmps:// (GnuTLS rekeying issue, same as Live).++ ffmpegOpenSslPath: env.OPENRE_FFMPEG_OPENSSL_PATH || '',++ maxPerStream: int(env.OPENRE_MAX_DESTINATIONS, 10),++ // SSRF: destinations may not point at loopback/private/link-local addresses unless++ // this is on (tests; a LAN restream box). Checked on write and again before ffmpeg.++ allowPrivateHosts: bool(env.OPENRE_DEST_ALLOW_PRIVATE, false),++ logsPerOutput: int(env.OPENRE_OUTPUT_LOG_LIMIT, 200),++ // Backoff, circuit breaker and live-ack timings (ported from Live's restream manager).++ restartBaseMs: int(env.OPENRE_RESTART_BASE_MS, 5000),++ restartMaxMs: int(env.OPENRE_RESTART_MAX_MS, 120000),++ maxRestarts: int(env.OPENRE_MAX_RESTARTS, 30),++ rapidCrashMs: int(env.OPENRE_RAPID_CRASH_MS, 5000),++ rapidCrashGiveUp: int(env.OPENRE_RAPID_CRASH_GIVEUP, 4),++ liveAckTimeoutMs: int(env.OPENRE_LIVE_ACK_TIMEOUT_MS, 20000),++ stableMs: int(env.OPENRE_STABLE_MS, 30000),++ startDelayMs: int(env.OPENRE_OUTPUT_START_DELAY_MS, 3000),++ cooldownMinutes: int(env.OPENRE_DEST_COOLDOWN_MINUTES, 30),++ },++++ // ── Events (OpenVibe.Events through the SDK outbox) ─────++ events: {++ url: trim(env.EVENTS_URL || ''),++ // Relay runs in the session coordinator. Off when EVENTS_URL or the client secret is++ // missing: rows still commit with every change and wait for the relay.++ publish: env.EVENTS_PUBLISH !== 'off',++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 1000),++ },++++ // ── Recording requests (OpenVibe.Media) ─────────────────++ media: {++ url: trim(env.MEDIA_URL || 'http://127.0.0.1:4100'),++ // The Media tenant the recording is filed under. 'live' keeps VODs in the Live++ // channel's gallery (Media still finalises and owns the object, ADR-006).++ appId: env.MEDIA_APP_ID || 'live',++ // 'key': the tenant's app key (MEDIA_API_KEY), which Media's VOD routes accept today.++ // 'service': a Network service token (audience openvibe.media) once Media names a++ // capability on its VOD ingest routes (README "Recording").++ auth: env.OPENRE_MEDIA_AUTH === 'service' ? 'service' : 'key',++ apiKey: env.MEDIA_API_KEY || '',++ enabled: env.OPENRE_RECORDING !== 'off',++ startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),++ },++++ // ── Live compatibility (links on the UI only; never a data dependency) ──++ liveUrl: trim(env.OV_LIVE_URL || 'https://openvibe.live'),++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * The session coordinator (unit openre-session-coordinator). Stateless between ticks: everything it++ * knows is in the database, so it can restart at any time without touching a transport.++ *++ * Each tick:++ * 1. lease only one coordinator acts at a time (a deploy overlap is harmless)++ * 2. liveness workers without a heartbeat for leaseMs are 'lost'; their open sessions fail++ * (worker_lost) and their outputs go back to pending for another worker++ * 3. orphans open sessions whose lease expired with no live worker fail too++ * 4. generations per kind, every ready worker older than the newest ready generation drains++ * (deadline = now + drainMaxMs). New sessions then only reach the newest one.++ * 5. outputs live sessions get their auto-start outputs; pending outputs are assigned to++ * the newest ready restream generation++ * 6. keys grace periods that ended become revocations++ * 7. recordings live sessions get a recording request; requests advance (Media calls)++ * The event relay (outbox → OpenVibe.Events) runs in this process too.++ */++const { KINDS } = require('./store/workers');++++function createCoordinator({ rt, media, log = console, holder = `coordinator:${process.pid}` }) {++ const { db, store, config, clock } = rt;++ const now = () => clock.now();++ const lease = {++ take: db.prepare(`INSERT INTO leases (name, holder, expires_at) VALUES ('coordinator', @holder, @exp)++ ON CONFLICT(name) DO UPDATE SET holder = @holder, expires_at = @exp WHERE leases.holder = @holder OR leases.expires_at < @now`),++ release: db.prepare("DELETE FROM leases WHERE name = 'coordinator' AND holder = ?"),++ };++ let timer = null;++ let running = false;++ let ticking = null;++ const stats = { ticks: 0, lastTickAt: null, lastError: null, holdsLease: false };++++ function takeLease() {++ const ok = lease.take.run({ holder, exp: now() + config.workers.leaseMs, now: now() }).changes > 0;++ stats.holdsLease = ok;++ return ok;++ }++++ function failSessionsOf(worker, reason) {++ let n = 0;++ for (const s of store.sessions.ofWorker(worker.id)) {++ const r = store.sessions.transition(s.id, 'failed', { reason, actor: 'coordinator' });++ if (r.ok) n++;++ }++ return n;++ }++++ /** The synchronous part of a tick (everything but Media calls and the relay). */++ function syncTick() {++ const out = { lost: 0, failed: 0, drained: 0, outputsCreated: 0, outputsAssigned: 0, keysRevoked: 0, recordingsCreated: 0 };++ // 2. liveness++ for (const w of store.workers.stale(now() - config.workers.leaseMs)) {++ if (store.workers.lose(w.id, 'heartbeat_missed')) {++ out.lost++;++ out.failed += failSessionsOf(w, 'worker_lost');++ store.outputs.release(w.id);++ log.warn(`[coordinator] worker ${w.kind}#${w.generation} (${w.id}) lost; sessions failed, outputs released`);++ }++ }++ // 3. orphans (lease expired, worker not alive)++ for (const s of store.sessions.expiredLeases()) {++ const w = s.worker_id ? store.workers.get(s.worker_id) : null;++ if (w && ['starting', 'ready', 'draining'].includes(w.state) && w.heartbeat_at >= now() - config.workers.leaseMs) continue;++ if (store.sessions.transition(s.id, 'failed', { reason: 'lease_expired', actor: 'coordinator' }).ok) out.failed++;++ }++ // 4. generations++ for (const kind of KINDS) {++ const newest = store.workers.newestReady(kind);++ if (!newest) continue;++ for (const old of store.workers.olderReady(kind, newest.generation)) {++ if (store.workers.drain(old.id, now() + config.workers.drainMaxMs)) {++ out.drained++;++ log.log(`[coordinator] ${kind}#${old.generation} drains (newest ready is #${newest.generation})`);++ }++ }++ }++ // 5. outputs++ out.outputsCreated = store.outputs.ensureAutoOutputs();++ const restream = store.workers.newestReady('restream');++ if (restream) out.outputsAssigned = store.outputs.assignPending(restream);++ store.outputs.stopOrphans();++ // 6. keys++ out.keysRevoked = store.definitions.expireGraceKeys();++ // 7. recordings (requests only; the Media calls are async below)++ if (media && media.configured) out.recordingsCreated = store.recordings.ensureRequests();++ return out;++ }++++ async function tick() {++ if (ticking) return ticking;++ ticking = (async () => {++ try {++ if (!takeLease()) return { skipped: 'lease held by another coordinator' };++ const out = syncTick();++ if (media) out.recordingSteps = await store.recordings.process(media);++ stats.ticks++;++ stats.lastTickAt = now();++ stats.lastError = null;++ return out;++ } catch (err) {++ stats.lastError = err.message;++ log.error(`[coordinator] tick failed: ${err.stack || err}`);++ return { error: err.message };++ }++ })().finally(() => { ticking = null; });++ return ticking;++ }++++ function schedule() {++ if (!running) return;++ timer = setTimeout(async () => { await tick(); schedule(); }, config.workers.coordinatorIntervalMs);++ }++++ return {++ tick,++ syncTick,++ start() { if (!running) { running = true; rt.events.start(); tick().finally(schedule); } },++ async stop() {++ running = false;++ clearTimeout(timer);++ if (ticking) await ticking.catch(() => {});++ await rt.events.stop();++ lease.release.run(holder);++ },++ stats: () => ({ ...stats }),++ };++}++++module.exports = { createCoordinator };nnew file mode 100644@@@ -0,0 +1,238 @@++'use strict';++/**++ * OpenRe's durable store: one SQLite database (WAL) shared by every OpenRe process.++ *++ * openre-api definitions, keys, destinations (owner writes)++ * openre-session-coordinator worker liveness, drain, output assignment, recordings, event relay++ * openre-rtmp-ingest sessions it admits, their leases and transitions++ * openre-restream-worker output state, health and logs++ *++ * ADR-007: SQLite stays the documented store while the platform is single-host. Several writer++ * processes is the exact trigger ADR-007 names for moving to PostgreSQL; every statement lives++ * behind server/store/* so that move changes one layer. WAL + busy_timeout keeps the writers++ * safe today (each write is one short transaction).++ *++ * Every table is created idempotently on open. Times are epoch milliseconds.++ */++const fs = require('fs');++const path = require('path');++const Database = require('better-sqlite3');++++const SCHEMA = `++CREATE TABLE IF NOT EXISTS stream_definitions (++ id TEXT PRIMARY KEY,++ owner_subject TEXT NOT NULL,++ title TEXT NOT NULL DEFAULT 'Untitled stream',++ description TEXT NOT NULL DEFAULT '',++ protocols TEXT NOT NULL DEFAULT '["rtmp"]',++ recording_mode TEXT NOT NULL DEFAULT 'vod' CHECK (recording_mode IN ('vod', 'clips', 'none')),++ recording_visibility TEXT NOT NULL DEFAULT 'public' CHECK (recording_visibility IN ('public', 'unlisted', 'private')),++ playback_visibility TEXT NOT NULL DEFAULT 'public' CHECK (playback_visibility IN ('public', 'unlisted', 'private')),++ mirror_to_live INTEGER NOT NULL DEFAULT 0,++ state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'disabled', 'archived')),++ revision INTEGER NOT NULL DEFAULT 1,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_definitions_owner ON stream_definitions(owner_subject, state);++++-- Typed references to other services' entities (plan §29.3): never a foreign key into them.++CREATE TABLE IF NOT EXISTS external_refs (++ definition_id TEXT NOT NULL REFERENCES stream_definitions(id) ON DELETE CASCADE,++ service TEXT NOT NULL,++ type TEXT NOT NULL,++ ref_id TEXT NOT NULL,++ label TEXT,++ created_at INTEGER NOT NULL,++ PRIMARY KEY (service, type, ref_id)++);++CREATE INDEX IF NOT EXISTS idx_external_refs_definition ON external_refs(definition_id);++++-- Ingest keys are stored as SHA-256 of a 256-bit random secret; the secret is shown once.++CREATE TABLE IF NOT EXISTS ingest_keys (++ id TEXT PRIMARY KEY,++ definition_id TEXT NOT NULL REFERENCES stream_definitions(id) ON DELETE CASCADE,++ key_hash TEXT NOT NULL UNIQUE,++ hint TEXT NOT NULL,++ status TEXT NOT NULL CHECK (status IN ('active', 'grace', 'revoked')),++ created_by TEXT,++ created_at INTEGER NOT NULL,++ grace_until INTEGER,++ revoked_at INTEGER,++ revoked_reason TEXT,++ last_used_at INTEGER++);++CREATE INDEX IF NOT EXISTS idx_ingest_keys_definition ON ingest_keys(definition_id, status);++++CREATE TABLE IF NOT EXISTS workers (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ generation INTEGER NOT NULL,++ release TEXT,++ pid INTEGER,++ host TEXT,++ state TEXT NOT NULL CHECK (state IN ('starting', 'ready', 'draining', 'stopped', 'lost')),++ endpoints TEXT NOT NULL DEFAULT '{}',++ started_at INTEGER NOT NULL,++ ready_at INTEGER,++ heartbeat_at INTEGER NOT NULL,++ drain_started_at INTEGER,++ drain_deadline INTEGER,++ stopped_at INTEGER,++ stop_reason TEXT,++ UNIQUE (kind, generation)++);++CREATE INDEX IF NOT EXISTS idx_workers_kind_state ON workers(kind, state, generation);++++CREATE TABLE IF NOT EXISTS ingest_sessions (++ id TEXT PRIMARY KEY,++ definition_id TEXT NOT NULL REFERENCES stream_definitions(id),++ key_id TEXT,++ protocol TEXT NOT NULL,++ state TEXT NOT NULL CHECK (state IN ('starting', 'live', 'ending', 'ended', 'failed')),++ worker_id TEXT,++ worker_kind TEXT,++ worker_generation INTEGER,++ lease_expires_at INTEGER,++ desired_state TEXT NOT NULL DEFAULT 'run' CHECK (desired_state IN ('run', 'end')),++ end_requested_by TEXT,++ end_reason TEXT,++ failure_reason TEXT,++ media_info TEXT,++ revision INTEGER NOT NULL DEFAULT 1,++ created_at INTEGER NOT NULL,++ live_at INTEGER,++ ending_at INTEGER,++ ended_at INTEGER,++ updated_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_sessions_definition ON ingest_sessions(definition_id, created_at);++CREATE INDEX IF NOT EXISTS idx_sessions_state ON ingest_sessions(state);++CREATE INDEX IF NOT EXISTS idx_sessions_worker ON ingest_sessions(worker_id, state);++++CREATE TABLE IF NOT EXISTS session_transitions (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ session_id TEXT NOT NULL REFERENCES ingest_sessions(id) ON DELETE CASCADE,++ from_state TEXT,++ to_state TEXT NOT NULL,++ reason TEXT,++ actor TEXT,++ at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_transitions_session ON session_transitions(session_id, id);++++CREATE TABLE IF NOT EXISTS destinations (++ id TEXT PRIMARY KEY,++ definition_id TEXT NOT NULL REFERENCES stream_definitions(id) ON DELETE CASCADE,++ platform TEXT NOT NULL CHECK (platform IN ('youtube', 'twitch', 'kick', 'custom')),++ name TEXT,++ server_url TEXT NOT NULL,++ stream_key_enc TEXT,++ key_hint TEXT,++ srt_passphrase_enc TEXT,++ srt_latency_ms INTEGER,++ enabled INTEGER NOT NULL DEFAULT 1,++ auto_start INTEGER NOT NULL DEFAULT 1,++ quality_preset TEXT NOT NULL DEFAULT 'auto',++ custom_video_bitrate INTEGER,++ custom_audio_bitrate INTEGER,++ custom_fps INTEGER,++ custom_encoder_preset TEXT,++ hold_reason TEXT,++ consecutive_failures INTEGER NOT NULL DEFAULT 0,++ cooldown_until INTEGER,++ last_error TEXT,++ last_failed_at INTEGER,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_destinations_definition ON destinations(definition_id);++++-- One run of one destination inside one session. A destination failure only ever touches its++-- own row: nothing here can move the source session's state.++CREATE TABLE IF NOT EXISTS outputs (++ id TEXT PRIMARY KEY,++ session_id TEXT NOT NULL REFERENCES ingest_sessions(id),++ destination_id TEXT NOT NULL REFERENCES destinations(id) ON DELETE CASCADE,++ desired TEXT NOT NULL DEFAULT 'run' CHECK (desired IN ('run', 'stop')),++ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'starting', 'live', 'error', 'failed', 'stopped')),++ worker_id TEXT,++ worker_generation INTEGER,++ restart_attempts INTEGER NOT NULL DEFAULT 0,++ next_restart_at INTEGER,++ ever_live INTEGER NOT NULL DEFAULT 0,++ last_error TEXT,++ progress TEXT,++ started_at INTEGER,++ live_at INTEGER,++ ended_at INTEGER,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL,++ UNIQUE (session_id, destination_id)++);++CREATE INDEX IF NOT EXISTS idx_outputs_worker ON outputs(worker_id, state);++CREATE INDEX IF NOT EXISTS idx_outputs_session ON outputs(session_id);++++-- Bounded per output (config.outputs.logsPerOutput); destination tests log with output_id NULL.++CREATE TABLE IF NOT EXISTS output_logs (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ output_id TEXT,++ destination_id TEXT NOT NULL,++ level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),++ message TEXT NOT NULL,++ at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_output_logs_output ON output_logs(output_id, id);++CREATE INDEX IF NOT EXISTS idx_output_logs_destination ON output_logs(destination_id, id);++++-- Recording requests to OpenVibe.Media. Media records and finalises; OpenRe only asks.++CREATE TABLE IF NOT EXISTS recordings (++ id TEXT PRIMARY KEY,++ session_id TEXT NOT NULL UNIQUE REFERENCES ingest_sessions(id),++ mode TEXT NOT NULL CHECK (mode IN ('vod', 'clips')),++ state TEXT NOT NULL CHECK (state IN ('pending', 'requested', 'recording', 'finalizing', 'finalized', 'failed', 'cancelled')),++ media_app TEXT,++ media_vod_id TEXT,++ attempts INTEGER NOT NULL DEFAULT 0,++ next_attempt_at INTEGER NOT NULL DEFAULT 0,++ last_error TEXT,++ created_at INTEGER NOT NULL,++ updated_at INTEGER NOT NULL++);++CREATE INDEX IF NOT EXISTS idx_recordings_state ON recordings(state, next_attempt_at);++++-- Every imported (or deliberately not imported) source record: plan §8.1 "never silently dropped".++CREATE TABLE IF NOT EXISTS migration_map (++ source_system TEXT NOT NULL,++ source_type TEXT NOT NULL,++ source_id TEXT NOT NULL,++ target_type TEXT,++ target_id TEXT,++ status TEXT NOT NULL CHECK (status IN ('imported', 'held', 'excluded')),++ reason TEXT,++ imported_at INTEGER NOT NULL,++ PRIMARY KEY (source_system, source_type, source_id)++);++++-- Named leases (one session coordinator at a time).++CREATE TABLE IF NOT EXISTS leases (++ name TEXT PRIMARY KEY,++ holder TEXT NOT NULL,++ expires_at INTEGER NOT NULL++);++`;++++function openDb(dbPath) {++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });++ const db = new Database(dbPath);++ // busy_timeout first: several OpenRe processes open the database at the same time on boot,++ // and every statement after this one (the WAL switch, the schema) may have to wait for a lock.++ db.pragma('busy_timeout = 5000');++ db.pragma('journal_mode = WAL');++ db.pragma('foreign_keys = ON');++ db.pragma('synchronous = NORMAL');++ db.exec(SCHEMA);++ return db;++}++++module.exports = { openDb, SCHEMA };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++/**++ * "Test" for a restream destination, without pushing any media: the URL passes the SSRF rules,++ * the host resolves to public addresses, and (RTMP/RTMPS) a TCP connection to the ingest port++ * opens within 5 s. SRT is UDP, so only the first two checks apply. It says nothing about whether++ * the platform accepts the key; that is only known once an output runs (its health shows it).++ */++const net = require('net');++const { validateDestinationUrl, checkResolvedHost } = require('./destination-url');++++function tcpProbe(host, port, timeoutMs = 5000) {++ return new Promise((resolve) => {++ const started = Date.now();++ const socket = net.connect({ host, port });++ const done = (ok, error) => { socket.destroy(); resolve({ ok, error, ms: Date.now() - started }); };++ socket.setTimeout(timeoutMs, () => done(false, `no answer from ${host}:${port} within ${timeoutMs / 1000}s`));++ socket.once('connect', () => done(true, null));++ socket.once('error', (err) => done(false, `${err.code || 'error'} connecting to ${host}:${port}`));++ });++}++++async function testDestination(dest, { allowPrivate = false, lookup, probe = tcpProbe } = {}) {++ const checks = [];++ const v = validateDestinationUrl(dest.server_url, { allowPrivate });++ checks.push({ check: 'url', ok: v.ok, detail: v.ok ? 'rtmp/rtmps/srt URL with a public host' : v.error });++ if (!v.ok) return { ok: false, checks };++ const r = await checkResolvedHost(v.host, { allowPrivate, ...(lookup ? { lookup } : {}) });++ checks.push({ check: 'dns', ok: r.ok, detail: r.ok ? (r.addresses.length ? `resolves to ${r.addresses.join(', ')}` : 'not checked (private hosts allowed)') : r.error });++ if (!r.ok) return { ok: false, checks };++ checks.push({ check: 'stream_key', ok: Boolean(dest.stream_key_enc || dest.has_stream_key), detail: dest.stream_key_enc || dest.has_stream_key ? 'set' : 'missing' });++ if (v.url.protocol === 'srt:') {++ checks.push({ check: 'connect', ok: true, detail: 'SRT is UDP; the receiver is only reached when an output runs' });++ } else {++ const port = Number(v.url.port) || (v.url.protocol === 'rtmps:' ? 443 : 1935);++ const p = await probe(v.host, port);++ checks.push({ check: 'connect', ok: p.ok, detail: p.ok ? `TCP ${v.host}:${port} answered in ${p.ms} ms` : p.error });++ }++ return { ok: checks.every(c => c.ok), checks };++}++++module.exports = { testDestination, tcpProbe };nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++/**++ * Restream destination URLs become ffmpeg's OUTPUT argument, so they are the SSRF surface of this++ * service (plan anti-goal 13). Rules, checked when a destination is written and again right++ * before ffmpeg starts (DNS can change in between):++ *++ * - rtmp://, rtmps:// or srt:// only (ffmpeg picks its output protocol from the URL: a path or++ * file:/http: URL would write files or talk to arbitrary hosts), with a hostname, no++ * user:password@, no whitespace or control characters, at most 2048 characters++ * - the host may not be (or resolve to) loopback, private, link-local, CGNAT, multicast or++ * unspecified addresses, unless OPENRE_DEST_ALLOW_PRIVATE is on (tests, a restream box on the++ * operator's LAN). Live allowed private hosts; OpenRe does not by default, and the migration++ * holds such destinations with a recorded reason instead of importing them enabled.++ */++const dns = require('dns');++const net = require('net');++++const PROTOCOLS = new Set(['rtmp:', 'rtmps:', 'srt:']);++++function isPrivateAddress(ip) {++ const v = net.isIP(ip);++ if (v === 4) {++ const [a, b] = ip.split('.').map(Number);++ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)++ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0)++ || (a === 198 && (b === 18 || b === 19)) || a >= 224;++ }++ if (v === 6) {++ const s = ip.toLowerCase();++ if (s === '::' || s === '::1') return true;++ if (s.startsWith('::ffff:')) return isPrivateAddress(s.slice(7));++ return /^(fc|fd|fe8|fe9|fea|feb|ff)/.test(s);++ }++ return true;++}++++/** Syntax + literal-address check. { ok, value, url } | { ok: false, error } */++function validateDestinationUrl(raw, { allowPrivate = false } = {}) {++ const value = String(raw || '').trim();++ if (!value) return { ok: false, error: 'Server URL is required' };++ if (value.length > 2048) return { ok: false, error: 'Server URL is too long' };++ if (/[\s\u0000-\u001f\u007f]/.test(value)) return { ok: false, error: 'Server URL contains invalid characters' };++ let u;++ try { u = new URL(value); } catch { return { ok: false, error: 'Server URL must be a full rtmp://, rtmps:// or srt:// address' }; }++ if (!PROTOCOLS.has(u.protocol)) return { ok: false, error: `Server URL must start with rtmp://, rtmps:// or srt:// (got "${u.protocol.replace(':', '')}")` };++ if (!u.hostname) return { ok: false, error: 'Server URL needs a hostname' };++ if (u.username || u.password) return { ok: false, error: 'Put credentials in the stream key, not in the server URL' };++ const host = u.hostname.replace(/^\[|\]$/g, '');++ if (!allowPrivate) {++ if (/^localhost$/i.test(host) || /\.localhost$/i.test(host) || /\.(internal|local|lan|home|arpa)$/i.test(host)) {++ return { ok: false, error: 'Server URL may not point at a local or internal host' };++ }++ if (net.isIP(host) && isPrivateAddress(host)) return { ok: false, error: 'Server URL may not point at a private, loopback or link-local address' };++ }++ return { ok: true, value, url: u, host };++}++++/** Resolve and refuse private answers (DNS rebinding guard). Resolves { ok } | { ok: false, error }. */++async function checkResolvedHost(host, { allowPrivate = false, lookup = dns.promises.lookup } = {}) {++ if (allowPrivate) return { ok: true, addresses: [] };++ if (net.isIP(host)) return isPrivateAddress(host) ? { ok: false, error: 'private address' } : { ok: true, addresses: [host] };++ let answers;++ try { answers = await lookup(host, { all: true }); } catch (err) { return { ok: false, error: `could not resolve ${host} (${err.code || err.message})` }; }++ const addresses = answers.map(a => a.address);++ if (!addresses.length) return { ok: false, error: `could not resolve ${host}` };++ if (addresses.some(isPrivateAddress)) return { ok: false, error: `${host} resolves to a private or loopback address` };++ return { ok: true, addresses };++}++++module.exports = { validateDestinationUrl, checkResolvedHost, isPrivateAddress };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * OpenRe → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * Every OpenRe process can enqueue (the row commits in the same SQLite transaction as the change++ * it describes); only the session coordinator runs the relay that publishes rows with OpenRe's++ * service token (audience openvibe.events, capability events.event.publish). Events down, Network++ * down or no credentials yet: rows wait and are retried; nothing in the transport path waits.++ *++ * Event types (source "openre"):++ * openre.session.started a session reached live subject ingest_session++ * openre.session.ended a session that had been live ended subject ingest_session++ * openre.session.failed a session failed (worker lost, error) subject ingest_session++ * openre.output.healthy a restream output confirmed live subject output++ * openre.output.failed an output gave up (circuit breaker) subject output++ * openre.recording.requested Media accepted a recording request subject recording++ * openre.key.rotated an ingest key was rotated subject stream++ * Payloads never carry an ingest key or a destination key.++ */++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++++const TYPES = Object.freeze({++ sessionStarted: 'openre.session.started',++ sessionEnded: 'openre.session.ended',++ sessionFailed: 'openre.session.failed',++ outputHealthy: 'openre.output.healthy',++ outputFailed: 'openre.output.failed',++ recordingRequested: 'openre.recording.requested',++ keyRotated: 'openre.key.rotated',++});++++/**++ * createEvents({ config, db, fetchImpl, now, log }) -> { outbox, enqueue, configured, start, stop, status }++ * enqueue(envelope) must run inside the transaction that makes the change.++ */++function createEvents({ config, db, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const configured = Boolean(config.events.url && config.oauth.clientSecret && config.events.publish);++ let tokenProvider = null;++ if (configured) {++ tokenProvider = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ fetch: fetchImpl,++ });++ }++ // Without credentials the client is only used to fill envelopes (prepare), never to send.++ const client = createClient({++ baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' },++ ...(tokenProvider ? { tokenProvider } : { token: 'unconfigured' }),++ fetch: fetchImpl,++ retries: 0,++ autoDiscover: false,++ onWarning: () => {},++ });++ const eventsClient = createEventsClient(client, { source: 'openre' });++ let lastError = null;++ const outbox = createOutbox(db, {++ events: eventsClient,++ intervalMs: config.events.intervalMs,++ now,++ onError: (err) => {++ const msg = err && err.message;++ if (msg !== lastError) log.warn(`[events] publish failed (will retry): ${msg}`);++ lastError = msg;++ },++ });++ outbox.ensureSchema();++++ return {++ outbox,++ configured,++ enqueue: (envelope) => outbox.enqueue(envelope),++ start() { if (configured) outbox.start(); return configured; },++ stop: () => outbox.stop(),++ status: () => ({ configured, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ };++}++++module.exports = { createEvents, TYPES };nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++/**++ * OpenRe identifiers: <prefix>_<ULID> (the ULID from openvibe-contracts ids, so they sort by++ * creation time like every other OpenVibe id). Prefixes are OpenRe's own entity types.++ */++const { ids } = require('openvibe-contracts');++++const PREFIX = Object.freeze({++ stream: 'std', // stream (input) definition++ key: 'key', // ingest key record (never the key itself)++ session: 'ses', // ingest session++ destination: 'dst', // restream destination++ output: 'out', // one destination run inside one session++ worker: 'wrk', // one transport worker process (one generation)++ recording: 'rec', // recording request to Media++});++++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));++++function newId(kind, now) {++ const p = PREFIX[kind];++ if (!p) throw new TypeError(`no OpenRe id prefix for "${kind}"`);++ return `${p}_${ids.ulid(now)}`;++}++++function isId(kind, value) {++ return typeof value === 'string' && Boolean(RE[kind]) && RE[kind].test(value);++}++++module.exports = { newId, isId, PREFIX };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * openre-api entry point (unit openre-api.service, port 4500).++ *++ * The API owns no transport: it reads and writes the store (definitions, keys, destinations,++ * end requests) and serves the UI. Restarting or redeploying it never touches a worker process,++ * a session or an ffmpeg output; see test/api-restart.test.js.++ *++ * start() is what the tests use too: it takes a config plus injectable clock/fetch/log.++ */++const path = require('path');++const { load } = require('./config');++const { openRuntime } = require('./store');++const { createKeyStore, createAuth } = require('./auth');++const { createApp } = require('./app');++++async function start({ config, clock, fetchImpl = globalThis.fetch, log = console, listen = true } = {}) {++ config = config || load();++ const rt = openRuntime({ config, clock, fetchImpl, log });++ const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });++ const auth = createAuth({ config, keys });++ const app = createApp({ rt, auth, keys, log, fetchImpl });++ const keyLoaded = keys.start().catch(() => null);++++ let server = null;++ if (listen) {++ server = await new Promise((resolve, reject) => {++ const s = app.listen(config.port, config.host, () => resolve(s));++ s.on('error', reject);++ });++ log.log(`[openre-api] listening on http://${config.host}:${server.address().port} (release ${config.release})`);++ }++++ async function close() {++ keys.stop();++ if (server) {++ server.closeAllConnections?.();++ await new Promise(resolve => server.close(() => resolve()));++ }++ rt.db.close();++ }++++ return { config, rt, keys, keyLoaded, auth, app, server, close };++}++++if (require.main === module) {++ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });++ start().then((h) => {++ const shutdown = (sig) => {++ console.log(`[openre-api] ${sig}: shutting down (workers are separate processes and keep running)`);++ h.close().then(() => process.exit(0), () => process.exit(1));++ setTimeout(() => process.exit(1), 10000).unref();++ };++ process.on('SIGTERM', () => shutdown('SIGTERM'));++ process.on('SIGINT', () => shutdown('SIGINT'));++ }).catch((err) => {++ console.error(`[openre-api] failed to start: ${err.stack || err}`);++ process.exit(1);++ });++}++++module.exports = { start };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++/**++ * OpenVibe.Media recording requests: the same Media API v1 contract Live's recorder uses++ * (OpenVibe.Live server/media-client.js + server/streaming/recorder.js), cut down to the calls a++ * recording needs:++ *++ * POST /api/v1/:app/vods { title, user_id?, managed_stream_id?, meta, visibility, clips_only } → { id }++ * POST /api/v1/:app/vods/:id/ingest/rtmp { rtmp_url } → 202 (Media pulls the URL with its own ffmpeg)++ * POST /api/v1/:app/vods/:id/finalize → Media closes the recording, probes, thumbnails, fires vod.ready++ * DELETE /api/v1/:app/vods/:id → drop an empty shell / an ephemeral clips-only recording++ *++ * The rtmp_url is the owning worker's loopback play URL (rtmp://127.0.0.1:<port>/live/<session id>):++ * no ingest key ever leaves OpenRe. Media records and finalises; OpenRe only asks.++ *++ * Auth: config.media.auth === 'key' sends the tenant app key (MEDIA_API_KEY), which Media's VOD++ * routes accept today. 'service' sends a Network service token (audience openvibe.media); Media's++ * VOD routes do not accept service tokens yet (they name no capability) — see README "Recording".++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++class MediaError extends Error {++ constructor(message, status, body) { super(message); this.status = status || 0; this.body = body || null; }++}++++function createMediaClient({ config, fetchImpl = globalThis.fetch }) {++ const base = `${config.media.url}/api/v1/${encodeURIComponent(config.media.appId)}`;++ const tokens = config.media.auth === 'service' && config.oauth.clientSecret++ ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', fetch: fetchImpl })++ : null;++++ const configured = config.media.enabled && Boolean(config.media.auth === 'service' ? tokens : config.media.apiKey);++++ async function authHeader() {++ if (tokens) return { Authorization: `Bearer ${await tokens.getToken()}` };++ return config.media.apiKey ? { Authorization: `Bearer ${config.media.apiKey}` } : {};++ }++++ async function request(method, path, { body, timeoutMs = 15000 } = {}) {++ const headers = { Accept: 'application/json', ...(await authHeader()) };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ let res;++ let text = '';++ try {++ res = await fetchImpl(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });++ text = await res.text().catch(() => '');++ } catch (err) {++ throw new MediaError(`Media unreachable (${method} ${path}): ${err.message}`, 0, null);++ }++ let json = null;++ if (text) { try { json = JSON.parse(text); } catch { json = null; } }++ if (!res.ok) throw new MediaError((json && (json.error || json.detail)) || `Media ${res.status} on ${method} ${path}`, res.status, json);++ return json;++ }++++ return {++ configured,++ appId: config.media.appId,++ createVod: (fields) => request('POST', '/vods', { body: fields }),++ ingestRtmp: (vodId, rtmpUrl) => request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtmp`, { body: { rtmp_url: rtmpUrl } }),++ finalizeVod: (vodId) => request('POST', `/vods/${encodeURIComponent(vodId)}/finalize`, { timeoutMs: 30000 }),++ deleteVod: (vodId) => request('DELETE', `/vods/${encodeURIComponent(vodId)}`),++ };++}++++module.exports = { createMediaClient, MediaError };nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * Two kinds of secret, handled differently:++ *++ * Ingest keys (what a streamer pastes into OBS) are OpenRe's own credentials. Only their SHA-256++ * is stored; the plain key exists once, in the response that created or rotated it. 256 bits of++ * randomness make a plain digest safe (no dictionary to try), and lookup stays one indexed read,++ * which matters because the RTMP worker authenticates synchronously inside the publish handshake.++ *++ * Destination stream keys (Twitch/YouTube/Kick/custom) belong to other platforms and must be++ * handed back to ffmpeg, so they are encrypted at rest with AES-256-GCM under OPENRE_SECRETS_KEY++ * and never returned in full by any API.++ */++const crypto = require('crypto');++++const KEY_PREFIX = 'ork_';++// ork_ + 43 base64url chars. Matches the RTMP path rule Live uses (/^[a-zA-Z0-9_-]{8,128}$/).++const INGEST_KEY_RE = /^ork_[A-Za-z0-9_-]{43}$/;++++function newIngestKey() {++ return KEY_PREFIX + crypto.randomBytes(32).toString('base64url');++}++++function hashIngestKey(key) {++ return crypto.createHash('sha256').update(String(key), 'utf8').digest('hex');++}++++function isIngestKeyShape(key) {++ return typeof key === 'string' && INGEST_KEY_RE.test(key);++}++++/** Last four characters, the only part of any secret that is ever displayed again. */++function hintOf(secret) {++ const s = String(secret || '');++ return s.length > 4 ? s.slice(-4) : '';++}++++// ── AES-256-GCM box ────────────────────────────────────────────++++function parseKey(raw) {++ if (!raw) return null;++ const s = String(raw).trim();++ let buf = null;++ if (/^[0-9a-fA-F]{64}$/.test(s)) buf = Buffer.from(s, 'hex');++ else {++ try { buf = Buffer.from(s, 'base64'); } catch { buf = null; }++ }++ if (!buf || buf.length !== 32) throw new Error('OPENRE_SECRETS_KEY must be 32 bytes (64 hex chars or base64)');++ return buf;++}++++function keyId(buf) {++ return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);++}++++/**++ * createBox({ key, previous }) -> { seal(plain) -> 'v1.<kid>.<iv>.<tag>.<ct>', open(sealed) -> plain, ready }++ * Without a key the box refuses to seal (destinations cannot be saved) rather than storing plain text.++ */++function createBox({ key, previous } = {}) {++ const current = parseKey(key);++ const old = parseKey(previous);++ const keys = new Map();++ if (current) keys.set(keyId(current), current);++ if (old) keys.set(keyId(old), old);++++ function seal(plain) {++ if (plain == null || plain === '') return null;++ if (!current) throw Object.assign(new Error('OPENRE_SECRETS_KEY is not configured'), { code: 'openre.secrets_unavailable' });++ const iv = crypto.randomBytes(12);++ const cipher = crypto.createCipheriv('aes-256-gcm', current, iv);++ const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);++ const tag = cipher.getAuthTag();++ return ['v1', keyId(current), iv.toString('base64url'), tag.toString('base64url'), ct.toString('base64url')].join('.');++ }++++ function open(sealed) {++ if (!sealed) return null;++ const parts = String(sealed).split('.');++ if (parts.length !== 5 || parts[0] !== 'v1') throw new Error('unrecognised sealed secret');++ const k = keys.get(parts[1]);++ if (!k) throw Object.assign(new Error('sealed with a key that is not configured'), { code: 'openre.secrets_unavailable' });++ const decipher = crypto.createDecipheriv('aes-256-gcm', k, Buffer.from(parts[2], 'base64url'));++ decipher.setAuthTag(Buffer.from(parts[3], 'base64url'));++ return Buffer.concat([decipher.update(Buffer.from(parts[4], 'base64url')), decipher.final()]).toString('utf8');++ }++++ return { seal, open, ready: Boolean(current) };++}++++module.exports = { newIngestKey, hashIngestKey, isIngestKeyShape, hintOf, createBox, KEY_PREFIX };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++/**++ * Ingest session lifecycle:++ *++ * starting ──► live ──► ending ──► ended++ * │ │ │++ * └──────────┴─────────┴──────► failed++ *++ * starting the worker accepted the publish handshake (key valid, lease taken)++ * live media is flowing (RTMP postPublish)++ * ending the publisher left or an end was requested; outputs and recording are wound down++ * ended terminal, clean++ * failed terminal, the transport was lost (worker died, lease expired, internal error)++ *++ * A session that never reached live may still go starting → ending → ended (a publisher that++ * disconnects during the handshake); it produced no started event, so it produces no ended event.++ */++++const STATES = Object.freeze(['starting', 'live', 'ending', 'ended', 'failed']);++const TERMINAL = new Set(['ended', 'failed']);++const NEXT = Object.freeze({++ starting: ['live', 'ending', 'failed'],++ live: ['ending', 'failed'],++ ending: ['ended', 'failed'],++ ended: [],++ failed: [],++});++++function canTransition(from, to) {++ return Boolean(NEXT[from]) && NEXT[from].includes(to);++}++++function isTerminal(state) {++ return TERMINAL.has(state);++}++++module.exports = { STATES, NEXT, TERMINAL, canTransition, isTerminal };nnew file mode 100644@@@ -0,0 +1,263 @@++'use strict';++/**++ * Stream (input) definitions and their ingest keys.++ *++ * A definition belongs to one owner subject (usr_… from OpenVibe.Network) and may carry typed++ * references to other services' entities, e.g. { service: 'live', type: 'managed_stream', id: '12' }++ * for the Live slot it serves. A reference is a pointer, never identity: ownership is the subject.++ */++const { ids } = require('openvibe-contracts');++const { newId, isId } = require('../ids');++const { newIngestKey, hashIngestKey, isIngestKeyShape, hintOf } = require('../secrets');++const { TYPES } = require('../events');++++const PROTOCOLS = Object.freeze(['rtmp', 'whip', 'webrtc', 'jsmpeg']);++const RECORDING_MODES = Object.freeze(['vod', 'clips', 'none']);++const VISIBILITIES = Object.freeze(['public', 'unlisted', 'private']);++const REF_SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const REF_TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;++++class StoreError extends Error {++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }++}++++function parseJson(v, d) {++ if (v == null) return d;++ try { return JSON.parse(v); } catch { return d; }++}++++function cleanText(v, max, d = '') {++ if (v == null) return d;++ return String(v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);++}++++function validateRefs(refs) {++ if (refs == null) return [];++ if (!Array.isArray(refs) || refs.length > 10) throw new StoreError(400, 'openre.invalid_ref', 'external_refs must be an array of at most 10 references');++ return refs.map((r) => {++ const service = String(r && r.service || '');++ const type = String(r && r.type || '');++ const id = String(r && r.id != null ? r.id : '');++ if (!REF_SERVICE_RE.test(service) || !REF_TYPE_RE.test(type) || !id || id.length > 128) {++ throw new StoreError(400, 'openre.invalid_ref', 'each external ref needs service, type and id');++ }++ return { service, type, id, label: r.label ? cleanText(r.label, 120) : null };++ });++}++++function createDefinitions({ db, config, events, clock }) {++ const now = () => clock.now();++++ const q = {++ insert: db.prepare(`INSERT INTO stream_definitions (id, owner_subject, title, description, protocols, recording_mode,++ recording_visibility, playback_visibility, mirror_to_live, state, revision, created_at, updated_at)++ VALUES (@id, @owner_subject, @title, @description, @protocols, @recording_mode, @recording_visibility,++ @playback_visibility, @mirror_to_live, 'active', 1, @now, @now)`),++ get: db.prepare('SELECT * FROM stream_definitions WHERE id = ?'),++ refs: db.prepare('SELECT service, type, ref_id AS id, label FROM external_refs WHERE definition_id = ? ORDER BY created_at, service, type'),++ insertRef: db.prepare('INSERT INTO external_refs (definition_id, service, type, ref_id, label, created_at) VALUES (?, ?, ?, ?, ?, ?)'),++ byRef: db.prepare('SELECT definition_id FROM external_refs WHERE service = ? AND type = ? AND ref_id = ?'),++ insertKey: db.prepare(`INSERT INTO ingest_keys (id, definition_id, key_hash, hint, status, created_by, created_at)++ VALUES (?, ?, ?, ?, 'active', ?, ?)`),++ activeKeys: db.prepare("SELECT * FROM ingest_keys WHERE definition_id = ? AND status = 'active'"),++ keysOf: db.prepare('SELECT id, hint, status, created_by, created_at, grace_until, revoked_at, revoked_reason, last_used_at FROM ingest_keys WHERE definition_id = ? ORDER BY created_at DESC LIMIT 50'),++ keyByHash: db.prepare('SELECT * FROM ingest_keys WHERE key_hash = ?'),++ touchKey: db.prepare('UPDATE ingest_keys SET last_used_at = ? WHERE id = ?'),++ revokeKey: db.prepare("UPDATE ingest_keys SET status = 'revoked', revoked_at = ?, revoked_reason = ?, grace_until = NULL WHERE id = ? AND status != 'revoked'"),++ graceKey: db.prepare("UPDATE ingest_keys SET status = 'grace', grace_until = ? WHERE id = ? AND status = 'active'"),++ expiredGrace: db.prepare("SELECT id FROM ingest_keys WHERE status = 'grace' AND grace_until <= ?"),++ };++++ function row(id) {++ const d = q.get.get(id);++ if (!d) return null;++ return {++ ...d,++ protocols: parseJson(d.protocols, ['rtmp']),++ mirror_to_live: Boolean(d.mirror_to_live),++ external_refs: q.refs.all(id),++ };++ }++++ function get(id) {++ return isId('stream', id) ? row(id) : null;++ }++++ function list({ owner_subject, state, limit = 100 } = {}) {++ const where = [];++ const args = [];++ if (owner_subject) { where.push('owner_subject = ?'); args.push(owner_subject); }++ if (state) { where.push('state = ?'); args.push(state); } else where.push("state != 'archived'");++ const rows = db.prepare(`SELECT id FROM stream_definitions ${where.length ? `WHERE ${where.join(' AND ')}` : ''}++ ORDER BY created_at DESC LIMIT ?`).all(...args, Math.min(Math.max(limit, 1), 500));++ return rows.map(r => row(r.id));++ }++++ function findByRef(service, type, refId) {++ const r = q.byRef.get(String(service), String(type), String(refId));++ return r ? row(r.definition_id) : null;++ }++++ function normalize(input, base = {}) {++ const out = {};++ if (input.title !== undefined || base.title === undefined) out.title = cleanText(input.title, 140, '') || base.title || 'Untitled stream';++ if (input.description !== undefined) out.description = cleanText(input.description, 2000, '');++ if (input.protocols !== undefined) {++ const p = Array.isArray(input.protocols) ? [...new Set(input.protocols.map(String))] : null;++ if (!p || !p.length || p.some(x => !PROTOCOLS.includes(x))) throw new StoreError(400, 'openre.invalid_protocols', `protocols must be a non-empty subset of ${PROTOCOLS.join(', ')}`);++ out.protocols = JSON.stringify(p);++ }++ for (const [k, allowed] of [['recording_mode', RECORDING_MODES], ['recording_visibility', VISIBILITIES], ['playback_visibility', VISIBILITIES]]) {++ if (input[k] !== undefined) {++ if (!allowed.includes(input[k])) throw new StoreError(400, 'openre.invalid_field', `${k} must be one of ${allowed.join(', ')}`);++ out[k] = input[k];++ }++ }++ if (input.mirror_to_live !== undefined) out.mirror_to_live = input.mirror_to_live ? 1 : 0;++ if (input.state !== undefined) {++ if (!['active', 'disabled'].includes(input.state)) throw new StoreError(400, 'openre.invalid_field', 'state must be active or disabled');++ out.state = input.state;++ }++ return out;++ }++++ /** Issue a new active key (inside the caller's transaction). Returns the plain key once. */++ function issueKey(definitionId, createdBy) {++ const plain = newIngestKey();++ const id = newId('key', now());++ q.insertKey.run(id, definitionId, hashIngestKey(plain), hintOf(plain), createdBy || null, now());++ return { id, key: plain, hint: hintOf(plain) };++ }++++ /**++ * create({ owner_subject, title, ..., external_refs, created_by }) -> { definition, key: { id, key, hint } }++ * The first ingest key is issued with the definition; its plain value is only in this return.++ */++ function create(input) {++ if (!ids.isSubjectId('user', input.owner_subject)) throw new StoreError(400, 'openre.invalid_owner', 'owner_subject must be a usr_… subject id');++ const given = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined && v !== null && v !== ''));++ const fields = normalize({ protocols: ['rtmp'], recording_mode: 'vod', recording_visibility: 'public', playback_visibility: 'public', mirror_to_live: false, ...given });++ const refs = validateRefs(input.external_refs);++ const id = newId('stream', now());++ return db.transaction(() => {++ for (const r of refs) {++ if (q.byRef.get(r.service, r.type, r.id)) throw new StoreError(409, 'openre.ref_taken', `${r.service}:${r.type}:${r.id} already belongs to another stream definition`);++ }++ q.insert.run({ id, owner_subject: input.owner_subject, description: '', ...fields, now: now() });++ for (const r of refs) q.insertRef.run(id, r.service, r.type, r.id, r.label, now());++ const key = issueKey(id, input.created_by);++ return { definition: row(id), key };++ })();++ }++++ function update(id, input) {++ const current = get(id);++ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');++ const fields = normalize(input, current);++ const keys = Object.keys(fields);++ if (!keys.length) return current;++ db.prepare(`UPDATE stream_definitions SET ${keys.map(k => `${k} = @${k}`).join(', ')}, revision = revision + 1, updated_at = @now WHERE id = @id`)++ .run({ ...fields, id, now: now() });++ return row(id);++ }++++ function archive(id) {++ const current = get(id);++ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');++ const live = db.prepare("SELECT id FROM ingest_sessions WHERE definition_id = ? AND state IN ('starting', 'live', 'ending')").get(id);++ if (live) throw new StoreError(409, 'openre.stream_live', 'end the live session before archiving this stream');++ db.transaction(() => {++ db.prepare("UPDATE stream_definitions SET state = 'archived', revision = revision + 1, updated_at = ? WHERE id = ?").run(now(), id);++ for (const k of q.activeKeys.all(id)) q.revokeKey.run(now(), 'archived', k.id);++ db.prepare("UPDATE ingest_keys SET status = 'revoked', revoked_at = ?, revoked_reason = 'archived' WHERE definition_id = ? AND status = 'grace'").run(now(), id);++ })();++ return row(id);++ }++++ /**++ * Rotate: issue a new key and retire every active one. grace_seconds = 0 revokes them now;++ * > 0 lets them keep authenticating NEW publishes until then (they are revoked by the++ * coordinator when the grace ends). Sessions already running are never cut by a rotation++ * unless end_sessions is set (the caller then asks the owning worker to end them).++ */++ function rotateKey(id, { grace_seconds = 0, rotated_by = null, reason = 'rotated' } = {}) {++ const current = get(id);++ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');++ const grace = Math.max(0, Math.min(Number(grace_seconds) || 0, 7 * 24 * 3600));++ return db.transaction(() => {++ const old = q.activeKeys.all(id);++ const graceUntil = grace ? now() + grace * 1000 : null;++ for (const k of old) {++ if (graceUntil) q.graceKey.run(graceUntil, k.id);++ else q.revokeKey.run(now(), reason, k.id);++ }++ const key = issueKey(id, rotated_by);++ events.enqueue({++ event_type: TYPES.keyRotated,++ actor: rotated_by && /^usr_/.test(rotated_by) ? { type: 'user', id: rotated_by } : { type: 'service', id: rotated_by ? String(rotated_by).replace(/^svc:/, '') : 'openre' },++ subject: { type: 'stream', id, revision: current.revision },++ visibility: 'internal',++ priority: 'important',++ payload: {++ stream_id: id,++ owner: { type: 'user', id: current.owner_subject },++ key_id: key.id,++ key_hint: key.hint,++ retired_key_ids: old.map(k => k.id),++ grace_until: graceUntil ? new Date(graceUntil).toISOString() : null,++ external_refs: current.external_refs,++ },++ });++ return { key, retired: old.map(k => ({ id: k.id, hint: k.hint, status: graceUntil ? 'grace' : 'revoked' })), grace_until: graceUntil };++ })();++ }++++ /** Revoke keys whose grace period is over (coordinator). */++ function expireGraceKeys() {++ const rows = q.expiredGrace.all(now());++ for (const r of rows) q.revokeKey.run(now(), 'grace_ended', r.id);++ return rows.length;++ }++++ function keys(id) {++ return q.keysOf.all(id);++ }++++ /**++ * The RTMP (or later WHIP) worker's check, synchronous by design: a publish handshake is++ * answered inside node-media-server's prePublish handler. Only OpenRe's own hashed keys count:++ * this never looks at any other service's database.++ */++ function resolveIngestKey(plain, protocol) {++ if (!isIngestKeyShape(plain)) return { error: 'malformed_key' };++ const key = q.keyByHash.get(hashIngestKey(plain));++ if (!key) return { error: 'unknown_key' };++ if (key.status === 'revoked') return { error: 'revoked_key' };++ if (key.status === 'grace' && !(key.grace_until > now())) return { error: 'revoked_key' };++ const definition = row(key.definition_id);++ if (!definition || definition.state !== 'active') return { error: 'stream_disabled' };++ if (!definition.protocols.includes(protocol)) return { error: 'protocol_not_allowed' };++ q.touchKey.run(now(), key.id);++ return { definition, key: { id: key.id, hint: key.hint, status: key.status } };++ }++++ /** What an encoder needs: server URL and which key (by hint) is current. Never the key. */++ function ingestEndpoints(definition) {++ const r = config.rtmp;++ const portSuffix = r.publicPort === 1935 ? '' : `:${r.publicPort}`;++ const active = keys(definition.id).find(k => k.status === 'active');++ const out = {};++ if (definition.protocols.includes('rtmp')) {++ out.rtmp = {++ url: `rtmp://${r.publicHost}${portSuffix}/live`,++ key_hint: active ? active.hint : null,++ key_id: active ? active.id : null,++ };++ }++ return out;++ }++++ return { get, list, findByRef, create, update, archive, rotateKey, expireGraceKeys, keys, resolveIngestKey, ingestEndpoints, issueKey, row };++}++++module.exports = { createDefinitions, StoreError, PROTOCOLS, RECORDING_MODES, VISIBILITIES, cleanText, parseJson, validateRefs };nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++/**++ * One entry point for every process: open the database, the event outbox and the store modules.++ *++ * const rt = openRuntime({ config }); // rt.db, rt.events, rt.store.{definitions,sessions,...}++ */++const { openDb } = require('../db');++const { createEvents } = require('../events');++const { createBox } = require('../secrets');++const { createDefinitions } = require('./definitions');++const { createWorkers } = require('./workers');++const { createSessions } = require('./sessions');++const { createOutputs } = require('./outputs');++const { createRecordings } = require('./recordings');++++function createStore({ db, config, events, clock, box, log = console }) {++ const definitions = createDefinitions({ db, config, events, clock });++ const workers = createWorkers({ db, config, clock });++ const sessions = createSessions({ db, config, events, clock, definitions, workers });++ const outputs = createOutputs({ db, config, events, clock, box, definitions, sessions });++ const recordings = createRecordings({ db, config, events, clock, definitions, sessions, log });++ return { definitions, workers, sessions, outputs, recordings };++}++++function openRuntime({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, db } = {}) {++ db = db || openDb(config.dbPath);++ const events = createEvents({ config, db, fetchImpl, now: () => clock.now(), log });++ const box = createBox({ key: config.secretsKey, previous: config.secretsKeyPrevious });++ const store = createStore({ db, config, events, clock, box, log });++ return { db, events, box, store, clock, config };++}++++module.exports = { openRuntime, createStore };nnew file mode 100644@@@ -0,0 +1,350 @@++'use strict';++/**++ * Restream destinations (per stream definition) and outputs (one destination running inside one++ * session), with health, a circuit breaker and bounded logs.++ *++ * Isolation rule: nothing in this file changes an ingest session. An output that fails, however++ * badly, ends in its own 'failed' row and an openre.output.failed event; the session carries on.++ */++const { newId, isId } = require('../ids');++const { hintOf } = require('../secrets');++const { TYPES } = require('../events');++const { validateDestinationUrl } = require('../destination-url');++const { StoreError, cleanText, parseJson } = require('./definitions');++++const PLATFORMS = Object.freeze(['youtube', 'twitch', 'kick', 'custom']);++const QUALITY_PRESETS = Object.freeze(['auto', 'low', 'medium', 'high', 'ultra', 'source']);++const ENCODER_PRESETS = Object.freeze(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow']);++const OPEN = "('pending', 'starting', 'live', 'error')";++++function createOutputs({ db, config, events, clock, box, definitions, sessions }) {++ const now = () => clock.now();++ const q = {++ getDest: db.prepare('SELECT * FROM destinations WHERE id = ?'),++ destsOf: db.prepare('SELECT * FROM destinations WHERE definition_id = ? ORDER BY created_at'),++ countOf: db.prepare('SELECT COUNT(*) AS n FROM destinations WHERE definition_id = ?'),++ deleteDest: db.prepare('DELETE FROM destinations WHERE id = ?'),++ getOut: db.prepare('SELECT * FROM outputs WHERE id = ?'),++ outBySessionDest: db.prepare('SELECT * FROM outputs WHERE session_id = ? AND destination_id = ?'),++ outsOfSession: db.prepare('SELECT * FROM outputs WHERE session_id = ? ORDER BY created_at'),++ insertOut: db.prepare(`INSERT INTO outputs (id, session_id, destination_id, desired, state, created_at, updated_at)++ VALUES (?, ?, ?, 'run', 'pending', ?, ?)`),++ pendingUnassigned: db.prepare(`SELECT o.* FROM outputs o JOIN ingest_sessions s ON s.id = o.session_id++ WHERE o.desired = 'run' AND o.state = 'pending' AND o.worker_id IS NULL AND s.state = 'live' ORDER BY o.created_at`),++ assign: db.prepare("UPDATE outputs SET worker_id = ?, worker_generation = ?, updated_at = ? WHERE id = ? AND worker_id IS NULL AND state = 'pending'"),++ forWorker: db.prepare(`SELECT * FROM outputs WHERE worker_id = ? AND state IN ${OPEN}`),++ release: db.prepare(`UPDATE outputs SET worker_id = NULL, worker_generation = NULL, state = 'pending', updated_at = ?++ WHERE worker_id = ? AND state IN ${OPEN} AND desired = 'run'`),++ stopOrphans: db.prepare(`UPDATE outputs SET state = 'stopped', ended_at = ?, updated_at = ?++ WHERE state IN ${OPEN} AND (desired = 'stop' AND (worker_id IS NULL OR worker_id IN (SELECT id FROM workers WHERE state IN ('stopped', 'lost'))))`),++ insertLog: db.prepare('INSERT INTO output_logs (output_id, destination_id, level, message, at) VALUES (?, ?, ?, ?, ?)'),++ pruneLogs: db.prepare(`DELETE FROM output_logs WHERE id IN (SELECT id FROM output_logs WHERE destination_id = ? AND++ (output_id IS ? OR output_id = ?) ORDER BY id DESC LIMIT -1 OFFSET ?)`),++ logsOfOutput: db.prepare('SELECT level, message, at FROM output_logs WHERE output_id = ? ORDER BY id DESC LIMIT ?'),++ logsOfDest: db.prepare('SELECT output_id, level, message, at FROM output_logs WHERE destination_id = ? ORDER BY id DESC LIMIT ?'),++ };++++ // ── Destinations ──────────────────────────────────────────++++ function publicDest(d) {++ if (!d) return null;++ return {++ id: d.id,++ stream_id: d.definition_id,++ platform: d.platform,++ name: d.name,++ server_url: d.server_url,++ transport: /^srt:\/\//i.test(d.server_url) ? 'srt' : 'rtmp',++ has_stream_key: Boolean(d.stream_key_enc),++ stream_key_hint: d.key_hint ? `****${d.key_hint}` : null,++ has_srt_passphrase: Boolean(d.srt_passphrase_enc),++ srt_latency_ms: d.srt_latency_ms,++ enabled: Boolean(d.enabled),++ auto_start: Boolean(d.auto_start),++ quality_preset: d.quality_preset,++ custom_video_bitrate: d.custom_video_bitrate,++ custom_audio_bitrate: d.custom_audio_bitrate,++ custom_fps: d.custom_fps,++ custom_encoder_preset: d.custom_encoder_preset,++ hold_reason: d.hold_reason,++ consecutive_failures: d.consecutive_failures,++ cooldown_until: d.cooldown_until ? new Date(d.cooldown_until).toISOString() : null,++ cooldown_ms: cooldownMs(d),++ last_error: d.last_error,++ last_failed_at: d.last_failed_at ? new Date(d.last_failed_at).toISOString() : null,++ created_at: new Date(d.created_at).toISOString(),++ updated_at: new Date(d.updated_at).toISOString(),++ };++ }++++ function cooldownMs(d) {++ return d && d.cooldown_until && d.cooldown_until > now() ? d.cooldown_until - now() : 0;++ }++++ const intIn = (v, lo, hi) => {++ if (v === null || v === '') return null;++ const n = parseInt(v, 10);++ return Number.isFinite(n) && n >= lo && n <= hi ? n : null;++ };++++ /** Validate and map API input to columns. Secrets are sealed here and never read back out. */++ function destFields(input, { creating }) {++ const f = {};++ if (creating || input.platform !== undefined) {++ if (!PLATFORMS.includes(input.platform)) throw new StoreError(400, 'openre.invalid_platform', `platform must be one of ${PLATFORMS.join(', ')}`);++ f.platform = input.platform;++ }++ if (input.name !== undefined) f.name = cleanText(input.name, 80, '') || null;++ if (creating || input.server_url !== undefined) {++ const v = validateDestinationUrl(input.server_url, { allowPrivate: config.outputs.allowPrivateHosts });++ if (!v.ok) throw new StoreError(400, 'openre.invalid_destination_url', v.error);++ f.server_url = v.value;++ }++ if (input.stream_key !== undefined && input.stream_key !== '__keep__') {++ const key = input.stream_key == null ? '' : String(input.stream_key).trim();++ if (key.length > 512 || /[\s\u0000-\u001f\u007f]/.test(key)) throw new StoreError(400, 'openre.invalid_stream_key', 'stream key must be at most 512 characters without spaces');++ f.stream_key_enc = key ? box.seal(key) : null;++ f.key_hint = key ? hintOf(key) : null;++ }++ if (input.srt_passphrase !== undefined && input.srt_passphrase !== '__keep__') {++ const p = input.srt_passphrase == null ? '' : String(input.srt_passphrase);++ if (p && (p.length < 10 || p.length > 79)) throw new StoreError(400, 'openre.invalid_srt_passphrase', 'SRT passphrase must be 10–79 characters');++ f.srt_passphrase_enc = p ? box.seal(p) : null;++ }++ if (input.srt_latency_ms !== undefined) f.srt_latency_ms = intIn(input.srt_latency_ms, 20, 8000);++ if (input.enabled !== undefined) f.enabled = input.enabled ? 1 : 0;++ if (input.auto_start !== undefined) f.auto_start = input.auto_start ? 1 : 0;++ if (input.quality_preset !== undefined) {++ if (!QUALITY_PRESETS.includes(input.quality_preset)) throw new StoreError(400, 'openre.invalid_field', `quality_preset must be one of ${QUALITY_PRESETS.join(', ')}`);++ f.quality_preset = input.quality_preset;++ }++ if (input.custom_video_bitrate !== undefined) f.custom_video_bitrate = intIn(input.custom_video_bitrate, 500, 50000);++ if (input.custom_audio_bitrate !== undefined) f.custom_audio_bitrate = intIn(input.custom_audio_bitrate, 32, 512);++ if (input.custom_fps !== undefined) f.custom_fps = intIn(input.custom_fps, 15, 120);++ if (input.custom_encoder_preset !== undefined) f.custom_encoder_preset = ENCODER_PRESETS.includes(input.custom_encoder_preset) ? input.custom_encoder_preset : null;++ return f;++ }++++ function createDestination(definitionId, input, { hold_reason = null } = {}) {++ const def = definitions.get(definitionId);++ if (!def || def.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');++ if (q.countOf.get(definitionId).n >= config.outputs.maxPerStream) throw new StoreError(409, 'openre.too_many_destinations', `at most ${config.outputs.maxPerStream} destinations per stream`);++ const f = destFields(input, { creating: true });++ const id = newId('destination', now());++ const row = { id, definition_id: definitionId, enabled: 1, auto_start: 1, quality_preset: 'auto', ...f, hold_reason, created_at: now(), updated_at: now() };++ if (hold_reason) row.enabled = 0;++ const cols = Object.keys(row);++ db.prepare(`INSERT INTO destinations (${cols.join(', ')}) VALUES (${cols.map(c => `@${c}`).join(', ')})`).run(row);++ return publicDest(q.getDest.get(id));++ }++++ function updateDestination(id, input) {++ const d = q.getDest.get(id);++ if (!d) throw new StoreError(404, 'openre.destination_not_found', 'no such destination');++ const f = destFields(input, { creating: false });++ // Re-enabling a held destination is an explicit owner decision: the hold is lifted only++ // when its URL now passes the rules (destFields validated any new server_url).++ if (f.enabled === 1 && d.hold_reason) {++ const v = validateDestinationUrl(f.server_url || d.server_url, { allowPrivate: config.outputs.allowPrivateHosts });++ if (!v.ok) throw new StoreError(409, 'openre.destination_held', `held: ${d.hold_reason} (${v.error})`);++ f.hold_reason = null;++ }++ if (!Object.keys(f).length) return publicDest(d);++ f.updated_at = now();++ const cols = Object.keys(f);++ db.prepare(`UPDATE destinations SET ${cols.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id`).run({ ...f, id });++ return publicDest(q.getDest.get(id));++ }++++ function deleteDestination(id) {++ const d = q.getDest.get(id);++ if (!d) throw new StoreError(404, 'openre.destination_not_found', 'no such destination');++ db.transaction(() => {++ db.prepare(`UPDATE outputs SET desired = 'stop', updated_at = ? WHERE destination_id = ? AND state IN ${OPEN}`).run(now(), id);++ const running = db.prepare(`SELECT 1 FROM outputs WHERE destination_id = ? AND state IN ${OPEN} AND worker_id IS NOT NULL`).get(id);++ if (running) throw new StoreError(409, 'openre.destination_running', 'the destination is running; it has been asked to stop, delete it again in a few seconds');++ q.deleteDest.run(id);++ })();++ return true;++ }++++ /** Destination row with its secrets opened, for the restream worker only. */++ function destinationForWorker(id) {++ const d = q.getDest.get(id);++ if (!d) return null;++ return { ...d, stream_key: box.open(d.stream_key_enc), srt_passphrase: box.open(d.srt_passphrase_enc) };++ }++++ function markDestinationFailure(id, error) {++ const d = q.getDest.get(id);++ if (!d) return null;++ const n = (d.consecutive_failures || 0) + 1;++ // Same escalation as Live: 15 min, 1 h, 6 h, then 24 h.++ const mins = n <= 1 ? 15 : n === 2 ? 60 : n === 3 ? 360 : 1440;++ db.prepare('UPDATE destinations SET consecutive_failures = ?, last_error = ?, last_failed_at = ?, cooldown_until = ?, updated_at = ? WHERE id = ?')++ .run(n, String(error || 'restream failed to go live').slice(0, 300), now(), now() + mins * 60000, now(), id);++ return { failures: n, cooldownMinutes: mins };++ }++++ function clearDestinationCooldown(id) {++ db.prepare('UPDATE destinations SET consecutive_failures = 0, cooldown_until = NULL, last_error = NULL WHERE id = ? AND (consecutive_failures > 0 OR cooldown_until IS NOT NULL OR last_error IS NOT NULL)').run(id);++ }++++ // ── Outputs ───────────────────────────────────────────────++++ function publicOutput(o) {++ if (!o) return null;++ return {++ id: o.id,++ session_id: o.session_id,++ destination_id: o.destination_id,++ desired: o.desired,++ state: o.state,++ worker: o.worker_id ? { id: o.worker_id, generation: o.worker_generation } : null,++ restart_attempts: o.restart_attempts,++ max_restart_attempts: config.outputs.maxRestarts,++ next_restart_at: o.next_restart_at ? new Date(o.next_restart_at).toISOString() : null,++ ever_live: Boolean(o.ever_live),++ last_error: o.last_error,++ progress: parseJson(o.progress, null),++ started_at: o.started_at ? new Date(o.started_at).toISOString() : null,++ live_at: o.live_at ? new Date(o.live_at).toISOString() : null,++ uptime_ms: o.state === 'live' && o.live_at ? now() - o.live_at : 0,++ ended_at: o.ended_at ? new Date(o.ended_at).toISOString() : null,++ updated_at: new Date(o.updated_at).toISOString(),++ };++ }++++ /** Coordinator: every live session gets an output per enabled, auto-start, usable destination. */++ function ensureAutoOutputs() {++ const rows = db.prepare(`SELECT s.id AS session_id, d.id AS destination_id FROM ingest_sessions s++ JOIN destinations d ON d.definition_id = s.definition_id++ LEFT JOIN outputs o ON o.session_id = s.id AND o.destination_id = d.id++ WHERE s.state = 'live' AND d.enabled = 1 AND d.auto_start = 1 AND d.hold_reason IS NULL++ AND d.stream_key_enc IS NOT NULL AND (d.cooldown_until IS NULL OR d.cooldown_until <= ?) AND o.id IS NULL`).all(now());++ for (const r of rows) q.insertOut.run(newId('output', now()), r.session_id, r.destination_id, now(), now());++ return rows.length;++ }++++ /**++ * Manual start (the owner pressed Start): creates or re-arms the output for the destination's++ * current live session. A manual start ignores a failure cooldown, like Live's.++ */++ function startDestination(destinationId) {++ const d = q.getDest.get(destinationId);++ if (!d) throw new StoreError(404, 'openre.destination_not_found', 'no such destination');++ if (d.hold_reason) throw new StoreError(409, 'openre.destination_held', `held: ${d.hold_reason}`);++ if (!d.enabled) throw new StoreError(409, 'openre.destination_disabled', 'enable the destination first');++ if (!d.stream_key_enc) throw new StoreError(409, 'openre.destination_no_key', 'the destination has no stream key');++ const session = sessions.list({ definition_id: d.definition_id, state: 'live', limit: 1 })[0];++ if (!session) throw new StoreError(409, 'openre.not_live', 'the stream is not live');++ return db.transaction(() => {++ clearDestinationCooldown(d.id);++ const existing = q.outBySessionDest.get(session.id, d.id);++ if (!existing) {++ const id = newId('output', now());++ q.insertOut.run(id, session.id, d.id, now(), now());++ return publicOutput(q.getOut.get(id));++ }++ if (['pending', 'starting', 'live', 'error'].includes(existing.state) && existing.desired === 'run') return publicOutput(existing);++ if (['pending', 'starting', 'live', 'error'].includes(existing.state)) {++ db.prepare("UPDATE outputs SET desired = 'run', updated_at = ? WHERE id = ?").run(now(), existing.id);++ } else {++ db.prepare(`UPDATE outputs SET desired = 'run', state = 'pending', worker_id = NULL, worker_generation = NULL,++ restart_attempts = 0, next_restart_at = NULL, last_error = NULL, ended_at = NULL, updated_at = ? WHERE id = ?`).run(now(), existing.id);++ }++ return publicOutput(q.getOut.get(existing.id));++ })();++ }++++ function stopDestination(destinationId) {++ const n = db.prepare(`UPDATE outputs SET desired = 'stop', updated_at = ? WHERE destination_id = ? AND desired = 'run' AND state IN ${OPEN}`).run(now(), destinationId).changes;++ // Pending outputs nobody has picked up can stop right here.++ db.prepare("UPDATE outputs SET state = 'stopped', ended_at = ?, updated_at = ? WHERE destination_id = ? AND desired = 'stop' AND state = 'pending' AND worker_id IS NULL").run(now(), now(), destinationId);++ return n;++ }++++ /** Coordinator: hand pending outputs to the newest ready restream generation. */++ function assignPending(worker) {++ let n = 0;++ for (const o of q.pendingUnassigned.all()) n += q.assign.run(worker.id, worker.generation, now(), o.id).changes;++ return n;++ }++++ /** State changes reported by the restream worker, with the events they imply. */++ function report(outputId, change) {++ return db.transaction(() => {++ const o = q.getOut.get(outputId);++ if (!o) return null;++ const set = { updated_at: now() };++ for (const k of ['state', 'last_error', 'restart_attempts', 'next_restart_at', 'started_at', 'live_at', 'ended_at']) {++ if (change[k] !== undefined) set[k] = change[k];++ }++ if (change.progress !== undefined) set.progress = change.progress ? JSON.stringify(change.progress) : null;++ if (change.state === 'live') set.ever_live = 1;++ const cols = Object.keys(set);++ db.prepare(`UPDATE outputs SET ${cols.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id`).run({ ...set, id: outputId });++ const after = q.getOut.get(outputId);++ if (change.state && change.state !== o.state && (change.state === 'live' || change.state === 'failed')) {++ const dest = q.getDest.get(o.destination_id);++ const session = sessions.get(o.session_id);++ if (dest && session) {++ events.enqueue({++ event_type: change.state === 'live' ? TYPES.outputHealthy : TYPES.outputFailed,++ actor: { type: 'service', id: 'openre' },++ subject: { type: 'output', id: o.id, revision: after.restart_attempts + 1 },++ visibility: 'internal',++ priority: change.state === 'failed' ? 'important' : 'low',++ payload: {++ output_id: o.id,++ session_id: o.session_id,++ stream_id: session.definition_id,++ destination: { id: dest.id, platform: dest.platform, name: dest.name },++ state: change.state,++ error: change.state === 'failed' ? after.last_error : null,++ cooldown_minutes: change.cooldown_minutes || null,++ },++ });++ }++ }++ return after;++ })();++ }++++ function log(outputId, destinationId, level, message) {++ const text = String(message || '').slice(0, 500);++ q.insertLog.run(outputId || null, destinationId, level, text, now());++ q.pruneLogs.run(destinationId, outputId || null, outputId || null, config.outputs.logsPerOutput);++ }++++ return {++ PLATFORMS, QUALITY_PRESETS, ENCODER_PRESETS,++ publicDest,++ publicOutput,++ getDestination: (id) => (isId('destination', id) ? publicDest(q.getDest.get(id)) : null),++ destinationRow: (id) => q.getDest.get(id),++ destinations: (definitionId) => q.destsOf.all(definitionId).map(publicDest),++ createDestination,++ updateDestination,++ deleteDestination,++ destinationForWorker,++ markDestinationFailure,++ clearDestinationCooldown,++ getOutput: (id) => (isId('output', id) ? publicOutput(q.getOut.get(id)) : null),++ outputRow: (id) => q.getOut.get(id),++ outputsOfSession: (sessionId) => q.outsOfSession.all(sessionId).map(publicOutput),++ ensureAutoOutputs,++ startDestination,++ stopDestination,++ assignPending,++ forWorker: (workerId) => q.forWorker.all(workerId),++ release: (workerId) => q.release.run(now(), workerId).changes,++ stopOrphans: () => q.stopOrphans.run(now(), now()).changes,++ report,++ log,++ logsOfOutput: (id, limit = 100) => q.logsOfOutput.all(id, limit),++ logsOfDestination: (id, limit = 100) => q.logsOfDest.all(id, limit),++ };++}++++module.exports = { createOutputs, PLATFORMS, QUALITY_PRESETS, ENCODER_PRESETS };nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++/**++ * Recording requests to OpenVibe.Media (plan §15.10: "recordings are independent Media jobs").++ *++ * pending ─► requested ─► recording ─► finalizing ─► finalized++ * │ │ │ │++ * └───────────┴────────────┴─────────────┴──► failed (retries exhausted)++ * pending ─► cancelled (the session ended before Media was asked)++ *++ * Driven by the session coordinator, never by a transport worker or the API: a worker only has to++ * keep the loopback play URL up, and Media's ffmpeg pulls from it. Media finalises.++ */++const { newId } = require('../ids');++const { TYPES } = require('../events');++const { isTerminal } = require('../state-machine');++++const BACKOFF_MS = [5000, 15000, 30000, 60000, 120000, 300000];++const MAX_ATTEMPTS = 10;++// Media refuses a recording while its disk is critically low and frees space within minutes;++// keep asking while the stream is live (Live's recorder does the same: 12 × 5 min).++const DISK_RETRY_MS = 5 * 60 * 1000;++const DISK_MAX_ATTEMPTS = 12;++++function createRecordings({ db, config, events, clock, definitions, sessions, log = console }) {++ const now = () => clock.now();++ const q = {++ get: db.prepare('SELECT * FROM recordings WHERE id = ?'),++ bySession: db.prepare('SELECT * FROM recordings WHERE session_id = ?'),++ missing: db.prepare(`SELECT s.id AS session_id, d.recording_mode AS mode FROM ingest_sessions s++ JOIN stream_definitions d ON d.id = s.definition_id LEFT JOIN recordings r ON r.session_id = s.id++ WHERE s.state = 'live' AND d.recording_mode IN ('vod', 'clips') AND s.live_at <= ? AND r.id IS NULL`),++ insert: db.prepare(`INSERT INTO recordings (id, session_id, mode, state, media_app, created_at, updated_at)++ VALUES (?, ?, ?, 'pending', ?, ?, ?)`),++ due: db.prepare(`SELECT r.* FROM recordings r JOIN ingest_sessions s ON s.id = r.session_id++ WHERE r.next_attempt_at <= ? AND (r.state IN ('pending', 'requested', 'finalizing')++ OR (r.state = 'recording' AND s.state IN ('ending', 'ended', 'failed')))++ ORDER BY r.created_at LIMIT 20`),++ set: (cols) => db.prepare(`UPDATE recordings SET ${cols.map(c => `${c} = @${c}`).join(', ')}, updated_at = @now WHERE id = @id`),++ };++++ function update(id, fields) {++ q.set(Object.keys(fields)).run({ ...fields, id, now: now() });++ return q.get.get(id);++ }++++ function ensureRequests() {++ const rows = q.missing.all(now() - config.media.startDelayMs);++ for (const r of rows) q.insert.run(newId('recording', now()), r.session_id, r.mode, config.media.appId, now(), now());++ return rows.length;++ }++++ function retryLater(rec, err, { disk = false } = {}) {++ const attempts = rec.attempts + 1;++ const limit = disk ? DISK_MAX_ATTEMPTS : MAX_ATTEMPTS;++ if (attempts >= limit) return update(rec.id, { attempts, state: 'failed', last_error: String(err.message || err).slice(0, 300) });++ const wait = disk ? DISK_RETRY_MS : BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)];++ return update(rec.id, { attempts, next_attempt_at: now() + wait, last_error: String(err.message || err).slice(0, 300) });++ }++++ function refOf(definition, service, type) {++ const r = definition.external_refs.find(x => x.service === service && x.type === type);++ return r ? r.id : undefined;++ }++++ async function step(rec, media) {++ const session = sessions.get(rec.session_id);++ if (!session) return update(rec.id, { state: 'failed', last_error: 'session missing' });++ const definition = definitions.row(session.definition_id);++ const ended = isTerminal(session.state) || session.state === 'ending';++++ if (rec.state === 'pending') {++ if (ended) return update(rec.id, { state: 'cancelled' });++ const pb = sessions.playback(session);++ if (!pb || !pb.rtmp) return retryLater(rec, new Error('no loopback play URL for this session yet'));++ let vodId = rec.media_vod_id;++ try {++ if (!vodId) {++ // Media's 'live' tenant files VODs under Live-local ids; they travel as typed++ // references on the definition (compatibility only, never identity).++ const liveUser = refOf(definition, 'live', 'user');++ const liveSlot = refOf(definition, 'live', 'managed_stream');++ const out = await media.createVod({++ title: definition.title || 'Stream Recording',++ user_id: liveUser != null ? Number(liveUser) : undefined,++ managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,++ clips_only: rec.mode === 'clips' || undefined,++ visibility: definition.recording_visibility,++ meta: { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: session.protocol, mode: rec.mode },++ });++ vodId = String(out && out.id);++ rec = update(rec.id, { media_vod_id: vodId, state: 'requested' });++ }++ await media.ingestRtmp(vodId, pb.rtmp.internal_url);++ } catch (err) {++ log.warn(`[recording] ${rec.id} request failed: ${err.message}`);++ // Media may have made the VOD row before the ingest was refused (disk low): drop the++ // empty shell so it never shows as a 0:00 VOD, and start over next time.++ if (vodId) await media.deleteVod(vodId).catch(() => {});++ update(rec.id, { media_vod_id: null, state: 'pending' });++ return retryLater(q.get.get(rec.id), err, { disk: /disk/i.test(String(err.message)) });++ }++ return db.transaction(() => {++ const r = update(rec.id, { state: 'recording', attempts: 0, last_error: null, next_attempt_at: 0 });++ events.enqueue({++ event_type: TYPES.recordingRequested,++ actor: { type: 'service', id: 'openre' },++ subject: { type: 'recording', id: rec.id, revision: 1 },++ visibility: 'internal',++ priority: 'important',++ payload: {++ recording_id: rec.id,++ session_id: session.id,++ stream_id: definition.id,++ owner: { type: 'user', id: definition.owner_subject },++ mode: rec.mode,++ media: { app: config.media.appId, vod_id: r.media_vod_id },++ external_refs: definition.external_refs,++ },++ });++ return r;++ })();++ }++++ if (rec.state === 'requested') {++ // A create that succeeded and an ingest that did not: handled above; a requested row++ // here means the process died in between. Start the ingest again.++ return update(rec.id, { state: 'pending' });++ }++++ if (rec.state === 'recording' && ended) rec = update(rec.id, { state: 'finalizing' });++ if (rec.state === 'finalizing') {++ try {++ await media.finalizeVod(rec.media_vod_id);++ if (rec.mode === 'clips') await media.deleteVod(rec.media_vod_id).catch(() => {});++ } catch (err) {++ // 409 = Media already finalising it (its ffmpeg saw the source end): that is success.++ if (err.status !== 409) return retryLater(rec, err);++ }++ return update(rec.id, { state: 'finalized', last_error: null });++ }++ return rec;++ }++++ /** One pass over due requests (coordinator). */++ async function process(media) {++ if (!media.configured) return 0;++ let n = 0;++ for (const rec of q.due.all(now())) {++ try { await step(rec, media); n++; } catch (err) { log.error(`[recording] ${rec.id}: ${err.stack || err}`); }++ }++ return n;++ }++++ return {++ ensureRequests,++ process,++ bySession: (sessionId) => q.bySession.get(sessionId) || null,++ };++}++++module.exports = { createRecordings };nnew file mode 100644@@@ -0,0 +1,226 @@++'use strict';++/**++ * Ingest sessions: admission, the state machine, leases and the playback descriptor.++ *++ * Every transition is one SQLite transaction that also writes the session_transitions audit row++ * and, where the transition is externally visible, the outbox event (so the event exists if and++ * only if the change committed). Workers and the coordinator can race on the same session (a++ * publisher leaving while its lease is being expired); whoever commits first wins and the other++ * gets { ok: false, code: 'invalid_transition' } without side effects.++ */++const { newId, isId } = require('../ids');++const { canTransition, isTerminal } = require('../state-machine');++const { TYPES } = require('../events');++const { parseJson } = require('./definitions');++++const OPEN = "('starting', 'live', 'ending')";++++function iso(ms) { return ms ? new Date(ms).toISOString() : null; }++++function createSessions({ db, config, events, clock, definitions, workers }) {++ const now = () => clock.now();++ const q = {++ get: db.prepare('SELECT * FROM ingest_sessions WHERE id = ?'),++ openForDefinition: db.prepare(`SELECT * FROM ingest_sessions WHERE definition_id = ? AND state IN ${OPEN} ORDER BY created_at DESC`),++ insert: db.prepare(`INSERT INTO ingest_sessions (id, definition_id, key_id, protocol, state, worker_id, worker_kind, worker_generation,++ lease_expires_at, created_at, updated_at) VALUES (@id, @definition_id, @key_id, @protocol, 'starting', @worker_id, @worker_kind,++ @worker_generation, @lease, @now, @now)`),++ transitionRow: db.prepare('INSERT INTO session_transitions (session_id, from_state, to_state, reason, actor, at) VALUES (?, ?, ?, ?, ?, ?)'),++ transitions: db.prepare('SELECT from_state, to_state, reason, actor, at FROM session_transitions WHERE session_id = ? ORDER BY id'),++ mediaInfo: db.prepare(`UPDATE ingest_sessions SET media_info = ? WHERE id = ? AND state IN ${OPEN}`),++ requestEnd: db.prepare(`UPDATE ingest_sessions SET desired_state = 'end', end_requested_by = ?, updated_at = ? WHERE id = ? AND state IN ${OPEN}`),++ endRequests: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND desired_state = 'end' AND state IN ${OPEN}`),++ ofWorker: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND state IN ${OPEN}`),++ expiredLeases: db.prepare(`SELECT * FROM ingest_sessions WHERE state IN ${OPEN} AND lease_expires_at < ?`),++ live: db.prepare("SELECT * FROM ingest_sessions WHERE state = 'live' ORDER BY live_at"),++ };++++ function shape(s) {++ if (!s) return null;++ return { ...s, media_info: parseJson(s.media_info, null) };++ }++++ function get(id) {++ return isId('session', id) ? shape(q.get.get(id)) : null;++ }++++ function list({ definition_id, owner_subject, state, limit = 50, before } = {}) {++ const where = [];++ const args = [];++ if (definition_id) { where.push('s.definition_id = ?'); args.push(definition_id); }++ if (owner_subject) { where.push('d.owner_subject = ?'); args.push(owner_subject); }++ if (state === 'open') where.push(`s.state IN ${OPEN}`);++ else if (state) { where.push('s.state = ?'); args.push(state); }++ if (before) { where.push('s.created_at < ?'); args.push(Number(before)); }++ return db.prepare(`SELECT s.* FROM ingest_sessions s JOIN stream_definitions d ON d.id = s.definition_id++ ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY s.created_at DESC LIMIT ?`)++ .all(...args, Math.min(Math.max(Number(limit) || 50, 1), 200)).map(shape);++ }++++ function envelopeFor(type, session, definition, extra = {}) {++ const worker = session.worker_id ? workers.get(session.worker_id) : null;++ const payload = {++ session_id: session.id,++ stream_id: definition.id,++ title: definition.title,++ owner: { type: 'user', id: definition.owner_subject },++ protocol: session.protocol,++ state: session.state,++ started_at: iso(session.live_at),++ worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation } : null,++ external_refs: definition.external_refs,++ mirror_to_live: definition.mirror_to_live,++ ...extra,++ };++ return {++ event_type: type,++ actor: { type: 'user', id: definition.owner_subject },++ subject: { type: 'ingest_session', id: session.id, revision: session.revision },++ visibility: 'internal',++ priority: 'important',++ payload,++ };++ }++++ /**++ * Admit a publish on `worker` (synchronous; called inside the RTMP handshake).++ * One open session per definition: a second publisher with a valid key is refused while the++ * first is alive, exactly like Live's in-process ingest. A leftover open session whose lease++ * has expired (its worker died) is failed first, so a crashed worker never locks a stream out.++ */++ function admit({ definition, key, protocol, worker }) {++ return db.transaction(() => {++ for (const open of q.openForDefinition.all(definition.id)) {++ const leaseOk = open.lease_expires_at && open.lease_expires_at > now();++ if (leaseOk) return { error: 'duplicate_publisher', existing: open.id };++ applyTransition(open, 'failed', { reason: 'lease_expired', actor: `worker:${worker.id}` });++ }++ const id = newId('session', now());++ q.insert.run({++ id, definition_id: definition.id, key_id: key ? key.id : null, protocol,++ worker_id: worker.id, worker_kind: worker.kind, worker_generation: worker.generation,++ lease: now() + config.workers.leaseMs, now: now(),++ });++ q.transitionRow.run(id, null, 'starting', 'publish_accepted', `worker:${worker.id}`, now());++ return { session: shape(q.get.get(id)) };++ }).immediate();++ }++++ // Inside a transaction.++ function applyTransition(current, to, { reason = null, actor = null, media_info } = {}) {++ if (!canTransition(current.state, to)) return { ok: false, code: 'invalid_transition', session: shape(current) };++ const t = now();++ const set = { state: to, updated_at: t, revision: current.revision + 1 };++ if (to === 'live') set.live_at = t;++ if (to === 'ending') { set.ending_at = t; set.end_reason = reason; }++ if (to === 'ended') { set.ended_at = t; if (!current.end_reason) set.end_reason = reason; }++ if (to === 'failed') { set.ended_at = t; set.failure_reason = reason; }++ if (media_info !== undefined) set.media_info = media_info ? JSON.stringify(media_info) : null;++ if (isTerminal(to)) set.lease_expires_at = null;++ const cols = Object.keys(set);++ db.prepare(`UPDATE ingest_sessions SET ${cols.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id AND state = @from`)++ .run({ ...set, id: current.id, from: current.state });++ q.transitionRow.run(current.id, current.state, to, reason, actor, t);++ const session = shape(q.get.get(current.id));++ const definition = definitions.row(session.definition_id);++ if (to === 'live') {++ events.enqueue(envelopeFor(TYPES.sessionStarted, session, definition, { playback: playback(session) }));++ } else if (to === 'ended' && session.live_at) {++ events.enqueue(envelopeFor(TYPES.sessionEnded, session, definition, {++ ended_at: iso(session.ended_at),++ duration_seconds: Math.round((session.ended_at - session.live_at) / 1000),++ end_reason: session.end_reason,++ }));++ } else if (to === 'failed') {++ events.enqueue(envelopeFor(TYPES.sessionFailed, session, definition, {++ ended_at: iso(session.ended_at),++ was_live: Boolean(session.live_at),++ duration_seconds: session.live_at ? Math.round((session.ended_at - session.live_at) / 1000) : 0,++ failure_reason: reason,++ }));++ }++ if (isTerminal(to) || to === 'ending') {++ // Outputs follow their session down; they never pull the session with them.++ db.prepare("UPDATE outputs SET desired = 'stop', updated_at = ? WHERE session_id = ? AND desired = 'run'").run(t, session.id);++ }++ return { ok: true, session };++ }++++ /** transition(id, to, { reason, actor, media_info }) -> { ok, session } | { ok: false, code } */++ function transition(id, to, opts = {}) {++ return db.transaction(() => {++ const current = q.get.get(id);++ if (!current) return { ok: false, code: 'not_found' };++ return applyTransition(current, to, opts);++ }).immediate();++ }++++ /** ending → ended in one call (the common clean stop). */++ function finish(id, { reason, actor } = {}) {++ return db.transaction(() => {++ let current = q.get.get(id);++ if (!current) return { ok: false, code: 'not_found' };++ if (current.state === 'starting' || current.state === 'live') {++ const r = applyTransition(current, 'ending', { reason, actor });++ if (!r.ok) return r;++ current = q.get.get(id);++ }++ return applyTransition(current, 'ended', { reason, actor });++ }).immediate();++ }++++ function requestEnd(id, by) {++ return q.requestEnd.run(String(by || 'unknown'), now(), id).changes > 0;++ }++++ /**++ * Where to watch a session. Internal URLs are loopback addresses on the OpenRe host (Live and++ * Media run there too); public_url is served by openre-api's /play proxy.++ */++ function playback(session) {++ if (!session) return null;++ const worker = session.worker_id ? workers.get(session.worker_id) : null;++ const ep = (worker && worker.endpoints) || {};++ const descriptor = {++ session_id: session.id,++ protocol: session.protocol,++ state: session.state,++ live: session.state === 'live',++ worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation, state: worker.state } : null,++ flv: null,++ rtmp: null,++ webrtc: null,++ hls: null,++ };++ if (session.protocol === 'rtmp' && ep.flvPort) {++ descriptor.flv = {++ internal_url: `http://127.0.0.1:${ep.flvPort}/live/${session.id}.flv`,++ public_url: `${config.baseUrl}/play/${session.id}.flv`,++ content_type: 'video/x-flv',++ };++ }++ if (session.protocol === 'rtmp' && ep.rtmpPlayPort) {++ descriptor.rtmp = { internal_url: `rtmp://127.0.0.1:${ep.rtmpPlayPort}/live/${session.id}` };++ }++ return descriptor;++ }++++ return {++ get,++ list,++ admit,++ transition,++ finish,++ requestEnd,++ playback,++ setMediaInfo: (id, info) => q.mediaInfo.run(JSON.stringify(info), id).changes > 0,++ transitions: (id) => q.transitions.all(id),++ endRequestsFor: (workerId) => q.endRequests.all(workerId).map(shape),++ ofWorker: (workerId) => q.ofWorker.all(workerId).map(shape),++ expiredLeases: () => q.expiredLeases.all(now()).map(shape),++ live: () => q.live.all().map(shape),++ openFor: (definitionId) => q.openForDefinition.all(definitionId).map(shape),++ };++}++++module.exports = { createSessions };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * Transport worker registry: which process of which kind and generation is alive, and which++ * generation takes new sessions.++ *++ * register() a worker process starts: it gets the next generation number for its kind++ * ready() it can take new sessions; the coordinator then drains every older generation++ * heartbeat() every few seconds; the same write renews the lease of every session it owns++ * draining set by the coordinator: take no new sessions, keep the running ones until they++ * end or the drain deadline passes++ * stopped the worker exited cleanly (idle after a drain, or never had a session)++ * lost no heartbeat within the lease: the coordinator fails its sessions++ *++ * Generation numbers are per kind and only ever grow, so "the newest ready generation" is one++ * indexed read and two releases can run side by side without guessing which is newer.++ */++const os = require('os');++const { newId } = require('../ids');++const { parseJson } = require('./definitions');++++const KINDS = Object.freeze(['rtmp-ingest', 'restream', 'webrtc-ingest', 'sfu', 'jsmpeg']);++const ALIVE = "('starting', 'ready', 'draining')";++++function createWorkers({ db, config, clock }) {++ const now = () => clock.now();++ const q = {++ maxGen: db.prepare('SELECT COALESCE(MAX(generation), 0) AS g FROM workers WHERE kind = ?'),++ insert: db.prepare(`INSERT INTO workers (id, kind, generation, release, pid, host, state, endpoints, started_at, heartbeat_at)++ VALUES (@id, @kind, @generation, @release, @pid, @host, 'starting', @endpoints, @now, @now)`),++ get: db.prepare('SELECT * FROM workers WHERE id = ?'),++ heartbeat: db.prepare(`UPDATE workers SET heartbeat_at = ? WHERE id = ? AND state IN ${ALIVE}`),++ renewLeases: db.prepare("UPDATE ingest_sessions SET lease_expires_at = ? WHERE worker_id = ? AND state IN ('starting', 'live', 'ending')"),++ setReady: db.prepare("UPDATE workers SET state = 'ready', ready_at = ?, heartbeat_at = ? WHERE id = ? AND state = 'starting'"),++ setEndpoints: db.prepare('UPDATE workers SET endpoints = ? WHERE id = ?'),++ drain: db.prepare(`UPDATE workers SET state = 'draining', drain_started_at = ?, drain_deadline = ? WHERE id = ? AND state IN ('starting', 'ready')`),++ stop: db.prepare(`UPDATE workers SET state = 'stopped', stopped_at = ?, stop_reason = ? WHERE id = ? AND state IN ${ALIVE}`),++ lose: db.prepare(`UPDATE workers SET state = 'lost', stopped_at = ?, stop_reason = ? WHERE id = ? AND state IN ${ALIVE}`),++ newestReady: db.prepare("SELECT * FROM workers WHERE kind = ? AND state = 'ready' ORDER BY generation DESC LIMIT 1"),++ stale: db.prepare(`SELECT * FROM workers WHERE state IN ${ALIVE} AND heartbeat_at < ?`),++ olderReady: db.prepare("SELECT * FROM workers WHERE kind = ? AND state IN ('starting', 'ready') AND generation < ?"),++ alive: db.prepare(`SELECT * FROM workers WHERE state IN ${ALIVE} ORDER BY kind, generation`),++ recent: db.prepare('SELECT * FROM workers ORDER BY started_at DESC LIMIT ?'),++ };++++ const shape = (w) => (w ? { ...w, endpoints: parseJson(w.endpoints, {}) } : null);++++ function register({ kind, release = config.release, pid = process.pid, host = os.hostname(), endpoints = {} }) {++ if (!KINDS.includes(kind)) throw new Error(`unknown worker kind ${kind}`);++ const id = newId('worker', now());++ db.transaction(() => {++ const generation = q.maxGen.get(kind).g + 1;++ q.insert.run({ id, kind, generation, release, pid, host, endpoints: JSON.stringify(endpoints), now: now() });++ }).immediate();++ return shape(q.get.get(id));++ }++++ return {++ KINDS,++ register,++ get: (id) => shape(q.get.get(id)),++ ready: (id) => q.setReady.run(now(), now(), id).changes > 0,++ setEndpoints: (id, endpoints) => q.setEndpoints.run(JSON.stringify(endpoints), id),++ /** Heartbeat + renew the leases of every session this worker owns, in one transaction. */++ heartbeat(id) {++ return db.transaction(() => {++ const alive = q.heartbeat.run(now(), id).changes > 0;++ if (alive) q.renewLeases.run(now() + config.workers.leaseMs, id);++ return shape(q.get.get(id));++ })();++ },++ drain: (id, deadline) => q.drain.run(now(), deadline, id).changes > 0,++ stop: (id, reason) => q.stop.run(now(), reason || 'exit', id).changes > 0,++ lose: (id, reason) => q.lose.run(now(), reason || 'lease_expired', id).changes > 0,++ newestReady: (kind) => shape(q.newestReady.get(kind)),++ stale: (before) => q.stale.all(before).map(shape),++ olderReady: (kind, generation) => q.olderReady.all(kind, generation).map(shape),++ alive: () => q.alive.all().map(shape),++ recent: (limit = 50) => q.recent.all(limit).map(shape),++ };++}++++module.exports = { createWorkers, KINDS };nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared++ * OpenVibe chrome (theme loader, navbar and footer from openvibe.network, footer SSR from the++ * pinned openvibe-shared release) the way OpenVibe.Community renders it.++ */++const NETWORK_URL = 'https://openvibe.network';++const SITE = 'OpenRe.Stream';++++function esc(v) {++ return String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++}++++const CSS = `++:root{--ore-bg:#0d131d;--ore-panel:#141c2b;--ore-line:#233049;--ore-text:#e6ecf5;--ore-muted:#93a2bd;--ore-accent:#3b82f6;--ore-ok:#22c55e;--ore-warn:#f59e0b;--ore-bad:#ef4444}++body{margin:0;background:var(--ore-bg);color:var(--ore-text);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}++main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}++.ore h1{font-size:1.6rem;margin:.2em 0 .4em}.ore h2{font-size:1.15rem;margin:1.6em 0 .6em}.ore h3{font-size:1rem;margin:1em 0 .4em}++.ore a{color:#93c5fd}.ore p.muted,.ore .muted{color:var(--ore-muted)}++.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}++.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}++.ore code,.ore .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88rem;word-break:break-all}++.ore .secret{display:block;background:#0a0f18;border:1px dashed var(--ore-warn);padding:10px;border-radius:8px;user-select:all}++.ore form.inline{display:inline}.ore label{display:block;margin:.4em 0 .15em;color:var(--ore-muted);font-size:.88rem}++.ore input[type=text],.ore input[type=password],.ore input[type=number],.ore select,.ore textarea{width:100%;box-sizing:border-box;background:#0a0f18;color:var(--ore-text);border:1px solid var(--ore-line);border-radius:6px;padding:7px 9px}++.ore .row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}++.ore button{background:var(--ore-accent);color:#fff;border:0;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600;margin:6px 6px 0 0}++.ore button.secondary{background:#334155}.ore button.danger{background:var(--ore-bad)}++.ore .pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:.8rem;font-weight:600;background:#334155}++.ore .pill.live,.ore .pill.ok{background:var(--ore-ok);color:#04110a}.ore .pill.starting,.ore .pill.ending,.ore .pill.pending,.ore .pill.error,.ore .pill.draining{background:var(--ore-warn);color:#1a1000}++.ore .pill.failed,.ore .pill.lost{background:var(--ore-bad)}++.ore .flash{border-left:4px solid var(--ore-accent);padding:8px 12px;background:var(--ore-panel);margin:12px 0}.ore .flash.bad{border-color:var(--ore-bad)}++@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}++`;++++function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config }) {++ const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — ingest and restream for OpenVibe`;++ const nav = {++ service: 'openre',++ apiBase: NETWORK_URL,++ links: [++ { label: 'Streams', href: '/streams' },++ { label: 'Sessions', href: '/sessions' },++ ],++ sessionUrl: '/auth/me',++ loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,++ };++ const foot = { service: 'openre', variant: 'compact', mount: '#ov-footer', brandName: SITE, legalBase: config.liveUrl,++ links: [{ heading: SITE, items: [{ name: 'Streams', url: '/streams' }, { name: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' }] }] };++ let footerSsr = '';++ try { footerSsr = require('openvibe-shared/footer').ssr({ service: 'openre', variant: 'compact' }); } catch { footerSsr = '<footer id="ov-footer"></footer>'; }++ let icon = '';++ try { icon = require('openvibe-shared/app-icon').headTags({ site: 'network' }); } catch { icon = ''; }++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(pageTitle)}</title>++<meta name="description" content="OpenRe.Stream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.">++<meta name="robots" content="${esc(robots)}">++<link rel="canonical" href="${esc(config.baseUrl + canonicalPath)}">++${icon}++<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>++<style>${CSS}</style>++<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++<script src="${NETWORK_URL}/shared/footer.js" defer></script>++</head>++<body>++<div id="navbar-mount"></div>++<noscript><nav class="ore" style="padding:8px 16px"><a href="/">OpenRe.Stream</a> · <a href="/streams">Streams</a> · <a href="/sessions">Sessions</a> · ${user ? '<a href="/auth/logout">Sign out</a>' : '<a href="/auth/login">Sign in</a>'}</nav></noscript>++<main id="main" class="ore">++${body}++</main>++${footerSsr}++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer: foot }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* chrome is optional */ }++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }++});++</script>++</body>++</html>`;++}++++module.exports = { renderPage, esc };nnew file mode 100644@@@ -0,0 +1,332 @@++'use strict';++/**++ * The standalone UI (openre.stream). Server-rendered, useful without JavaScript, forms post back++ * to the same process and go through the same store rules as the API.++ *++ * / what OpenRe is, and the signed-in owner's streams++ * /streams definitions + create++ * /streams/:id ingest URL, keys (rotate: the new key is shown once), settings,++ * destinations (add, edit, delete, test, start, stop), sessions++ * /sessions recent sessions++ * /sessions/:id lifecycle, outputs with health and logs, recording, playback++ *++ * CSRF: every form carries a token derived from the session cookie (an attacker's page cannot++ * read the cookie, so it cannot produce the token); SameSite=Lax cookies are the second layer.++ */++const crypto = require('crypto');++const express = require('express');++const { renderPage, esc } = require('./layout');++const { StoreError } = require('../store/definitions');++const { testDestination } = require('../destination-test');++++const iso = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');++const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;++++function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }++++function createUiRouter({ rt, auth }) {++ const { store, config } = rt;++ const router = express.Router();++ router.use(express.urlencoded({ extended: false, limit: '64kb' }));++ router.use(auth.middleware({ services: false }));++++ const page = (req, res, o, status = 200) => res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));++ const csrfField = (req) => `<input type="hidden" name="_csrf" value="${esc(csrfFor(req.caller.token))}">`;++ const form = (req, action, inner, { cls = '', confirm } = {}) => `<form method="post" action="${esc(action)}" class="${cls}"${confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ''}>${csrfField(req)}${inner}</form>`;++++ function needUser(req, res) {++ if (req.caller.kind === 'user' && req.caller.subject) return true;++ page(req, res, {++ title: 'Sign in', canonicalPath: req.path,++ body: `<h1>Sign in</h1><p>OpenRe.Stream uses your OpenVibe account.</p><p><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,++ }, 401);++ return false;++ }++++ function checkCsrf(req, res) {++ const given = String((req.body && req.body._csrf) || '');++ const expected = csrfFor(req.caller.token || '');++ if (given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return true;++ page(req, res, { title: 'Expired form', body: '<h1>That form expired</h1><p>Go back, reload the page and try again.</p>' }, 403);++ return false;++ }++++ function ownDefinition(req, res, id) {++ const d = store.definitions.get(id);++ if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {++ page(req, res, { title: 'Not found', body: '<h1>Stream not found</h1><p><a href="/streams">Your streams</a></p>' }, 404);++ return null;++ }++ return d;++ }++++ function ownDestination(req, res, id) {++ const dest = store.outputs.destinationRow(id);++ const d = dest ? ownDefinition(req, res, dest.definition_id) : null;++ if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: '<h1>Destination not found</h1>' }, 404);++ return d ? { dest, definition: d } : null;++ }++++ const post = (path, fn) => router.post(path, async (req, res, next) => {++ if (!needUser(req, res) || !checkCsrf(req, res)) return;++ try { await fn(req, res); } catch (err) {++ if (err instanceof StoreError || err.code === 'openre.secrets_unavailable') {++ return page(req, res, { title: 'Could not save', body: `<div class="flash bad">${esc(err.message)}</div><p><a href="javascript:history.back()">Back</a></p>` }, err.status || 400);++ }++ return next(err);++ }++ });++++ // ── Pages ─────────────────────────────────────────────────++++ router.get('/', (req, res) => {++ const signedIn = req.caller.kind === 'user' && req.caller.subject;++ const mine = signedIn ? store.definitions.list({ owner_subject: req.caller.subject }) : [];++ page(req, res, {++ canonicalPath: '/', robots: 'index,follow',++ body: `<h1>OpenRe.Stream</h1>++<p>Ingest and restream for the OpenVibe network: stream definitions with hashed ingest keys, RTMP ingest sessions that run in transport workers separate from any web deploy, restream outputs with health and logs, and recording requests to OpenVibe.Media.</p>++<p class="muted">Status: alpha. RTMP ingest and RTMP/SRT restreaming work here; WHIP, WebRTC/SFU and JSMPEG are still served by OpenVibe.Live. Channels, discovery and watch pages stay on <a href="${esc(config.liveUrl)}">openvibe.live</a>.</p>++${signedIn ? `<h2>Your streams</h2>${mine.length ? streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}`,++ });++ });++++ function streamTable(list) {++ return `<table><tr><th>Stream</th><th>State</th><th>Now</th><th>Linked to</th></tr>${list.map((d) => {++ const open = store.sessions.openFor(d.id)[0];++ const refs = d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', ');++ return `<tr><td><a href="/streams/${esc(d.id)}">${esc(d.title)}</a></td><td>${pill(d.state)}</td><td>${open ? pill(open.state) : '<span class="muted">offline</span>'}</td><td class="mono">${esc(refs) || '—'}</td></tr>`;++ }).join('')}</table>`;++ }++++ router.get('/streams', (req, res) => {++ if (!needUser(req, res)) return;++ const list = store.definitions.list({ owner_subject: req.caller.subject });++ page(req, res, {++ title: 'Streams', canonicalPath: '/streams',++ body: `<h1>Streams</h1>${list.length ? streamTable(list) : '<p class="muted">No streams yet.</p>'}++<div class="card"><h2>New stream</h2>${form(req, '/streams', `++<label>Title</label><input type="text" name="title" maxlength="140" required>++<div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>++<div><label>Recording visibility</label><select name="recording_visibility"><option>public</option><option>unlisted</option><option>private</option></select></div></div>++<button type="submit">Create stream</button><p class="muted">The ingest key is shown once, on the next page.</p>`)}</div>`,++ });++ });++++ function keyPage(req, res, definition, key, heading) {++ const ep = store.definitions.ingestEndpoints(definition);++ res.set('Cache-Control', 'no-store');++ page(req, res, {++ title: heading, canonicalPath: `/streams/${definition.id}`,++ body: `<h1>${esc(heading)}</h1>++<div class="card"><p><strong>Copy the key now.</strong> OpenRe stores only a hash of it; this page is the only time it is shown.</p>++<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>++<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>++<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,++ });++ }++++ post('/streams', (req, res) => {++ const b = req.body || {};++ const { definition, key } = store.definitions.create({++ owner_subject: req.caller.subject, title: b.title, recording_mode: b.recording_mode,++ recording_visibility: b.recording_visibility, created_by: req.caller.subject,++ });++ keyPage(req, res, definition, key, 'Stream created');++ });++++ router.get('/streams/:id', (req, res) => {++ if (!needUser(req, res)) return;++ const d = ownDefinition(req, res, req.params.id);++ if (!d) return;++ const ep = store.definitions.ingestEndpoints(d);++ const keys = store.definitions.keys(d.id).filter(k => k.status !== 'revoked');++ const dests = store.outputs.destinations(d.id);++ const sessions = store.sessions.list({ definition_id: d.id, limit: 15 });++ const open = sessions.find(s => ['starting', 'live', 'ending'].includes(s.state));++ const outputs = open ? store.outputs.outputsOfSession(open.id) : [];++ const outFor = (destId) => outputs.find(o => o.destination_id === destId);++ page(req, res, {++ title: d.title, canonicalPath: `/streams/${d.id}`,++ body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>++<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>++<div class="card"><h2>Ingest</h2>++${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}++<table><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>++${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key stays valid for (seconds, 0 = revoke now)</label><input type="number" name="grace_seconds" min="0" max="604800" value="0"></div>++<div><label><input type="checkbox" name="end_sessions" value="1"> also disconnect a running session</label></div></div><button type="submit" class="danger">Rotate key</button>`, { confirm: 'Issue a new key and retire the current one?' })}++</div>++<div class="card"><h2>Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>++${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>++<div class="card"><h2>Restream destinations</h2>++${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${dests.map((x) => {++ const o = outFor(x.id);++ return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td>++<td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td>++<td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td>++<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}++<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;++ }).join('')}</table>` : '<p class="muted">No destinations.</p>'}++<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>++<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `++<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">++<div class="row"><div><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>++<div><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>++<div><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>++<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>++<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRe)</label>++<button type="submit">Save</button>`)}</div>++<h2>Sessions</h2>${sessionTable(sessions)}`,++ });++ });++++ function destinationFields(x) {++ const sel = (name, opts, cur) => `<select name="${name}">${opts.map(o => `<option${o === cur ? ' selected' : ''}>${o}</option>`).join('')}</select>`;++ return `<div class="row"><div><label>Platform</label>${sel('platform', ['twitch', 'youtube', 'kick', 'custom'], x.platform)}</div>++<div><label>Name</label><input type="text" name="name" maxlength="80" value="${esc(x.name || '')}"></div></div>++<label>Server URL (rtmp://, rtmps:// or srt://)</label><input type="text" name="server_url" maxlength="2048" value="${esc(x.server_url || '')}" required>++<label>Stream key ${x.id ? '(leave empty to keep the stored key)' : ''}</label><input type="password" name="stream_key" autocomplete="off" maxlength="512">++<div class="row"><div><label>Quality (only used when re-encoding)</label>${sel('quality_preset', ['auto', 'low', 'medium', 'high', 'ultra', 'source'], x.quality_preset || 'auto')}</div>++<div><label>SRT latency (ms)</label><input type="number" name="srt_latency_ms" min="20" max="8000" value="${esc(x.srt_latency_ms || '')}"></div>++<div><label>SRT passphrase ${x.id ? '(empty = keep)' : ''}</label><input type="password" name="srt_passphrase" autocomplete="off" maxlength="79"></div></div>++<label><input type="checkbox" name="enabled" value="1"${x.enabled === false ? '' : ' checked'}> enabled</label>++<label><input type="checkbox" name="auto_start" value="1"${x.auto_start === false ? '' : ' checked'}> start automatically when the stream goes live</label>++<button type="submit">${x.id ? 'Save destination' : 'Add destination'}</button>`;++ }++++ function destInput(b, { editing }) {++ const out = {++ platform: b.platform, name: b.name, server_url: b.server_url, quality_preset: b.quality_preset,++ enabled: b.enabled === '1', auto_start: b.auto_start === '1',++ srt_latency_ms: b.srt_latency_ms === '' || b.srt_latency_ms == null ? null : b.srt_latency_ms,++ };++ if (!editing || b.stream_key) out.stream_key = b.stream_key || '';++ if (!editing || b.srt_passphrase) out.srt_passphrase = b.srt_passphrase || '';++ return out;++ }++++ function sessionTable(list) {++ if (!list.length) return '<p class="muted">No sessions yet.</p>';++ return `<table><tr><th>Session</th><th>State</th><th>Started</th><th>Ended</th><th>Worker</th></tr>${list.map(s => `<tr><td><a class="mono" href="/sessions/${esc(s.id)}">${esc(s.id)}</a></td><td>${pill(s.state)}${s.failure_reason ? ` <span class="muted">${esc(s.failure_reason)}</span>` : ''}</td><td>${esc(iso(s.live_at || s.created_at))}</td><td>${esc(iso(s.ended_at))}</td><td>${esc(s.worker_kind)} #${esc(s.worker_generation)}</td></tr>`).join('')}</table>`;++ }++++ post('/streams/:id', (req, res) => {++ const d = ownDefinition(req, res, req.params.id);++ if (!d) return;++ const b = req.body || {};++ store.definitions.update(d.id, { title: b.title, recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, state: b.state, mirror_to_live: b.mirror_to_live === '1' });++ res.redirect(303, `/streams/${d.id}`);++ });++++ post('/streams/:id/rotate', (req, res) => {++ const d = ownDefinition(req, res, req.params.id);++ if (!d) return;++ const r = store.definitions.rotateKey(d.id, { grace_seconds: Number(req.body.grace_seconds) || 0, rotated_by: req.caller.subject });++ if (req.body.end_sessions === '1') for (const s of store.sessions.openFor(d.id)) store.sessions.requestEnd(s.id, `key_rotation:${req.caller.subject}`);++ keyPage(req, res, store.definitions.get(d.id), r.key, 'New ingest key');++ });++++ post('/streams/:id/destinations', (req, res) => {++ const d = ownDefinition(req, res, req.params.id);++ if (!d) return;++ store.outputs.createDestination(d.id, destInput(req.body || {}, { editing: false }));++ res.redirect(303, `/streams/${d.id}`);++ });++++ router.get('/destinations/:id', (req, res) => {++ if (!needUser(req, res)) return;++ const own = ownDestination(req, res, req.params.id);++ if (!own) return;++ const x = store.outputs.publicDest(own.dest);++ const logs = store.outputs.logsOfDestination(x.id, 50);++ page(req, res, {++ title: `Destination ${x.name || x.platform}`, canonicalPath: `/destinations/${x.id}`,++ body: `<h1>${esc(x.name || x.platform)}</h1><p><a href="/streams/${esc(own.definition.id)}">← ${esc(own.definition.title)}</a></p>++<div class="card">${form(req, `/destinations/${x.id}`, destinationFields(x))}</div>++<h2>Log</h2>${logs.length ? `<table><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,++ });++ });++++ post('/destinations/:id', (req, res) => {++ const own = ownDestination(req, res, req.params.id);++ if (!own) return;++ store.outputs.updateDestination(own.dest.id, destInput(req.body || {}, { editing: true }));++ res.redirect(303, `/streams/${own.definition.id}`);++ });++++ post('/destinations/:id/delete', (req, res) => {++ const own = ownDestination(req, res, req.params.id);++ if (!own) return;++ store.outputs.deleteDestination(own.dest.id);++ res.redirect(303, `/streams/${own.definition.id}`);++ });++++ post('/destinations/:id/test', async (req, res) => {++ const own = ownDestination(req, res, req.params.id);++ if (!own) return;++ const result = await testDestination(own.dest, { allowPrivate: config.outputs.allowPrivateHosts });++ store.outputs.log(null, own.dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);++ page(req, res, {++ title: 'Destination test',++ body: `<h1>Test ${result.ok ? pill('ok') : pill('failed')}</h1><table>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>++<p class="muted">A passing test means the ingest is reachable. Whether the platform accepts the key shows once an output runs.</p><p><a href="/streams/${esc(own.definition.id)}">Back</a></p>`,++ });++ });++++ post('/destinations/:id/start', (req, res) => {++ const own = ownDestination(req, res, req.params.id);++ if (!own) return;++ store.outputs.startDestination(own.dest.id);++ res.redirect(303, `/streams/${own.definition.id}`);++ });++++ post('/destinations/:id/stop', (req, res) => {++ const own = ownDestination(req, res, req.params.id);++ if (!own) return;++ store.outputs.stopDestination(own.dest.id);++ res.redirect(303, `/streams/${own.definition.id}`);++ });++++ router.get('/sessions', (req, res) => {++ if (!needUser(req, res)) return;++ const all = req.caller.staff && req.query.all === '1';++ page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `<h1>Sessions</h1>${req.caller.staff ? `<p class="muted"><a href="/sessions${all ? '' : '?all=1'}">${all ? 'Only mine' : 'Everyone (staff)'}</a></p>` : ''}${sessionTable(store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });++ });++++ router.get('/sessions/:id', (req, res) => {++ if (!needUser(req, res)) return;++ const s = store.sessions.get(req.params.id);++ const d = s ? store.definitions.row(s.definition_id) : null;++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);++ const outputs = store.outputs.outputsOfSession(s.id);++ const rec = store.recordings.bySession(s.id);++ const pb = store.sessions.playback(s);++ const open = ['starting', 'live', 'ending'].includes(s.state);++ return page(req, res, {++ title: `Session ${s.id}`, canonicalPath: `/sessions/${s.id}`,++ body: `<h1>Session ${pill(s.state)}</h1><p class="mono">${esc(s.id)}</p><p><a href="/streams/${esc(d.id)}">← ${esc(d.title)}</a></p>++<div class="card"><table>++<tr><th>Protocol</th><td>${esc(s.protocol)}</td></tr>++<tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr>++<tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>++<tr><th>Media</th><td>${s.media_info ? esc(`${s.media_info.video_codec || '?'} ${s.media_info.width || '?'}×${s.media_info.height || '?'} @${s.media_info.fps || '?'} · ${s.media_info.audio_codec || 'no audio'} · ${s.media_info.bitrate_kbps || '?'} kbit/s`) : '—'}</td></tr>++<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}` : '<span class="muted">none</span>'}</td></tr>++<tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr>++</table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>++<h2>Outputs</h2>${outputs.length ? `<table><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`).join('')}</table>` : '<p class="muted">No outputs.</p>'}++<h2>Lifecycle</h2><table>${store.sessions.transitions(s.id).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,++ });++ });++++ post('/sessions/:id/end', (req, res) => {++ const s = store.sessions.get(req.params.id);++ const d = s ? store.definitions.row(s.definition_id) : null;++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);++ store.sessions.requestEnd(s.id, req.caller.subject);++ return res.redirect(303, `/sessions/${s.id}`);++ });++++ return router;++}++++module.exports = { createUiRouter, csrfFor };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++// ADR-009 acceptance: "Deploying the OpenRe API does not end worker-owned transports."++// The API runs as its own process; a fake worker process owns a live session. The API is stopped++// (SIGTERM, as systemd does on a deploy), the session stays live and its lease keeps being renewed++// by the worker; a new API process starts and serves the same live session.++const assert = require('assert');++const path = require('path');++const { tmpDir, testEnv, child, waitFor, request, userToken, freePort, sleep, suite, OWNER } = require('./helpers');++const { load } = require('../server/config');++const { openRuntime } = require('../server/store');++++const t = suite('api-restart');++const dir = tmpDir();++let env;++let api;++let fake;++let sessionId;++let workerId;++const token = userToken({ subjectId: OWNER });++++async function startApi() {++ const p = child('server/index.js', env);++ const base = `http://127.0.0.1:${env.PORT}`;++ await waitFor(async () => { try { return (await request(base, 'GET', '/api/ready')).status === 200; } catch { return false; } }, { what: 'openre-api ready' });++ return { proc: p, base };++}++++t('setup: API process + fake worker process holding a live session', async () => {++ env = testEnv(dir, { PORT: String(await freePort()), OPENRE_WORKER_HEARTBEAT_MS: '200', OPENRE_WORKER_LEASE_MS: '1500' });++ const rt = openRuntime({ config: load(env), log: { log() {}, warn() {}, error() {} } });++ const { definition } = rt.store.definitions.create({ owner_subject: OWNER, title: 'Restart test' });++ rt.db.close();++ api = await startApi();++ fake = child(path.join('test', 'fixtures', 'fake-worker.js'), { ...env, FAKE_STREAM_ID: definition.id });++ await waitFor(() => /SESSION (ses_\S+) WORKER (wrk_\S+)/.test(fake.output), { what: 'fake worker session' });++ [, sessionId, workerId] = /SESSION (ses_\S+) WORKER (wrk_\S+)/.exec(fake.output);++ const r = await request(api.base, 'GET', `/api/v1/sessions/${sessionId}`, { token });++ assert.strictEqual(r.body.session.state, 'live');++});++++t('stopping the API (SIGTERM) leaves the worker process and its session alone', async () => {++ const rt = openRuntime({ config: load(env), log: { log() {}, warn() {}, error() {} } });++ const leaseBefore = rt.store.sessions.get(sessionId).lease_expires_at;++ api.proc.kill('SIGTERM');++ const exit = await api.proc.exited;++ assert.strictEqual(exit.code, 0, 'the API exits cleanly');++ // Longer than a lease: if anything depended on the API, the session would now be failing.++ await sleep(2500);++ assert.strictEqual(fake.exitCode, null, 'fake worker still running');++ const s = rt.store.sessions.get(sessionId);++ assert.strictEqual(s.state, 'live');++ assert.ok(s.lease_expires_at > leaseBefore, 'the worker kept renewing its lease while the API was down');++ assert.strictEqual(rt.store.workers.get(workerId).state, 'ready');++ rt.db.close();++});++++t('a new API process serves the same live session', async () => {++ api = await startApi();++ const r = await request(api.base, 'GET', `/api/v1/sessions/${sessionId}`, { token });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.session.state, 'live');++ assert.strictEqual(r.body.session.worker.id, workerId);++ assert.deepStrictEqual(r.body.session.transitions.map(x => x.to_state), ['starting', 'live'], 'no transition happened across the restart');++});++++t('teardown', async () => {++ api.proc.kill('SIGTERM');++ fake.kill('SIGKILL');++ await Promise.all([api.proc.exited, fake.exited]);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,158 @@++'use strict';++// openre-api: capability guards, owner scoping, keys shown once, destination secrets never++// returned, problem+json, rotation and end requests over HTTP, the playback descriptor.++const assert = require('assert');++const { bootApi, request, serviceToken, userToken, suite, OWNER, OTHER } = require('./helpers');++++const t = suite('api');++let api;++const owner = userToken({ subjectId: OWNER });++const other = userToken({ subjectId: OTHER });++const admin = userToken({ subjectId: 'usr_01J0000000000000000000000C', role: 'admin' });++const live = serviceToken('live', ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate', 'openre.session.read', 'openre.session.end', 'openre.output.read', 'openre.output.write']);++++t('boot', async () => {++ api = await bootApi();++ const ready = await request(api.base, 'GET', '/api/ready');++ assert.strictEqual(ready.status, 200);++ assert.strictEqual(ready.body.checks.db, true);++ assert.strictEqual(ready.body.checks.key, true);++});++++t('anonymous and ungranted callers are refused with problem+json', async () => {++ const r = await request(api.base, 'GET', '/api/v1/streams');++ assert.strictEqual(r.status, 401);++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(r.body.code, 'token.missing');++ const noCap = await request(api.base, 'GET', '/api/v1/streams', { token: serviceToken('tools', ['openre.session.read']) });++ assert.strictEqual(noCap.status, 403);++ assert.strictEqual(noCap.body.code, 'capability.denied');++ const wrongAud = await request(api.base, 'GET', '/api/v1/streams', { token: serviceToken('live', ['openre.stream.read'], { aud: 'openvibe.media' }) });++ assert.strictEqual(wrongAud.status, 401);++ const badSubject = await request(api.base, 'GET', '/api/v1/streams', { token: live, headers: { 'X-OV-Subject': 'not-a-subject' } });++ assert.strictEqual(badSubject.status, 400);++});++++let streamId;++let firstKey;++++t('an owner creates a stream: the key comes back once and never again', async () => {++ const r = await request(api.base, 'POST', '/api/v1/streams', { token: owner, body: { title: 'Evening show', recording_mode: 'vod' } });++ assert.strictEqual(r.status, 201);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.match(r.body.key.key, /^ork_[A-Za-z0-9_-]{43}$/);++ assert.strictEqual(r.body.stream.owner.id, OWNER);++ assert.strictEqual(r.body.stream.ingest.rtmp.url, `rtmp://127.0.0.1:1936/live`);++ assert.strictEqual(r.body.stream.ingest.rtmp.key_hint, r.body.key.key.slice(-4));++ streamId = r.body.stream.id;++ firstKey = r.body.key.key;++ for (const p of [`/api/v1/streams/${streamId}`, '/api/v1/streams', `/api/v1/streams/${streamId}/keys`]) {++ const g = await request(api.base, 'GET', p, { token: owner });++ assert.strictEqual(g.status, 200, p);++ assert.ok(!g.text.includes(firstKey), `${p} must not return the key`);++ assert.ok(!g.text.includes('key_hash'), `${p} must not return the hash`);++ }++});++++t('other owners cannot see or touch the stream; admin staff can read it', async () => {++ assert.strictEqual((await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: other })).status, 404);++ assert.strictEqual((await request(api.base, 'POST', `/api/v1/streams/${streamId}/keys/rotate`, { token: other, body: {} })).status, 404);++ assert.deepStrictEqual((await request(api.base, 'GET', '/api/v1/streams', { token: other })).body.streams, []);++ assert.strictEqual((await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: admin })).status, 200);++});++++t('a service acting for a subject is limited to that owner; external refs resolve', async () => {++ const created = await request(api.base, 'POST', '/api/v1/streams', {++ token: live, headers: { 'X-OV-Subject': OTHER },++ body: { title: 'Slot 44', external_refs: [{ service: 'live', type: 'managed_stream', id: '44' }, { service: 'live', type: 'user', id: '9' }], mirror_to_live: true },++ });++ assert.strictEqual(created.status, 201);++ assert.strictEqual(created.body.stream.owner.id, OTHER);++ const byRef = await request(api.base, 'GET', '/api/v1/streams?external_ref=live:managed_stream:44', { token: live });++ assert.strictEqual(byRef.body.streams[0].id, created.body.stream.id);++ const asOwner = await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: live, headers: { 'X-OV-Subject': OTHER } });++ assert.strictEqual(asOwner.status, 404, 'acting for OTHER, the service cannot read OWNER\'s stream');++ const unscoped = await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: live });++ assert.strictEqual(unscoped.status, 200, 'without a subject the grant covers every stream');++});++++t('rotation over HTTP returns the new key once and the old one stops authenticating', async () => {++ const r = await request(api.base, 'POST', `/api/v1/streams/${streamId}/keys/rotate`, { token: live, body: { grace_seconds: 0 } });++ assert.strictEqual(r.status, 200);++ assert.notStrictEqual(r.body.key.key, firstKey);++ assert.strictEqual(r.body.retired[0].status, 'revoked');++ assert.strictEqual(api.rt.store.definitions.resolveIngestKey(firstKey, 'rtmp').error, 'revoked_key');++ assert.ok(api.rt.store.definitions.resolveIngestKey(r.body.key.key, 'rtmp').definition);++});++++t('destinations: validated, masked, never returned in full; SSRF rules apply', async () => {++ const bad = await request(api.base, 'POST', `/api/v1/streams/${streamId}/destinations`, { token: owner, body: { platform: 'custom', server_url: 'file:///etc/passwd', stream_key: 'x' } });++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.body.code, 'openre.invalid_destination_url');++ const r = await request(api.base, 'POST', `/api/v1/streams/${streamId}/destinations`, { token: owner, body: { platform: 'youtube', name: 'YT', server_url: 'rtmp://a.rtmp.youtube.com/live2', stream_key: 'abcd-efgh-ijkl-mnop' } });++ assert.strictEqual(r.status, 201);++ assert.strictEqual(r.body.destination.stream_key_hint, '****mnop');++ const id = r.body.destination.id;++ const list = await request(api.base, 'GET', `/api/v1/streams/${streamId}/destinations`, { token: owner });++ assert.ok(!list.text.includes('abcd-efgh-ijkl-mnop'));++ assert.ok(!list.text.includes('stream_key_enc'));++ const patched = await request(api.base, 'PATCH', `/api/v1/destinations/${id}`, { token: owner, body: { name: 'YouTube main', auto_start: false } });++ assert.strictEqual(patched.body.destination.name, 'YouTube main');++ assert.strictEqual(patched.body.destination.stream_key_hint, '****mnop', 'omitted key is kept');++ const notLive = await request(api.base, 'POST', `/api/v1/destinations/${id}/start`, { token: owner });++ assert.strictEqual(notLive.status, 409);++ assert.strictEqual(notLive.body.code, 'openre.not_live');++ const del = await request(api.base, 'DELETE', `/api/v1/destinations/${id}`, { token: owner });++ assert.strictEqual(del.status, 204);++});++++t('the destination test reports URL, DNS and TCP checks without pushing media', async () => {++ const net = require('net');++ const srv = net.createServer((s) => s.destroy());++ await new Promise(r => srv.listen(0, '127.0.0.1', r));++ const r = await request(api.base, 'POST', `/api/v1/streams/${streamId}/destinations`, { token: owner, body: { platform: 'custom', server_url: `rtmp://127.0.0.1:${srv.address().port}/app`, stream_key: 'k12345' } });++ const test = await request(api.base, 'POST', `/api/v1/destinations/${r.body.destination.id}/test`, { token: owner });++ assert.strictEqual(test.status, 200);++ assert.strictEqual(test.body.ok, true);++ assert.deepStrictEqual(test.body.checks.map(c => c.check), ['url', 'dns', 'stream_key', 'connect']);++ const logs = await request(api.base, 'GET', `/api/v1/destinations/${r.body.destination.id}/logs`, { token: owner });++ assert.match(logs.body.logs[0].message, /^test passed/);++ srv.close();++});++++t('sessions: list, detail with playback descriptor, end request', async () => {++ const rt = api.rt;++ const w = rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: { publicPort: 1936, rtmpPlayPort: 19370, flvPort: 19371 } });++ rt.store.workers.ready(w.id);++ const def = rt.store.definitions.get(streamId);++ const a = rt.store.sessions.admit({ definition: def, key: null, protocol: 'rtmp', worker: rt.store.workers.get(w.id) });++ rt.store.sessions.transition(a.session.id, 'live');++ const list = await request(api.base, 'GET', '/api/v1/sessions?state=live', { token: owner });++ assert.deepStrictEqual(list.body.sessions.map(s => s.id), [a.session.id]);++ const detail = await request(api.base, 'GET', `/api/v1/sessions/${a.session.id}`, { token: owner });++ assert.strictEqual(detail.body.session.playback.flv.internal_url, `http://127.0.0.1:19371/live/${a.session.id}.flv`);++ assert.strictEqual(detail.body.session.playback.flv.public_url, `${api.config.baseUrl}/play/${a.session.id}.flv`);++ assert.deepStrictEqual(detail.body.session.transitions.map(x => x.to_state), ['starting', 'live']);++ assert.strictEqual((await request(api.base, 'GET', `/api/v1/sessions/${a.session.id}`, { token: other })).status, 404);++ const noEndCap = await request(api.base, 'POST', `/api/v1/sessions/${a.session.id}/end`, { token: serviceToken('live', ['openre.session.read']) });++ assert.strictEqual(noEndCap.status, 403);++ const end = await request(api.base, 'POST', `/api/v1/sessions/${a.session.id}/end`, { token: owner });++ assert.strictEqual(end.status, 202);++ assert.strictEqual(rt.store.sessions.get(a.session.id).desired_state, 'end');++ // Archiving is refused while it is open.++ const del = await request(api.base, 'DELETE', `/api/v1/streams/${streamId}`, { token: owner });++ assert.strictEqual(del.status, 409);++ rt.store.sessions.finish(a.session.id, { reason: 'test' });++ assert.strictEqual((await request(api.base, 'GET', `/play/${a.session.id}.flv`)).status, 404, 'no playback for an ended session');++});++++t('workers are visible to staff and services only', async () => {++ assert.strictEqual((await request(api.base, 'GET', '/api/v1/workers', { token: owner })).status, 403);++ const w = await request(api.base, 'GET', '/api/v1/workers', { token: admin });++ assert.strictEqual(w.status, 200);++ assert.ok(w.body.workers.length >= 1);++});++++t('close', async () => { await api.close(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++// Session coordinator: generations and draining, lost workers, lease expiry, output assignment to++// the newest restream generation, drain-deadline handover and the single-coordinator lease.++const assert = require('assert');++const { runtime, manualClock, suite, OWNER, outboxTypes, silent } = require('./helpers');++const { createCoordinator } = require('../server/coordinator');++const { createWorkerRuntime } = require('../workers/runtime');++++const t = suite('coordinator');++++function setup() {++ const clock = manualClock();++ const rt = runtime({ clock });++ const coordinator = createCoordinator({ rt, media: null, log: silent });++ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER });++ const worker = (kind) => { const w = rt.store.workers.register({ kind, endpoints: { flvPort: 19361, rtmpPlayPort: 19360 } }); rt.store.workers.ready(w.id); return rt.store.workers.get(w.id); };++ return { rt, clock, coordinator, definition, key, worker };++}++++t('generations grow per kind; when a newer one is ready every older one drains with a deadline', () => {++ const { rt, clock, coordinator, worker } = setup();++ const g1 = worker('rtmp-ingest');++ const r1 = worker('restream');++ assert.strictEqual(g1.generation, 1);++ assert.strictEqual(r1.generation, 1);++ assert.strictEqual(coordinator.syncTick().drained, 0);++ const g2 = worker('rtmp-ingest');++ assert.strictEqual(g2.generation, 2);++ assert.strictEqual(coordinator.syncTick().drained, 1);++ const old = rt.store.workers.get(g1.id);++ assert.strictEqual(old.state, 'draining');++ assert.strictEqual(old.drain_deadline, clock.now() + rt.config.workers.drainMaxMs);++ assert.strictEqual(rt.store.workers.get(r1.id).state, 'ready', 'other kinds are independent');++ assert.strictEqual(rt.store.workers.newestReady('rtmp-ingest').id, g2.id);++});++++t('a draining generation keeps its live session; new sessions only reach the newest generation', () => {++ const { rt, coordinator, definition, key, worker } = setup();++ const g1 = worker('rtmp-ingest');++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: g1 });++ rt.store.sessions.transition(a.session.id, 'live');++ worker('rtmp-ingest');++ coordinator.syncTick();++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live');++ assert.strictEqual(rt.store.workers.get(g1.id).state, 'draining');++ // The worker runtime of the draining generation refuses to take sessions (rtmp-ingest checks++ // runtime.draining / state === 'ready' in its publish handler).++ const exits = [];++ const wr = createWorkerRuntime({ rt, kind: 'rtmp-ingest', log: silent, exit: (c) => exits.push(c), hooks: { activeCount: () => 1 } });++ wr.register({});++ wr.ready();++ wr.stop();++});++++t('a worker without heartbeats is lost: its sessions fail (session.failed) and its outputs go back to pending', () => {++ const { rt, clock, coordinator, definition, key, worker } = setup();++ const ing = worker('rtmp-ingest');++ const rs = worker('restream');++ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/app', stream_key: 'abcd1' });++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: ing });++ rt.store.sessions.transition(a.session.id, 'live');++ const tick1 = coordinator.syncTick();++ assert.strictEqual(tick1.outputsCreated, 1);++ assert.strictEqual(tick1.outputsAssigned, 1);++ const out = rt.store.outputs.outputsOfSession(a.session.id)[0];++ assert.strictEqual(out.worker.id, rs.id);++++ // The restream worker dies; the ingest keeps heartbeating.++ clock.advance(rt.config.workers.leaseMs + 1000);++ rt.store.workers.heartbeat(ing.id);++ const tick2 = coordinator.syncTick();++ assert.strictEqual(tick2.lost, 1);++ assert.strictEqual(rt.store.workers.get(rs.id).state, 'lost');++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live', 'a lost restream worker never ends the source session');++ assert.strictEqual(rt.store.outputs.getOutput(out.id).state, 'pending');++ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker, null);++ // A new restream generation picks it up.++ const rs2 = worker('restream');++ assert.strictEqual(coordinator.syncTick().outputsAssigned, 1);++ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker.id, rs2.id);++++ // Now the ingest worker dies too: its session fails.++ clock.advance(rt.config.workers.leaseMs + 1000);++ rt.store.workers.heartbeat(rs2.id);++ coordinator.syncTick();++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'failed');++ assert.strictEqual(rt.store.sessions.get(a.session.id).failure_reason, 'worker_lost');++ assert.ok(outboxTypes(rt.db).includes('openre.session.failed'));++ assert.strictEqual(rt.store.outputs.getOutput(out.id).desired, 'stop');++});++++t('outputs are only created for enabled, auto-start destinations with a key and no cooldown', () => {++ const { rt, clock, coordinator, definition, key, worker } = setup();++ const ing = worker('rtmp-ingest');++ worker('restream');++ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/a', stream_key: 'k1111' });++ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/b', stream_key: 'k2222', auto_start: false });++ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/c', stream_key: 'k3333', enabled: false });++ const cooling = rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/d', stream_key: 'k4444' });++ rt.store.outputs.markDestinationFailure(cooling.id, 'dead');++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: ing });++ rt.store.sessions.transition(a.session.id, 'live');++ assert.strictEqual(coordinator.syncTick().outputsCreated, 1);++ // A manual start ignores the cooldown (like Live's /start).++ rt.store.outputs.startDestination(cooling.id);++ assert.strictEqual(rt.store.outputs.outputsOfSession(a.session.id).length, 2);++ clock.advance(1);++});++++t('a restream generation at its drain deadline hands its outputs to the newest generation', () => {++ const { rt, coordinator, definition, key, worker } = setup();++ const ing = worker('rtmp-ingest');++ const rs1 = worker('restream');++ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/a', stream_key: 'k1111' });++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: ing });++ rt.store.sessions.transition(a.session.id, 'live');++ coordinator.syncTick();++ const out = rt.store.outputs.outputsOfSession(a.session.id)[0];++ rt.store.outputs.report(out.id, { state: 'live' });++ const rs2 = worker('restream');++ coordinator.syncTick();++ assert.strictEqual(rt.store.workers.get(rs1.id).state, 'draining');++ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker.id, rs1.id, 'the running output stays on the draining generation');++ assert.strictEqual(rt.store.outputs.release(rs1.id), 1); // what restream-worker does at its deadline++ coordinator.syncTick();++ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker.id, rs2.id);++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live');++});++++t('only one coordinator acts at a time', async () => {++ const { rt } = setup();++ const a = createCoordinator({ rt, media: null, log: silent, holder: 'a' });++ const b = createCoordinator({ rt, media: null, log: silent, holder: 'b' });++ assert.ok(!(await a.tick()).skipped);++ assert.ok((await b.tick()).skipped);++ rt.clock.advance(rt.config.workers.leaseMs + 1);++ assert.ok(!(await b.tick()).skipped, 'an expired lease can be taken over');++});++++t('the worker runtime exits by itself when a draining generation is idle, and on being lost', async () => {++ const { rt, worker } = setup();++ const exits = [];++ let active = 1;++ const wr = createWorkerRuntime({ rt, kind: 'restream', log: silent, exit: (c) => exits.push(c), hooks: { activeCount: () => active } });++ wr.register({});++ wr.ready();++ worker('restream');++ rt.store.workers.drain(wr.me.id, Date.now() + 60000);++ wr.beat();++ assert.ok(wr.draining);++ assert.deepStrictEqual(exits, [], 'still carrying an output');++ active = 0;++ wr.beat();++ await new Promise(r => setImmediate(r));++ assert.deepStrictEqual(exits, [0]);++ assert.strictEqual(rt.store.workers.get(wr.me.id).state, 'stopped');++++ const exits2 = [];++ const wr2 = createWorkerRuntime({ rt, kind: 'rtmp-ingest', log: silent, exit: (c) => exits2.push(c), hooks: { activeCount: () => 1 } });++ wr2.register({});++ wr2.ready();++ rt.store.workers.lose(wr2.me.id, 'test');++ wr2.beat();++ await new Promise(r => setImmediate(r));++ assert.deepStrictEqual(exits2, [1]);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++/**++ * A fake transport worker for test/api-restart.test.js: a separate process that registers an++ * rtmp-ingest generation, admits a session for the stream named in FAKE_STREAM_ID, takes it live++ * and keeps heartbeating (renewing its lease) until killed. It holds no socket: the point is that++ * nothing the API does can reach into it.++ */++const { load } = require('../../server/config');++const { openRuntime } = require('../../server/store');++const { createWorkerRuntime } = require('../../workers/runtime');++++const rt = openRuntime({ config: load(), log: { log() {}, warn() {}, error: console.error } });++const worker = createWorkerRuntime({ rt, kind: 'rtmp-ingest', hooks: { activeCount: () => 1 }, log: { log() {}, warn() {}, error: console.error } });++worker.register({ publicPort: 0, rtmpPlayPort: 0, flvPort: 0 });++worker.ready();++const definition = rt.store.definitions.get(process.env.FAKE_STREAM_ID);++const a = rt.store.sessions.admit({ definition, key: null, protocol: 'rtmp', worker: worker.me });++rt.store.sessions.transition(a.session.id, 'live', { reason: 'fake_media' });++console.log(`SESSION ${a.session.id} WORKER ${worker.me.id}`);++setInterval(() => {}, 1 << 30);nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++/**++ * Shared test fixtures: a generated Network signing key and token minting, temp databases, a++ * manual clock, runtime/API boot helpers, child-process helpers and a tiny sequential runner.++ */++const crypto = require('crypto');++const fs = require('fs');++const net = require('net');++const os = require('os');++const path = require('path');++const { spawn, spawnSync } = require('child_process');++const { serviceAuth } = require('openvibe-contracts');++const { load } = require('../server/config');++const { openRuntime } = require('../server/store');++++const ROOT = path.join(__dirname, '..');++const ISSUER = 'https://openvibe.network';++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++});++const SECRETS_KEY = crypto.randomBytes(32).toString('hex');++const OWNER = 'usr_01J0000000000000000000000A';++const OTHER = 'usr_01J0000000000000000000000B';++++const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };++++function serviceToken(slug, cap, { aud = 'openvibe.openre', exp = Math.floor(Date.now() / 1000) + 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: ISSUER, sub: `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ }, privateKey);++}++++function userToken({ subjectId = OWNER, role = 'user', aud = ['openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600 } = {}) {++ return serviceAuth.signServiceToken({++ sub: 57, id: 57, subject_id: subjectId, username: 'streamer', display_name: 'Streamer', role, iss: ISSUER, aud,++ iat: Math.floor(Date.now() / 1000), exp,++ }, privateKey);++}++++function manualClock(t = Date.now()) {++ return { t, now() { return this.t; }, advance(ms) { this.t += ms; return this.t; } };++}++++const made = [];++process.on('exit', () => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });++function tmpDir() {++ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'openre-test-'));++ made.push(d);++ return d;++}++++function testEnv(dir, extra = {}) {++ return {++ NODE_ENV: 'test',++ PORT: '0',++ OPENRE_DB_PATH: path.join(dir, 'openre.db'),++ OPENRE_SECRETS_KEY: SECRETS_KEY,++ OV_NETWORK_PUBLIC_KEY: publicKey,++ OPENRE_DEST_ALLOW_PRIVATE: '1',++ OPENRE_RTMP_PUBLIC_HOST: '127.0.0.1',++ ...extra,++ };++}++++/** A runtime on a temp database (no network). */++function runtime({ env = {}, clock, dir = tmpDir(), fetchImpl } = {}) {++ const config = load(testEnv(dir, env));++ return { ...openRuntime({ config, clock, log: silent, fetchImpl }), dir };++}++++async function bootApi({ env = {}, dir = tmpDir(), clock, fetchImpl } = {}) {++ const { start } = require('../server/index');++ const config = load(testEnv(dir, env));++ const h = await start({ config, clock, log: silent, fetchImpl });++ const base = `http://127.0.0.1:${h.server.address().port}`;++ return { ...h, base, dir };++}++++async function request(base, method, p, { token, body, headers = {}, cookie, form } = {}) {++ const h = { ...headers };++ if (token) h.Authorization = `Bearer ${token}`;++ if (cookie) h.Cookie = cookie;++ let payload;++ if (form) { h['Content-Type'] = 'application/x-www-form-urlencoded'; payload = new URLSearchParams(form).toString(); }++ else if (body !== undefined) { h['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }++ const res = await fetch(base + p, { method, headers: h, body: payload, redirect: 'manual' });++ const text = await res.text();++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }++ return { status: res.status, body: json, text, headers: res.headers };++}++++function freePort() {++ return new Promise((resolve, reject) => {++ const s = net.createServer();++ s.once('error', reject);++ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });++ });++}++++/** A child process running one of the service's entry points with a test env. */++function child(script, env, { onLine } = {}) {++ const proc = spawn(process.execPath, [path.join(ROOT, script)], {++ cwd: ROOT,++ env: { ...process.env, ...env, OPENRE_ENV_FILE: '/nonexistent' },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ proc.output = '';++ const onData = (c) => {++ const s = c.toString();++ proc.output += s;++ if (process.env.DEBUG) process.stderr.write(`[${path.basename(script)}] ${s}`);++ if (onLine) for (const line of s.split('\n')) if (line) onLine(line);++ };++ proc.stdout.on('data', onData);++ proc.stderr.on('data', onData);++ proc.exited = new Promise(r => proc.on('exit', (code, signal) => r({ code, signal })));++ return proc;++}++++async function waitFor(pred, { timeoutMs = 10000, intervalMs = 50, what = 'condition' } = {}) {++ const until = Date.now() + timeoutMs;++ for (;;) {++ const v = await pred();++ if (v) return v;++ if (Date.now() > until) throw new Error(`timed out waiting for ${what}`);++ await sleep(intervalMs);++ }++}++++const sleep = (ms) => new Promise(r => setTimeout(r, ms));++++function hasFfmpeg() {++ try { return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; } catch { return false; }++}++++/** Publish a lavfi test pattern over RTMP with the system ffmpeg. */++function publish(url, { seconds } = {}) {++ const args = ['-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=15', '-f', 'lavfi', '-i', 'sine=frequency=440',++ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-g', '15', '-c:a', 'aac', '-b:a', '64k'];++ if (seconds) args.push('-t', String(seconds));++ args.push('-f', 'flv', url);++ const p = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });++ p.err = '';++ p.stderr.on('data', (c) => { p.err += c; });++ p.exited = new Promise(r => p.on('exit', (code, signal) => r({ code, signal })));++ return p;++}++++/** An RTMP sink (ffmpeg -listen 1) that counts as a working restream destination. */++function rtmpSink(port, key = 'sinkkey') {++ const p = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-listen', '1', '-i', `rtmp://127.0.0.1:${port}/app/${key}`, '-f', 'null', '-'], { stdio: 'ignore' });++ p.exited = new Promise(r => p.on('exit', (code, signal) => r({ code, signal })));++ return p;++}++++function suite(name) {++ const tests = [];++ const t = (n, fn) => tests.push([n, fn]);++ t.run = async () => {++ let failed = 0;++ for (const [n, fn] of tests) {++ try { await fn(); console.log(` ok ${n}`); } catch (err) { failed++; console.log(` FAIL ${n}\n${err.stack}`); }++ }++ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);++ process.exit(failed ? 1 : 0);++ };++ return t;++}++++/** The event types in the outbox, in order. */++function outboxTypes(db) {++ return db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope).event_type);++}++function outboxEnvelopes(db) {++ return db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope));++}++++module.exports = {++ ROOT, ISSUER, privateKey, publicKey, SECRETS_KEY, OWNER, OTHER, silent, serviceToken, userToken, manualClock, tmpDir, testEnv,++ runtime, bootApi, request, freePort, child, waitFor, sleep, hasFfmpeg, publish, rtmpSink, suite, outboxTypes, outboxEnvelopes,++};nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// Ingest keys: stored hashed, shown once, rotation (immediate and with a grace period), revocation++// by the coordinator, archive, and destination secrets sealed at rest.++const assert = require('assert');++const { runtime, manualClock, suite, OWNER, outboxEnvelopes } = require('./helpers');++const { hashIngestKey, isIngestKeyShape, createBox } = require('../server/secrets');++const { createCoordinator } = require('../server/coordinator');++++const t = suite('keys');++++t('a new definition issues one key; only its SHA-256 is stored', () => {++ const rt = runtime();++ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER, title: 'Main' });++ assert.ok(isIngestKeyShape(key.key), 'ork_ + 43 base64url chars');++ const rows = rt.db.prepare('SELECT * FROM ingest_keys').all();++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].key_hash, hashIngestKey(key.key));++ assert.strictEqual(rows[0].hint, key.key.slice(-4));++ // The plain key is nowhere in the database file's tables.++ const dump = JSON.stringify(rt.db.prepare("SELECT * FROM ingest_keys").all()) + JSON.stringify(rt.db.prepare('SELECT * FROM stream_definitions').all());++ assert.ok(!dump.includes(key.key));++ const r = rt.store.definitions.resolveIngestKey(key.key, 'rtmp');++ assert.strictEqual(r.definition.id, definition.id);++ assert.ok(rt.db.prepare('SELECT last_used_at FROM ingest_keys').get().last_used_at > 0);++});++++t('unknown, malformed and wrong-protocol keys are refused', () => {++ const rt = runtime();++ const { key } = rt.store.definitions.create({ owner_subject: OWNER });++ assert.strictEqual(rt.store.definitions.resolveIngestKey('ork_' + 'A'.repeat(43), 'rtmp').error, 'unknown_key');++ assert.strictEqual(rt.store.definitions.resolveIngestKey('a3f9c2', 'rtmp').error, 'malformed_key');++ assert.strictEqual(rt.store.definitions.resolveIngestKey(key.key, 'whip').error, 'protocol_not_allowed');++});++++t('rotate revokes the old key immediately and emits openre.key.rotated without any key', () => {++ const rt = runtime();++ const { definition, key: first } = rt.store.definitions.create({ owner_subject: OWNER, external_refs: [{ service: 'live', type: 'managed_stream', id: '7' }] });++ const r = rt.store.definitions.rotateKey(definition.id, { rotated_by: OWNER });++ assert.notStrictEqual(r.key.key, first.key);++ assert.strictEqual(rt.store.definitions.resolveIngestKey(first.key, 'rtmp').error, 'revoked_key');++ assert.strictEqual(rt.store.definitions.resolveIngestKey(r.key.key, 'rtmp').definition.id, definition.id);++ const ev = outboxEnvelopes(rt.db).find(e => e.event_type === 'openre.key.rotated');++ assert.ok(ev);++ assert.deepStrictEqual(ev.subject, { type: 'stream', id: definition.id, revision: 1 });++ assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });++ const text = JSON.stringify(ev);++ assert.ok(!text.includes(first.key) && !text.includes(r.key.key), 'no key in the event');++ assert.strictEqual(ev.payload.retired_key_ids.length, 1);++});++++t('rotate with a grace period keeps the old key for new publishes until the coordinator revokes it', async () => {++ const clock = manualClock();++ const rt = runtime({ clock });++ const { definition, key: first } = rt.store.definitions.create({ owner_subject: OWNER });++ const r = rt.store.definitions.rotateKey(definition.id, { grace_seconds: 600 });++ assert.strictEqual(rt.store.definitions.resolveIngestKey(first.key, 'rtmp').key.status, 'grace');++ assert.ok(rt.store.definitions.resolveIngestKey(r.key.key, 'rtmp').definition);++ clock.advance(601 * 1000);++ // Past the grace period the key no longer authenticates, even before the coordinator runs...++ assert.strictEqual(rt.store.definitions.resolveIngestKey(first.key, 'rtmp').error, 'revoked_key');++ // ...and the coordinator records the revocation.++ const out = createCoordinator({ rt, media: null, log: { log() {}, warn() {}, error() {} } }).syncTick();++ assert.strictEqual(out.keysRevoked, 1);++ assert.strictEqual(rt.db.prepare("SELECT status FROM ingest_keys WHERE key_hash = ?").get(hashIngestKey(first.key)).status, 'revoked');++});++++t('archiving revokes every key and is refused while a session is open', () => {++ const rt = runtime();++ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER });++ const w = rt.store.workers.register({ kind: 'rtmp-ingest' });++ rt.store.workers.ready(w.id);++ const a = rt.store.sessions.admit({ definition, key: { id: 'k' }, protocol: 'rtmp', worker: rt.store.workers.get(w.id) });++ assert.throws(() => rt.store.definitions.archive(definition.id), /end the live session/);++ rt.store.sessions.finish(a.session.id, { reason: 'test' });++ rt.store.definitions.archive(definition.id);++ assert.strictEqual(rt.store.definitions.resolveIngestKey(key.key, 'rtmp').error, 'revoked_key');++});++++t('external refs are unique across definitions', () => {++ const rt = runtime();++ rt.store.definitions.create({ owner_subject: OWNER, external_refs: [{ service: 'live', type: 'managed_stream', id: '9' }] });++ assert.throws(() => rt.store.definitions.create({ owner_subject: OWNER, external_refs: [{ service: 'live', type: 'managed_stream', id: '9' }] }), /already belongs/);++ assert.ok(rt.store.definitions.findByRef('live', 'managed_stream', '9'));++});++++t('destination keys are sealed with AES-256-GCM and only the last four characters come back', () => {++ const rt = runtime();++ const { definition } = rt.store.definitions.create({ owner_subject: OWNER });++ const d = rt.store.outputs.createDestination(definition.id, { platform: 'twitch', server_url: 'rtmps://live.twitch.tv/app', stream_key: 'live_1234567890_secret' });++ assert.strictEqual(d.stream_key_hint, '****cret');++ assert.ok(!JSON.stringify(d).includes('live_1234567890_secret'));++ const row = rt.db.prepare('SELECT stream_key_enc FROM destinations').get();++ assert.ok(row.stream_key_enc.startsWith('v1.') && !row.stream_key_enc.includes('secret'));++ assert.strictEqual(rt.store.outputs.destinationForWorker(d.id).stream_key, 'live_1234567890_secret');++ // A second key can take over: the previous one still opens old rows.++ const box2 = createBox({ key: require('crypto').randomBytes(32).toString('hex'), previous: rt.config.secretsKey });++ assert.strictEqual(box2.open(row.stream_key_enc), 'live_1234567890_secret');++ // Without a key nothing is stored in the clear.++ assert.throws(() => createBox({}).seal('x'), /OPENRE_SECRETS_KEY/);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,275 @@++'use strict';++// End to end with real processes and real RTMP (system ffmpeg): openre-api, openre-rtmp-ingest,++// openre-restream-worker and openre-session-coordinator as separate child processes, a stub++// OpenVibe.Network (token endpoint), a stub OpenVibe.Events and a stub OpenVibe.Media.++//++// Proves: ingest with OpenRe's own keys (wrong and duplicate publishers refused), restream to a++// working destination while a dead destination fails without touching the session, recording++// requested from Media with a key-free loopback URL that Media can actually pull, an API restart++// during the broadcast that ends nothing, HTTP-FLV playback through the new API, a worker++// generation rollover (new sessions on the new generation, the old one drains and exits on its++// own), and the events published to OpenVibe.Events.++const assert = require('assert');++const http = require('http');++const { spawn } = require('child_process');++const {++ tmpDir, testEnv, child, waitFor, request, userToken, freePort, sleep, suite, hasFfmpeg, publish, rtmpSink, OWNER,++} = require('./helpers');++++if (!hasFfmpeg()) {++ console.log('rtmp-e2e: SKIPPED (no ffmpeg on PATH)');++ process.exit(0);++}++++const t = suite('rtmp-e2e');++const token = userToken({ subjectId: OWNER });++const procs = [];++const published = [];++const mediaCalls = [];++let env;++let api;++let coordinator;++let base;++let rtmpPort;++let ingest1;++let stubs = [];++++function stubServer(handler) {++ return new Promise((resolve) => {++ const s = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => handler(req, res, body));++ });++ s.listen(0, '127.0.0.1', () => { stubs.push(s); resolve(`http://127.0.0.1:${s.address().port}`); });++ });++}++++const json = (res, status, obj) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };++++function startChild(script, extra = {}) {++ const p = child(script, { ...env, ...extra });++ procs.push(p);++ return p;++}++++async function startApi() {++ const p = startChild('server/index.js');++ await waitFor(async () => { try { return (await request(base, 'GET', '/api/ready')).status === 200; } catch { return false; } }, { what: 'api ready' });++ return p;++}++++async function session(streamId, state = 'live') {++ const r = await request(base, 'GET', `/api/v1/sessions?stream_id=${streamId}`, { token });++ return (r.body.sessions || []).find(s => s.state === state) || null;++}++++let streamA;++let keyA;++let pubA;++let sessionA;++let sink;++++t('setup: stubs and the four OpenRe processes', async () => {++ const network = await stubServer((req, res, body) => {++ if (req.url === '/oauth/token') {++ const p = new URLSearchParams(body);++ return json(res, 200, { access_token: `tok-${p.get('audience')}`, token_type: 'Bearer', expires_in: 300 });++ }++ return json(res, 404, {});++ });++ const events = await stubServer((req, res, body) => {++ if (req.url === '/api/v1/events' && req.method === 'POST') {++ assert.strictEqual(req.headers.authorization, 'Bearer tok-openvibe.events');++ const parsed = JSON.parse(body);++ const list = parsed.events || [parsed];++ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });++ return json(res, parsed.events ? 200 : 201, parsed.events ? { results } : results[0]);++ }++ return json(res, 404, {});++ });++ const media = await stubServer((req, res, body) => {++ const call = { method: req.method, url: req.url, auth: req.headers.authorization, body: body ? JSON.parse(body) : null };++ mediaCalls.push(call);++ if (req.method === 'POST' && req.url === '/api/v1/live/vods') return json(res, 201, { id: 77 });++ if (req.method === 'POST' && req.url === '/api/v1/live/vods/77/ingest/rtmp') {++ // Behave like Media: pull the URL with ffmpeg (here for 2 s) and remember the result.++ const pull = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-rw_timeout', '5000000', '-i', call.body.rtmp_url, '-t', '2', '-f', 'null', '-'], { stdio: 'ignore' });++ procs.push(pull);++ pull.on('exit', (code) => { call.pullExit = code; });++ return json(res, 202, { id: 77, status: 'recording' });++ }++ if (req.method === 'POST' && req.url === '/api/v1/live/vods/77/finalize') return json(res, 200, { ok: true });++ return json(res, 404, { error: 'no' });++ });++ rtmpPort = await freePort();++ const dir = tmpDir();++ env = testEnv(dir, {++ PORT: String(await freePort()),++ OPENRE_RTMP_PORT: String(rtmpPort),++ OPENRE_RTMP_INTERNAL_PORT_MIN: String(20000 + Math.floor(Math.random() * 20000)),++ OV_NETWORK_INTERNAL_URL: network,++ OV_OAUTH_CLIENT_SECRET: 'test-secret',++ EVENTS_URL: events,++ EVENTS_RELAY_INTERVAL_MS: '200',++ MEDIA_URL: media,++ MEDIA_API_KEY: 'media-app-key',++ OPENRE_RECORDING_START_DELAY_MS: '300',++ OPENRE_WORKER_HEARTBEAT_MS: '250',++ OPENRE_WORKER_LEASE_MS: '4000',++ OPENRE_COORDINATOR_INTERVAL_MS: '250',++ OPENRE_RESTREAM_POLL_MS: '250',++ OPENRE_OUTPUT_START_DELAY_MS: '500',++ OPENRE_RESTART_BASE_MS: '200',++ OPENRE_MAX_RESTARTS: '3',++ OPENRE_RAPID_CRASH_GIVEUP: '2',++ });++ env.OPENRE_RTMP_INTERNAL_PORT_MAX = String(Number(env.OPENRE_RTMP_INTERNAL_PORT_MIN) + 20);++ base = `http://127.0.0.1:${env.PORT}`;++ coordinator = startChild('workers/coordinator.js');++ ingest1 = startChild('workers/rtmp-ingest.js');++ startChild('workers/restream-worker.js');++ api = await startApi();++ await waitFor(() => /generation 1 ready/.test(ingest1.output), { what: 'rtmp worker ready' });++});++++t('a stream with a working and a dead destination', async () => {++ const r = await request(base, 'POST', '/api/v1/streams', { token, body: { title: 'E2E', recording_mode: 'vod', external_refs: [{ service: 'live', type: 'managed_stream', id: '5' }, { service: 'live', type: 'user', id: '42' }] } });++ assert.strictEqual(r.status, 201);++ streamA = r.body.stream.id;++ keyA = r.body.key.key;++ const sinkPort = await freePort();++ sink = rtmpSink(sinkPort);++ procs.push(sink);++ const deadPort = await freePort();++ assert.strictEqual((await request(base, 'POST', `/api/v1/streams/${streamA}/destinations`, { token, body: { platform: 'custom', name: 'sink', server_url: `rtmp://127.0.0.1:${sinkPort}/app`, stream_key: 'sinkkey' } })).status, 201);++ assert.strictEqual((await request(base, 'POST', `/api/v1/streams/${streamA}/destinations`, { token, body: { platform: 'custom', name: 'dead', server_url: `rtmp://127.0.0.1:${deadPort}/app`, stream_key: 'deadkey' } })).status, 201);++});++++t('a wrong key is refused; the right key goes live on generation 1', async () => {++ const wrong = publish(`rtmp://127.0.0.1:${rtmpPort}/live/ork_${'x'.repeat(43)}`, { seconds: 3 });++ procs.push(wrong);++ const w = await wrong.exited;++ assert.notStrictEqual(w.code, 0, 'ffmpeg with a wrong key fails');++ pubA = publish(`rtmp://127.0.0.1:${rtmpPort}/live/${keyA}`);++ procs.push(pubA);++ sessionA = await waitFor(() => session(streamA), { what: 'session A live', timeoutMs: 15000 });++ assert.strictEqual(sessionA.worker.generation, 1);++});++++t('a second publisher with the same key is refused while the first is live', async () => {++ const dup = publish(`rtmp://127.0.0.1:${rtmpPort}/live/${keyA}`, { seconds: 3 });++ procs.push(dup);++ const d = await dup.exited;++ assert.notStrictEqual(d.code, 0);++ assert.ok((await session(streamA)).id === sessionA.id);++});++++t('the working destination goes live, the dead one fails, the session stays live', async () => {++ const outputs = await waitFor(async () => {++ const r = await request(base, 'GET', `/api/v1/sessions/${sessionA.id}/outputs`, { token });++ const o = r.body.outputs || [];++ return o.length === 2 && o.some(x => x.state === 'live') && o.some(x => x.state === 'failed') ? o : null;++ }, { what: 'outputs live + failed', timeoutMs: 40000 });++ const failed = outputs.find(o => o.state === 'failed');++ assert.match(failed.last_error, /connect|closed|refused|rapid/i);++ assert.strictEqual((await session(streamA)).id, sessionA.id, 'the source session is still live');++ assert.strictEqual(pubA.exitCode, null, 'the encoder is still connected');++});++++t('recording is requested from Media with a loopback URL that carries no key, and Media can pull it', async () => {++ const ingestCall = await waitFor(() => mediaCalls.find(c => c.url.endsWith('/ingest/rtmp')), { what: 'media ingest call', timeoutMs: 10000 });++ const create = mediaCalls.find(c => c.url === '/api/v1/live/vods');++ assert.strictEqual(create.auth, 'Bearer media-app-key');++ assert.strictEqual(create.body.user_id, 42);++ assert.strictEqual(create.body.managed_stream_id, 5);++ assert.strictEqual(create.body.meta.openre_session_id, sessionA.id);++ assert.match(ingestCall.body.rtmp_url, new RegExp(`^rtmp://127\\.0\\.0\\.1:\\d+/live/${sessionA.id}$`));++ assert.ok(!ingestCall.body.rtmp_url.includes(keyA));++ await waitFor(() => ingestCall.pullExit !== undefined, { what: 'media pull', timeoutMs: 15000 });++ assert.strictEqual(ingestCall.pullExit, 0, 'Media-side ffmpeg read 2 s from the loopback play URL');++ const detail = await request(base, 'GET', `/api/v1/sessions/${sessionA.id}`, { token });++ assert.strictEqual(detail.body.session.recording.state, 'recording');++});++++t('restarting the API during the broadcast ends nothing; playback works through the new API', async () => {++ api.kill('SIGTERM');++ assert.strictEqual((await api.exited).code, 0);++ await sleep(1500);++ api = await startApi();++ const s = await session(streamA);++ assert.strictEqual(s && s.id, sessionA.id);++ assert.strictEqual(pubA.exitCode, null);++ const outputs = (await request(base, 'GET', `/api/v1/sessions/${sessionA.id}/outputs`, { token })).body.outputs;++ assert.ok(outputs.some(o => o.state === 'live'), 'the restream kept running');++ const head = await new Promise((resolve, reject) => {++ http.get(`${base}/play/${sessionA.id}.flv`, (res) => {++ assert.strictEqual(res.statusCode, 200);++ assert.strictEqual(res.headers['content-type'], 'video/x-flv');++ res.once('data', (c) => { resolve(c.subarray(0, 3).toString()); res.destroy(); });++ }).on('error', reject);++ });++ assert.strictEqual(head, 'FLV');++});++++let streamB;++let pubB;++let ingest2;++++t('a new rtmp-ingest generation takes new sessions; generation 1 drains but keeps session A', async () => {++ ingest2 = startChild('workers/rtmp-ingest.js');++ await waitFor(() => /generation 2 ready/.test(ingest2.output), { what: 'generation 2 ready' });++ await waitFor(() => /draining/.test(ingest1.output) && /public listener closed/.test(ingest1.output), { what: 'generation 1 draining', timeoutMs: 10000 });++ const r = await request(base, 'POST', '/api/v1/streams', { token, body: { title: 'B', recording_mode: 'none' } });++ streamB = r.body.stream.id;++ pubB = publish(`rtmp://127.0.0.1:${rtmpPort}/live/${r.body.key.key}`);++ procs.push(pubB);++ const sB = await waitFor(() => session(streamB), { what: 'session B live', timeoutMs: 15000 });++ assert.strictEqual(sB.worker.generation, 2, 'new sessions reach the newest generation');++ const sA = await session(streamA);++ assert.strictEqual(sA.id, sessionA.id);++ assert.strictEqual(sA.worker.generation, 1, 'the running session stays on its generation');++ assert.strictEqual(ingest1.exitCode, null);++});++++t('when session A ends, generation 1 exits by itself; Media is asked to finalise', async () => {++ pubA.kill('SIGINT');++ await waitFor(async () => {++ const r = await request(base, 'GET', `/api/v1/sessions/${sessionA.id}`, { token });++ return r.body.session.state === 'ended' ? r.body.session : null;++ }, { what: 'session A ended', timeoutMs: 15000 });++ const exit = await Promise.race([ingest1.exited, sleep(10000).then(() => ({ code: 'timeout' }))]);++ assert.strictEqual(exit.code, 0, 'the drained generation exited cleanly');++ await waitFor(() => mediaCalls.some(c => c.url.endsWith('/finalize')), { what: 'media finalize', timeoutMs: 10000 });++ const sB = await session(streamB);++ assert.ok(sB, 'session B on generation 2 is unaffected');++});++++t('events reached OpenVibe.Events through the outbox relay, without keys', async () => {++ const types = await waitFor(() => {++ const ts = published.map(e => e.event_type);++ return ['openre.session.started', 'openre.output.healthy', 'openre.output.failed', 'openre.recording.requested', 'openre.session.ended'].every(x => ts.includes(x)) ? ts : null;++ }, { what: 'published events', timeoutMs: 10000 });++ assert.strictEqual(types.filter(x => x === 'openre.session.started').length, 2);++ assert.ok(!types.includes('openre.session.failed'));++ const all = JSON.stringify(published);++ assert.ok(!all.includes(keyA), 'no ingest key in any event');++ assert.ok(!all.includes('sinkkey') && !all.includes('deadkey'), 'no destination key in any event');++ const started = published.find(e => e.event_type === 'openre.session.started' && e.subject.id === sessionA.id);++ assert.deepStrictEqual(started.payload.external_refs.map(r => `${r.service}:${r.type}:${r.id}`), ['live:managed_stream:5', 'live:user:42']);++});++++t('no OpenRe process crashed along the way', () => {++ assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);++ assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);++ assert.strictEqual(ingest2.exitCode, null, `generation 2 exited:\n${ingest2.output}`);++});++++t('teardown', async () => {++ if (process.exitCode || process.env.DEBUG) for (const p of procs) if (p.output) console.log(p.output);++ for (const p of procs) { try { p.kill('SIGKILL'); } catch { /* */ } }++ for (const s of stubs) { s.closeAllConnections?.(); s.close(); }++ stubs = [];++});++++t.run();nnew file mode 100644@@@ -0,0 +1,57 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails++ * if any of them fails. They use temp SQLite databases, generated RSA keys and stub subscribers++ * on random ports; none of them needs the network or a running OpenVibe.Network. The RTMP end-to-end tests use++ * the system ffmpeg and say SKIPPED when it is missing.++ *++ * npm test # everything++ * npm test -- publish sse # 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 = 180000;++++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(32)} ${String(r.ms).padStart(6)}ms`);++ }++ 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(-60).join('\n'));++ }++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);++ process.exit(failed.length ? 1 : 0);++})();nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++// Session state machine: allowed transitions, events emitted exactly per the lifecycle rules,++// admission (one open session per stream), and outputs following their session down.++const assert = require('assert');++const { contracts } = { contracts: require('openvibe-contracts') };++const { runtime, manualClock, suite, OWNER, outboxTypes, outboxEnvelopes } = require('./helpers');++const { NEXT, canTransition } = require('../server/state-machine');++++const t = suite('sessions');++++function setup(clock = manualClock()) {++ const rt = runtime({ clock });++ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER, title: 'Show', external_refs: [{ service: 'live', type: 'managed_stream', id: '12' }] });++ const w = rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: { publicPort: 1936, rtmpPlayPort: 19360, flvPort: 19361 } });++ rt.store.workers.ready(w.id);++ return { rt, clock, definition, key, worker: rt.store.workers.get(w.id) };++}++++t('the transition table is exactly starting→live→ending→ended with failed from any open state', () => {++ assert.deepStrictEqual(NEXT, { starting: ['live', 'ending', 'failed'], live: ['ending', 'failed'], ending: ['ended', 'failed'], ended: [], failed: [] });++ assert.ok(!canTransition('ended', 'live'));++ assert.ok(!canTransition('live', 'starting'));++ assert.ok(!canTransition('failed', 'ended'));++});++++t('a clean session emits started then ended; envelopes are valid events.event-envelope@1', () => {++ const { rt, clock, definition, key, worker } = setup();++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ assert.strictEqual(a.session.state, 'starting');++ assert.deepStrictEqual(outboxTypes(rt.db), [], 'starting is internal');++ assert.ok(rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing' }).ok);++ clock.advance(90 * 1000);++ const f = rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });++ assert.ok(f.ok);++ assert.strictEqual(f.session.state, 'ended');++ assert.deepStrictEqual(outboxTypes(rt.db), ['openre.session.started', 'openre.session.ended']);++ const [started, ended] = outboxEnvelopes(rt.db);++ for (const ev of [started, ended]) {++ const v = contracts.validate('events.event-envelope@1', ev);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(ev.source, 'openre');++ assert.strictEqual(ev.subject.type, 'ingest_session');++ assert.ok(!JSON.stringify(ev).includes(key.key), 'no ingest key in events');++ }++ assert.strictEqual(started.subject.revision, 2);++ assert.strictEqual(ended.subject.revision, 4);++ assert.deepStrictEqual(started.payload.external_refs, [{ service: 'live', type: 'managed_stream', id: '12', label: null }]);++ assert.strictEqual(started.payload.playback.flv.internal_url, `http://127.0.0.1:19361/live/${a.session.id}.flv`);++ assert.strictEqual(ended.payload.duration_seconds, 90);++ assert.deepStrictEqual(rt.store.sessions.transitions(a.session.id).map(x => x.to_state), ['starting', 'live', 'ending', 'ended']);++});++++t('a publisher that leaves during the handshake ends without events; failures always emit', () => {++ const { rt, definition, key, worker } = setup();++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });++ assert.deepStrictEqual(outboxTypes(rt.db), []);++ const b = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ rt.store.sessions.transition(b.session.id, 'failed', { reason: 'worker_lost' });++ const ev = outboxEnvelopes(rt.db).pop();++ assert.strictEqual(ev.event_type, 'openre.session.failed');++ assert.strictEqual(ev.payload.was_live, false);++ assert.strictEqual(ev.payload.failure_reason, 'worker_lost');++});++++t('invalid transitions change nothing (a worker and the coordinator racing)', () => {++ const { rt, definition, key, worker } = setup();++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ rt.store.sessions.transition(a.session.id, 'live');++ rt.store.sessions.transition(a.session.id, 'failed', { reason: 'lease_expired' });++ const before = outboxTypes(rt.db).length;++ const r = rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });++ assert.strictEqual(r.ok, false);++ assert.strictEqual(r.code, 'invalid_transition');++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'failed');++ assert.strictEqual(outboxTypes(rt.db).length, before);++});++++t('one open session per stream: a second publisher is refused while the first holds its lease', () => {++ const { rt, clock, definition, key, worker } = setup();++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ assert.strictEqual(rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker }).error, 'duplicate_publisher');++ // Lease expired (worker died): the stale session fails and the new publish is admitted.++ clock.advance(rt.config.workers.leaseMs + 1);++ const b = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ assert.ok(b.session);++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'failed');++ assert.strictEqual(rt.store.sessions.get(a.session.id).failure_reason, 'lease_expired');++});++++t('heartbeats renew the lease of every session the worker owns', () => {++ const { rt, clock, definition, key, worker } = setup();++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ const lease1 = rt.store.sessions.get(a.session.id).lease_expires_at;++ clock.advance(5000);++ rt.store.workers.heartbeat(worker.id);++ assert.strictEqual(rt.store.sessions.get(a.session.id).lease_expires_at, lease1 + 5000);++});++++t('ending a session asks its outputs to stop; an output never moves the session', () => {++ const { rt, definition, key, worker } = setup();++ const dest = rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/app', stream_key: 'k1234' });++ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:2/app', stream_key: 'k5678' });++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ rt.store.sessions.transition(a.session.id, 'live');++ assert.strictEqual(rt.store.outputs.ensureAutoOutputs(), 2);++ const [out, other] = rt.store.outputs.outputsOfSession(a.session.id);++ rt.store.outputs.report(other.id, { state: 'live' });++ rt.store.outputs.report(out.id, { state: 'failed', last_error: 'boom' });++ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live', 'a failed output leaves the session live');++ assert.ok(outboxTypes(rt.db).includes('openre.output.failed'));++ rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });++ assert.strictEqual(rt.db.prepare('SELECT desired FROM outputs WHERE id = ?').get(other.id).desired, 'stop', 'the running output is told to stop');++ assert.ok(dest.id);++});++++t('an end request is recorded for the owning worker to act on', () => {++ const { rt, definition, key, worker } = setup();++ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });++ assert.ok(rt.store.sessions.requestEnd(a.session.id, OWNER));++ assert.deepStrictEqual(rt.store.sessions.endRequestsFor(worker.id).map(s => s.id), [a.session.id]);++});++++t.run();nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++/**++ * openre-session-coordinator — leases, generations, output assignment, recording requests and the++ * event relay (unit openre-session-coordinator.service). See server/coordinator.js.++ *++ * Restarting it never touches a transport: workers keep their sessions and heartbeats; a stopped++ * coordinator only delays output assignment, recording requests and event publishing.++ */++const path = require('path');++++if (require.main === module) {++ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });++ const { load } = require('../server/config');++ const { openRuntime } = require('../server/store');++ const { createCoordinator } = require('../server/coordinator');++ const { createMediaClient } = require('../server/media-client');++ const config = load();++ const rt = openRuntime({ config });++ const media = createMediaClient({ config });++ const coordinator = createCoordinator({ rt, media });++ coordinator.start();++ console.log(`[coordinator] running (events relay ${rt.events.configured ? 'on' : 'off: EVENTS_URL/OV_OAUTH_CLIENT_SECRET not set'}, recordings ${media.configured ? `on → ${config.media.url} app ${config.media.appId}` : 'off'})`);++ const prune = setInterval(() => { try { rt.events.outbox.prune(); } catch { /* next time */ } }, 6 * 60 * 60 * 1000);++ prune.unref();++ const shutdown = (sig) => {++ console.log(`[coordinator] ${sig}: stopping`);++ coordinator.stop().then(() => { rt.db.close(); process.exit(0); }, () => process.exit(1));++ setTimeout(() => process.exit(1), 10000).unref();++ };++ process.on('SIGTERM', () => shutdown('SIGTERM'));++ process.on('SIGINT', () => shutdown('SIGINT'));++}nnew file mode 100644@@@ -0,0 +1,3 @@++'use strict';++/** openre-jsmpeg: worker interface only, the transport is not ported yet (see unported-transport.js). */++if (require.main === module) require('./unported-transport').main('jsmpeg');nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++/**++ * openre-restream-worker — runs restream outputs (unit openre-restream-worker@<release>.service).++ *++ * The coordinator assigns each pending output to the newest ready restream generation; this++ * process picks up the outputs assigned to it, runs one ffmpeg per output (OutputRunner, ported++ * from Live's restream manager) and reports state, progress and logs. It pulls the source from the++ * RTMP worker that owns the session (its loopback HTTP-FLV endpoint), so it never needs a key.++ *++ * Drain: a draining generation keeps its outputs running until their sessions end; at the drain++ * deadline it stops them and releases them to the coordinator, which hands them to the newest++ * generation (the destination sees one reconnect). Then the process exits.++ */++const path = require('path');++const { OutputRunner } = require('./restream/output-runner');++const { createWorkerRuntime } = require('./runtime');++++function createRestreamWorker({ rt, log = console, exit = (code) => process.exit(code), spawnImpl, lookup }) {++ const { store, config } = rt;++ const runners = new Map(); // output id → OutputRunner++ let pollTimer = null;++ let closing = false;++++ const runtime = createWorkerRuntime({++ rt, kind: 'restream', log, exit,++ hooks: {++ onDrainDeadline: () => handOver(),++ onLost: () => stopAll('worker lost'),++ activeCount: () => runners.size,++ onExit: () => { closing = true; clearTimeout(pollTimer); },++ },++ });++++ function inputUrlFor(session) {++ if (session.protocol !== 'rtmp') return null;++ const pb = store.sessions.playback(session);++ return pb && pb.flv ? pb.flv.internal_url : null;++ }++++ function poll() {++ if (closing || !runtime.me) return;++ const rows = store.outputs.forWorker(runtime.me.id);++ const seen = new Set();++ for (const row of rows) {++ seen.add(row.id);++ let runner = runners.get(row.id);++ const session = store.sessions.get(row.session_id);++ const shouldRun = row.desired === 'run' && session && session.state === 'live';++ if (!shouldRun) {++ if (runner) { runner.stop(row.desired === 'stop' ? 'stop requested' : 'session not live'); runners.delete(row.id); } else store.outputs.report(row.id, { state: 'stopped', ended_at: Date.now() });++ continue;++ }++ if (runner && runner.stopped) { runners.delete(row.id); runner = null; continue; }++ if (runner) continue;++ if (runtime.draining) continue; // a draining generation starts nothing new++ const input = inputUrlFor(session);++ if (!input) { store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }); continue; }++ // Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).++ if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;++ runner = new OutputRunner({ outputId: row.id, destinationId: row.destination_id, inputUrl: input, store, config, log, spawnImpl, lookup });++ runners.set(row.id, runner);++ runner.start().catch((err) => runner.fail(err.message, { cooldown: false }));++ }++ for (const [id, runner] of runners) {++ if (!seen.has(id)) { runner.stop('no longer assigned'); runners.delete(id); }++ else if (runner.stopped && !runner.proc) runners.delete(id);++ }++ if (runtime.draining) runtime.exitWhenIdle();++ }++++ function schedule() {++ clearTimeout(pollTimer);++ if (closing) return;++ pollTimer = setTimeout(() => {++ try { poll(); } catch (err) { log.error(`[restream] poll failed: ${err.stack || err}`); }++ schedule();++ }, config.workers.restreamPollMs);++ }++++ function stopAll(reason) {++ for (const [id, r] of runners) { r.stop(reason); runners.delete(id); }++ }++++ /** Drain deadline: stop here and let the coordinator give the outputs to the newest generation. */++ function handOver() {++ for (const [id, r] of runners) { r.stopped = true; r.clearTimers(); if (r.proc) { try { r.proc.kill('SIGTERM'); } catch { /* */ } } runners.delete(id); }++ const n = store.outputs.release(runtime.me.id);++ log.log(`[restream] drain deadline: released ${n} output(s) to the newest generation`);++ }++++ function start() {++ runtime.register({});++ runtime.ready();++ schedule();++ log.log(`[restream] generation ${runtime.me.generation} ready`);++ return runtime.me;++ }++++ return {++ start,++ poll,++ runtime,++ runners: () => runners,++ drain: (reason) => runtime.drainNow(reason),++ close() { closing = true; clearTimeout(pollTimer); stopAll('closing'); runtime.stop(); },++ };++}++++if (require.main === module) {++ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });++ const { load } = require('../server/config');++ const { openRuntime } = require('../server/store');++ const rt = openRuntime({ config: load() });++ const worker = createRestreamWorker({ rt });++ worker.start();++ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => worker.drain(sig));++}++++module.exports = { createRestreamWorker };nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++/**++ * FFmpeg argument building for restream outputs, ported from OpenVibe.Live++ * server/streaming/restream-manager.js (quality presets, destination URL normalisation, SRT,++ * output flags, friendly errors). Kept byte-for-byte equivalent where it matters so a stream that++ * restreams correctly from Live restreams the same way from OpenRe.++ *++ * Only the RTMP source is ported: RTMP → destination is a codec copy (zero CPU). The JSMPEG++ * (MPEG-TS on stdin) and WebRTC (mediasoup PlainRTP → SDP) sources stay in Live until those++ * ingest protocols move; encodingArgs() is here for them and for a future re-encode option.++ */++++const SRT_DEFAULT_LATENCY_MS = 120;++++const QUALITY_PRESETS = {++ low: { label: 'Low (720p 1500k)', videoBitrate: '1500k', maxrate: '1800k', bufsize: '1500k', audioBitrate: '96k', preset: 'ultrafast', scale: '1280:720', fps: 30, gop: 60 },++ medium: { label: 'Medium (720p 2500k)', videoBitrate: '2500k', maxrate: '3000k', bufsize: '2500k', audioBitrate: '128k', preset: 'ultrafast', scale: '1280:720', fps: 30, gop: 60 },++ high: { label: 'High (720p 4000k)', videoBitrate: '4000k', maxrate: '4500k', bufsize: '4000k', audioBitrate: '160k', preset: 'superfast', scale: '1280:720', fps: 30, gop: 60 },++ ultra: { label: 'Ultra (1080p 6000k)', videoBitrate: '6000k', maxrate: '6500k', bufsize: '6000k', audioBitrate: '192k', preset: 'veryfast', scale: null, fps: 30, gop: 60 },++ source: { label: 'Source (native 8000k)', videoBitrate: '8000k', maxrate: '8500k', bufsize: '16000k', audioBitrate: '192k', preset: 'fast', scale: null, fps: 0, gop: 60 },++};++const PLATFORM_DEFAULT_PRESET = { twitch: 'medium', youtube: 'high', kick: 'medium', custom: 'medium' };++const ENCODER_PRESETS = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow'];++++function isSrtUrl(url) { return /^srt:\/\//i.test(String(url || '')); }++++/**++ * Full output URL from server_url + stream key. Twitch is normalised to RTMPS and Kick gets its++ * required /app path, exactly as Live does. `dest` carries the decrypted stream_key/srt_passphrase.++ */++function buildDestUrl(dest) {++ if (!dest.server_url || !dest.stream_key) return null;++ if (!/^(rtmps?|srt):\/\/[^\s/]+/i.test(String(dest.server_url).trim())) return null;++ let url = dest.server_url.replace(/\/+$/, '');++ if (dest.platform === 'twitch' && url.startsWith('rtmp://')) url = url.replace(/^rtmp:\/\//, 'rtmps://');++ if (dest.platform === 'kick' && !url.endsWith('/app')) url += '/app';++ if (isSrtUrl(url)) return buildSrtUrl(url, dest);++ return `${url}/${dest.stream_key}`;++}++++function buildSrtUrl(base, dest) {++ const u = new URL(base);++ const q = u.searchParams;++ if (dest.stream_key && !q.has('streamid')) q.set('streamid', dest.stream_key);++ const latency = Number(dest.srt_latency_ms) > 0 ? Number(dest.srt_latency_ms) : SRT_DEFAULT_LATENCY_MS;++ q.set('latency', String(latency * 1000));++ if (dest.srt_passphrase) q.set('passphrase', dest.srt_passphrase);++ if (!q.has('mode')) q.set('mode', 'caller');++ if (!q.has('pkt_size')) q.set('pkt_size', '1316');++ return u.toString();++}++++function outputArgs(destUrl) {++ const common = ['-muxdelay', '0', '-muxpreload', '0', '-flush_packets', '1', '-max_muxing_queue_size', '4096'];++ if (isSrtUrl(destUrl)) return [...common, '-f', 'mpegts', '-mpegts_flags', '+resend_headers', destUrl];++ return [...common, '-rtmp_live', 'live', '-f', 'flv', '-flvflags', 'no_duration_filesize', destUrl];++}++++/** RTMP source → destination, codec copy (Live's _startRtmpRestream). */++function rtmpCopyArgs(flvUrl, destUrl) {++ return [++ '-hide_banner',++ '-loglevel', 'warning',++ '-rw_timeout', '10000000',++ '-i', flvUrl,++ '-c', 'copy',++ '-fflags', '+nobuffer+discardcorrupt',++ ...outputArgs(destUrl),++ ];++}++++/** `-progress pipe:1 -stats_period 1` after the global flags: the live ACK and the health line. */++function withProgress(args) {++ return args[0] === '-hide_banner'++ ? ['-hide_banner', '-progress', 'pipe:1', '-stats_period', '1', ...args.slice(1)]++ : ['-progress', 'pipe:1', ...args];++}++++function resolvePreset(destination) {++ const key = destination && destination.quality_preset || 'auto';++ if (key !== 'auto' && QUALITY_PRESETS[key]) return QUALITY_PRESETS[key];++ return QUALITY_PRESETS[PLATFORM_DEFAULT_PRESET[destination && destination.platform || 'custom'] || 'medium'];++}++++function customOverrides(d) {++ const o = {};++ if (d && d.custom_video_bitrate && Number.isFinite(Number(d.custom_video_bitrate))) o.videoBitrate = `${d.custom_video_bitrate}k`;++ if (d && d.custom_audio_bitrate && Number.isFinite(Number(d.custom_audio_bitrate))) o.audioBitrate = `${d.custom_audio_bitrate}k`;++ if (d && d.custom_fps && Number(d.custom_fps) > 0) o.fps = Number(d.custom_fps);++ if (d && d.custom_encoder_preset && ENCODER_PRESETS.includes(d.custom_encoder_preset)) o.encoderPreset = d.custom_encoder_preset;++ return o;++}++++/** H.264/AAC CBR encode args (Live's _getEncodingArgs), for re-encoding sources. */++function encodingArgs(preset, { hasAudio = true, overrides = {} } = {}) {++ const videoBitrate = overrides.videoBitrate || preset.videoBitrate;++ const audioBitrate = overrides.audioBitrate || preset.audioBitrate;++ const fps = overrides.fps || preset.fps;++ const encoderPreset = overrides.encoderPreset || preset.preset;++ let maxrate = videoBitrate;++ let bufsize = preset.bufsize;++ if (overrides.videoBitrate) { const kbps = parseInt(overrides.videoBitrate, 10); maxrate = `${kbps}k`; bufsize = `${kbps}k`; }++ const args = ['-map', '0:v:0', '-c:v', 'libx264', '-preset', encoderPreset, '-tune', 'zerolatency', '-b:v', videoBitrate,++ '-minrate', videoBitrate, '-maxrate', maxrate, '-bufsize', bufsize, '-g', String(preset.gop), '-keyint_min', String(preset.gop),++ '-sc_threshold', '0', '-flags', '+cgop', '-pix_fmt', 'yuv420p', '-threads', '2', '-x264-params', 'nal-hrd=cbr:force-cfr=1'];++ if (preset.scale) args.push('-vf', `scale=${preset.scale}:force_original_aspect_ratio=decrease,pad=${preset.scale}:(ow-iw)/2:(oh-ih)/2`);++ if (fps > 0) args.push('-r', String(fps), '-fps_mode', 'cfr');++ if (hasAudio) args.push('-map', '0:a:0', '-c:a', 'aac', '-b:a', audioBitrate, '-ar', '48000', '-ac', '2');++ return args;++}++++/** ffmpeg stderr → something a streamer can act on (Live's friendlyFfmpegError). */++function friendlyError(raw, platform = 'the destination') {++ const t = String(raw || '');++ const name = platform === 'custom' ? 'the destination' : platform.charAt(0).toUpperCase() + platform.slice(1);++ if (/NetStream\.Publish\.BadName|Authentication|Unauthorized|403|Access denied|invalid stream key|bad name|not authorized|Publish rejected/i.test(t)) return `${name} rejected the stream key — check the key (and that it is a live key, not an expired one)`;++ if (/Name or service not known|Temporary failure in name resolution|getaddrinfo|could not resolve/i.test(t)) return 'Could not resolve the ingest host name — check the server URL';++ if (/Connection refused|No route to host|Network is unreachable|Connection timed out|timed out|Failed to connect|Cannot open connection/i.test(t)) return `Could not connect to ${name}'s ingest server — it may be down or the URL/port is wrong`;++ if (/TLS|SSL|gnutls|handshake|certificate/i.test(t)) return `TLS handshake with ${name} failed — try the plain rtmp:// ingest URL or the platform's rtmps:// address`;++ if (/Broken pipe|Connection reset|End of file|EOF|I\/O error|Input\/output error|Server closed/i.test(t)) return `${name} closed the connection — usually a rejected key, a second stream on the same key, or a platform-side hiccup (auto-retrying)`;++ if (/srt|SRT/.test(t) && /passphrase|encrypt|crypto|Wrong password/i.test(t)) return 'SRT passphrase rejected — the passphrase (or its length, 10–79 characters) does not match the receiver';++ if (/SRT.*(Connection setup failure|rejected|timeout)/i.test(t)) return 'SRT receiver did not accept the connection — check host, port, streamid and that the receiver is in listener mode';++ if (/Unrecognized option|Invalid argument|Option .* not found/i.test(t)) return `Encoder configuration was rejected by ffmpeg (${t.slice(0, 80)})`;++ if (/No such file|not found/i.test(t) && /ffmpeg/i.test(t)) return 'ffmpeg is not installed on the server';++ if (!t.trim()) return 'Restream process stopped without a message';++ return t.slice(0, 160);++}++++/** Mask keys in URLs before anything is logged (rtmp path key, SRT streamid/passphrase). */++function redactUrl(value) {++ const s = String(value || '');++ if (/^srt:\/\//i.test(s)) {++ try {++ const u = new URL(s);++ for (const k of ['streamid', 'passphrase']) if (u.searchParams.has(k)) u.searchParams.set(k, '****');++ return u.toString();++ } catch { return 'srt://****'; }++ }++ return s.replace(/^(rtmps?:\/\/[^/\s]+(?:\/[^/\s]+)*)\/([^/\s]{5,})$/i, (m, base, key) => `${base}/****${key.slice(-4)}`);++}++++module.exports = {++ QUALITY_PRESETS, PLATFORM_DEFAULT_PRESET, ENCODER_PRESETS, SRT_DEFAULT_LATENCY_MS,++ isSrtUrl, buildDestUrl, buildSrtUrl, outputArgs, rtmpCopyArgs, withProgress, resolvePreset, customOverrides, encodingArgs, friendlyError, redactUrl,++};nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++/**++ * One restream output: one ffmpeg process pushing one session to one destination, restarted with++ * backoff and cut off by a circuit breaker. Ported from Live's RestreamManager._spawnFFmpeg and++ * _scheduleRestart (the same timings, live-ACK rule and rapid-crash detection), with two changes:++ * state is reported to the store (output row, logs, events) instead of an in-memory map, and a++ * failure only ever ends this output — the source session is never touched from here.++ */++const { spawn } = require('child_process');++const fs = require('fs');++const { rtmpCopyArgs, withProgress, buildDestUrl, friendlyError, redactUrl } = require('./ffmpeg-args');++const { validateDestinationUrl, checkResolvedHost } = require('../../server/destination-url');++++class OutputRunner {++ constructor({ outputId, destinationId, inputUrl, store, config, log = console, spawnImpl = spawn, lookup }) {++ this.outputId = outputId;++ this.destinationId = destinationId;++ this.inputUrl = inputUrl;++ this.store = store;++ this.config = config;++ this.log = log;++ this.spawnImpl = spawnImpl;++ this.lookup = lookup;++ this.o = config.outputs;++ this.status = 'starting';++ this.proc = null;++ this.restartAttempts = 0;++ this.restartDelay = this.o.restartBaseMs;++ this.rapidCrashCount = 0;++ this.everLive = false;++ this.stopped = false;++ this.timers = new Set();++ this.lastProgressReport = 0;++ this.platform = 'custom';++ }++++ timer(fn, ms) {++ const t = setTimeout(() => { this.timers.delete(t); fn(); }, ms);++ this.timers.add(t);++ return t;++ }++++ report(change) { try { return this.store.outputs.report(this.outputId, change); } catch (err) { this.log.error(`[restream] report ${this.outputId}: ${err.message}`); return null; } }++ note(level, message) { try { this.store.outputs.log(this.outputId, this.destinationId, level, message); } catch { /* logs are best effort */ } }++++ async start() {++ if (this.stopped) return;++ let dest;++ try { dest = this.store.outputs.destinationForWorker(this.destinationId); } catch (err) {++ return this.fail(`destination secrets unavailable: ${err.message}`, { cooldown: false });++ }++ if (!dest || !dest.enabled || dest.hold_reason) return this.stop('destination disabled');++ this.platform = dest.platform || 'custom';++ const v = validateDestinationUrl(dest.server_url, { allowPrivate: this.o.allowPrivateHosts });++ if (!v.ok) return this.fail(v.error, { cooldown: false });++ const resolved = await checkResolvedHost(v.host, { allowPrivate: this.o.allowPrivateHosts, ...(this.lookup ? { lookup: this.lookup } : {}) });++ if (this.stopped) return;++ if (!resolved.ok) return this.fail(`destination refused: ${resolved.error}`, { cooldown: false });++ const destUrl = buildDestUrl(dest);++ if (!destUrl) return this.fail('destination has no usable URL or stream key', { cooldown: false });++ this.spawn(destUrl);++ }++++ spawn(destUrl) {++ const args = withProgress(rtmpCopyArgs(this.inputUrl, destUrl));++ const rtmps = destUrl.startsWith('rtmps://');++ const bin = rtmps && this.o.ffmpegOpenSslPath && fs.existsSync(this.o.ffmpegOpenSslPath) ? this.o.ffmpegOpenSslPath : this.o.ffmpegPath;++ this.note('info', `starting ffmpeg → ${redactUrl(destUrl)}${bin !== this.o.ffmpegPath ? ' (openssl build)' : ''}`);++ let proc;++ try {++ proc = this.spawnImpl(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });++ } catch (err) {++ this.onExit(null, `spawn failed: ${err.message}`);++ return;++ }++ this.proc = proc;++ const startedAt = Date.now();++ this.startedAt = startedAt;++ this.liveThisRun = false;++ this.status = 'starting';++ this.report({ state: 'starting', started_at: startedAt, restart_attempts: this.restartAttempts, next_restart_at: null });++++ let stderr = '';++ let progressBuf = '';++ let progressSeen = false;++ const cur = {};++ proc.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 4096) stderr = stderr.slice(-4096); });++ proc.stdout.on('data', (d) => {++ progressBuf += d.toString();++ if (progressBuf.length > 8192) progressBuf = progressBuf.slice(-8192);++ let nl;++ while ((nl = progressBuf.indexOf('\n')) >= 0) {++ const line = progressBuf.slice(0, nl).trim();++ progressBuf = progressBuf.slice(nl + 1);++ const eq = line.indexOf('=');++ if (eq <= 0) continue;++ const k = line.slice(0, eq);++ const val = line.slice(eq + 1).trim();++ if (k === 'frame') cur.frame = +val || 0;++ else if (k === 'fps') cur.fps = +val || 0;++ else if (k === 'bitrate') cur.bitrate_kbps = val.endsWith('kbits/s') ? +parseFloat(val) || 0 : null;++ else if (k === 'speed') cur.speed = val === 'N/A' ? null : +parseFloat(val) || 0;++ else if (k === 'drop_frames') cur.dropped = +val || 0;++ else if (k === 'out_time_us' || k === 'out_time_ms') cur.out_ms = Math.round((+val || 0) / 1000);++ else if (k === 'progress') {++ progressSeen = true;++ cur.at = Date.now();++ if (!this.liveThisRun && ((cur.frame || 0) > 0 || (cur.out_ms || 0) > 0)) this.confirmLive(proc);++ if (Date.now() - this.lastProgressReport > 2000) {++ this.lastProgressReport = Date.now();++ this.report({ progress: { ...cur } });++ }++ }++ }++ });++ proc.on('error', (err) => { if (this.proc === proc) this.onExit(null, `ffmpeg error: ${err.message}`); });++ proc.on('close', (code) => {++ if (this.proc !== proc) return;++ const lastLines = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');++ this.onExit(code, lastLines);++ });++ // No progress at all within the ACK window: the ingest never took our data.++ this.timer(() => {++ if (this.proc !== proc || this.liveThisRun || this.stopped) return;++ if (progressSeen) { this.confirmLive(proc); return; }++ this.ackTimeout = true;++ this.note('warn', `no response from the ingest within ${this.o.liveAckTimeoutMs / 1000}s`);++ try { proc.kill('SIGTERM'); } catch { /* */ }++ }, this.o.liveAckTimeoutMs);++ }++++ confirmLive(proc) {++ if (this.proc !== proc || this.liveThisRun || this.stopped) return;++ this.liveThisRun = true;++ this.everLive = true;++ this.rapidCrashCount = 0;++ this.status = 'live';++ try { this.store.outputs.clearDestinationCooldown(this.destinationId); } catch { /* */ }++ this.report({ state: 'live', live_at: Date.now(), last_error: null });++ this.note('info', 'output is live');++ this.timer(() => {++ if (this.proc === proc && this.status === 'live') {++ this.restartAttempts = 0;++ this.restartDelay = this.o.restartBaseMs;++ this.rapidCrashCount = 0;++ this.report({ restart_attempts: 0 });++ }++ }, this.o.stableMs);++ }++++ onExit(code, raw) {++ this.proc = null;++ if (this.stopped) return;++ const ran = Date.now() - (this.startedAt || Date.now());++ if (code === 0) {++ this.status = 'stopped';++ this.note('info', `ffmpeg exited cleanly after ${(ran / 1000).toFixed(1)}s`);++ // A clean exit means the source ended; the session transition stops the output.++ this.report({ state: 'stopped', ended_at: Date.now() });++ this.stopped = true;++ return;++ }++ const message = this.ackTimeout++ ? `No response from the ingest within ${this.o.liveAckTimeoutMs / 1000}s — ${friendlyError(raw, this.platform)}`++ : friendlyError(raw || `ffmpeg exit code ${code}`, this.platform);++ this.ackTimeout = false;++ this.note('error', `ffmpeg exited (code ${code}) after ${(ran / 1000).toFixed(1)}s: ${message}`);++ this.status = 'error';++ this.report({ state: 'error', last_error: message });++ this.scheduleRestart(ran, message);++ }++++ scheduleRestart(ranMs, message) {++ if (this.stopped) return;++ if (this.restartAttempts >= this.o.maxRestarts) return this.fail(`${message} (gave up after ${this.o.maxRestarts} restarts)`, { cooldown: !this.everLive });++ if (!this.liveThisRun && ranMs < this.o.rapidCrashMs) {++ this.rapidCrashCount++;++ this.restartDelay = Math.min(this.restartDelay * 2, this.o.restartMaxMs);++ } else {++ this.rapidCrashCount = 0;++ }++ if (this.rapidCrashCount >= this.o.rapidCrashGiveUp && !this.everLive) {++ return this.fail(`${message} (${this.rapidCrashCount} rapid crashes without ever going live)`, { cooldown: true });++ }++ const delay = this.restartDelay;++ this.restartDelay = Math.min(this.restartDelay * 1.5, this.o.restartMaxMs);++ this.restartAttempts++;++ this.report({ restart_attempts: this.restartAttempts, next_restart_at: Date.now() + delay });++ this.timer(() => { this.start().catch(err => this.fail(err.message, { cooldown: false })); }, delay);++ return undefined;++ }++++ /** The circuit breaker: this output is done; the destination may cool down. */++ fail(message, { cooldown }) {++ if (this.stopped) return;++ this.stopped = true;++ this.clearTimers();++ let cooldownMinutes = null;++ if (cooldown) {++ const r = this.store.outputs.markDestinationFailure(this.destinationId, message);++ cooldownMinutes = r ? r.cooldownMinutes : null;++ }++ this.status = 'failed';++ this.note('error', `output failed: ${message}${cooldownMinutes ? ` (destination cooling down ${cooldownMinutes} min)` : ''}`);++ this.report({ state: 'failed', last_error: message, ended_at: Date.now(), next_restart_at: null, cooldown_minutes: cooldownMinutes });++ }++++ stop(reason = 'stopped') {++ if (this.stopped && !this.proc) return;++ this.stopped = true;++ this.clearTimers();++ const proc = this.proc;++ this.proc = null;++ if (proc) {++ try { proc.kill('SIGTERM'); } catch { /* */ }++ const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* */ } }, 5000);++ t.unref?.();++ }++ this.status = 'stopped';++ this.note('info', `output stopped (${reason})`);++ this.report({ state: 'stopped', ended_at: Date.now(), next_restart_at: null });++ }++++ clearTimers() { for (const t of this.timers) clearTimeout(t); this.timers.clear(); }++}++++module.exports = { OutputRunner };nnew file mode 100644@@@ -0,0 +1,258 @@++'use strict';++/**++ * openre-rtmp-ingest — the RTMP transport worker (unit openre-rtmp-ingest@<release>.service).++ *++ * The same node-media-server 2.7.4 session code Live runs in-process (OpenVibe.Live++ * server/streaming/rtmp-server.js), with the lifecycle and ownership moved here:++ *++ * public 0.0.0.0:OPENRE_RTMP_PORT (1936 until the RTMP cutover), SO_REUSEPORT so two++ * generations can listen during a drain. Publish only; play is refused here.++ * internal 127.0.0.1:<rtmpPlayPort> RTMP play for Media's recorder (rtmp://…/live/<session id>)++ * 127.0.0.1:<flvPort> HTTP-FLV for the restream worker and the playback proxy++ * Both ports are this generation's own (from OPENRE_RTMP_INTERNAL_PORT_MIN..MAX) and are++ * published in its worker row, so consumers always reach the process that holds the++ * publisher.++ *++ * Authentication happens inside the publish handshake, synchronously, against OpenRe's hashed++ * keys only. An accepted publish is renamed from /live/<key> to /live/<session id> before++ * node-media-server registers it, so the key never appears in any play URL, log or descriptor.++ *++ * Drain: when the coordinator marks this generation draining (a newer one is ready) the public++ * listener closes — new encoder connections reach the new generation through the shared port —++ * and running publishers stay until they leave or the drain deadline passes. Then the process++ * exits by itself. SIGTERM starts the same drain; it never drops a live publisher.++ */++const http = require('http');++const net = require('net');++const path = require('path');++const NodeRtmpSession = require('node-media-server/src/node_rtmp_session');++const NodeFlvSession = require('node-media-server/src/node_flv_session');++const context = require('node-media-server/src/node_core_ctx');++const NmsLogger = require('node-media-server/src/node_core_logger');++const { createWorkerRuntime } = require('./runtime');++++const SESSION_PATH_RE = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})$/;++++/** Bind the first free loopback port in [min, max] (skipping `skip`). */++function listenInRange(server, min, max, skip = new Set()) {++ return new Promise((resolve, reject) => {++ let port = min;++ const tryNext = () => {++ while (skip.has(port)) port++;++ if (port > max) { reject(new Error(`no free loopback port in ${min}-${max}`)); return; }++ const onError = (err) => {++ server.removeListener('listening', onListening);++ if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { port++; tryNext(); } else reject(err);++ };++ const onListening = () => { server.removeListener('error', onError); resolve(server.address().port); };++ server.once('error', onError);++ server.once('listening', onListening);++ server.listen(port, '127.0.0.1');++ };++ tryNext();++ });++}++++function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(code) }) {++ const { store, config } = rt;++ NmsLogger.setLogType(1); // errors only: the library's info lines would print stream paths++ const nmsConfig = {++ logType: 1,++ rtmp: { port: config.rtmp.port, chunk_size: config.rtmp.chunkSize, gop_cache: true, ping: 30, ping_timeout: 60 },++ };++ /** node-media-server session id → { sessionId, definitionId, endReason } */++ const publishers = new Map();++ let publicServer = null;++ let playServer = null;++ let flvServer = null;++ let endpoints = null;++ const handlers = [];++++ const runtime = createWorkerRuntime({++ rt, kind: 'rtmp-ingest', log, exit,++ hooks: {++ onDrain: () => closePublic(),++ onDrainDeadline: () => endAll('drain_deadline'),++ onEndRequested: (s) => endSession(s.id, 'end_requested'),++ onLost: () => { for (const [nmsId] of publishers) stopNms(nmsId); return closeAll(); },++ onHeartbeat: () => refreshMediaInfo(),++ activeCount: () => publishers.size,++ onExit: () => closeAll(),++ },++ });++++ function nms(id) { return context.sessions.get(id); }++ function stopNms(id) { const s = nms(id); if (s) { try { s.reject(); } catch { /* socket already gone */ } } }++++ function on(event, fn) {++ const wrapped = (...args) => {++ try { fn(...args); } catch (err) {++ // A throw inside a node-media-server event is an uncaught exception, and that would++ // take every publisher on this worker with it.++ log.error(`[rtmp] ${event} handler failed: ${err.stack || err}`);++ }++ };++ context.nodeEvent.on(event, wrapped);++ handlers.push([event, wrapped]);++ }++++ function isPublicSocket(session) {++ return Boolean(session && session.socket && session.socket.localPort === config.rtmp.port);++ }++++ on('prePublish', (id, streamPath) => {++ const s = nms(id);++ if (!s) return;++ const reject = (why) => { log.log(`[rtmp] publish refused (${why}) from ${s.ip}`); s.reject(); };++ if (!isPublicSocket(s)) return reject('not the public listener');++ if (runtime.draining || !runtime.me || runtime.me.state !== 'ready') return reject('generation not taking sessions');++ if (publishers.size >= config.rtmp.maxPublishersPerWorker) return reject('worker full');++ const parts = String(streamPath).split('/');++ if (parts.length !== 3 || parts[1] !== 'live') return reject('bad path');++ const r = store.definitions.resolveIngestKey(parts[2], 'rtmp');++ if (r.error) return reject(r.error);++ const a = store.sessions.admit({ definition: r.definition, key: r.key, protocol: 'rtmp', worker: runtime.me });++ if (a.error) return reject(a.error);++ // Rename before node-media-server registers the publisher (it reads publishStreamPath++ // right after this synchronous event): from here on the stream is /live/<session id>.++ s.publishStreamPath = `/live/${a.session.id}`;++ publishers.set(id, { sessionId: a.session.id, definitionId: r.definition.id, endReason: null });++ log.log(`[rtmp] session ${a.session.id} accepted for ${r.definition.id} (key …${r.key.hint})`);++ });++++ on('postPublish', (id) => {++ const p = publishers.get(id);++ if (!p) return;++ const t = store.sessions.transition(p.sessionId, 'live', { reason: 'media_flowing', actor: `worker:${runtime.me.id}` });++ if (!t.ok) {++ // The coordinator failed it meanwhile (lease) — the transport must not outlive its record.++ log.warn(`[rtmp] session ${p.sessionId} could not go live (${t.code}); closing`);++ stopNms(id);++ }++ });++++ on('donePublish', (id) => {++ const p = publishers.get(id);++ if (!p) return;++ publishers.delete(id);++ store.sessions.finish(p.sessionId, { reason: p.endReason || 'publisher_disconnected', actor: `worker:${runtime.me.id}` });++ log.log(`[rtmp] session ${p.sessionId} ended (${p.endReason || 'publisher_disconnected'})`);++ if (runtime.draining) runtime.exitWhenIdle();++ });++++ on('prePlay', (id, streamPath) => {++ const s = nms(id);++ if (!s) return;++ // RTMP play only on the loopback play port, and only by session path.++ if (s instanceof NodeRtmpSession && s.socket.localPort !== endpoints.rtmpPlayPort) { s.reject(); return; }++ if (!SESSION_PATH_RE.test(String(streamPath))) s.reject();++ });++++ function refreshMediaInfo() {++ for (const [id, p] of publishers) {++ const s = nms(id);++ if (!s || !s.isPublishing) continue;++ store.sessions.setMediaInfo(p.sessionId, {++ video_codec: s.videoCodecName || null,++ width: s.videoWidth || null,++ height: s.videoHeight || null,++ fps: s.videoFps || null,++ audio_codec: s.audioCodecName || null,++ audio_samplerate: s.audioSamplerate || null,++ bitrate_kbps: s.bitrate || null,++ });++ }++ }++++ function endSession(sessionId, reason) {++ for (const [id, p] of publishers) {++ if (p.sessionId === sessionId) { p.endReason = reason; stopNms(id); return true; }++ }++ // Not ours any more (publisher already gone): make sure the record is closed.++ store.sessions.finish(sessionId, { reason, actor: `worker:${runtime.me.id}` });++ return false;++ }++++ function endAll(reason) {++ for (const [id, p] of publishers) { p.endReason = reason; stopNms(id); }++ }++++ function closePublic() {++ if (!publicServer) return;++ const srv = publicServer;++ publicServer = null;++ srv.close();++ log.log('[rtmp] public listener closed; running publishers stay until they leave');++ }++++ function closeAll() {++ closePublic();++ const closing = [];++ for (const srv of [playServer, flvServer]) {++ if (srv) closing.push(new Promise(r => { srv.closeAllConnections?.(); srv.close(() => r()); }));++ }++ playServer = null;++ flvServer = null;++ for (const [event, fn] of handlers.splice(0)) context.nodeEvent.removeListener(event, fn);++ return Promise.all(closing);++ }++++ function newRtmpServer() {++ return net.createServer((socket) => {++ const session = new NodeRtmpSession(nmsConfig, socket);++ session.run();++ });++ }++++ async function start() {++ // Internal endpoints first: they are how this generation is reached, so they go in the++ // worker row before it can take a session.++ playServer = newRtmpServer();++ const rtmpPlayPort = await listenInRange(playServer, config.rtmp.internalPortMin, config.rtmp.internalPortMax);++ flvServer = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ const m = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})\.flv$/.exec(url.pathname);++ if (req.method !== 'GET' || !m) { res.statusCode = 404; return res.end(); }++ if (!context.publishers.has(`/live/${m[1]}`)) { res.statusCode = 404; return res.end(); }++ res.setHeader('Content-Type', 'video/x-flv');++ res.setHeader('Cache-Control', 'no-cache, no-store');++ req.nmsConnectionType = 'http';++ new NodeFlvSession(nmsConfig, req, res).run();++ return undefined;++ });++ const flvPort = await listenInRange(flvServer, config.rtmp.internalPortMin, config.rtmp.internalPortMax, new Set([rtmpPlayPort]));++ endpoints = { publicPort: config.rtmp.port, rtmpPlayPort, flvPort };++ runtime.register(endpoints);++++ publicServer = newRtmpServer();++ await new Promise((resolve, reject) => {++ publicServer.once('error', reject);++ publicServer.listen({ port: config.rtmp.port, host: config.rtmp.bindHost, reusePort: true }, () => resolve());++ });++ publicServer.on('error', (err) => log.error(`[rtmp] public listener: ${err.message}`));++ runtime.ready();++ log.log(`[rtmp] generation ${runtime.me.generation} ready: publish ${config.rtmp.bindHost}:${config.rtmp.port}, play 127.0.0.1:${rtmpPlayPort}, flv 127.0.0.1:${flvPort}`);++ return endpoints;++ }++++ return {++ start,++ runtime,++ endpoints: () => endpoints,++ publishers: () => [...publishers.values()],++ drain: (reason) => runtime.drainNow(reason),++ close: closeAll,++ };++}++++if (require.main === module) {++ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });++ const { load } = require('../server/config');++ const { openRuntime } = require('../server/store');++ const rt = openRuntime({ config: load() });++ const worker = createRtmpIngest({ rt });++ worker.start().catch((err) => { console.error(`[rtmp] failed to start: ${err.stack || err}`); process.exit(1); });++ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => worker.drain(sig));++}++++module.exports = { createRtmpIngest, listenInRange };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * The worker interface every OpenRe transport worker implements (RTMP ingest, restream, and the++ * WHIP/WebRTC/SFU/JSMPEG kinds as they are ported):++ *++ * const w = createWorkerRuntime({ rt, kind, log, hooks });++ * w.register(endpoints) → a new generation of `kind` ('starting')++ * w.ready() → takes new sessions; the coordinator drains older generations++ * hooks.onDrain() → coordinator marked this generation draining: take nothing new++ * hooks.onDrainDeadline() → the drain lifetime is over: end what is left (encoders reconnect++ * to the newest generation)++ * hooks.onEndRequested(s) → an owner/Live asked for session s to end++ * hooks.onLost() → the coordinator gave this worker up (missed heartbeats): its++ * sessions are already failed, so drop the transports and exit++ * hooks.activeCount() → how many transports this process still carries++ * w.exitWhenIdle() → when draining and activeCount() is 0: mark stopped and exit(0)++ *++ * The heartbeat (every heartbeatMs) is also the lease renewal for every session the worker owns.++ * A worker never depends on openre-api being up; it needs only the database.++ */++function createWorkerRuntime({ rt, kind, log = console, hooks = {}, exit = (code) => process.exit(code) }) {++ const { store, config } = rt;++ let me = null;++ let timer = null;++ let draining = false;++ let deadlineHandled = false;++ let stopped = false;++++ function register(endpoints = {}) {++ me = store.workers.register({ kind, endpoints });++ log.log(`[${kind}] registered ${me.id} generation ${me.generation} (release ${me.release})`);++ return me;++ }++++ function ready() {++ store.workers.ready(me.id);++ me = store.workers.get(me.id);++ schedule();++ return me;++ }++++ function beat() {++ if (stopped || !me) return null;++ const row = store.workers.heartbeat(me.id);++ me = row;++ if (!row) return null;++ if (row.state === 'lost') {++ log.error(`[${kind}] coordinator marked ${me.id} lost; dropping transports and exiting`);++ stopped = true;++ clearTimeout(timer);++ Promise.resolve(hooks.onLost && hooks.onLost()).finally(() => exit(1));++ return row;++ }++ if (row.state === 'draining' && !draining) {++ draining = true;++ log.log(`[${kind}] generation ${row.generation} draining (deadline ${new Date(row.drain_deadline).toISOString()})`);++ try { hooks.onDrain && hooks.onDrain(row); } catch (err) { log.error(`[${kind}] onDrain: ${err.message}`); }++ }++ if (draining && row.drain_deadline && row.drain_deadline <= Date.now() && !deadlineHandled) {++ deadlineHandled = true;++ log.warn(`[${kind}] drain deadline reached; ending remaining transports`);++ try { hooks.onDrainDeadline && hooks.onDrainDeadline(row); } catch (err) { log.error(`[${kind}] onDrainDeadline: ${err.message}`); }++ }++ if (hooks.onEndRequested) {++ for (const s of store.sessions.endRequestsFor(me.id)) {++ try { hooks.onEndRequested(s); } catch (err) { log.error(`[${kind}] onEndRequested: ${err.message}`); }++ }++ }++ if (hooks.onHeartbeat) {++ try { hooks.onHeartbeat(row); } catch (err) { log.error(`[${kind}] onHeartbeat: ${err.message}`); }++ }++ if (draining) exitWhenIdle();++ return row;++ }++++ function schedule() {++ clearTimeout(timer);++ if (stopped) return;++ timer = setTimeout(() => {++ try { beat(); } catch (err) { log.error(`[${kind}] heartbeat failed: ${err.message}`); }++ schedule();++ }, config.workers.heartbeatMs);++ }++++ /** SIGTERM: the operator (or systemd) asks this generation to go away → same as a drain. */++ function drainNow(reason = 'signal') {++ if (!me || stopped) return;++ if (!draining) {++ store.workers.drain(me.id, Date.now() + config.workers.drainMaxMs);++ log.log(`[${kind}] draining on ${reason}`);++ }++ beat();++ }++++ function exitWhenIdle() {++ const active = hooks.activeCount ? hooks.activeCount() : 0;++ if (active > 0 || stopped) return false;++ stopped = true;++ clearTimeout(timer);++ store.workers.stop(me.id, draining ? 'drained' : 'exit');++ log.log(`[${kind}] generation ${me.generation} idle after drain; exiting`);++ Promise.resolve(hooks.onExit && hooks.onExit()).finally(() => exit(0));++ return true;++ }++++ return {++ register,++ ready,++ beat,++ drainNow,++ exitWhenIdle,++ get me() { return me; },++ get draining() { return draining; },++ stop() { stopped = true; clearTimeout(timer); if (me) store.workers.stop(me.id, 'stopped'); },++ };++}++++module.exports = { createWorkerRuntime };nnew file mode 100644@@@ -0,0 +1,3 @@++'use strict';++/** openre-sfu: worker interface only, the transport is not ported yet (see unported-transport.js). */++if (require.main === module) require('./unported-transport').main('sfu');nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++/**++ * The worker interface for the transports OpenRe does not carry yet: WHIP/WebRTC ingest++ * (werift), the mediasoup SFU and JSMPEG. Each registers a generation with the coordinator, takes++ * part in the drain protocol and heartbeats like a real transport worker, and carries nothing:++ * activeCount() is always 0, and no session of these protocols is ever admitted (their stream++ * definitions may list the protocol, but no worker accepts it, so encoders keep using Live).++ *++ * This is the seam the port lands in: a ported transport replaces `admit nothing` with its++ * session code and reports its sessions through store.sessions exactly like rtmp-ingest.js.++ * Nothing in production runs these (deploy/ has no unit for them).++ */++const path = require('path');++const { createWorkerRuntime } = require('./runtime');++++const PORT_STATUS = Object.freeze({++ 'webrtc-ingest': 'WHIP/WebRTC ingest (Live: server/streaming/whip-handler.js, werift) — not ported',++ sfu: 'WebRTC SFU (Live: server/streaming/webrtc-sfu.js + broadcast-server.js, mediasoup) — not ported',++ jsmpeg: 'JSMPEG relay (Live: server/streaming/jsmpeg-relay.js) — not ported',++});++++function createUnportedTransport({ rt, kind, log = console, exit = (code) => process.exit(code) }) {++ if (!PORT_STATUS[kind]) throw new Error(`no unported transport "${kind}"`);++ const runtime = createWorkerRuntime({ rt, kind, log, exit, hooks: { activeCount: () => 0 } });++ return {++ runtime,++ start() {++ runtime.register({ ported: false, note: PORT_STATUS[kind] });++ runtime.ready();++ log.log(`[${kind}] registered generation ${runtime.me.generation}: ${PORT_STATUS[kind]}`);++ return runtime.me;++ },++ drain: (reason) => runtime.drainNow(reason),++ };++}++++function main(kind) {++ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });++ const { load } = require('../server/config');++ const { openRuntime } = require('../server/store');++ const w = createUnportedTransport({ rt: openRuntime({ config: load() }), kind });++ w.start();++ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => w.drain(sig));++}++++module.exports = { createUnportedTransport, PORT_STATUS, main };nnew file mode 100644@@@ -0,0 +1,3 @@++'use strict';++/** openre-webrtc-ingest: worker interface only, the transport is not ported yet (see unported-transport.js). */++if (require.main === module) require('./unported-transport').main('webrtc-ingest');
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,1675 @@
@@ -0,0 +1,1675 @@
+{
+ "name": "openre-stream",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openre-stream",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "node-media-server": "2.7.4",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.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.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.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/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "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/basic-auth-connect": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/basic-auth-connect/-/basic-auth-connect-1.1.0.tgz",
+ "integrity": "sha512-rKcWjfiRZ3p5WS9e5q6msXa07s6DaFAMXoyowV+mb2xQG+oYdw2QEUyKi0Xp95JvXzShlM+oGy5QuqSK6TfC1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tsscmp": "^1.0.6"
+ }
+ },
+ "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/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/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "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/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "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/dateformat": {
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
+ "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "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/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-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/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/http2-express": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/http2-express/-/http2-express-1.1.1.tgz",
+ "integrity": "sha512-iROf3EIQdJZLNk+/LTkENZp1WB8B+IrYT+z/TPP3/RyGk6w9P9QZNDvs/lo5zAOozNuNYdHewIyWVXXAkBGQ7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "express": ">=4.0.0"
+ }
+ },
+ "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": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "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": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-2.1.6.tgz",
+ "integrity": "sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "dist/cjs/src/bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "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/node-media-server": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/node-media-server/-/node-media-server-2.7.4.tgz",
+ "integrity": "sha512-4nsqfukfnI6tY7d1deqBQSj3KywOiNVlPQkY0tLIjF62rzXo1SDXJXgUU+cVs5e06uO4x/55O4SiAaRH3li/Vg==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth-connect": "^1.1.0",
+ "chalk": "^4.1.2",
+ "dateformat": "^4.6.3",
+ "express": "^4.21.1",
+ "http2-express": "^1.0.0",
+ "lodash": "^4.17.21",
+ "minimist": "^1.2.8",
+ "mkdirp": "^2.1.6",
+ "ws": "^8.18.0"
+ },
+ "bin": {
+ "node-media-server": "bin/app.js"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "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.8.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.0",
+ "integrity": "sha512-2Msdb+k+fJZf9kQTPS2jdY8+o9FhL9tBuu3FzArHs3JcBcc06ReHpOi2sgnSBgQcSvZSaSsWT2YVbmhjnEplqQ==",
+ "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.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
+ "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/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/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/tsscmp": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
+ "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.x"
+ }
+ },
+ "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"
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+{
+ "name": "openre-stream",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenRe.Stream: ingest and restream authority for the OpenVibe network. Stream definitions, hashed ingest keys, ingest sessions, transport workers with generations and leases, restream outputs with health and logs, recording requests to OpenVibe.Media.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "coordinator": "node workers/coordinator.js",
+ "rtmp-ingest": "node workers/rtmp-ingest.js",
+ "restream-worker": "node workers/restream-worker.js",
+ "migrate-from-live": "node scripts/migrate-from-live.js",
+ "test": "node test/run.js"
+ },
+ "keywords": ["openvibe", "openre", "rtmp", "restream", "ingest"],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenRe.Stream",
+ "dependencies": {
+ "better-sqlite3": "^11.10.0",
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "node-media-server": "2.7.4",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.8.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.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,320 @@
@@ -0,0 +1,320 @@
+'use strict';
+/**
+ * OpenRe API v1 (openre-api). Every route checks one capability for service callers; owners
+ * (Network user JWT) act on their own streams. Errors are RFC 9457 problem+json.
+ *
+ * GET /api/v1/streams openre.stream.read list (owner, ?external_ref=live:managed_stream:12)
+ * POST /api/v1/streams openre.stream.write create → first ingest key, shown once
+ * GET /api/v1/streams/:id openre.stream.read definition + ingest URLs + key hints
+ * PATCH /api/v1/streams/:id openre.stream.write
+ * DELETE /api/v1/streams/:id openre.stream.write archive (refused while live)
+ * GET /api/v1/streams/:id/keys openre.stream.read key metadata (never a key)
+ * POST /api/v1/streams/:id/keys/rotate openre.key.rotate { grace_seconds, end_sessions } → new key, shown once
+ * GET /api/v1/streams/:id/destinations openre.output.read
+ * POST /api/v1/streams/:id/destinations openre.output.write
+ * PATCH /api/v1/destinations/:id openre.output.write stream_key / srt_passphrase are write-only
+ * DELETE /api/v1/destinations/:id openre.output.write
+ * POST /api/v1/destinations/:id/test openre.output.write URL rules + DNS + TCP reachability
+ * POST /api/v1/destinations/:id/start|stop openre.output.write for the stream's live session
+ * GET /api/v1/destinations/:id/logs openre.output.read
+ * GET /api/v1/sessions openre.session.read ?stream_id=&state=open|live|ended|failed
+ * GET /api/v1/sessions/:id openre.session.read session + transitions + outputs + recording
+ * GET /api/v1/sessions/:id/playback openre.session.read playback descriptor
+ * POST /api/v1/sessions/:id/end openre.session.end the owning worker disconnects the encoder
+ * GET /api/v1/sessions/:id/outputs openre.output.read outputs with health
+ * GET /api/v1/outputs/:id/logs openre.output.read
+ * GET /api/v1/workers openre.session.read worker generations (staff/services)
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { StoreError } = require('../store/definitions');
+const { testDestination } = require('../destination-test');
+
+function iso(ms) { return ms ? new Date(ms).toISOString() : null; }
+
+function createV1Router({ rt, auth }) {
+ const { store, config } = rt;
+ const router = express.Router();
+ const guard = auth.guard;
+
+ const fail = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const handle = (fn) => async (req, res, next) => {
+ try { await fn(req, res, next); } catch (err) {
+ if (err instanceof StoreError) return fail(req, res, err.status, err.code, err.message);
+ if (err && err.code === 'openre.secrets_unavailable') return fail(req, res, 503, err.code, err.message);
+ return next(err);
+ }
+ };
+
+ function publicDefinition(d, { withSessions = true } = {}) {
+ const keys = store.definitions.keys(d.id).filter(k => k.status !== 'revoked');
+ const open = withSessions ? store.sessions.openFor(d.id)[0] || null : null;
+ return {
+ id: d.id,
+ title: d.title,
+ description: d.description,
+ owner: { type: 'user', id: d.owner_subject },
+ protocols: d.protocols,
+ recording_mode: d.recording_mode,
+ recording_visibility: d.recording_visibility,
+ playback_visibility: d.playback_visibility,
+ mirror_to_live: d.mirror_to_live,
+ state: d.state,
+ revision: d.revision,
+ external_refs: d.external_refs,
+ ingest: store.definitions.ingestEndpoints(d),
+ keys: keys.map(k => ({ id: k.id, hint: k.hint, status: k.status, grace_until: iso(k.grace_until), created_at: iso(k.created_at), last_used_at: iso(k.last_used_at) })),
+ session: open ? { id: open.id, state: open.state, protocol: open.protocol, live_at: iso(open.live_at) } : null,
+ created_at: iso(d.created_at),
+ updated_at: iso(d.updated_at),
+ };
+ }
+
+ function publicSession(s, { detail = false } = {}) {
+ const out = {
+ id: s.id,
+ stream_id: s.definition_id,
+ protocol: s.protocol,
+ state: s.state,
+ desired_state: s.desired_state,
+ worker: s.worker_id ? { id: s.worker_id, kind: s.worker_kind, generation: s.worker_generation } : null,
+ lease_expires_at: iso(s.lease_expires_at),
+ key_id: s.key_id,
+ end_reason: s.end_reason,
+ failure_reason: s.failure_reason,
+ media_info: s.media_info,
+ revision: s.revision,
+ created_at: iso(s.created_at),
+ live_at: iso(s.live_at),
+ ended_at: iso(s.ended_at),
+ duration_seconds: s.live_at ? Math.round(((s.ended_at || Date.now()) - s.live_at) / 1000) : 0,
+ };
+ if (detail) {
+ out.transitions = store.sessions.transitions(s.id).map(t => ({ ...t, at: iso(t.at) }));
+ out.outputs = store.outputs.outputsOfSession(s.id);
+ const rec = store.recordings.bySession(s.id);
+ out.recording = rec ? { id: rec.id, mode: rec.mode, state: rec.state, media: rec.media_vod_id ? { app: rec.media_app, vod_id: rec.media_vod_id } : null, last_error: rec.last_error } : null;
+ out.playback = store.sessions.playback(s);
+ }
+ return out;
+ }
+
+ function loadDefinition(req, res, id) {
+ const d = store.definitions.get(id);
+ if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {
+ fail(req, res, 404, 'openre.stream_not_found', 'no such stream definition');
+ return null;
+ }
+ return d;
+ }
+
+ function loadDestination(req, res, id) {
+ const dest = store.outputs.destinationRow(id);
+ const d = dest ? store.definitions.get(dest.definition_id) : null;
+ if (!dest || !d || !auth.canAccess(req.caller, d.owner_subject)) {
+ fail(req, res, 404, 'openre.destination_not_found', 'no such destination');
+ return null;
+ }
+ return dest;
+ }
+
+ function loadSession(req, res, id) {
+ const s = store.sessions.get(id);
+ const d = s ? store.definitions.row(s.definition_id) : null;
+ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) {
+ fail(req, res, 404, 'openre.session_not_found', 'no such session');
+ return null;
+ }
+ return s;
+ }
+
+ // ── Streams ───────────────────────────────────────────────
+
+ router.get('/streams', guard('openre.stream.read'), handle((req, res) => {
+ const ref = String(req.query.external_ref || '');
+ if (ref) {
+ const [service, type, ...rest] = ref.split(':');
+ const d = store.definitions.findByRef(service, type, rest.join(':'));
+ const visible = d && d.state !== 'archived' && auth.canAccess(req.caller, d.owner_subject);
+ return res.json({ streams: visible ? [publicDefinition(d)] : [] });
+ }
+ const c = req.caller;
+ let owner = c.subject || null;
+ if (c.kind === 'user' && c.staff && req.query.all === '1') owner = null;
+ const list = store.definitions.list({ owner_subject: owner || undefined, limit: Number(req.query.limit) || 100 });
+ return res.json({ streams: list.map(d => publicDefinition(d)) });
+ }));
+
+ router.post('/streams', guard('openre.stream.write'), handle((req, res) => {
+ const owner = req.caller.kind === 'user' ? req.caller.subject : (req.caller.subject || (req.body && req.body.owner_subject));
+ if (!owner) return fail(req, res, 400, 'openre.invalid_owner', 'name the owner (X-OV-Subject)');
+ if (req.caller.kind === 'service' && req.caller.subject && req.body && req.body.owner_subject && req.body.owner_subject !== req.caller.subject) {
+ return fail(req, res, 400, 'openre.invalid_owner', 'owner_subject differs from X-OV-Subject');
+ }
+ const b = req.body || {};
+ const { definition, key } = store.definitions.create({
+ owner_subject: owner,
+ title: b.title, description: b.description, protocols: b.protocols,
+ recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility,
+ mirror_to_live: b.mirror_to_live, external_refs: b.external_refs,
+ created_by: auth.actorOf(req.caller),
+ });
+ res.set('Cache-Control', 'no-store');
+ return res.status(201).json({ stream: publicDefinition(definition), key: { id: key.id, key: key.key, hint: key.hint, shown_once: true } });
+ }));
+
+ router.get('/streams/:id', guard('openre.stream.read'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (d) res.json({ stream: publicDefinition(d) });
+ }));
+
+ router.patch('/streams/:id', guard('openre.stream.write'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (!d) return;
+ const b = req.body || {};
+ const updated = store.definitions.update(d.id, {
+ title: b.title, description: b.description, protocols: b.protocols, recording_mode: b.recording_mode,
+ recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, mirror_to_live: b.mirror_to_live, state: b.state,
+ });
+ res.json({ stream: publicDefinition(updated) });
+ }));
+
+ router.delete('/streams/:id', guard('openre.stream.write'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (!d) return;
+ store.definitions.archive(d.id);
+ res.status(204).end();
+ }));
+
+ router.get('/streams/:id/keys', guard('openre.stream.read'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (d) res.json({ keys: store.definitions.keys(d.id).map(k => ({ ...k, created_at: iso(k.created_at), grace_until: iso(k.grace_until), revoked_at: iso(k.revoked_at), last_used_at: iso(k.last_used_at) })) });
+ }));
+
+ router.post('/streams/:id/keys/rotate', guard('openre.key.rotate'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (!d) return;
+ const b = req.body || {};
+ const r = store.definitions.rotateKey(d.id, { grace_seconds: b.grace_seconds, rotated_by: auth.actorOf(req.caller), reason: 'rotated' });
+ let ended = 0;
+ if (b.end_sessions) {
+ for (const s of store.sessions.openFor(d.id)) if (store.sessions.requestEnd(s.id, `key_rotation:${auth.actorOf(req.caller)}`)) ended++;
+ }
+ res.set('Cache-Control', 'no-store');
+ res.json({
+ key: { id: r.key.id, key: r.key.key, hint: r.key.hint, shown_once: true },
+ retired: r.retired,
+ grace_until: iso(r.grace_until),
+ sessions_ending: ended,
+ ingest: store.definitions.ingestEndpoints(store.definitions.get(d.id)),
+ });
+ }));
+
+ // ── Destinations ──────────────────────────────────────────
+
+ router.get('/streams/:id/destinations', guard('openre.output.read'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (d) res.json({ destinations: store.outputs.destinations(d.id) });
+ }));
+
+ router.post('/streams/:id/destinations', guard('openre.output.write'), handle((req, res) => {
+ const d = loadDefinition(req, res, req.params.id);
+ if (d) res.status(201).json({ destination: store.outputs.createDestination(d.id, req.body || {}) });
+ }));
+
+ router.patch('/destinations/:id', guard('openre.output.write'), handle((req, res) => {
+ const dest = loadDestination(req, res, req.params.id);
+ if (dest) res.json({ destination: store.outputs.updateDestination(dest.id, req.body || {}) });
+ }));
+
+ router.delete('/destinations/:id', guard('openre.output.write'), handle((req, res) => {
+ const dest = loadDestination(req, res, req.params.id);
+ if (!dest) return;
+ store.outputs.deleteDestination(dest.id);
+ res.status(204).end();
+ }));
+
+ router.post('/destinations/:id/test', guard('openre.output.write'), handle(async (req, res) => {
+ const dest = loadDestination(req, res, req.params.id);
+ if (!dest) return;
+ const result = await testDestination(dest, { allowPrivate: config.outputs.allowPrivateHosts });
+ store.outputs.log(null, dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);
+ res.json(result);
+ }));
+
+ router.post('/destinations/:id/start', guard('openre.output.write'), handle((req, res) => {
+ const dest = loadDestination(req, res, req.params.id);
+ if (dest) res.json({ output: store.outputs.startDestination(dest.id) });
+ }));
+
+ router.post('/destinations/:id/stop', guard('openre.output.write'), handle((req, res) => {
+ const dest = loadDestination(req, res, req.params.id);
+ if (dest) res.json({ stopping: store.outputs.stopDestination(dest.id) });
+ }));
+
+ router.get('/destinations/:id/logs', guard('openre.output.read'), handle((req, res) => {
+ const dest = loadDestination(req, res, req.params.id);
+ if (dest) res.json({ logs: store.outputs.logsOfDestination(dest.id, Math.min(Number(req.query.limit) || 100, 500)).map(l => ({ ...l, at: iso(l.at) })) });
+ }));
+
+ // ── Sessions ──────────────────────────────────────────────
+
+ router.get('/sessions', guard('openre.session.read'), handle((req, res) => {
+ const q = req.query;
+ if (q.stream_id) {
+ const d = loadDefinition(req, res, String(q.stream_id));
+ if (!d) return;
+ }
+ const c = req.caller;
+ // Owners see their own sessions; staff may ask for all (?all=1); a service sees the
+ // sessions of the subject it acts for, or every session without X-OV-Subject.
+ let owner = c.subject || null;
+ if (c.kind === 'user' && c.staff && q.all === '1') owner = null;
+ const list = store.sessions.list({
+ definition_id: q.stream_id ? String(q.stream_id) : undefined,
+ owner_subject: q.stream_id ? undefined : (owner || undefined),
+ state: q.state ? String(q.state) : undefined,
+ limit: q.limit,
+ before: q.before,
+ });
+ res.json({ sessions: list.map(s => publicSession(s)) });
+ }));
+
+ router.get('/sessions/:id', guard('openre.session.read'), handle((req, res) => {
+ const s = loadSession(req, res, req.params.id);
+ if (s) res.json({ session: publicSession(s, { detail: true }) });
+ }));
+
+ router.get('/sessions/:id/playback', guard('openre.session.read'), handle((req, res) => {
+ const s = loadSession(req, res, req.params.id);
+ if (s) res.json({ playback: store.sessions.playback(s) });
+ }));
+
+ router.post('/sessions/:id/end', guard('openre.session.end'), handle((req, res) => {
+ const s = loadSession(req, res, req.params.id);
+ if (!s) return;
+ const requested = store.sessions.requestEnd(s.id, auth.actorOf(req.caller));
+ res.status(requested ? 202 : 409).json({ requested, state: store.sessions.get(s.id).state });
+ }));
+
+ router.get('/sessions/:id/outputs', guard('openre.output.read'), handle((req, res) => {
+ const s = loadSession(req, res, req.params.id);
+ if (s) res.json({ outputs: store.outputs.outputsOfSession(s.id) });
+ }));
+
+ router.get('/outputs/:id/logs', guard('openre.output.read'), handle((req, res) => {
+ const o = store.outputs.outputRow(req.params.id);
+ const s = o ? loadSession(req, res, o.session_id) : null;
+ if (!o) return fail(req, res, 404, 'openre.output_not_found', 'no such output');
+ if (s) res.json({ logs: store.outputs.logsOfOutput(o.id, Math.min(Number(req.query.limit) || 100, 500)).map(l => ({ ...l, at: iso(l.at) })) });
+ }));
+
+ router.get('/workers', guard('openre.session.read'), handle((req, res) => {
+ if (req.caller.kind === 'user' && !req.caller.staff) return fail(req, res, 403, 'capability.denied', 'staff only');
+ res.json({ workers: store.workers.recent(100).map(w => ({ ...w, started_at: iso(w.started_at), ready_at: iso(w.ready_at), heartbeat_at: iso(w.heartbeat_at), drain_started_at: iso(w.drain_started_at), drain_deadline: iso(w.drain_deadline), stopped_at: iso(w.stopped_at) })) });
+ }));
+
+ return { router, publicDefinition, publicSession };
+}
+
+module.exports = { createV1Router };
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/** openre-api Express app: request context, health/readiness, API v1, playback proxy, SSO, UI. */
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const contracts = require('openvibe-contracts');
+const { createV1Router } = require('./api/v1');
+const { createSsoRoutes } = require('./auth/sso');
+const { createUiRouter } = require('./ui/routes');
+const pkg = require('../package.json');
+
+const SESSION_FLV_RE = /^(ses_[0-9A-HJKMNP-TV-Z]{26})\.flv$/;
+
+function createApp({ rt, auth, keys, log = console, fetchImpl }) {
+ const { config, store, events, db } = rt;
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ app.use(contracts.http.middleware());
+ app.use((req, res, next) => {
+ res.setHeader('X-Content-Type-Options', 'nosniff');
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
+ next();
+ });
+ app.use(cookieParser());
+
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openre-api', version: pkg.version, release: config.release }));
+
+ // Ready = the database answers and the Network key is loaded. Worker and coordinator state is
+ // reported, not required: the API serves reads while a worker generation rolls over.
+ app.get('/api/ready', (_req, res) => {
+ let dbOk = false;
+ try { dbOk = db.prepare('SELECT 1 AS ok').get().ok === 1; } catch { dbOk = false; }
+ const checks = { db: dbOk, key: keys.loaded() };
+ const ready = Object.values(checks).every(Boolean);
+ let workers = [];
+ let coordinator = null;
+ try {
+ workers = store.workers.alive().map(w => ({ kind: w.kind, generation: w.generation, state: w.state, heartbeat_age_ms: Date.now() - w.heartbeat_at }));
+ const l = db.prepare("SELECT holder, expires_at FROM leases WHERE name = 'coordinator'").get();
+ coordinator = l ? { holder: l.holder, lease_valid: l.expires_at > Date.now() } : null;
+ } catch { /* reported as empty */ }
+ res.status(ready ? 200 : 503).json({
+ status: ready ? 'ready' : 'not_ready',
+ checks,
+ store: { engine: 'sqlite', path_configured: Boolean(config.dbPath) },
+ workers,
+ coordinator,
+ events: dbOk ? events.status() : null,
+ });
+ });
+
+ const apiJson = express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] });
+ const v1 = createV1Router({ rt, auth });
+ app.use('/api/v1', apiJson, auth.middleware({ services: true }), v1.router);
+
+ // Public HTTP-FLV playback of a live session, proxied from the worker that holds it. A viewer
+ // deploy of this API interrupts viewers of this URL (they reconnect), never the ingest.
+ app.get('/play/:file', (req, res) => {
+ const m = SESSION_FLV_RE.exec(req.params.file);
+ const s = m ? store.sessions.get(m[1]) : null;
+ const d = s ? store.definitions.row(s.definition_id) : null;
+ if (!s || !d || s.state !== 'live' || d.playback_visibility === 'private') return res.status(404).end();
+ const pb = store.sessions.playback(s);
+ if (!pb || !pb.flv) return res.status(404).end();
+ const upstream = http.get(pb.flv.internal_url, (up) => {
+ if (up.statusCode !== 200) { res.status(502).end(); up.resume(); return; }
+ res.writeHead(200, { 'Content-Type': 'video/x-flv', 'Cache-Control': 'no-cache, no-store', 'Access-Control-Allow-Origin': '*' });
+ up.pipe(res);
+ });
+ upstream.on('error', () => { if (!res.headersSent) res.status(502).end(); else res.end(); });
+ req.on('close', () => upstream.destroy());
+ return undefined;
+ });
+
+ app.use('/auth', createSsoRoutes({ config, auth, fetchImpl }));
+ app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /$\nDisallow: /streams\nDisallow: /sessions\nDisallow: /destinations\nDisallow: /api/\nDisallow: /play/\n'));
+ app.use('/', createUiRouter({ rt, auth }));
+
+ app.use((req, res) => contracts.http.sendProblem(res, 404, 'openre.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ if (err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'openre.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });
+ if (err.type === 'entity.too.large') return contracts.http.sendProblem(res, 413, 'openre.too_large', { detail: 'request body too large', ctx: req.ov });
+ log.error(`[app] ${req.method} ${req.path}: ${err.stack || err}`);
+ if (res.headersSent) return res.end();
+ return contracts.http.sendProblem(res, 500, 'openre.internal', { detail: 'internal error', ctx: req.ov });
+ });
+ return app;
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+'use strict';
+/**
+ * Who is calling openre-api, resolved once per request into req.caller:
+ *
+ * { kind: 'service', sub: 'svc:live', claims, subject: 'usr_…'|null }
+ * An OpenVibe.Network client-credentials token (RS256, audience openvibe.openre), verified
+ * offline. Each route checks ONE capability (guard('openre.<noun>.<verb>')). A service may
+ * name the person it acts for in X-OV-Subject; it is then limited to that owner's streams.
+ * Without it the grant applies to every stream (first-party integrations such as Live's
+ * mirror read sessions for any channel that opted in).
+ * { kind: 'user', subject: 'usr_…', staff, claims }
+ * A browser/owner with the Network user JWT (ov_token cookie or Bearer). Owners act on
+ * their own streams; Network role admin is staff (read everything, end sessions).
+ * { kind: 'anonymous' }
+ *
+ * The capability ids are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts
+ * yet. Until the release that defines them, allows() grants them with the contracts rule (exact id
+ * or a `family.*` grant) and hands the decision to capabilities.check() once contracts know the id
+ * (the same bridge OpenVibe.Events used).
+ */
+const crypto = require('crypto');
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod):/;
+const STAFF_ROLES = new Set(['admin']);
+
+// ── Network public key ─────────────────────────────────────────
+
+function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {
+ let key = pem ? toPem(pem) : null;
+ let retryTimer = null;
+ let refreshTimer = null;
+
+ function toPem(value) { return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' }); }
+
+ async function fetchOnce() {
+ for (const base of urls) {
+ if (!base) continue;
+ const url = `${base}/api/.well-known/jwks`;
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find(k => k.kty === 'RSA');
+ if (jwk) key = toPem({ key: jwk, format: 'jwk' });
+ else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);
+ else throw new Error('no RSA key in response');
+ log.log(`[auth] Network public key loaded from ${base}`);
+ return key;
+ } catch (err) {
+ log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);
+ }
+ }
+ return null;
+ }
+
+ function start() {
+ if (pem) return Promise.resolve(key);
+ const attempt = async () => {
+ const k = await fetchOnce();
+ if (!k && !key) { retryTimer = setTimeout(attempt, 30000); retryTimer.unref?.(); }
+ return k;
+ };
+ refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);
+ refreshTimer.unref?.();
+ return attempt();
+ }
+
+ return { get: () => key, loaded: () => Boolean(key), start, stop() { clearTimeout(retryTimer); clearInterval(refreshTimer); }, fetchOnce };
+}
+
+// ── Capabilities ───────────────────────────────────────────────
+
+function hasCap(claims, id) {
+ const granted = claims && Array.isArray(claims.cap) ? claims.cap : [];
+ return granted.some(g => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
+}
+
+function allows(claims, id) {
+ if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
+ const c = capabilities.check(claims, id);
+ if (c.code === 'capability.unknown' && !capabilities.get(id)) return { allowed: true, code: null, reason: null };
+ return c;
+}
+
+// ── Tokens ─────────────────────────────────────────────────────
+
+const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
+
+function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {
+ if (!publicKey || typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const header = b64json(parts[0]);
+ if (header.alg !== 'RS256') return null;
+ if (!crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return null;
+ const claims = b64json(parts[1]);
+ const t = Math.floor(now / 1000);
+ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;
+ if (issuer && claims.iss !== issuer) return null;
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
+ if (!aud.some(a => audiences.includes(a))) return null;
+ if (claims.actor_type || PRINCIPAL_SUB.test(String(claims.sub))) return null;
+ return claims;
+ } catch {
+ return null;
+ }
+}
+
+function bearer(req) {
+ const h = String(req.headers.authorization || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
+}
+
+function cookie(req, name) {
+ if (req.cookies && req.cookies[name]) return req.cookies[name];
+ const raw = String(req.headers.cookie || '');
+ for (const part of raw.split(';')) {
+ const i = part.indexOf('=');
+ if (i > 0 && part.slice(0, i).trim() === name) {
+ try { return decodeURIComponent(part.slice(i + 1).trim()); } catch { return null; }
+ }
+ }
+ return null;
+}
+
+function decodePayload(token) {
+ try { return b64json(String(token).split('.')[1]); } catch { return null; }
+}
+
+class AuthError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function createAuth({ config, keys }) {
+ function verifyService(token) {
+ const publicKey = keys.get();
+ if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
+ return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
+ }
+
+ function verifyUser(token) {
+ return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });
+ }
+
+ function userCaller(claims, token) {
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), claims, token, name: claims.display_name || claims.username || null };
+ }
+
+ /**
+ * resolve(req, { services }) — services: false for server-rendered pages (a service token is
+ * no identity there). A presented service token must verify: it is never downgraded.
+ */
+ function resolve(req, { services = true } = {}) {
+ const token = bearer(req);
+ if (token) {
+ const payload = decodePayload(token);
+ if (payload && PRINCIPAL_SUB.test(String(payload.sub))) {
+ if (!services) return { kind: 'anonymous' };
+ const r = verifyService(token);
+ if (!r.ok) throw new AuthError(r.code === 'token.unavailable' ? 503 : 401, r.code, r.reason);
+ const subjectHeader = req.get('x-ov-subject');
+ let subject = null;
+ if (subjectHeader) {
+ if (!ids.isSubjectId('user', subjectHeader)) throw new AuthError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id');
+ subject = subjectHeader;
+ }
+ return { kind: 'service', sub: r.claims.sub, claims: r.claims, subject };
+ }
+ const claims = verifyUser(token);
+ if (claims) return userCaller(claims, token);
+ if (!keys.loaded()) throw new AuthError(503, 'token.unavailable', 'signing key not loaded yet');
+ throw new AuthError(401, 'token.invalid', 'token does not verify');
+ }
+ const fromCookie = cookie(req, 'ov_token');
+ const claims = fromCookie ? verifyUser(fromCookie) : null;
+ if (claims) return userCaller(claims, fromCookie);
+ return { kind: 'anonymous' };
+ }
+
+ function middleware(opts) {
+ return (req, res, next) => {
+ try {
+ req.caller = resolve(req, opts);
+ next();
+ } catch (err) {
+ if (!(err instanceof AuthError)) return next(err);
+ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
+ }
+ };
+ }
+
+ /**
+ * API guard: a service needs the capability; a signed-in owner may act on their own streams
+ * (the route checks ownership with canAccess). Anonymous callers are refused.
+ */
+ function guard(capabilityId) {
+ return function capGuard(req, res, next) {
+ const c = req.caller || { kind: 'anonymous' };
+ if (c.kind === 'service') {
+ const r = allows(c.claims, capabilityId);
+ if (!r.allowed) return http.sendProblem(res, 403, r.code, { detail: r.reason, ctx: req.ov });
+ req.capability = capabilityId;
+ return next();
+ }
+ if (c.kind === 'user') {
+ if (!c.subject) return http.sendProblem(res, 403, 'subject.missing', { detail: 'this account has no canonical subject yet; sign in again', ctx: req.ov });
+ req.capability = capabilityId;
+ return next();
+ }
+ return http.sendProblem(res, 401, 'token.missing', { detail: 'sign in, or call with a service token', ctx: req.ov });
+ };
+ }
+
+ /** May this caller act on a stream owned by ownerSubject? */
+ function canAccess(caller, ownerSubject, { staffAllowed = true } = {}) {
+ if (!caller) return false;
+ if (caller.kind === 'service') return !caller.subject || caller.subject === ownerSubject;
+ if (caller.kind === 'user') return caller.subject === ownerSubject || (staffAllowed && caller.staff);
+ return false;
+ }
+
+ /** The subject recorded as the actor of a change. */
+ function actorOf(caller) {
+ if (!caller) return null;
+ if (caller.kind === 'user') return caller.subject;
+ if (caller.kind === 'service') return caller.subject || caller.sub;
+ return null;
+ }
+
+ return { resolve, middleware, guard, canAccess, actorOf, verifyService, verifyUser };
+}
+
+module.exports = { createKeyStore, createAuth, allows, hasCap, verifyUserJwt, bearer, cookie, AuthError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * Browser sign-in for the standalone UI: OAuth2 authorization-code client of OpenVibe.Network
+ * (client `openre`, redirect https://openre.stream/auth/callback). The same shape as
+ * OpenVibe.Community's session layer, without FedCM:
+ *
+ * GET /auth/login → Network /oauth/authorize (state cookie; ?next= same-site path)
+ * GET /auth/callback → code exchange server-side, sets ov_token (+ ov_refresh)
+ * GET /auth/logout → clears cookies
+ * GET /auth/me → the signed-in user (offline JWT verification)
+ * POST /auth/refresh → refresh_token grant (the shared navbar calls it)
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+
+const ACCESS = 'ov_token';
+const REFRESH = 'ov_refresh';
+const HINT = 'ov_sso_hint';
+const STATE = 'ov_oauth_state';
+const NEXT = 'ov_oauth_next';
+
+function sanitizeNext(next) {
+ if (typeof next === 'string' && /^\/(?!\/|\\)/.test(next)) return next;
+ return '/';
+}
+
+function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
+ const router = express.Router();
+ const client = new OpenVibeAuthClient({
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, redirectUri: config.oauth.redirectUri,
+ publicKey: null, authBase: config.networkUrl, internalBase: config.networkInternalUrl,
+ });
+ const base = { sameSite: 'lax', secure: config.cookies.secure };
+ const flow = { ...base, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 };
+
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const b of [config.networkInternalUrl, config.networkUrl]) {
+ try {
+ const res = await fetchImpl(`${b}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
+ signal: AbortSignal.timeout(10000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status });
+ return data;
+ } catch (err) {
+ lastErr = err;
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ function setSession(res, access, refresh) {
+ res.cookie(ACCESS, access, { ...base, httpOnly: false, path: '/', maxAge: 24 * 60 * 60 * 1000 });
+ if (refresh) res.cookie(REFRESH, refresh, { ...base, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT, 'account', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+
+ router.get('/login', (req, res) => {
+ const { url, state } = client.getAuthorizationUrl(config.oauth.scope);
+ res.cookie(STATE, state, flow);
+ res.cookie(NEXT, sanitizeNext(req.query.next), flow);
+ res.redirect(url);
+ });
+
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies && req.cookies[NEXT]);
+ const expected = req.cookies && req.cookies[STATE];
+ res.clearCookie(STATE, { path: '/auth' });
+ res.clearCookie(NEXT, { path: '/auth' });
+ if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);
+ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
+ if (!expected || !state || !crypto.timingSafeEqual(
+ Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)),
+ )) return res.status(400).type('text/plain').send('OAuth state mismatch, please sign in again.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);
+ }
+ });
+
+ router.get('/logout', (req, res) => {
+ res.clearCookie(ACCESS, { path: '/' });
+ res.clearCookie(REFRESH, { path: '/auth' });
+ res.cookie(HINT, 'guest', { ...base, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(sanitizeNext(req.query.next));
+ });
+
+ router.get('/me', (req, res) => {
+ const token = (req.cookies && req.cookies[ACCESS]) || null;
+ const claims = token ? auth.verifyUser(token) : null;
+ if (!claims) return res.status(401).json({ error: 'Not authenticated' });
+ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
+ return res.json({ user, expires_at: exp ? exp * 1000 : null });
+ });
+
+ router.post('/refresh', async (req, res) => {
+ const refresh = req.cookies && req.cookies[REFRESH];
+ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ setSession(res, data.access_token, data.refresh_token);
+ return res.json({ token: data.access_token });
+ } catch (err) {
+ if (err.status && err.status < 500) return res.status(401).json({ error: 'Refresh token rejected, please sign in again' });
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+module.exports = { createSsoRoutes, sanitizeNext };
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * OpenRe.Stream configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/openre.env in production); .env.example documents every name.
+ *
+ * load(env) is pure so tests (and every worker process) can build a config without touching
+ * process.env. All processes of the service (openre-api, openre-session-coordinator and the
+ * transport workers) read the same file and share one SQLite database (ADR-007: one service,
+ * one store; the processes are the reason this service is the first candidate for PostgreSQL).
+ */
+
+const int = (v, d) => {
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) ? n : d;
+};
+const bool = (v, d) => (v == null || v === '' ? d : /^(1|true|yes|on)$/i.test(String(v)));
+const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
+const trim = (v) => String(v || '').replace(/\/+$/, '');
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4500);
+ const rtmpPort = int(env.OPENRE_RTMP_PORT, 1936);
+ return {
+ service: 'openre',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl: trim(env.BASE_URL || (isProduction ? 'https://openre.stream' : `http://localhost:${port}`)),
+ // A release label for logs and worker generations (deploy.sh sets the git sha).
+ release: env.OPENRE_RELEASE || 'dev',
+
+ // ── Identity: OpenVibe.Network ──────────────────────────
+ networkUrl: trim(env.OV_NETWORK_URL || 'https://openvibe.network'),
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ issuer: trim(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
+ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
+ audience: 'openvibe.openre',
+ // A browser's Network user JWT is accepted when its aud contains one of these.
+ userAudiences: list(env.OPENRE_USER_AUDIENCES, ['openvibe.openre', 'openvibe.network']),
+ // OAuth client `openre` (browser sign-in for the standalone UI, and this service's own
+ // client-credentials tokens for Events and Media).
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'openre',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || (isProduction ? 'https://openre.stream/auth/callback' : `http://localhost:${port}/auth/callback`),
+ scope: 'profile',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+
+ // ── Store ───────────────────────────────────────────────
+ dbPath: env.OPENRE_DB_PATH || './data/openre.db',
+
+ // ── Secrets ─────────────────────────────────────────────
+ // 32-byte key (hex or base64) that encrypts destination stream keys and SRT passphrases
+ // at rest (AES-256-GCM). OPENRE_SECRETS_KEY_PREVIOUS is tried for decryption only, so the
+ // key can be rotated (scripts/reencrypt is not needed: rows are re-encrypted on write).
+ secretsKey: env.OPENRE_SECRETS_KEY || '',
+ secretsKeyPrevious: env.OPENRE_SECRETS_KEY_PREVIOUS || '',
+
+ // ── Ingest (RTMP) ───────────────────────────────────────
+ rtmp: {
+ // Public ingest port. Live's in-process ingest owns 1935 until the RTMP cutover
+ // (README "Port plan"); OpenRe listens on 1936 until then.
+ port: rtmpPort,
+ bindHost: env.OPENRE_RTMP_BIND || '0.0.0.0',
+ // What streamers paste into OBS: rtmp://<publicHost>[:port]/live
+ publicHost: env.OPENRE_RTMP_PUBLIC_HOST || (isProduction ? 'ingest.openre.stream' : '127.0.0.1'),
+ publicPort: int(env.OPENRE_RTMP_PUBLIC_PORT, rtmpPort),
+ // Loopback ports a worker takes for its internal RTMP play and HTTP-FLV endpoints (the
+ // restream worker, Media's recorder and the playback proxy pull from these). Each
+ // worker generation takes the first free pair from this range, so two generations can
+ // run side by side during a drain.
+ internalPortMin: int(env.OPENRE_RTMP_INTERNAL_PORT_MIN, 19360),
+ internalPortMax: int(env.OPENRE_RTMP_INTERNAL_PORT_MAX, 19399),
+ chunkSize: int(env.OPENRE_RTMP_CHUNK_SIZE, 60000),
+ maxPublishersPerWorker: int(env.OPENRE_RTMP_MAX_PUBLISHERS, 64),
+ },
+
+ // ── Workers, leases, generations ────────────────────────
+ workers: {
+ heartbeatMs: int(env.OPENRE_WORKER_HEARTBEAT_MS, 2000),
+ // A worker (and every session it owns) is lost when it has not heartbeated this long.
+ leaseMs: int(env.OPENRE_WORKER_LEASE_MS, 15000),
+ // A draining worker keeps its sessions at most this long, then ends them (the encoder
+ // reconnects and lands on the newest generation). Plan §15.10 "explicit maximum-lifetime".
+ drainMaxMs: int(env.OPENRE_DRAIN_MAX_MS, 24 * 60 * 60 * 1000),
+ coordinatorIntervalMs: int(env.OPENRE_COORDINATOR_INTERVAL_MS, 1000),
+ restreamPollMs: int(env.OPENRE_RESTREAM_POLL_MS, 1000),
+ },
+
+ // ── Restream outputs ────────────────────────────────────
+ outputs: {
+ ffmpegPath: env.OPENRE_FFMPEG_PATH || 'ffmpeg',
+ // Static OpenSSL ffmpeg for rtmps:// (GnuTLS rekeying issue, same as Live).
+ ffmpegOpenSslPath: env.OPENRE_FFMPEG_OPENSSL_PATH || '',
+ maxPerStream: int(env.OPENRE_MAX_DESTINATIONS, 10),
+ // SSRF: destinations may not point at loopback/private/link-local addresses unless
+ // this is on (tests; a LAN restream box). Checked on write and again before ffmpeg.
+ allowPrivateHosts: bool(env.OPENRE_DEST_ALLOW_PRIVATE, false),
+ logsPerOutput: int(env.OPENRE_OUTPUT_LOG_LIMIT, 200),
+ // Backoff, circuit breaker and live-ack timings (ported from Live's restream manager).
+ restartBaseMs: int(env.OPENRE_RESTART_BASE_MS, 5000),
+ restartMaxMs: int(env.OPENRE_RESTART_MAX_MS, 120000),
+ maxRestarts: int(env.OPENRE_MAX_RESTARTS, 30),
+ rapidCrashMs: int(env.OPENRE_RAPID_CRASH_MS, 5000),
+ rapidCrashGiveUp: int(env.OPENRE_RAPID_CRASH_GIVEUP, 4),
+ liveAckTimeoutMs: int(env.OPENRE_LIVE_ACK_TIMEOUT_MS, 20000),
+ stableMs: int(env.OPENRE_STABLE_MS, 30000),
+ startDelayMs: int(env.OPENRE_OUTPUT_START_DELAY_MS, 3000),
+ cooldownMinutes: int(env.OPENRE_DEST_COOLDOWN_MINUTES, 30),
+ },
+
+ // ── Events (OpenVibe.Events through the SDK outbox) ─────
+ events: {
+ url: trim(env.EVENTS_URL || ''),
+ // Relay runs in the session coordinator. Off when EVENTS_URL or the client secret is
+ // missing: rows still commit with every change and wait for the relay.
+ publish: env.EVENTS_PUBLISH !== 'off',
+ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 1000),
+ },
+
+ // ── Recording requests (OpenVibe.Media) ─────────────────
+ media: {
+ url: trim(env.MEDIA_URL || 'http://127.0.0.1:4100'),
+ // The Media tenant the recording is filed under. 'live' keeps VODs in the Live
+ // channel's gallery (Media still finalises and owns the object, ADR-006).
+ appId: env.MEDIA_APP_ID || 'live',
+ // 'key': the tenant's app key (MEDIA_API_KEY), which Media's VOD routes accept today.
+ // 'service': a Network service token (audience openvibe.media) once Media names a
+ // capability on its VOD ingest routes (README "Recording").
+ auth: env.OPENRE_MEDIA_AUTH === 'service' ? 'service' : 'key',
+ apiKey: env.MEDIA_API_KEY || '',
+ enabled: env.OPENRE_RECORDING !== 'off',
+ startDelayMs: int(env.OPENRE_RECORDING_START_DELAY_MS, 2000),
+ },
+
+ // ── Live compatibility (links on the UI only; never a data dependency) ──
+ liveUrl: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * The session coordinator (unit openre-session-coordinator). Stateless between ticks: everything it
+ * knows is in the database, so it can restart at any time without touching a transport.
+ *
+ * Each tick:
+ * 1. lease only one coordinator acts at a time (a deploy overlap is harmless)
+ * 2. liveness workers without a heartbeat for leaseMs are 'lost'; their open sessions fail
+ * (worker_lost) and their outputs go back to pending for another worker
+ * 3. orphans open sessions whose lease expired with no live worker fail too
+ * 4. generations per kind, every ready worker older than the newest ready generation drains
+ * (deadline = now + drainMaxMs). New sessions then only reach the newest one.
+ * 5. outputs live sessions get their auto-start outputs; pending outputs are assigned to
+ * the newest ready restream generation
+ * 6. keys grace periods that ended become revocations
+ * 7. recordings live sessions get a recording request; requests advance (Media calls)
+ * The event relay (outbox → OpenVibe.Events) runs in this process too.
+ */
+const { KINDS } = require('./store/workers');
+
+function createCoordinator({ rt, media, log = console, holder = `coordinator:${process.pid}` }) {
+ const { db, store, config, clock } = rt;
+ const now = () => clock.now();
+ const lease = {
+ take: db.prepare(`INSERT INTO leases (name, holder, expires_at) VALUES ('coordinator', @holder, @exp)
+ ON CONFLICT(name) DO UPDATE SET holder = @holder, expires_at = @exp WHERE leases.holder = @holder OR leases.expires_at < @now`),
+ release: db.prepare("DELETE FROM leases WHERE name = 'coordinator' AND holder = ?"),
+ };
+ let timer = null;
+ let running = false;
+ let ticking = null;
+ const stats = { ticks: 0, lastTickAt: null, lastError: null, holdsLease: false };
+
+ function takeLease() {
+ const ok = lease.take.run({ holder, exp: now() + config.workers.leaseMs, now: now() }).changes > 0;
+ stats.holdsLease = ok;
+ return ok;
+ }
+
+ function failSessionsOf(worker, reason) {
+ let n = 0;
+ for (const s of store.sessions.ofWorker(worker.id)) {
+ const r = store.sessions.transition(s.id, 'failed', { reason, actor: 'coordinator' });
+ if (r.ok) n++;
+ }
+ return n;
+ }
+
+ /** The synchronous part of a tick (everything but Media calls and the relay). */
+ function syncTick() {
+ const out = { lost: 0, failed: 0, drained: 0, outputsCreated: 0, outputsAssigned: 0, keysRevoked: 0, recordingsCreated: 0 };
+ // 2. liveness
+ for (const w of store.workers.stale(now() - config.workers.leaseMs)) {
+ if (store.workers.lose(w.id, 'heartbeat_missed')) {
+ out.lost++;
+ out.failed += failSessionsOf(w, 'worker_lost');
+ store.outputs.release(w.id);
+ log.warn(`[coordinator] worker ${w.kind}#${w.generation} (${w.id}) lost; sessions failed, outputs released`);
+ }
+ }
+ // 3. orphans (lease expired, worker not alive)
+ for (const s of store.sessions.expiredLeases()) {
+ const w = s.worker_id ? store.workers.get(s.worker_id) : null;
+ if (w && ['starting', 'ready', 'draining'].includes(w.state) && w.heartbeat_at >= now() - config.workers.leaseMs) continue;
+ if (store.sessions.transition(s.id, 'failed', { reason: 'lease_expired', actor: 'coordinator' }).ok) out.failed++;
+ }
+ // 4. generations
+ for (const kind of KINDS) {
+ const newest = store.workers.newestReady(kind);
+ if (!newest) continue;
+ for (const old of store.workers.olderReady(kind, newest.generation)) {
+ if (store.workers.drain(old.id, now() + config.workers.drainMaxMs)) {
+ out.drained++;
+ log.log(`[coordinator] ${kind}#${old.generation} drains (newest ready is #${newest.generation})`);
+ }
+ }
+ }
+ // 5. outputs
+ out.outputsCreated = store.outputs.ensureAutoOutputs();
+ const restream = store.workers.newestReady('restream');
+ if (restream) out.outputsAssigned = store.outputs.assignPending(restream);
+ store.outputs.stopOrphans();
+ // 6. keys
+ out.keysRevoked = store.definitions.expireGraceKeys();
+ // 7. recordings (requests only; the Media calls are async below)
+ if (media && media.configured) out.recordingsCreated = store.recordings.ensureRequests();
+ return out;
+ }
+
+ async function tick() {
+ if (ticking) return ticking;
+ ticking = (async () => {
+ try {
+ if (!takeLease()) return { skipped: 'lease held by another coordinator' };
+ const out = syncTick();
+ if (media) out.recordingSteps = await store.recordings.process(media);
+ stats.ticks++;
+ stats.lastTickAt = now();
+ stats.lastError = null;
+ return out;
+ } catch (err) {
+ stats.lastError = err.message;
+ log.error(`[coordinator] tick failed: ${err.stack || err}`);
+ return { error: err.message };
+ }
+ })().finally(() => { ticking = null; });
+ return ticking;
+ }
+
+ function schedule() {
+ if (!running) return;
+ timer = setTimeout(async () => { await tick(); schedule(); }, config.workers.coordinatorIntervalMs);
+ }
+
+ return {
+ tick,
+ syncTick,
+ start() { if (!running) { running = true; rt.events.start(); tick().finally(schedule); } },
+ async stop() {
+ running = false;
+ clearTimeout(timer);
+ if (ticking) await ticking.catch(() => {});
+ await rt.events.stop();
+ lease.release.run(holder);
+ },
+ stats: () => ({ ...stats }),
+ };
+}
+
+module.exports = { createCoordinator };
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
+'use strict';
+/**
+ * OpenRe's durable store: one SQLite database (WAL) shared by every OpenRe process.
+ *
+ * openre-api definitions, keys, destinations (owner writes)
+ * openre-session-coordinator worker liveness, drain, output assignment, recordings, event relay
+ * openre-rtmp-ingest sessions it admits, their leases and transitions
+ * openre-restream-worker output state, health and logs
+ *
+ * ADR-007: SQLite stays the documented store while the platform is single-host. Several writer
+ * processes is the exact trigger ADR-007 names for moving to PostgreSQL; every statement lives
+ * behind server/store/* so that move changes one layer. WAL + busy_timeout keeps the writers
+ * safe today (each write is one short transaction).
+ *
+ * Every table is created idempotently on open. Times are epoch milliseconds.
+ */
+const fs = require('fs');
+const path = require('path');
+const Database = require('better-sqlite3');
+
+const SCHEMA = `
+CREATE TABLE IF NOT EXISTS stream_definitions (
+ id TEXT PRIMARY KEY,
+ owner_subject TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT 'Untitled stream',
+ description TEXT NOT NULL DEFAULT '',
+ protocols TEXT NOT NULL DEFAULT '["rtmp"]',
+ recording_mode TEXT NOT NULL DEFAULT 'vod' CHECK (recording_mode IN ('vod', 'clips', 'none')),
+ recording_visibility TEXT NOT NULL DEFAULT 'public' CHECK (recording_visibility IN ('public', 'unlisted', 'private')),
+ playback_visibility TEXT NOT NULL DEFAULT 'public' CHECK (playback_visibility IN ('public', 'unlisted', 'private')),
+ mirror_to_live INTEGER NOT NULL DEFAULT 0,
+ state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'disabled', 'archived')),
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_definitions_owner ON stream_definitions(owner_subject, state);
+
+-- Typed references to other services' entities (plan §29.3): never a foreign key into them.
+CREATE TABLE IF NOT EXISTS external_refs (
+ definition_id TEXT NOT NULL REFERENCES stream_definitions(id) ON DELETE CASCADE,
+ service TEXT NOT NULL,
+ type TEXT NOT NULL,
+ ref_id TEXT NOT NULL,
+ label TEXT,
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (service, type, ref_id)
+);
+CREATE INDEX IF NOT EXISTS idx_external_refs_definition ON external_refs(definition_id);
+
+-- Ingest keys are stored as SHA-256 of a 256-bit random secret; the secret is shown once.
+CREATE TABLE IF NOT EXISTS ingest_keys (
+ id TEXT PRIMARY KEY,
+ definition_id TEXT NOT NULL REFERENCES stream_definitions(id) ON DELETE CASCADE,
+ key_hash TEXT NOT NULL UNIQUE,
+ hint TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('active', 'grace', 'revoked')),
+ created_by TEXT,
+ created_at INTEGER NOT NULL,
+ grace_until INTEGER,
+ revoked_at INTEGER,
+ revoked_reason TEXT,
+ last_used_at INTEGER
+);
+CREATE INDEX IF NOT EXISTS idx_ingest_keys_definition ON ingest_keys(definition_id, status);
+
+CREATE TABLE IF NOT EXISTS workers (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ generation INTEGER NOT NULL,
+ release TEXT,
+ pid INTEGER,
+ host TEXT,
+ state TEXT NOT NULL CHECK (state IN ('starting', 'ready', 'draining', 'stopped', 'lost')),
+ endpoints TEXT NOT NULL DEFAULT '{}',
+ started_at INTEGER NOT NULL,
+ ready_at INTEGER,
+ heartbeat_at INTEGER NOT NULL,
+ drain_started_at INTEGER,
+ drain_deadline INTEGER,
+ stopped_at INTEGER,
+ stop_reason TEXT,
+ UNIQUE (kind, generation)
+);
+CREATE INDEX IF NOT EXISTS idx_workers_kind_state ON workers(kind, state, generation);
+
+CREATE TABLE IF NOT EXISTS ingest_sessions (
+ id TEXT PRIMARY KEY,
+ definition_id TEXT NOT NULL REFERENCES stream_definitions(id),
+ key_id TEXT,
+ protocol TEXT NOT NULL,
+ state TEXT NOT NULL CHECK (state IN ('starting', 'live', 'ending', 'ended', 'failed')),
+ worker_id TEXT,
+ worker_kind TEXT,
+ worker_generation INTEGER,
+ lease_expires_at INTEGER,
+ desired_state TEXT NOT NULL DEFAULT 'run' CHECK (desired_state IN ('run', 'end')),
+ end_requested_by TEXT,
+ end_reason TEXT,
+ failure_reason TEXT,
+ media_info TEXT,
+ revision INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ live_at INTEGER,
+ ending_at INTEGER,
+ ended_at INTEGER,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_sessions_definition ON ingest_sessions(definition_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_sessions_state ON ingest_sessions(state);
+CREATE INDEX IF NOT EXISTS idx_sessions_worker ON ingest_sessions(worker_id, state);
+
+CREATE TABLE IF NOT EXISTS session_transitions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL REFERENCES ingest_sessions(id) ON DELETE CASCADE,
+ from_state TEXT,
+ to_state TEXT NOT NULL,
+ reason TEXT,
+ actor TEXT,
+ at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_transitions_session ON session_transitions(session_id, id);
+
+CREATE TABLE IF NOT EXISTS destinations (
+ id TEXT PRIMARY KEY,
+ definition_id TEXT NOT NULL REFERENCES stream_definitions(id) ON DELETE CASCADE,
+ platform TEXT NOT NULL CHECK (platform IN ('youtube', 'twitch', 'kick', 'custom')),
+ name TEXT,
+ server_url TEXT NOT NULL,
+ stream_key_enc TEXT,
+ key_hint TEXT,
+ srt_passphrase_enc TEXT,
+ srt_latency_ms INTEGER,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ auto_start INTEGER NOT NULL DEFAULT 1,
+ quality_preset TEXT NOT NULL DEFAULT 'auto',
+ custom_video_bitrate INTEGER,
+ custom_audio_bitrate INTEGER,
+ custom_fps INTEGER,
+ custom_encoder_preset TEXT,
+ hold_reason TEXT,
+ consecutive_failures INTEGER NOT NULL DEFAULT 0,
+ cooldown_until INTEGER,
+ last_error TEXT,
+ last_failed_at INTEGER,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_destinations_definition ON destinations(definition_id);
+
+-- One run of one destination inside one session. A destination failure only ever touches its
+-- own row: nothing here can move the source session's state.
+CREATE TABLE IF NOT EXISTS outputs (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL REFERENCES ingest_sessions(id),
+ destination_id TEXT NOT NULL REFERENCES destinations(id) ON DELETE CASCADE,
+ desired TEXT NOT NULL DEFAULT 'run' CHECK (desired IN ('run', 'stop')),
+ state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'starting', 'live', 'error', 'failed', 'stopped')),
+ worker_id TEXT,
+ worker_generation INTEGER,
+ restart_attempts INTEGER NOT NULL DEFAULT 0,
+ next_restart_at INTEGER,
+ ever_live INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ progress TEXT,
+ started_at INTEGER,
+ live_at INTEGER,
+ ended_at INTEGER,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE (session_id, destination_id)
+);
+CREATE INDEX IF NOT EXISTS idx_outputs_worker ON outputs(worker_id, state);
+CREATE INDEX IF NOT EXISTS idx_outputs_session ON outputs(session_id);
+
+-- Bounded per output (config.outputs.logsPerOutput); destination tests log with output_id NULL.
+CREATE TABLE IF NOT EXISTS output_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ output_id TEXT,
+ destination_id TEXT NOT NULL,
+ level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
+ message TEXT NOT NULL,
+ at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_output_logs_output ON output_logs(output_id, id);
+CREATE INDEX IF NOT EXISTS idx_output_logs_destination ON output_logs(destination_id, id);
+
+-- Recording requests to OpenVibe.Media. Media records and finalises; OpenRe only asks.
+CREATE TABLE IF NOT EXISTS recordings (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL UNIQUE REFERENCES ingest_sessions(id),
+ mode TEXT NOT NULL CHECK (mode IN ('vod', 'clips')),
+ state TEXT NOT NULL CHECK (state IN ('pending', 'requested', 'recording', 'finalizing', 'finalized', 'failed', 'cancelled')),
+ media_app TEXT,
+ media_vod_id TEXT,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_recordings_state ON recordings(state, next_attempt_at);
+
+-- Every imported (or deliberately not imported) source record: plan §8.1 "never silently dropped".
+CREATE TABLE IF NOT EXISTS migration_map (
+ source_system TEXT NOT NULL,
+ source_type TEXT NOT NULL,
+ source_id TEXT NOT NULL,
+ target_type TEXT,
+ target_id TEXT,
+ status TEXT NOT NULL CHECK (status IN ('imported', 'held', 'excluded')),
+ reason TEXT,
+ imported_at INTEGER NOT NULL,
+ PRIMARY KEY (source_system, source_type, source_id)
+);
+
+-- Named leases (one session coordinator at a time).
+CREATE TABLE IF NOT EXISTS leases (
+ name TEXT PRIMARY KEY,
+ holder TEXT NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+`;
+
+function openDb(dbPath) {
+ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
+ const db = new Database(dbPath);
+ // busy_timeout first: several OpenRe processes open the database at the same time on boot,
+ // and every statement after this one (the WAL switch, the schema) may have to wait for a lock.
+ db.pragma('busy_timeout = 5000');
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+ db.pragma('synchronous = NORMAL');
+ db.exec(SCHEMA);
+ return db;
+}
+
+module.exports = { openDb, SCHEMA };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+/**
+ * "Test" for a restream destination, without pushing any media: the URL passes the SSRF rules,
+ * the host resolves to public addresses, and (RTMP/RTMPS) a TCP connection to the ingest port
+ * opens within 5 s. SRT is UDP, so only the first two checks apply. It says nothing about whether
+ * the platform accepts the key; that is only known once an output runs (its health shows it).
+ */
+const net = require('net');
+const { validateDestinationUrl, checkResolvedHost } = require('./destination-url');
+
+function tcpProbe(host, port, timeoutMs = 5000) {
+ return new Promise((resolve) => {
+ const started = Date.now();
+ const socket = net.connect({ host, port });
+ const done = (ok, error) => { socket.destroy(); resolve({ ok, error, ms: Date.now() - started }); };
+ socket.setTimeout(timeoutMs, () => done(false, `no answer from ${host}:${port} within ${timeoutMs / 1000}s`));
+ socket.once('connect', () => done(true, null));
+ socket.once('error', (err) => done(false, `${err.code || 'error'} connecting to ${host}:${port}`));
+ });
+}
+
+async function testDestination(dest, { allowPrivate = false, lookup, probe = tcpProbe } = {}) {
+ const checks = [];
+ const v = validateDestinationUrl(dest.server_url, { allowPrivate });
+ checks.push({ check: 'url', ok: v.ok, detail: v.ok ? 'rtmp/rtmps/srt URL with a public host' : v.error });
+ if (!v.ok) return { ok: false, checks };
+ const r = await checkResolvedHost(v.host, { allowPrivate, ...(lookup ? { lookup } : {}) });
+ checks.push({ check: 'dns', ok: r.ok, detail: r.ok ? (r.addresses.length ? `resolves to ${r.addresses.join(', ')}` : 'not checked (private hosts allowed)') : r.error });
+ if (!r.ok) return { ok: false, checks };
+ checks.push({ check: 'stream_key', ok: Boolean(dest.stream_key_enc || dest.has_stream_key), detail: dest.stream_key_enc || dest.has_stream_key ? 'set' : 'missing' });
+ if (v.url.protocol === 'srt:') {
+ checks.push({ check: 'connect', ok: true, detail: 'SRT is UDP; the receiver is only reached when an output runs' });
+ } else {
+ const port = Number(v.url.port) || (v.url.protocol === 'rtmps:' ? 443 : 1935);
+ const p = await probe(v.host, port);
+ checks.push({ check: 'connect', ok: p.ok, detail: p.ok ? `TCP ${v.host}:${port} answered in ${p.ms} ms` : p.error });
+ }
+ return { ok: checks.every(c => c.ok), checks };
+}
+
+module.exports = { testDestination, tcpProbe };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * Restream destination URLs become ffmpeg's OUTPUT argument, so they are the SSRF surface of this
+ * service (plan anti-goal 13). Rules, checked when a destination is written and again right
+ * before ffmpeg starts (DNS can change in between):
+ *
+ * - rtmp://, rtmps:// or srt:// only (ffmpeg picks its output protocol from the URL: a path or
+ * file:/http: URL would write files or talk to arbitrary hosts), with a hostname, no
+ * user:password@, no whitespace or control characters, at most 2048 characters
+ * - the host may not be (or resolve to) loopback, private, link-local, CGNAT, multicast or
+ * unspecified addresses, unless OPENRE_DEST_ALLOW_PRIVATE is on (tests, a restream box on the
+ * operator's LAN). Live allowed private hosts; OpenRe does not by default, and the migration
+ * holds such destinations with a recorded reason instead of importing them enabled.
+ */
+const dns = require('dns');
+const net = require('net');
+
+const PROTOCOLS = new Set(['rtmp:', 'rtmps:', 'srt:']);
+
+function isPrivateAddress(ip) {
+ const v = net.isIP(ip);
+ if (v === 4) {
+ const [a, b] = ip.split('.').map(Number);
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0)
+ || (a === 198 && (b === 18 || b === 19)) || a >= 224;
+ }
+ if (v === 6) {
+ const s = ip.toLowerCase();
+ if (s === '::' || s === '::1') return true;
+ if (s.startsWith('::ffff:')) return isPrivateAddress(s.slice(7));
+ return /^(fc|fd|fe8|fe9|fea|feb|ff)/.test(s);
+ }
+ return true;
+}
+
+/** Syntax + literal-address check. { ok, value, url } | { ok: false, error } */
+function validateDestinationUrl(raw, { allowPrivate = false } = {}) {
+ const value = String(raw || '').trim();
+ if (!value) return { ok: false, error: 'Server URL is required' };
+ if (value.length > 2048) return { ok: false, error: 'Server URL is too long' };
+ if (/[\s\u0000-\u001f\u007f]/.test(value)) return { ok: false, error: 'Server URL contains invalid characters' };
+ let u;
+ try { u = new URL(value); } catch { return { ok: false, error: 'Server URL must be a full rtmp://, rtmps:// or srt:// address' }; }
+ if (!PROTOCOLS.has(u.protocol)) return { ok: false, error: `Server URL must start with rtmp://, rtmps:// or srt:// (got "${u.protocol.replace(':', '')}")` };
+ if (!u.hostname) return { ok: false, error: 'Server URL needs a hostname' };
+ if (u.username || u.password) return { ok: false, error: 'Put credentials in the stream key, not in the server URL' };
+ const host = u.hostname.replace(/^\[|\]$/g, '');
+ if (!allowPrivate) {
+ if (/^localhost$/i.test(host) || /\.localhost$/i.test(host) || /\.(internal|local|lan|home|arpa)$/i.test(host)) {
+ return { ok: false, error: 'Server URL may not point at a local or internal host' };
+ }
+ if (net.isIP(host) && isPrivateAddress(host)) return { ok: false, error: 'Server URL may not point at a private, loopback or link-local address' };
+ }
+ return { ok: true, value, url: u, host };
+}
+
+/** Resolve and refuse private answers (DNS rebinding guard). Resolves { ok } | { ok: false, error }. */
+async function checkResolvedHost(host, { allowPrivate = false, lookup = dns.promises.lookup } = {}) {
+ if (allowPrivate) return { ok: true, addresses: [] };
+ if (net.isIP(host)) return isPrivateAddress(host) ? { ok: false, error: 'private address' } : { ok: true, addresses: [host] };
+ let answers;
+ try { answers = await lookup(host, { all: true }); } catch (err) { return { ok: false, error: `could not resolve ${host} (${err.code || err.message})` }; }
+ const addresses = answers.map(a => a.address);
+ if (!addresses.length) return { ok: false, error: `could not resolve ${host}` };
+ if (addresses.some(isPrivateAddress)) return { ok: false, error: `${host} resolves to a private or loopback address` };
+ return { ok: true, addresses };
+}
+
+module.exports = { validateDestinationUrl, checkResolvedHost, isPrivateAddress };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * OpenRe → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * Every OpenRe process can enqueue (the row commits in the same SQLite transaction as the change
+ * it describes); only the session coordinator runs the relay that publishes rows with OpenRe's
+ * service token (audience openvibe.events, capability events.event.publish). Events down, Network
+ * down or no credentials yet: rows wait and are retried; nothing in the transport path waits.
+ *
+ * Event types (source "openre"):
+ * openre.session.started a session reached live subject ingest_session
+ * openre.session.ended a session that had been live ended subject ingest_session
+ * openre.session.failed a session failed (worker lost, error) subject ingest_session
+ * openre.output.healthy a restream output confirmed live subject output
+ * openre.output.failed an output gave up (circuit breaker) subject output
+ * openre.recording.requested Media accepted a recording request subject recording
+ * openre.key.rotated an ingest key was rotated subject stream
+ * Payloads never carry an ingest key or a destination key.
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+
+const TYPES = Object.freeze({
+ sessionStarted: 'openre.session.started',
+ sessionEnded: 'openre.session.ended',
+ sessionFailed: 'openre.session.failed',
+ outputHealthy: 'openre.output.healthy',
+ outputFailed: 'openre.output.failed',
+ recordingRequested: 'openre.recording.requested',
+ keyRotated: 'openre.key.rotated',
+});
+
+/**
+ * createEvents({ config, db, fetchImpl, now, log }) -> { outbox, enqueue, configured, start, stop, status }
+ * enqueue(envelope) must run inside the transaction that makes the change.
+ */
+function createEvents({ config, db, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const configured = Boolean(config.events.url && config.oauth.clientSecret && config.events.publish);
+ let tokenProvider = null;
+ if (configured) {
+ tokenProvider = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ fetch: fetchImpl,
+ });
+ }
+ // Without credentials the client is only used to fill envelopes (prepare), never to send.
+ const client = createClient({
+ baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' },
+ ...(tokenProvider ? { tokenProvider } : { token: 'unconfigured' }),
+ fetch: fetchImpl,
+ retries: 0,
+ autoDiscover: false,
+ onWarning: () => {},
+ });
+ const eventsClient = createEventsClient(client, { source: 'openre' });
+ let lastError = null;
+ const outbox = createOutbox(db, {
+ events: eventsClient,
+ intervalMs: config.events.intervalMs,
+ now,
+ onError: (err) => {
+ const msg = err && err.message;
+ if (msg !== lastError) log.warn(`[events] publish failed (will retry): ${msg}`);
+ lastError = msg;
+ },
+ });
+ outbox.ensureSchema();
+
+ return {
+ outbox,
+ configured,
+ enqueue: (envelope) => outbox.enqueue(envelope),
+ start() { if (configured) outbox.start(); return configured; },
+ stop: () => outbox.stop(),
+ status: () => ({ configured, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ };
+}
+
+module.exports = { createEvents, TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+/**
+ * OpenRe identifiers: <prefix>_<ULID> (the ULID from openvibe-contracts ids, so they sort by
+ * creation time like every other OpenVibe id). Prefixes are OpenRe's own entity types.
+ */
+const { ids } = require('openvibe-contracts');
+
+const PREFIX = Object.freeze({
+ stream: 'std', // stream (input) definition
+ key: 'key', // ingest key record (never the key itself)
+ session: 'ses', // ingest session
+ destination: 'dst', // restream destination
+ output: 'out', // one destination run inside one session
+ worker: 'wrk', // one transport worker process (one generation)
+ recording: 'rec', // recording request to Media
+});
+
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
+
+function newId(kind, now) {
+ const p = PREFIX[kind];
+ if (!p) throw new TypeError(`no OpenRe id prefix for "${kind}"`);
+ return `${p}_${ids.ulid(now)}`;
+}
+
+function isId(kind, value) {
+ return typeof value === 'string' && Boolean(RE[kind]) && RE[kind].test(value);
+}
+
+module.exports = { newId, isId, PREFIX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * openre-api entry point (unit openre-api.service, port 4500).
+ *
+ * The API owns no transport: it reads and writes the store (definitions, keys, destinations,
+ * end requests) and serves the UI. Restarting or redeploying it never touches a worker process,
+ * a session or an ffmpeg output; see test/api-restart.test.js.
+ *
+ * start() is what the tests use too: it takes a config plus injectable clock/fetch/log.
+ */
+const path = require('path');
+const { load } = require('./config');
+const { openRuntime } = require('./store');
+const { createKeyStore, createAuth } = require('./auth');
+const { createApp } = require('./app');
+
+async function start({ config, clock, fetchImpl = globalThis.fetch, log = console, listen = true } = {}) {
+ config = config || load();
+ const rt = openRuntime({ config, clock, fetchImpl, log });
+ const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
+ const auth = createAuth({ config, keys });
+ const app = createApp({ rt, auth, keys, log, fetchImpl });
+ const keyLoaded = keys.start().catch(() => null);
+
+ let server = null;
+ if (listen) {
+ server = await new Promise((resolve, reject) => {
+ const s = app.listen(config.port, config.host, () => resolve(s));
+ s.on('error', reject);
+ });
+ log.log(`[openre-api] listening on http://${config.host}:${server.address().port} (release ${config.release})`);
+ }
+
+ async function close() {
+ keys.stop();
+ if (server) {
+ server.closeAllConnections?.();
+ await new Promise(resolve => server.close(() => resolve()));
+ }
+ rt.db.close();
+ }
+
+ return { config, rt, keys, keyLoaded, auth, app, server, close };
+}
+
+if (require.main === module) {
+ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
+ start().then((h) => {
+ const shutdown = (sig) => {
+ console.log(`[openre-api] ${sig}: shutting down (workers are separate processes and keep running)`);
+ h.close().then(() => process.exit(0), () => process.exit(1));
+ setTimeout(() => process.exit(1), 10000).unref();
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+ }).catch((err) => {
+ console.error(`[openre-api] failed to start: ${err.stack || err}`);
+ process.exit(1);
+ });
+}
+
+module.exports = { start };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+/**
+ * OpenVibe.Media recording requests: the same Media API v1 contract Live's recorder uses
+ * (OpenVibe.Live server/media-client.js + server/streaming/recorder.js), cut down to the calls a
+ * recording needs:
+ *
+ * POST /api/v1/:app/vods { title, user_id?, managed_stream_id?, meta, visibility, clips_only } → { id }
+ * POST /api/v1/:app/vods/:id/ingest/rtmp { rtmp_url } → 202 (Media pulls the URL with its own ffmpeg)
+ * POST /api/v1/:app/vods/:id/finalize → Media closes the recording, probes, thumbnails, fires vod.ready
+ * DELETE /api/v1/:app/vods/:id → drop an empty shell / an ephemeral clips-only recording
+ *
+ * The rtmp_url is the owning worker's loopback play URL (rtmp://127.0.0.1:<port>/live/<session id>):
+ * no ingest key ever leaves OpenRe. Media records and finalises; OpenRe only asks.
+ *
+ * Auth: config.media.auth === 'key' sends the tenant app key (MEDIA_API_KEY), which Media's VOD
+ * routes accept today. 'service' sends a Network service token (audience openvibe.media); Media's
+ * VOD routes do not accept service tokens yet (they name no capability) — see README "Recording".
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+class MediaError extends Error {
+ constructor(message, status, body) { super(message); this.status = status || 0; this.body = body || null; }
+}
+
+function createMediaClient({ config, fetchImpl = globalThis.fetch }) {
+ const base = `${config.media.url}/api/v1/${encodeURIComponent(config.media.appId)}`;
+ const tokens = config.media.auth === 'service' && config.oauth.clientSecret
+ ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.media', fetch: fetchImpl })
+ : null;
+
+ const configured = config.media.enabled && Boolean(config.media.auth === 'service' ? tokens : config.media.apiKey);
+
+ async function authHeader() {
+ if (tokens) return { Authorization: `Bearer ${await tokens.getToken()}` };
+ return config.media.apiKey ? { Authorization: `Bearer ${config.media.apiKey}` } : {};
+ }
+
+ async function request(method, path, { body, timeoutMs = 15000 } = {}) {
+ const headers = { Accept: 'application/json', ...(await authHeader()) };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ let res;
+ let text = '';
+ try {
+ res = await fetchImpl(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
+ text = await res.text().catch(() => '');
+ } catch (err) {
+ throw new MediaError(`Media unreachable (${method} ${path}): ${err.message}`, 0, null);
+ }
+ let json = null;
+ if (text) { try { json = JSON.parse(text); } catch { json = null; } }
+ if (!res.ok) throw new MediaError((json && (json.error || json.detail)) || `Media ${res.status} on ${method} ${path}`, res.status, json);
+ return json;
+ }
+
+ return {
+ configured,
+ appId: config.media.appId,
+ createVod: (fields) => request('POST', '/vods', { body: fields }),
+ ingestRtmp: (vodId, rtmpUrl) => request('POST', `/vods/${encodeURIComponent(vodId)}/ingest/rtmp`, { body: { rtmp_url: rtmpUrl } }),
+ finalizeVod: (vodId) => request('POST', `/vods/${encodeURIComponent(vodId)}/finalize`, { timeoutMs: 30000 }),
+ deleteVod: (vodId) => request('DELETE', `/vods/${encodeURIComponent(vodId)}`),
+ };
+}
+
+module.exports = { createMediaClient, MediaError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * Two kinds of secret, handled differently:
+ *
+ * Ingest keys (what a streamer pastes into OBS) are OpenRe's own credentials. Only their SHA-256
+ * is stored; the plain key exists once, in the response that created or rotated it. 256 bits of
+ * randomness make a plain digest safe (no dictionary to try), and lookup stays one indexed read,
+ * which matters because the RTMP worker authenticates synchronously inside the publish handshake.
+ *
+ * Destination stream keys (Twitch/YouTube/Kick/custom) belong to other platforms and must be
+ * handed back to ffmpeg, so they are encrypted at rest with AES-256-GCM under OPENRE_SECRETS_KEY
+ * and never returned in full by any API.
+ */
+const crypto = require('crypto');
+
+const KEY_PREFIX = 'ork_';
+// ork_ + 43 base64url chars. Matches the RTMP path rule Live uses (/^[a-zA-Z0-9_-]{8,128}$/).
+const INGEST_KEY_RE = /^ork_[A-Za-z0-9_-]{43}$/;
+
+function newIngestKey() {
+ return KEY_PREFIX + crypto.randomBytes(32).toString('base64url');
+}
+
+function hashIngestKey(key) {
+ return crypto.createHash('sha256').update(String(key), 'utf8').digest('hex');
+}
+
+function isIngestKeyShape(key) {
+ return typeof key === 'string' && INGEST_KEY_RE.test(key);
+}
+
+/** Last four characters, the only part of any secret that is ever displayed again. */
+function hintOf(secret) {
+ const s = String(secret || '');
+ return s.length > 4 ? s.slice(-4) : '';
+}
+
+// ── AES-256-GCM box ────────────────────────────────────────────
+
+function parseKey(raw) {
+ if (!raw) return null;
+ const s = String(raw).trim();
+ let buf = null;
+ if (/^[0-9a-fA-F]{64}$/.test(s)) buf = Buffer.from(s, 'hex');
+ else {
+ try { buf = Buffer.from(s, 'base64'); } catch { buf = null; }
+ }
+ if (!buf || buf.length !== 32) throw new Error('OPENRE_SECRETS_KEY must be 32 bytes (64 hex chars or base64)');
+ return buf;
+}
+
+function keyId(buf) {
+ return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
+}
+
+/**
+ * createBox({ key, previous }) -> { seal(plain) -> 'v1.<kid>.<iv>.<tag>.<ct>', open(sealed) -> plain, ready }
+ * Without a key the box refuses to seal (destinations cannot be saved) rather than storing plain text.
+ */
+function createBox({ key, previous } = {}) {
+ const current = parseKey(key);
+ const old = parseKey(previous);
+ const keys = new Map();
+ if (current) keys.set(keyId(current), current);
+ if (old) keys.set(keyId(old), old);
+
+ function seal(plain) {
+ if (plain == null || plain === '') return null;
+ if (!current) throw Object.assign(new Error('OPENRE_SECRETS_KEY is not configured'), { code: 'openre.secrets_unavailable' });
+ const iv = crypto.randomBytes(12);
+ const cipher = crypto.createCipheriv('aes-256-gcm', current, iv);
+ const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return ['v1', keyId(current), iv.toString('base64url'), tag.toString('base64url'), ct.toString('base64url')].join('.');
+ }
+
+ function open(sealed) {
+ if (!sealed) return null;
+ const parts = String(sealed).split('.');
+ if (parts.length !== 5 || parts[0] !== 'v1') throw new Error('unrecognised sealed secret');
+ const k = keys.get(parts[1]);
+ if (!k) throw Object.assign(new Error('sealed with a key that is not configured'), { code: 'openre.secrets_unavailable' });
+ const decipher = crypto.createDecipheriv('aes-256-gcm', k, Buffer.from(parts[2], 'base64url'));
+ decipher.setAuthTag(Buffer.from(parts[3], 'base64url'));
+ return Buffer.concat([decipher.update(Buffer.from(parts[4], 'base64url')), decipher.final()]).toString('utf8');
+ }
+
+ return { seal, open, ready: Boolean(current) };
+}
+
+module.exports = { newIngestKey, hashIngestKey, isIngestKeyShape, hintOf, createBox, KEY_PREFIX };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+/**
+ * Ingest session lifecycle:
+ *
+ * starting ──► live ──► ending ──► ended
+ * │ │ │
+ * └──────────┴─────────┴──────► failed
+ *
+ * starting the worker accepted the publish handshake (key valid, lease taken)
+ * live media is flowing (RTMP postPublish)
+ * ending the publisher left or an end was requested; outputs and recording are wound down
+ * ended terminal, clean
+ * failed terminal, the transport was lost (worker died, lease expired, internal error)
+ *
+ * A session that never reached live may still go starting → ending → ended (a publisher that
+ * disconnects during the handshake); it produced no started event, so it produces no ended event.
+ */
+
+const STATES = Object.freeze(['starting', 'live', 'ending', 'ended', 'failed']);
+const TERMINAL = new Set(['ended', 'failed']);
+const NEXT = Object.freeze({
+ starting: ['live', 'ending', 'failed'],
+ live: ['ending', 'failed'],
+ ending: ['ended', 'failed'],
+ ended: [],
+ failed: [],
+});
+
+function canTransition(from, to) {
+ return Boolean(NEXT[from]) && NEXT[from].includes(to);
+}
+
+function isTerminal(state) {
+ return TERMINAL.has(state);
+}
+
+module.exports = { STATES, NEXT, TERMINAL, canTransition, isTerminal };
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -0,0 +1,263 @@
+'use strict';
+/**
+ * Stream (input) definitions and their ingest keys.
+ *
+ * A definition belongs to one owner subject (usr_… from OpenVibe.Network) and may carry typed
+ * references to other services' entities, e.g. { service: 'live', type: 'managed_stream', id: '12' }
+ * for the Live slot it serves. A reference is a pointer, never identity: ownership is the subject.
+ */
+const { ids } = require('openvibe-contracts');
+const { newId, isId } = require('../ids');
+const { newIngestKey, hashIngestKey, isIngestKeyShape, hintOf } = require('../secrets');
+const { TYPES } = require('../events');
+
+const PROTOCOLS = Object.freeze(['rtmp', 'whip', 'webrtc', 'jsmpeg']);
+const RECORDING_MODES = Object.freeze(['vod', 'clips', 'none']);
+const VISIBILITIES = Object.freeze(['public', 'unlisted', 'private']);
+const REF_SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const REF_TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
+
+class StoreError extends Error {
+ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
+}
+
+function parseJson(v, d) {
+ if (v == null) return d;
+ try { return JSON.parse(v); } catch { return d; }
+}
+
+function cleanText(v, max, d = '') {
+ if (v == null) return d;
+ return String(v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, max);
+}
+
+function validateRefs(refs) {
+ if (refs == null) return [];
+ if (!Array.isArray(refs) || refs.length > 10) throw new StoreError(400, 'openre.invalid_ref', 'external_refs must be an array of at most 10 references');
+ return refs.map((r) => {
+ const service = String(r && r.service || '');
+ const type = String(r && r.type || '');
+ const id = String(r && r.id != null ? r.id : '');
+ if (!REF_SERVICE_RE.test(service) || !REF_TYPE_RE.test(type) || !id || id.length > 128) {
+ throw new StoreError(400, 'openre.invalid_ref', 'each external ref needs service, type and id');
+ }
+ return { service, type, id, label: r.label ? cleanText(r.label, 120) : null };
+ });
+}
+
+function createDefinitions({ db, config, events, clock }) {
+ const now = () => clock.now();
+
+ const q = {
+ insert: db.prepare(`INSERT INTO stream_definitions (id, owner_subject, title, description, protocols, recording_mode,
+ recording_visibility, playback_visibility, mirror_to_live, state, revision, created_at, updated_at)
+ VALUES (@id, @owner_subject, @title, @description, @protocols, @recording_mode, @recording_visibility,
+ @playback_visibility, @mirror_to_live, 'active', 1, @now, @now)`),
+ get: db.prepare('SELECT * FROM stream_definitions WHERE id = ?'),
+ refs: db.prepare('SELECT service, type, ref_id AS id, label FROM external_refs WHERE definition_id = ? ORDER BY created_at, service, type'),
+ insertRef: db.prepare('INSERT INTO external_refs (definition_id, service, type, ref_id, label, created_at) VALUES (?, ?, ?, ?, ?, ?)'),
+ byRef: db.prepare('SELECT definition_id FROM external_refs WHERE service = ? AND type = ? AND ref_id = ?'),
+ insertKey: db.prepare(`INSERT INTO ingest_keys (id, definition_id, key_hash, hint, status, created_by, created_at)
+ VALUES (?, ?, ?, ?, 'active', ?, ?)`),
+ activeKeys: db.prepare("SELECT * FROM ingest_keys WHERE definition_id = ? AND status = 'active'"),
+ keysOf: db.prepare('SELECT id, hint, status, created_by, created_at, grace_until, revoked_at, revoked_reason, last_used_at FROM ingest_keys WHERE definition_id = ? ORDER BY created_at DESC LIMIT 50'),
+ keyByHash: db.prepare('SELECT * FROM ingest_keys WHERE key_hash = ?'),
+ touchKey: db.prepare('UPDATE ingest_keys SET last_used_at = ? WHERE id = ?'),
+ revokeKey: db.prepare("UPDATE ingest_keys SET status = 'revoked', revoked_at = ?, revoked_reason = ?, grace_until = NULL WHERE id = ? AND status != 'revoked'"),
+ graceKey: db.prepare("UPDATE ingest_keys SET status = 'grace', grace_until = ? WHERE id = ? AND status = 'active'"),
+ expiredGrace: db.prepare("SELECT id FROM ingest_keys WHERE status = 'grace' AND grace_until <= ?"),
+ };
+
+ function row(id) {
+ const d = q.get.get(id);
+ if (!d) return null;
+ return {
+ ...d,
+ protocols: parseJson(d.protocols, ['rtmp']),
+ mirror_to_live: Boolean(d.mirror_to_live),
+ external_refs: q.refs.all(id),
+ };
+ }
+
+ function get(id) {
+ return isId('stream', id) ? row(id) : null;
+ }
+
+ function list({ owner_subject, state, limit = 100 } = {}) {
+ const where = [];
+ const args = [];
+ if (owner_subject) { where.push('owner_subject = ?'); args.push(owner_subject); }
+ if (state) { where.push('state = ?'); args.push(state); } else where.push("state != 'archived'");
+ const rows = db.prepare(`SELECT id FROM stream_definitions ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+ ORDER BY created_at DESC LIMIT ?`).all(...args, Math.min(Math.max(limit, 1), 500));
+ return rows.map(r => row(r.id));
+ }
+
+ function findByRef(service, type, refId) {
+ const r = q.byRef.get(String(service), String(type), String(refId));
+ return r ? row(r.definition_id) : null;
+ }
+
+ function normalize(input, base = {}) {
+ const out = {};
+ if (input.title !== undefined || base.title === undefined) out.title = cleanText(input.title, 140, '') || base.title || 'Untitled stream';
+ if (input.description !== undefined) out.description = cleanText(input.description, 2000, '');
+ if (input.protocols !== undefined) {
+ const p = Array.isArray(input.protocols) ? [...new Set(input.protocols.map(String))] : null;
+ if (!p || !p.length || p.some(x => !PROTOCOLS.includes(x))) throw new StoreError(400, 'openre.invalid_protocols', `protocols must be a non-empty subset of ${PROTOCOLS.join(', ')}`);
+ out.protocols = JSON.stringify(p);
+ }
+ for (const [k, allowed] of [['recording_mode', RECORDING_MODES], ['recording_visibility', VISIBILITIES], ['playback_visibility', VISIBILITIES]]) {
+ if (input[k] !== undefined) {
+ if (!allowed.includes(input[k])) throw new StoreError(400, 'openre.invalid_field', `${k} must be one of ${allowed.join(', ')}`);
+ out[k] = input[k];
+ }
+ }
+ if (input.mirror_to_live !== undefined) out.mirror_to_live = input.mirror_to_live ? 1 : 0;
+ if (input.state !== undefined) {
+ if (!['active', 'disabled'].includes(input.state)) throw new StoreError(400, 'openre.invalid_field', 'state must be active or disabled');
+ out.state = input.state;
+ }
+ return out;
+ }
+
+ /** Issue a new active key (inside the caller's transaction). Returns the plain key once. */
+ function issueKey(definitionId, createdBy) {
+ const plain = newIngestKey();
+ const id = newId('key', now());
+ q.insertKey.run(id, definitionId, hashIngestKey(plain), hintOf(plain), createdBy || null, now());
+ return { id, key: plain, hint: hintOf(plain) };
+ }
+
+ /**
+ * create({ owner_subject, title, ..., external_refs, created_by }) -> { definition, key: { id, key, hint } }
+ * The first ingest key is issued with the definition; its plain value is only in this return.
+ */
+ function create(input) {
+ if (!ids.isSubjectId('user', input.owner_subject)) throw new StoreError(400, 'openre.invalid_owner', 'owner_subject must be a usr_… subject id');
+ const given = Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined && v !== null && v !== ''));
+ const fields = normalize({ protocols: ['rtmp'], recording_mode: 'vod', recording_visibility: 'public', playback_visibility: 'public', mirror_to_live: false, ...given });
+ const refs = validateRefs(input.external_refs);
+ const id = newId('stream', now());
+ return db.transaction(() => {
+ for (const r of refs) {
+ if (q.byRef.get(r.service, r.type, r.id)) throw new StoreError(409, 'openre.ref_taken', `${r.service}:${r.type}:${r.id} already belongs to another stream definition`);
+ }
+ q.insert.run({ id, owner_subject: input.owner_subject, description: '', ...fields, now: now() });
+ for (const r of refs) q.insertRef.run(id, r.service, r.type, r.id, r.label, now());
+ const key = issueKey(id, input.created_by);
+ return { definition: row(id), key };
+ })();
+ }
+
+ function update(id, input) {
+ const current = get(id);
+ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');
+ const fields = normalize(input, current);
+ const keys = Object.keys(fields);
+ if (!keys.length) return current;
+ db.prepare(`UPDATE stream_definitions SET ${keys.map(k => `${k} = @${k}`).join(', ')}, revision = revision + 1, updated_at = @now WHERE id = @id`)
+ .run({ ...fields, id, now: now() });
+ return row(id);
+ }
+
+ function archive(id) {
+ const current = get(id);
+ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');
+ const live = db.prepare("SELECT id FROM ingest_sessions WHERE definition_id = ? AND state IN ('starting', 'live', 'ending')").get(id);
+ if (live) throw new StoreError(409, 'openre.stream_live', 'end the live session before archiving this stream');
+ db.transaction(() => {
+ db.prepare("UPDATE stream_definitions SET state = 'archived', revision = revision + 1, updated_at = ? WHERE id = ?").run(now(), id);
+ for (const k of q.activeKeys.all(id)) q.revokeKey.run(now(), 'archived', k.id);
+ db.prepare("UPDATE ingest_keys SET status = 'revoked', revoked_at = ?, revoked_reason = 'archived' WHERE definition_id = ? AND status = 'grace'").run(now(), id);
+ })();
+ return row(id);
+ }
+
+ /**
+ * Rotate: issue a new key and retire every active one. grace_seconds = 0 revokes them now;
+ * > 0 lets them keep authenticating NEW publishes until then (they are revoked by the
+ * coordinator when the grace ends). Sessions already running are never cut by a rotation
+ * unless end_sessions is set (the caller then asks the owning worker to end them).
+ */
+ function rotateKey(id, { grace_seconds = 0, rotated_by = null, reason = 'rotated' } = {}) {
+ const current = get(id);
+ if (!current || current.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');
+ const grace = Math.max(0, Math.min(Number(grace_seconds) || 0, 7 * 24 * 3600));
+ return db.transaction(() => {
+ const old = q.activeKeys.all(id);
+ const graceUntil = grace ? now() + grace * 1000 : null;
+ for (const k of old) {
+ if (graceUntil) q.graceKey.run(graceUntil, k.id);
+ else q.revokeKey.run(now(), reason, k.id);
+ }
+ const key = issueKey(id, rotated_by);
+ events.enqueue({
+ event_type: TYPES.keyRotated,
+ actor: rotated_by && /^usr_/.test(rotated_by) ? { type: 'user', id: rotated_by } : { type: 'service', id: rotated_by ? String(rotated_by).replace(/^svc:/, '') : 'openre' },
+ subject: { type: 'stream', id, revision: current.revision },
+ visibility: 'internal',
+ priority: 'important',
+ payload: {
+ stream_id: id,
+ owner: { type: 'user', id: current.owner_subject },
+ key_id: key.id,
+ key_hint: key.hint,
+ retired_key_ids: old.map(k => k.id),
+ grace_until: graceUntil ? new Date(graceUntil).toISOString() : null,
+ external_refs: current.external_refs,
+ },
+ });
+ return { key, retired: old.map(k => ({ id: k.id, hint: k.hint, status: graceUntil ? 'grace' : 'revoked' })), grace_until: graceUntil };
+ })();
+ }
+
+ /** Revoke keys whose grace period is over (coordinator). */
+ function expireGraceKeys() {
+ const rows = q.expiredGrace.all(now());
+ for (const r of rows) q.revokeKey.run(now(), 'grace_ended', r.id);
+ return rows.length;
+ }
+
+ function keys(id) {
+ return q.keysOf.all(id);
+ }
+
+ /**
+ * The RTMP (or later WHIP) worker's check, synchronous by design: a publish handshake is
+ * answered inside node-media-server's prePublish handler. Only OpenRe's own hashed keys count:
+ * this never looks at any other service's database.
+ */
+ function resolveIngestKey(plain, protocol) {
+ if (!isIngestKeyShape(plain)) return { error: 'malformed_key' };
+ const key = q.keyByHash.get(hashIngestKey(plain));
+ if (!key) return { error: 'unknown_key' };
+ if (key.status === 'revoked') return { error: 'revoked_key' };
+ if (key.status === 'grace' && !(key.grace_until > now())) return { error: 'revoked_key' };
+ const definition = row(key.definition_id);
+ if (!definition || definition.state !== 'active') return { error: 'stream_disabled' };
+ if (!definition.protocols.includes(protocol)) return { error: 'protocol_not_allowed' };
+ q.touchKey.run(now(), key.id);
+ return { definition, key: { id: key.id, hint: key.hint, status: key.status } };
+ }
+
+ /** What an encoder needs: server URL and which key (by hint) is current. Never the key. */
+ function ingestEndpoints(definition) {
+ const r = config.rtmp;
+ const portSuffix = r.publicPort === 1935 ? '' : `:${r.publicPort}`;
+ const active = keys(definition.id).find(k => k.status === 'active');
+ const out = {};
+ if (definition.protocols.includes('rtmp')) {
+ out.rtmp = {
+ url: `rtmp://${r.publicHost}${portSuffix}/live`,
+ key_hint: active ? active.hint : null,
+ key_id: active ? active.id : null,
+ };
+ }
+ return out;
+ }
+
+ return { get, list, findByRef, create, update, archive, rotateKey, expireGraceKeys, keys, resolveIngestKey, ingestEndpoints, issueKey, row };
+}
+
+module.exports = { createDefinitions, StoreError, PROTOCOLS, RECORDING_MODES, VISIBILITIES, cleanText, parseJson, validateRefs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+/**
+ * One entry point for every process: open the database, the event outbox and the store modules.
+ *
+ * const rt = openRuntime({ config }); // rt.db, rt.events, rt.store.{definitions,sessions,...}
+ */
+const { openDb } = require('../db');
+const { createEvents } = require('../events');
+const { createBox } = require('../secrets');
+const { createDefinitions } = require('./definitions');
+const { createWorkers } = require('./workers');
+const { createSessions } = require('./sessions');
+const { createOutputs } = require('./outputs');
+const { createRecordings } = require('./recordings');
+
+function createStore({ db, config, events, clock, box, log = console }) {
+ const definitions = createDefinitions({ db, config, events, clock });
+ const workers = createWorkers({ db, config, clock });
+ const sessions = createSessions({ db, config, events, clock, definitions, workers });
+ const outputs = createOutputs({ db, config, events, clock, box, definitions, sessions });
+ const recordings = createRecordings({ db, config, events, clock, definitions, sessions, log });
+ return { definitions, workers, sessions, outputs, recordings };
+}
+
+function openRuntime({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, db } = {}) {
+ db = db || openDb(config.dbPath);
+ const events = createEvents({ config, db, fetchImpl, now: () => clock.now(), log });
+ const box = createBox({ key: config.secretsKey, previous: config.secretsKeyPrevious });
+ const store = createStore({ db, config, events, clock, box, log });
+ return { db, events, box, store, clock, config };
+}
+
+module.exports = { openRuntime, createStore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,350 @@
@@ -0,0 +1,350 @@
+'use strict';
+/**
+ * Restream destinations (per stream definition) and outputs (one destination running inside one
+ * session), with health, a circuit breaker and bounded logs.
+ *
+ * Isolation rule: nothing in this file changes an ingest session. An output that fails, however
+ * badly, ends in its own 'failed' row and an openre.output.failed event; the session carries on.
+ */
+const { newId, isId } = require('../ids');
+const { hintOf } = require('../secrets');
+const { TYPES } = require('../events');
+const { validateDestinationUrl } = require('../destination-url');
+const { StoreError, cleanText, parseJson } = require('./definitions');
+
+const PLATFORMS = Object.freeze(['youtube', 'twitch', 'kick', 'custom']);
+const QUALITY_PRESETS = Object.freeze(['auto', 'low', 'medium', 'high', 'ultra', 'source']);
+const ENCODER_PRESETS = Object.freeze(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow']);
+const OPEN = "('pending', 'starting', 'live', 'error')";
+
+function createOutputs({ db, config, events, clock, box, definitions, sessions }) {
+ const now = () => clock.now();
+ const q = {
+ getDest: db.prepare('SELECT * FROM destinations WHERE id = ?'),
+ destsOf: db.prepare('SELECT * FROM destinations WHERE definition_id = ? ORDER BY created_at'),
+ countOf: db.prepare('SELECT COUNT(*) AS n FROM destinations WHERE definition_id = ?'),
+ deleteDest: db.prepare('DELETE FROM destinations WHERE id = ?'),
+ getOut: db.prepare('SELECT * FROM outputs WHERE id = ?'),
+ outBySessionDest: db.prepare('SELECT * FROM outputs WHERE session_id = ? AND destination_id = ?'),
+ outsOfSession: db.prepare('SELECT * FROM outputs WHERE session_id = ? ORDER BY created_at'),
+ insertOut: db.prepare(`INSERT INTO outputs (id, session_id, destination_id, desired, state, created_at, updated_at)
+ VALUES (?, ?, ?, 'run', 'pending', ?, ?)`),
+ pendingUnassigned: db.prepare(`SELECT o.* FROM outputs o JOIN ingest_sessions s ON s.id = o.session_id
+ WHERE o.desired = 'run' AND o.state = 'pending' AND o.worker_id IS NULL AND s.state = 'live' ORDER BY o.created_at`),
+ assign: db.prepare("UPDATE outputs SET worker_id = ?, worker_generation = ?, updated_at = ? WHERE id = ? AND worker_id IS NULL AND state = 'pending'"),
+ forWorker: db.prepare(`SELECT * FROM outputs WHERE worker_id = ? AND state IN ${OPEN}`),
+ release: db.prepare(`UPDATE outputs SET worker_id = NULL, worker_generation = NULL, state = 'pending', updated_at = ?
+ WHERE worker_id = ? AND state IN ${OPEN} AND desired = 'run'`),
+ stopOrphans: db.prepare(`UPDATE outputs SET state = 'stopped', ended_at = ?, updated_at = ?
+ WHERE state IN ${OPEN} AND (desired = 'stop' AND (worker_id IS NULL OR worker_id IN (SELECT id FROM workers WHERE state IN ('stopped', 'lost'))))`),
+ insertLog: db.prepare('INSERT INTO output_logs (output_id, destination_id, level, message, at) VALUES (?, ?, ?, ?, ?)'),
+ pruneLogs: db.prepare(`DELETE FROM output_logs WHERE id IN (SELECT id FROM output_logs WHERE destination_id = ? AND
+ (output_id IS ? OR output_id = ?) ORDER BY id DESC LIMIT -1 OFFSET ?)`),
+ logsOfOutput: db.prepare('SELECT level, message, at FROM output_logs WHERE output_id = ? ORDER BY id DESC LIMIT ?'),
+ logsOfDest: db.prepare('SELECT output_id, level, message, at FROM output_logs WHERE destination_id = ? ORDER BY id DESC LIMIT ?'),
+ };
+
+ // ── Destinations ──────────────────────────────────────────
+
+ function publicDest(d) {
+ if (!d) return null;
+ return {
+ id: d.id,
+ stream_id: d.definition_id,
+ platform: d.platform,
+ name: d.name,
+ server_url: d.server_url,
+ transport: /^srt:\/\//i.test(d.server_url) ? 'srt' : 'rtmp',
+ has_stream_key: Boolean(d.stream_key_enc),
+ stream_key_hint: d.key_hint ? `****${d.key_hint}` : null,
+ has_srt_passphrase: Boolean(d.srt_passphrase_enc),
+ srt_latency_ms: d.srt_latency_ms,
+ enabled: Boolean(d.enabled),
+ auto_start: Boolean(d.auto_start),
+ quality_preset: d.quality_preset,
+ custom_video_bitrate: d.custom_video_bitrate,
+ custom_audio_bitrate: d.custom_audio_bitrate,
+ custom_fps: d.custom_fps,
+ custom_encoder_preset: d.custom_encoder_preset,
+ hold_reason: d.hold_reason,
+ consecutive_failures: d.consecutive_failures,
+ cooldown_until: d.cooldown_until ? new Date(d.cooldown_until).toISOString() : null,
+ cooldown_ms: cooldownMs(d),
+ last_error: d.last_error,
+ last_failed_at: d.last_failed_at ? new Date(d.last_failed_at).toISOString() : null,
+ created_at: new Date(d.created_at).toISOString(),
+ updated_at: new Date(d.updated_at).toISOString(),
+ };
+ }
+
+ function cooldownMs(d) {
+ return d && d.cooldown_until && d.cooldown_until > now() ? d.cooldown_until - now() : 0;
+ }
+
+ const intIn = (v, lo, hi) => {
+ if (v === null || v === '') return null;
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) && n >= lo && n <= hi ? n : null;
+ };
+
+ /** Validate and map API input to columns. Secrets are sealed here and never read back out. */
+ function destFields(input, { creating }) {
+ const f = {};
+ if (creating || input.platform !== undefined) {
+ if (!PLATFORMS.includes(input.platform)) throw new StoreError(400, 'openre.invalid_platform', `platform must be one of ${PLATFORMS.join(', ')}`);
+ f.platform = input.platform;
+ }
+ if (input.name !== undefined) f.name = cleanText(input.name, 80, '') || null;
+ if (creating || input.server_url !== undefined) {
+ const v = validateDestinationUrl(input.server_url, { allowPrivate: config.outputs.allowPrivateHosts });
+ if (!v.ok) throw new StoreError(400, 'openre.invalid_destination_url', v.error);
+ f.server_url = v.value;
+ }
+ if (input.stream_key !== undefined && input.stream_key !== '__keep__') {
+ const key = input.stream_key == null ? '' : String(input.stream_key).trim();
+ if (key.length > 512 || /[\s\u0000-\u001f\u007f]/.test(key)) throw new StoreError(400, 'openre.invalid_stream_key', 'stream key must be at most 512 characters without spaces');
+ f.stream_key_enc = key ? box.seal(key) : null;
+ f.key_hint = key ? hintOf(key) : null;
+ }
+ if (input.srt_passphrase !== undefined && input.srt_passphrase !== '__keep__') {
+ const p = input.srt_passphrase == null ? '' : String(input.srt_passphrase);
+ if (p && (p.length < 10 || p.length > 79)) throw new StoreError(400, 'openre.invalid_srt_passphrase', 'SRT passphrase must be 10–79 characters');
+ f.srt_passphrase_enc = p ? box.seal(p) : null;
+ }
+ if (input.srt_latency_ms !== undefined) f.srt_latency_ms = intIn(input.srt_latency_ms, 20, 8000);
+ if (input.enabled !== undefined) f.enabled = input.enabled ? 1 : 0;
+ if (input.auto_start !== undefined) f.auto_start = input.auto_start ? 1 : 0;
+ if (input.quality_preset !== undefined) {
+ if (!QUALITY_PRESETS.includes(input.quality_preset)) throw new StoreError(400, 'openre.invalid_field', `quality_preset must be one of ${QUALITY_PRESETS.join(', ')}`);
+ f.quality_preset = input.quality_preset;
+ }
+ if (input.custom_video_bitrate !== undefined) f.custom_video_bitrate = intIn(input.custom_video_bitrate, 500, 50000);
+ if (input.custom_audio_bitrate !== undefined) f.custom_audio_bitrate = intIn(input.custom_audio_bitrate, 32, 512);
+ if (input.custom_fps !== undefined) f.custom_fps = intIn(input.custom_fps, 15, 120);
+ if (input.custom_encoder_preset !== undefined) f.custom_encoder_preset = ENCODER_PRESETS.includes(input.custom_encoder_preset) ? input.custom_encoder_preset : null;
+ return f;
+ }
+
+ function createDestination(definitionId, input, { hold_reason = null } = {}) {
+ const def = definitions.get(definitionId);
+ if (!def || def.state === 'archived') throw new StoreError(404, 'openre.stream_not_found', 'no such stream definition');
+ if (q.countOf.get(definitionId).n >= config.outputs.maxPerStream) throw new StoreError(409, 'openre.too_many_destinations', `at most ${config.outputs.maxPerStream} destinations per stream`);
+ const f = destFields(input, { creating: true });
+ const id = newId('destination', now());
+ const row = { id, definition_id: definitionId, enabled: 1, auto_start: 1, quality_preset: 'auto', ...f, hold_reason, created_at: now(), updated_at: now() };
+ if (hold_reason) row.enabled = 0;
+ const cols = Object.keys(row);
+ db.prepare(`INSERT INTO destinations (${cols.join(', ')}) VALUES (${cols.map(c => `@${c}`).join(', ')})`).run(row);
+ return publicDest(q.getDest.get(id));
+ }
+
+ function updateDestination(id, input) {
+ const d = q.getDest.get(id);
+ if (!d) throw new StoreError(404, 'openre.destination_not_found', 'no such destination');
+ const f = destFields(input, { creating: false });
+ // Re-enabling a held destination is an explicit owner decision: the hold is lifted only
+ // when its URL now passes the rules (destFields validated any new server_url).
+ if (f.enabled === 1 && d.hold_reason) {
+ const v = validateDestinationUrl(f.server_url || d.server_url, { allowPrivate: config.outputs.allowPrivateHosts });
+ if (!v.ok) throw new StoreError(409, 'openre.destination_held', `held: ${d.hold_reason} (${v.error})`);
+ f.hold_reason = null;
+ }
+ if (!Object.keys(f).length) return publicDest(d);
+ f.updated_at = now();
+ const cols = Object.keys(f);
+ db.prepare(`UPDATE destinations SET ${cols.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id`).run({ ...f, id });
+ return publicDest(q.getDest.get(id));
+ }
+
+ function deleteDestination(id) {
+ const d = q.getDest.get(id);
+ if (!d) throw new StoreError(404, 'openre.destination_not_found', 'no such destination');
+ db.transaction(() => {
+ db.prepare(`UPDATE outputs SET desired = 'stop', updated_at = ? WHERE destination_id = ? AND state IN ${OPEN}`).run(now(), id);
+ const running = db.prepare(`SELECT 1 FROM outputs WHERE destination_id = ? AND state IN ${OPEN} AND worker_id IS NOT NULL`).get(id);
+ if (running) throw new StoreError(409, 'openre.destination_running', 'the destination is running; it has been asked to stop, delete it again in a few seconds');
+ q.deleteDest.run(id);
+ })();
+ return true;
+ }
+
+ /** Destination row with its secrets opened, for the restream worker only. */
+ function destinationForWorker(id) {
+ const d = q.getDest.get(id);
+ if (!d) return null;
+ return { ...d, stream_key: box.open(d.stream_key_enc), srt_passphrase: box.open(d.srt_passphrase_enc) };
+ }
+
+ function markDestinationFailure(id, error) {
+ const d = q.getDest.get(id);
+ if (!d) return null;
+ const n = (d.consecutive_failures || 0) + 1;
+ // Same escalation as Live: 15 min, 1 h, 6 h, then 24 h.
+ const mins = n <= 1 ? 15 : n === 2 ? 60 : n === 3 ? 360 : 1440;
+ db.prepare('UPDATE destinations SET consecutive_failures = ?, last_error = ?, last_failed_at = ?, cooldown_until = ?, updated_at = ? WHERE id = ?')
+ .run(n, String(error || 'restream failed to go live').slice(0, 300), now(), now() + mins * 60000, now(), id);
+ return { failures: n, cooldownMinutes: mins };
+ }
+
+ function clearDestinationCooldown(id) {
+ db.prepare('UPDATE destinations SET consecutive_failures = 0, cooldown_until = NULL, last_error = NULL WHERE id = ? AND (consecutive_failures > 0 OR cooldown_until IS NOT NULL OR last_error IS NOT NULL)').run(id);
+ }
+
+ // ── Outputs ───────────────────────────────────────────────
+
+ function publicOutput(o) {
+ if (!o) return null;
+ return {
+ id: o.id,
+ session_id: o.session_id,
+ destination_id: o.destination_id,
+ desired: o.desired,
+ state: o.state,
+ worker: o.worker_id ? { id: o.worker_id, generation: o.worker_generation } : null,
+ restart_attempts: o.restart_attempts,
+ max_restart_attempts: config.outputs.maxRestarts,
+ next_restart_at: o.next_restart_at ? new Date(o.next_restart_at).toISOString() : null,
+ ever_live: Boolean(o.ever_live),
+ last_error: o.last_error,
+ progress: parseJson(o.progress, null),
+ started_at: o.started_at ? new Date(o.started_at).toISOString() : null,
+ live_at: o.live_at ? new Date(o.live_at).toISOString() : null,
+ uptime_ms: o.state === 'live' && o.live_at ? now() - o.live_at : 0,
+ ended_at: o.ended_at ? new Date(o.ended_at).toISOString() : null,
+ updated_at: new Date(o.updated_at).toISOString(),
+ };
+ }
+
+ /** Coordinator: every live session gets an output per enabled, auto-start, usable destination. */
+ function ensureAutoOutputs() {
+ const rows = db.prepare(`SELECT s.id AS session_id, d.id AS destination_id FROM ingest_sessions s
+ JOIN destinations d ON d.definition_id = s.definition_id
+ LEFT JOIN outputs o ON o.session_id = s.id AND o.destination_id = d.id
+ WHERE s.state = 'live' AND d.enabled = 1 AND d.auto_start = 1 AND d.hold_reason IS NULL
+ AND d.stream_key_enc IS NOT NULL AND (d.cooldown_until IS NULL OR d.cooldown_until <= ?) AND o.id IS NULL`).all(now());
+ for (const r of rows) q.insertOut.run(newId('output', now()), r.session_id, r.destination_id, now(), now());
+ return rows.length;
+ }
+
+ /**
+ * Manual start (the owner pressed Start): creates or re-arms the output for the destination's
+ * current live session. A manual start ignores a failure cooldown, like Live's.
+ */
+ function startDestination(destinationId) {
+ const d = q.getDest.get(destinationId);
+ if (!d) throw new StoreError(404, 'openre.destination_not_found', 'no such destination');
+ if (d.hold_reason) throw new StoreError(409, 'openre.destination_held', `held: ${d.hold_reason}`);
+ if (!d.enabled) throw new StoreError(409, 'openre.destination_disabled', 'enable the destination first');
+ if (!d.stream_key_enc) throw new StoreError(409, 'openre.destination_no_key', 'the destination has no stream key');
+ const session = sessions.list({ definition_id: d.definition_id, state: 'live', limit: 1 })[0];
+ if (!session) throw new StoreError(409, 'openre.not_live', 'the stream is not live');
+ return db.transaction(() => {
+ clearDestinationCooldown(d.id);
+ const existing = q.outBySessionDest.get(session.id, d.id);
+ if (!existing) {
+ const id = newId('output', now());
+ q.insertOut.run(id, session.id, d.id, now(), now());
+ return publicOutput(q.getOut.get(id));
+ }
+ if (['pending', 'starting', 'live', 'error'].includes(existing.state) && existing.desired === 'run') return publicOutput(existing);
+ if (['pending', 'starting', 'live', 'error'].includes(existing.state)) {
+ db.prepare("UPDATE outputs SET desired = 'run', updated_at = ? WHERE id = ?").run(now(), existing.id);
+ } else {
+ db.prepare(`UPDATE outputs SET desired = 'run', state = 'pending', worker_id = NULL, worker_generation = NULL,
+ restart_attempts = 0, next_restart_at = NULL, last_error = NULL, ended_at = NULL, updated_at = ? WHERE id = ?`).run(now(), existing.id);
+ }
+ return publicOutput(q.getOut.get(existing.id));
+ })();
+ }
+
+ function stopDestination(destinationId) {
+ const n = db.prepare(`UPDATE outputs SET desired = 'stop', updated_at = ? WHERE destination_id = ? AND desired = 'run' AND state IN ${OPEN}`).run(now(), destinationId).changes;
+ // Pending outputs nobody has picked up can stop right here.
+ db.prepare("UPDATE outputs SET state = 'stopped', ended_at = ?, updated_at = ? WHERE destination_id = ? AND desired = 'stop' AND state = 'pending' AND worker_id IS NULL").run(now(), now(), destinationId);
+ return n;
+ }
+
+ /** Coordinator: hand pending outputs to the newest ready restream generation. */
+ function assignPending(worker) {
+ let n = 0;
+ for (const o of q.pendingUnassigned.all()) n += q.assign.run(worker.id, worker.generation, now(), o.id).changes;
+ return n;
+ }
+
+ /** State changes reported by the restream worker, with the events they imply. */
+ function report(outputId, change) {
+ return db.transaction(() => {
+ const o = q.getOut.get(outputId);
+ if (!o) return null;
+ const set = { updated_at: now() };
+ for (const k of ['state', 'last_error', 'restart_attempts', 'next_restart_at', 'started_at', 'live_at', 'ended_at']) {
+ if (change[k] !== undefined) set[k] = change[k];
+ }
+ if (change.progress !== undefined) set.progress = change.progress ? JSON.stringify(change.progress) : null;
+ if (change.state === 'live') set.ever_live = 1;
+ const cols = Object.keys(set);
+ db.prepare(`UPDATE outputs SET ${cols.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id`).run({ ...set, id: outputId });
+ const after = q.getOut.get(outputId);
+ if (change.state && change.state !== o.state && (change.state === 'live' || change.state === 'failed')) {
+ const dest = q.getDest.get(o.destination_id);
+ const session = sessions.get(o.session_id);
+ if (dest && session) {
+ events.enqueue({
+ event_type: change.state === 'live' ? TYPES.outputHealthy : TYPES.outputFailed,
+ actor: { type: 'service', id: 'openre' },
+ subject: { type: 'output', id: o.id, revision: after.restart_attempts + 1 },
+ visibility: 'internal',
+ priority: change.state === 'failed' ? 'important' : 'low',
+ payload: {
+ output_id: o.id,
+ session_id: o.session_id,
+ stream_id: session.definition_id,
+ destination: { id: dest.id, platform: dest.platform, name: dest.name },
+ state: change.state,
+ error: change.state === 'failed' ? after.last_error : null,
+ cooldown_minutes: change.cooldown_minutes || null,
+ },
+ });
+ }
+ }
+ return after;
+ })();
+ }
+
+ function log(outputId, destinationId, level, message) {
+ const text = String(message || '').slice(0, 500);
+ q.insertLog.run(outputId || null, destinationId, level, text, now());
+ q.pruneLogs.run(destinationId, outputId || null, outputId || null, config.outputs.logsPerOutput);
+ }
+
+ return {
+ PLATFORMS, QUALITY_PRESETS, ENCODER_PRESETS,
+ publicDest,
+ publicOutput,
+ getDestination: (id) => (isId('destination', id) ? publicDest(q.getDest.get(id)) : null),
+ destinationRow: (id) => q.getDest.get(id),
+ destinations: (definitionId) => q.destsOf.all(definitionId).map(publicDest),
+ createDestination,
+ updateDestination,
+ deleteDestination,
+ destinationForWorker,
+ markDestinationFailure,
+ clearDestinationCooldown,
+ getOutput: (id) => (isId('output', id) ? publicOutput(q.getOut.get(id)) : null),
+ outputRow: (id) => q.getOut.get(id),
+ outputsOfSession: (sessionId) => q.outsOfSession.all(sessionId).map(publicOutput),
+ ensureAutoOutputs,
+ startDestination,
+ stopDestination,
+ assignPending,
+ forWorker: (workerId) => q.forWorker.all(workerId),
+ release: (workerId) => q.release.run(now(), workerId).changes,
+ stopOrphans: () => q.stopOrphans.run(now(), now()).changes,
+ report,
+ log,
+ logsOfOutput: (id, limit = 100) => q.logsOfOutput.all(id, limit),
+ logsOfDestination: (id, limit = 100) => q.logsOfDest.all(id, limit),
+ };
+}
+
+module.exports = { createOutputs, PLATFORMS, QUALITY_PRESETS, ENCODER_PRESETS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+/**
+ * Recording requests to OpenVibe.Media (plan §15.10: "recordings are independent Media jobs").
+ *
+ * pending ─► requested ─► recording ─► finalizing ─► finalized
+ * │ │ │ │
+ * └───────────┴────────────┴─────────────┴──► failed (retries exhausted)
+ * pending ─► cancelled (the session ended before Media was asked)
+ *
+ * Driven by the session coordinator, never by a transport worker or the API: a worker only has to
+ * keep the loopback play URL up, and Media's ffmpeg pulls from it. Media finalises.
+ */
+const { newId } = require('../ids');
+const { TYPES } = require('../events');
+const { isTerminal } = require('../state-machine');
+
+const BACKOFF_MS = [5000, 15000, 30000, 60000, 120000, 300000];
+const MAX_ATTEMPTS = 10;
+// Media refuses a recording while its disk is critically low and frees space within minutes;
+// keep asking while the stream is live (Live's recorder does the same: 12 × 5 min).
+const DISK_RETRY_MS = 5 * 60 * 1000;
+const DISK_MAX_ATTEMPTS = 12;
+
+function createRecordings({ db, config, events, clock, definitions, sessions, log = console }) {
+ const now = () => clock.now();
+ const q = {
+ get: db.prepare('SELECT * FROM recordings WHERE id = ?'),
+ bySession: db.prepare('SELECT * FROM recordings WHERE session_id = ?'),
+ missing: db.prepare(`SELECT s.id AS session_id, d.recording_mode AS mode FROM ingest_sessions s
+ JOIN stream_definitions d ON d.id = s.definition_id LEFT JOIN recordings r ON r.session_id = s.id
+ WHERE s.state = 'live' AND d.recording_mode IN ('vod', 'clips') AND s.live_at <= ? AND r.id IS NULL`),
+ insert: db.prepare(`INSERT INTO recordings (id, session_id, mode, state, media_app, created_at, updated_at)
+ VALUES (?, ?, ?, 'pending', ?, ?, ?)`),
+ due: db.prepare(`SELECT r.* FROM recordings r JOIN ingest_sessions s ON s.id = r.session_id
+ WHERE r.next_attempt_at <= ? AND (r.state IN ('pending', 'requested', 'finalizing')
+ OR (r.state = 'recording' AND s.state IN ('ending', 'ended', 'failed')))
+ ORDER BY r.created_at LIMIT 20`),
+ set: (cols) => db.prepare(`UPDATE recordings SET ${cols.map(c => `${c} = @${c}`).join(', ')}, updated_at = @now WHERE id = @id`),
+ };
+
+ function update(id, fields) {
+ q.set(Object.keys(fields)).run({ ...fields, id, now: now() });
+ return q.get.get(id);
+ }
+
+ function ensureRequests() {
+ const rows = q.missing.all(now() - config.media.startDelayMs);
+ for (const r of rows) q.insert.run(newId('recording', now()), r.session_id, r.mode, config.media.appId, now(), now());
+ return rows.length;
+ }
+
+ function retryLater(rec, err, { disk = false } = {}) {
+ const attempts = rec.attempts + 1;
+ const limit = disk ? DISK_MAX_ATTEMPTS : MAX_ATTEMPTS;
+ if (attempts >= limit) return update(rec.id, { attempts, state: 'failed', last_error: String(err.message || err).slice(0, 300) });
+ const wait = disk ? DISK_RETRY_MS : BACKOFF_MS[Math.min(attempts - 1, BACKOFF_MS.length - 1)];
+ return update(rec.id, { attempts, next_attempt_at: now() + wait, last_error: String(err.message || err).slice(0, 300) });
+ }
+
+ function refOf(definition, service, type) {
+ const r = definition.external_refs.find(x => x.service === service && x.type === type);
+ return r ? r.id : undefined;
+ }
+
+ async function step(rec, media) {
+ const session = sessions.get(rec.session_id);
+ if (!session) return update(rec.id, { state: 'failed', last_error: 'session missing' });
+ const definition = definitions.row(session.definition_id);
+ const ended = isTerminal(session.state) || session.state === 'ending';
+
+ if (rec.state === 'pending') {
+ if (ended) return update(rec.id, { state: 'cancelled' });
+ const pb = sessions.playback(session);
+ if (!pb || !pb.rtmp) return retryLater(rec, new Error('no loopback play URL for this session yet'));
+ let vodId = rec.media_vod_id;
+ try {
+ if (!vodId) {
+ // Media's 'live' tenant files VODs under Live-local ids; they travel as typed
+ // references on the definition (compatibility only, never identity).
+ const liveUser = refOf(definition, 'live', 'user');
+ const liveSlot = refOf(definition, 'live', 'managed_stream');
+ const out = await media.createVod({
+ title: definition.title || 'Stream Recording',
+ user_id: liveUser != null ? Number(liveUser) : undefined,
+ managed_stream_id: liveSlot != null ? Number(liveSlot) : undefined,
+ clips_only: rec.mode === 'clips' || undefined,
+ visibility: definition.recording_visibility,
+ meta: { source: 'openre', openre_session_id: session.id, openre_stream_id: definition.id, protocol: session.protocol, mode: rec.mode },
+ });
+ vodId = String(out && out.id);
+ rec = update(rec.id, { media_vod_id: vodId, state: 'requested' });
+ }
+ await media.ingestRtmp(vodId, pb.rtmp.internal_url);
+ } catch (err) {
+ log.warn(`[recording] ${rec.id} request failed: ${err.message}`);
+ // Media may have made the VOD row before the ingest was refused (disk low): drop the
+ // empty shell so it never shows as a 0:00 VOD, and start over next time.
+ if (vodId) await media.deleteVod(vodId).catch(() => {});
+ update(rec.id, { media_vod_id: null, state: 'pending' });
+ return retryLater(q.get.get(rec.id), err, { disk: /disk/i.test(String(err.message)) });
+ }
+ return db.transaction(() => {
+ const r = update(rec.id, { state: 'recording', attempts: 0, last_error: null, next_attempt_at: 0 });
+ events.enqueue({
+ event_type: TYPES.recordingRequested,
+ actor: { type: 'service', id: 'openre' },
+ subject: { type: 'recording', id: rec.id, revision: 1 },
+ visibility: 'internal',
+ priority: 'important',
+ payload: {
+ recording_id: rec.id,
+ session_id: session.id,
+ stream_id: definition.id,
+ owner: { type: 'user', id: definition.owner_subject },
+ mode: rec.mode,
+ media: { app: config.media.appId, vod_id: r.media_vod_id },
+ external_refs: definition.external_refs,
+ },
+ });
+ return r;
+ })();
+ }
+
+ if (rec.state === 'requested') {
+ // A create that succeeded and an ingest that did not: handled above; a requested row
+ // here means the process died in between. Start the ingest again.
+ return update(rec.id, { state: 'pending' });
+ }
+
+ if (rec.state === 'recording' && ended) rec = update(rec.id, { state: 'finalizing' });
+ if (rec.state === 'finalizing') {
+ try {
+ await media.finalizeVod(rec.media_vod_id);
+ if (rec.mode === 'clips') await media.deleteVod(rec.media_vod_id).catch(() => {});
+ } catch (err) {
+ // 409 = Media already finalising it (its ffmpeg saw the source end): that is success.
+ if (err.status !== 409) return retryLater(rec, err);
+ }
+ return update(rec.id, { state: 'finalized', last_error: null });
+ }
+ return rec;
+ }
+
+ /** One pass over due requests (coordinator). */
+ async function process(media) {
+ if (!media.configured) return 0;
+ let n = 0;
+ for (const rec of q.due.all(now())) {
+ try { await step(rec, media); n++; } catch (err) { log.error(`[recording] ${rec.id}: ${err.stack || err}`); }
+ }
+ return n;
+ }
+
+ return {
+ ensureRequests,
+ process,
+ bySession: (sessionId) => q.bySession.get(sessionId) || null,
+ };
+}
+
+module.exports = { createRecordings };
new file mode 100644
new file mode 100644
@@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
+'use strict';
+/**
+ * Ingest sessions: admission, the state machine, leases and the playback descriptor.
+ *
+ * Every transition is one SQLite transaction that also writes the session_transitions audit row
+ * and, where the transition is externally visible, the outbox event (so the event exists if and
+ * only if the change committed). Workers and the coordinator can race on the same session (a
+ * publisher leaving while its lease is being expired); whoever commits first wins and the other
+ * gets { ok: false, code: 'invalid_transition' } without side effects.
+ */
+const { newId, isId } = require('../ids');
+const { canTransition, isTerminal } = require('../state-machine');
+const { TYPES } = require('../events');
+const { parseJson } = require('./definitions');
+
+const OPEN = "('starting', 'live', 'ending')";
+
+function iso(ms) { return ms ? new Date(ms).toISOString() : null; }
+
+function createSessions({ db, config, events, clock, definitions, workers }) {
+ const now = () => clock.now();
+ const q = {
+ get: db.prepare('SELECT * FROM ingest_sessions WHERE id = ?'),
+ openForDefinition: db.prepare(`SELECT * FROM ingest_sessions WHERE definition_id = ? AND state IN ${OPEN} ORDER BY created_at DESC`),
+ insert: db.prepare(`INSERT INTO ingest_sessions (id, definition_id, key_id, protocol, state, worker_id, worker_kind, worker_generation,
+ lease_expires_at, created_at, updated_at) VALUES (@id, @definition_id, @key_id, @protocol, 'starting', @worker_id, @worker_kind,
+ @worker_generation, @lease, @now, @now)`),
+ transitionRow: db.prepare('INSERT INTO session_transitions (session_id, from_state, to_state, reason, actor, at) VALUES (?, ?, ?, ?, ?, ?)'),
+ transitions: db.prepare('SELECT from_state, to_state, reason, actor, at FROM session_transitions WHERE session_id = ? ORDER BY id'),
+ mediaInfo: db.prepare(`UPDATE ingest_sessions SET media_info = ? WHERE id = ? AND state IN ${OPEN}`),
+ requestEnd: db.prepare(`UPDATE ingest_sessions SET desired_state = 'end', end_requested_by = ?, updated_at = ? WHERE id = ? AND state IN ${OPEN}`),
+ endRequests: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND desired_state = 'end' AND state IN ${OPEN}`),
+ ofWorker: db.prepare(`SELECT * FROM ingest_sessions WHERE worker_id = ? AND state IN ${OPEN}`),
+ expiredLeases: db.prepare(`SELECT * FROM ingest_sessions WHERE state IN ${OPEN} AND lease_expires_at < ?`),
+ live: db.prepare("SELECT * FROM ingest_sessions WHERE state = 'live' ORDER BY live_at"),
+ };
+
+ function shape(s) {
+ if (!s) return null;
+ return { ...s, media_info: parseJson(s.media_info, null) };
+ }
+
+ function get(id) {
+ return isId('session', id) ? shape(q.get.get(id)) : null;
+ }
+
+ function list({ definition_id, owner_subject, state, limit = 50, before } = {}) {
+ const where = [];
+ const args = [];
+ if (definition_id) { where.push('s.definition_id = ?'); args.push(definition_id); }
+ if (owner_subject) { where.push('d.owner_subject = ?'); args.push(owner_subject); }
+ if (state === 'open') where.push(`s.state IN ${OPEN}`);
+ else if (state) { where.push('s.state = ?'); args.push(state); }
+ if (before) { where.push('s.created_at < ?'); args.push(Number(before)); }
+ return db.prepare(`SELECT s.* FROM ingest_sessions s JOIN stream_definitions d ON d.id = s.definition_id
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY s.created_at DESC LIMIT ?`)
+ .all(...args, Math.min(Math.max(Number(limit) || 50, 1), 200)).map(shape);
+ }
+
+ function envelopeFor(type, session, definition, extra = {}) {
+ const worker = session.worker_id ? workers.get(session.worker_id) : null;
+ const payload = {
+ session_id: session.id,
+ stream_id: definition.id,
+ title: definition.title,
+ owner: { type: 'user', id: definition.owner_subject },
+ protocol: session.protocol,
+ state: session.state,
+ started_at: iso(session.live_at),
+ worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation } : null,
+ external_refs: definition.external_refs,
+ mirror_to_live: definition.mirror_to_live,
+ ...extra,
+ };
+ return {
+ event_type: type,
+ actor: { type: 'user', id: definition.owner_subject },
+ subject: { type: 'ingest_session', id: session.id, revision: session.revision },
+ visibility: 'internal',
+ priority: 'important',
+ payload,
+ };
+ }
+
+ /**
+ * Admit a publish on `worker` (synchronous; called inside the RTMP handshake).
+ * One open session per definition: a second publisher with a valid key is refused while the
+ * first is alive, exactly like Live's in-process ingest. A leftover open session whose lease
+ * has expired (its worker died) is failed first, so a crashed worker never locks a stream out.
+ */
+ function admit({ definition, key, protocol, worker }) {
+ return db.transaction(() => {
+ for (const open of q.openForDefinition.all(definition.id)) {
+ const leaseOk = open.lease_expires_at && open.lease_expires_at > now();
+ if (leaseOk) return { error: 'duplicate_publisher', existing: open.id };
+ applyTransition(open, 'failed', { reason: 'lease_expired', actor: `worker:${worker.id}` });
+ }
+ const id = newId('session', now());
+ q.insert.run({
+ id, definition_id: definition.id, key_id: key ? key.id : null, protocol,
+ worker_id: worker.id, worker_kind: worker.kind, worker_generation: worker.generation,
+ lease: now() + config.workers.leaseMs, now: now(),
+ });
+ q.transitionRow.run(id, null, 'starting', 'publish_accepted', `worker:${worker.id}`, now());
+ return { session: shape(q.get.get(id)) };
+ }).immediate();
+ }
+
+ // Inside a transaction.
+ function applyTransition(current, to, { reason = null, actor = null, media_info } = {}) {
+ if (!canTransition(current.state, to)) return { ok: false, code: 'invalid_transition', session: shape(current) };
+ const t = now();
+ const set = { state: to, updated_at: t, revision: current.revision + 1 };
+ if (to === 'live') set.live_at = t;
+ if (to === 'ending') { set.ending_at = t; set.end_reason = reason; }
+ if (to === 'ended') { set.ended_at = t; if (!current.end_reason) set.end_reason = reason; }
+ if (to === 'failed') { set.ended_at = t; set.failure_reason = reason; }
+ if (media_info !== undefined) set.media_info = media_info ? JSON.stringify(media_info) : null;
+ if (isTerminal(to)) set.lease_expires_at = null;
+ const cols = Object.keys(set);
+ db.prepare(`UPDATE ingest_sessions SET ${cols.map(c => `${c} = @${c}`).join(', ')} WHERE id = @id AND state = @from`)
+ .run({ ...set, id: current.id, from: current.state });
+ q.transitionRow.run(current.id, current.state, to, reason, actor, t);
+ const session = shape(q.get.get(current.id));
+ const definition = definitions.row(session.definition_id);
+ if (to === 'live') {
+ events.enqueue(envelopeFor(TYPES.sessionStarted, session, definition, { playback: playback(session) }));
+ } else if (to === 'ended' && session.live_at) {
+ events.enqueue(envelopeFor(TYPES.sessionEnded, session, definition, {
+ ended_at: iso(session.ended_at),
+ duration_seconds: Math.round((session.ended_at - session.live_at) / 1000),
+ end_reason: session.end_reason,
+ }));
+ } else if (to === 'failed') {
+ events.enqueue(envelopeFor(TYPES.sessionFailed, session, definition, {
+ ended_at: iso(session.ended_at),
+ was_live: Boolean(session.live_at),
+ duration_seconds: session.live_at ? Math.round((session.ended_at - session.live_at) / 1000) : 0,
+ failure_reason: reason,
+ }));
+ }
+ if (isTerminal(to) || to === 'ending') {
+ // Outputs follow their session down; they never pull the session with them.
+ db.prepare("UPDATE outputs SET desired = 'stop', updated_at = ? WHERE session_id = ? AND desired = 'run'").run(t, session.id);
+ }
+ return { ok: true, session };
+ }
+
+ /** transition(id, to, { reason, actor, media_info }) -> { ok, session } | { ok: false, code } */
+ function transition(id, to, opts = {}) {
+ return db.transaction(() => {
+ const current = q.get.get(id);
+ if (!current) return { ok: false, code: 'not_found' };
+ return applyTransition(current, to, opts);
+ }).immediate();
+ }
+
+ /** ending → ended in one call (the common clean stop). */
+ function finish(id, { reason, actor } = {}) {
+ return db.transaction(() => {
+ let current = q.get.get(id);
+ if (!current) return { ok: false, code: 'not_found' };
+ if (current.state === 'starting' || current.state === 'live') {
+ const r = applyTransition(current, 'ending', { reason, actor });
+ if (!r.ok) return r;
+ current = q.get.get(id);
+ }
+ return applyTransition(current, 'ended', { reason, actor });
+ }).immediate();
+ }
+
+ function requestEnd(id, by) {
+ return q.requestEnd.run(String(by || 'unknown'), now(), id).changes > 0;
+ }
+
+ /**
+ * Where to watch a session. Internal URLs are loopback addresses on the OpenRe host (Live and
+ * Media run there too); public_url is served by openre-api's /play proxy.
+ */
+ function playback(session) {
+ if (!session) return null;
+ const worker = session.worker_id ? workers.get(session.worker_id) : null;
+ const ep = (worker && worker.endpoints) || {};
+ const descriptor = {
+ session_id: session.id,
+ protocol: session.protocol,
+ state: session.state,
+ live: session.state === 'live',
+ worker: worker ? { id: worker.id, kind: worker.kind, generation: worker.generation, state: worker.state } : null,
+ flv: null,
+ rtmp: null,
+ webrtc: null,
+ hls: null,
+ };
+ if (session.protocol === 'rtmp' && ep.flvPort) {
+ descriptor.flv = {
+ internal_url: `http://127.0.0.1:${ep.flvPort}/live/${session.id}.flv`,
+ public_url: `${config.baseUrl}/play/${session.id}.flv`,
+ content_type: 'video/x-flv',
+ };
+ }
+ if (session.protocol === 'rtmp' && ep.rtmpPlayPort) {
+ descriptor.rtmp = { internal_url: `rtmp://127.0.0.1:${ep.rtmpPlayPort}/live/${session.id}` };
+ }
+ return descriptor;
+ }
+
+ return {
+ get,
+ list,
+ admit,
+ transition,
+ finish,
+ requestEnd,
+ playback,
+ setMediaInfo: (id, info) => q.mediaInfo.run(JSON.stringify(info), id).changes > 0,
+ transitions: (id) => q.transitions.all(id),
+ endRequestsFor: (workerId) => q.endRequests.all(workerId).map(shape),
+ ofWorker: (workerId) => q.ofWorker.all(workerId).map(shape),
+ expiredLeases: () => q.expiredLeases.all(now()).map(shape),
+ live: () => q.live.all().map(shape),
+ openFor: (definitionId) => q.openForDefinition.all(definitionId).map(shape),
+ };
+}
+
+module.exports = { createSessions };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * Transport worker registry: which process of which kind and generation is alive, and which
+ * generation takes new sessions.
+ *
+ * register() a worker process starts: it gets the next generation number for its kind
+ * ready() it can take new sessions; the coordinator then drains every older generation
+ * heartbeat() every few seconds; the same write renews the lease of every session it owns
+ * draining set by the coordinator: take no new sessions, keep the running ones until they
+ * end or the drain deadline passes
+ * stopped the worker exited cleanly (idle after a drain, or never had a session)
+ * lost no heartbeat within the lease: the coordinator fails its sessions
+ *
+ * Generation numbers are per kind and only ever grow, so "the newest ready generation" is one
+ * indexed read and two releases can run side by side without guessing which is newer.
+ */
+const os = require('os');
+const { newId } = require('../ids');
+const { parseJson } = require('./definitions');
+
+const KINDS = Object.freeze(['rtmp-ingest', 'restream', 'webrtc-ingest', 'sfu', 'jsmpeg']);
+const ALIVE = "('starting', 'ready', 'draining')";
+
+function createWorkers({ db, config, clock }) {
+ const now = () => clock.now();
+ const q = {
+ maxGen: db.prepare('SELECT COALESCE(MAX(generation), 0) AS g FROM workers WHERE kind = ?'),
+ insert: db.prepare(`INSERT INTO workers (id, kind, generation, release, pid, host, state, endpoints, started_at, heartbeat_at)
+ VALUES (@id, @kind, @generation, @release, @pid, @host, 'starting', @endpoints, @now, @now)`),
+ get: db.prepare('SELECT * FROM workers WHERE id = ?'),
+ heartbeat: db.prepare(`UPDATE workers SET heartbeat_at = ? WHERE id = ? AND state IN ${ALIVE}`),
+ renewLeases: db.prepare("UPDATE ingest_sessions SET lease_expires_at = ? WHERE worker_id = ? AND state IN ('starting', 'live', 'ending')"),
+ setReady: db.prepare("UPDATE workers SET state = 'ready', ready_at = ?, heartbeat_at = ? WHERE id = ? AND state = 'starting'"),
+ setEndpoints: db.prepare('UPDATE workers SET endpoints = ? WHERE id = ?'),
+ drain: db.prepare(`UPDATE workers SET state = 'draining', drain_started_at = ?, drain_deadline = ? WHERE id = ? AND state IN ('starting', 'ready')`),
+ stop: db.prepare(`UPDATE workers SET state = 'stopped', stopped_at = ?, stop_reason = ? WHERE id = ? AND state IN ${ALIVE}`),
+ lose: db.prepare(`UPDATE workers SET state = 'lost', stopped_at = ?, stop_reason = ? WHERE id = ? AND state IN ${ALIVE}`),
+ newestReady: db.prepare("SELECT * FROM workers WHERE kind = ? AND state = 'ready' ORDER BY generation DESC LIMIT 1"),
+ stale: db.prepare(`SELECT * FROM workers WHERE state IN ${ALIVE} AND heartbeat_at < ?`),
+ olderReady: db.prepare("SELECT * FROM workers WHERE kind = ? AND state IN ('starting', 'ready') AND generation < ?"),
+ alive: db.prepare(`SELECT * FROM workers WHERE state IN ${ALIVE} ORDER BY kind, generation`),
+ recent: db.prepare('SELECT * FROM workers ORDER BY started_at DESC LIMIT ?'),
+ };
+
+ const shape = (w) => (w ? { ...w, endpoints: parseJson(w.endpoints, {}) } : null);
+
+ function register({ kind, release = config.release, pid = process.pid, host = os.hostname(), endpoints = {} }) {
+ if (!KINDS.includes(kind)) throw new Error(`unknown worker kind ${kind}`);
+ const id = newId('worker', now());
+ db.transaction(() => {
+ const generation = q.maxGen.get(kind).g + 1;
+ q.insert.run({ id, kind, generation, release, pid, host, endpoints: JSON.stringify(endpoints), now: now() });
+ }).immediate();
+ return shape(q.get.get(id));
+ }
+
+ return {
+ KINDS,
+ register,
+ get: (id) => shape(q.get.get(id)),
+ ready: (id) => q.setReady.run(now(), now(), id).changes > 0,
+ setEndpoints: (id, endpoints) => q.setEndpoints.run(JSON.stringify(endpoints), id),
+ /** Heartbeat + renew the leases of every session this worker owns, in one transaction. */
+ heartbeat(id) {
+ return db.transaction(() => {
+ const alive = q.heartbeat.run(now(), id).changes > 0;
+ if (alive) q.renewLeases.run(now() + config.workers.leaseMs, id);
+ return shape(q.get.get(id));
+ })();
+ },
+ drain: (id, deadline) => q.drain.run(now(), deadline, id).changes > 0,
+ stop: (id, reason) => q.stop.run(now(), reason || 'exit', id).changes > 0,
+ lose: (id, reason) => q.lose.run(now(), reason || 'lease_expired', id).changes > 0,
+ newestReady: (kind) => shape(q.newestReady.get(kind)),
+ stale: (before) => q.stale.all(before).map(shape),
+ olderReady: (kind, generation) => q.olderReady.all(kind, generation).map(shape),
+ alive: () => q.alive.all().map(shape),
+ recent: (limit = 50) => q.recent.all(limit).map(shape),
+ };
+}
+
+module.exports = { createWorkers, KINDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Page shell for openre.stream: server-rendered HTML that works without JavaScript, with the shared
+ * OpenVibe chrome (theme loader, navbar and footer from openvibe.network, footer SSR from the
+ * pinned openvibe-shared release) the way OpenVibe.Community renders it.
+ */
+const NETWORK_URL = 'https://openvibe.network';
+const SITE = 'OpenRe.Stream';
+
+function esc(v) {
+ return String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+const CSS = `
+:root{--ore-bg:#0d131d;--ore-panel:#141c2b;--ore-line:#233049;--ore-text:#e6ecf5;--ore-muted:#93a2bd;--ore-accent:#3b82f6;--ore-ok:#22c55e;--ore-warn:#f59e0b;--ore-bad:#ef4444}
+body{margin:0;background:var(--ore-bg);color:var(--ore-text);font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
+main.ore{max-width:1040px;margin:0 auto;padding:24px 16px 64px}
+.ore h1{font-size:1.6rem;margin:.2em 0 .4em}.ore h2{font-size:1.15rem;margin:1.6em 0 .6em}.ore h3{font-size:1rem;margin:1em 0 .4em}
+.ore a{color:#93c5fd}.ore p.muted,.ore .muted{color:var(--ore-muted)}
+.ore .card{background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:10px;padding:14px 16px;margin:12px 0}
+.ore table{width:100%;border-collapse:collapse;font-size:.92rem}.ore th,.ore td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--ore-line);vertical-align:top}
+.ore code,.ore .mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88rem;word-break:break-all}
+.ore .secret{display:block;background:#0a0f18;border:1px dashed var(--ore-warn);padding:10px;border-radius:8px;user-select:all}
+.ore form.inline{display:inline}.ore label{display:block;margin:.4em 0 .15em;color:var(--ore-muted);font-size:.88rem}
+.ore input[type=text],.ore input[type=password],.ore input[type=number],.ore select,.ore textarea{width:100%;box-sizing:border-box;background:#0a0f18;color:var(--ore-text);border:1px solid var(--ore-line);border-radius:6px;padding:7px 9px}
+.ore .row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}
+.ore button{background:var(--ore-accent);color:#fff;border:0;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600;margin:6px 6px 0 0}
+.ore button.secondary{background:#334155}.ore button.danger{background:var(--ore-bad)}
+.ore .pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:.8rem;font-weight:600;background:#334155}
+.ore .pill.live,.ore .pill.ok{background:var(--ore-ok);color:#04110a}.ore .pill.starting,.ore .pill.ending,.ore .pill.pending,.ore .pill.error,.ore .pill.draining{background:var(--ore-warn);color:#1a1000}
+.ore .pill.failed,.ore .pill.lost{background:var(--ore-bad)}
+.ore .flash{border-left:4px solid var(--ore-accent);padding:8px 12px;background:var(--ore-panel);margin:12px 0}.ore .flash.bad{border-color:var(--ore-bad)}
+@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
+`;
+
+function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,nofollow', config }) {
+ const pageTitle = title ? `${title} · ${SITE}` : `${SITE} — ingest and restream for OpenVibe`;
+ const nav = {
+ service: 'openre',
+ apiBase: NETWORK_URL,
+ links: [
+ { label: 'Streams', href: '/streams' },
+ { label: 'Sessions', href: '/sessions' },
+ ],
+ sessionUrl: '/auth/me',
+ loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,
+ };
+ const foot = { service: 'openre', variant: 'compact', mount: '#ov-footer', brandName: SITE, legalBase: config.liveUrl,
+ links: [{ heading: SITE, items: [{ name: 'Streams', url: '/streams' }, { name: 'Source code', url: 'https://github.com/OpenVibers/OpenRe.Stream' }] }] };
+ let footerSsr = '';
+ try { footerSsr = require('openvibe-shared/footer').ssr({ service: 'openre', variant: 'compact' }); } catch { footerSsr = '<footer id="ov-footer"></footer>'; }
+ let icon = '';
+ try { icon = require('openvibe-shared/app-icon').headTags({ site: 'network' }); } catch { icon = ''; }
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(pageTitle)}</title>
+<meta name="description" content="OpenRe.Stream: stream definitions, ingest keys, sessions and restream outputs for the OpenVibe network.">
+<meta name="robots" content="${esc(robots)}">
+<link rel="canonical" href="${esc(config.baseUrl + canonicalPath)}">
+${icon}
+<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>
+<style>${CSS}</style>
+<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+<script src="${NETWORK_URL}/shared/footer.js" defer></script>
+</head>
+<body>
+<div id="navbar-mount"></div>
+<noscript><nav class="ore" style="padding:8px 16px"><a href="/">OpenRe.Stream</a> · <a href="/streams">Streams</a> · <a href="/sessions">Sessions</a> · ${user ? '<a href="/auth/logout">Sign out</a>' : '<a href="/auth/login">Sign in</a>'}</nav></noscript>
+<main id="main" class="ore">
+${body}
+</main>
+${footerSsr}
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer: foot }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* chrome is optional */ }
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
+});
+</script>
+</body>
+</html>`;
+}
+
+module.exports = { renderPage, esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,332 @@
@@ -0,0 +1,332 @@
+'use strict';
+/**
+ * The standalone UI (openre.stream). Server-rendered, useful without JavaScript, forms post back
+ * to the same process and go through the same store rules as the API.
+ *
+ * / what OpenRe is, and the signed-in owner's streams
+ * /streams definitions + create
+ * /streams/:id ingest URL, keys (rotate: the new key is shown once), settings,
+ * destinations (add, edit, delete, test, start, stop), sessions
+ * /sessions recent sessions
+ * /sessions/:id lifecycle, outputs with health and logs, recording, playback
+ *
+ * CSRF: every form carries a token derived from the session cookie (an attacker's page cannot
+ * read the cookie, so it cannot produce the token); SameSite=Lax cookies are the second layer.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { renderPage, esc } = require('./layout');
+const { StoreError } = require('../store/definitions');
+const { testDestination } = require('../destination-test');
+
+const iso = (ms) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');
+const pill = (state) => `<span class="pill ${esc(state)}">${esc(state)}</span>`;
+
+function csrfFor(token) { return crypto.createHash('sha256').update(`openre-csrf:${token}`).digest('hex').slice(0, 32); }
+
+function createUiRouter({ rt, auth }) {
+ const { store, config } = rt;
+ const router = express.Router();
+ router.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ router.use(auth.middleware({ services: false }));
+
+ const page = (req, res, o, status = 200) => res.status(status).type('html').send(renderPage({ ...o, user: req.caller.kind === 'user' ? req.caller : null, config }));
+ const csrfField = (req) => `<input type="hidden" name="_csrf" value="${esc(csrfFor(req.caller.token))}">`;
+ const form = (req, action, inner, { cls = '', confirm } = {}) => `<form method="post" action="${esc(action)}" class="${cls}"${confirm ? ` onsubmit="return confirm('${esc(confirm)}')"` : ''}>${csrfField(req)}${inner}</form>`;
+
+ function needUser(req, res) {
+ if (req.caller.kind === 'user' && req.caller.subject) return true;
+ page(req, res, {
+ title: 'Sign in', canonicalPath: req.path,
+ body: `<h1>Sign in</h1><p>OpenRe.Stream uses your OpenVibe account.</p><p><a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
+ }, 401);
+ return false;
+ }
+
+ function checkCsrf(req, res) {
+ const given = String((req.body && req.body._csrf) || '');
+ const expected = csrfFor(req.caller.token || '');
+ if (given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return true;
+ page(req, res, { title: 'Expired form', body: '<h1>That form expired</h1><p>Go back, reload the page and try again.</p>' }, 403);
+ return false;
+ }
+
+ function ownDefinition(req, res, id) {
+ const d = store.definitions.get(id);
+ if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {
+ page(req, res, { title: 'Not found', body: '<h1>Stream not found</h1><p><a href="/streams">Your streams</a></p>' }, 404);
+ return null;
+ }
+ return d;
+ }
+
+ function ownDestination(req, res, id) {
+ const dest = store.outputs.destinationRow(id);
+ const d = dest ? ownDefinition(req, res, dest.definition_id) : null;
+ if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: '<h1>Destination not found</h1>' }, 404);
+ return d ? { dest, definition: d } : null;
+ }
+
+ const post = (path, fn) => router.post(path, async (req, res, next) => {
+ if (!needUser(req, res) || !checkCsrf(req, res)) return;
+ try { await fn(req, res); } catch (err) {
+ if (err instanceof StoreError || err.code === 'openre.secrets_unavailable') {
+ return page(req, res, { title: 'Could not save', body: `<div class="flash bad">${esc(err.message)}</div><p><a href="javascript:history.back()">Back</a></p>` }, err.status || 400);
+ }
+ return next(err);
+ }
+ });
+
+ // ── Pages ─────────────────────────────────────────────────
+
+ router.get('/', (req, res) => {
+ const signedIn = req.caller.kind === 'user' && req.caller.subject;
+ const mine = signedIn ? store.definitions.list({ owner_subject: req.caller.subject }) : [];
+ page(req, res, {
+ canonicalPath: '/', robots: 'index,follow',
+ body: `<h1>OpenRe.Stream</h1>
+<p>Ingest and restream for the OpenVibe network: stream definitions with hashed ingest keys, RTMP ingest sessions that run in transport workers separate from any web deploy, restream outputs with health and logs, and recording requests to OpenVibe.Media.</p>
+<p class="muted">Status: alpha. RTMP ingest and RTMP/SRT restreaming work here; WHIP, WebRTC/SFU and JSMPEG are still served by OpenVibe.Live. Channels, discovery and watch pages stay on <a href="${esc(config.liveUrl)}">openvibe.live</a>.</p>
+${signedIn ? `<h2>Your streams</h2>${mine.length ? streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}`,
+ });
+ });
+
+ function streamTable(list) {
+ return `<table><tr><th>Stream</th><th>State</th><th>Now</th><th>Linked to</th></tr>${list.map((d) => {
+ const open = store.sessions.openFor(d.id)[0];
+ const refs = d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', ');
+ return `<tr><td><a href="/streams/${esc(d.id)}">${esc(d.title)}</a></td><td>${pill(d.state)}</td><td>${open ? pill(open.state) : '<span class="muted">offline</span>'}</td><td class="mono">${esc(refs) || '—'}</td></tr>`;
+ }).join('')}</table>`;
+ }
+
+ router.get('/streams', (req, res) => {
+ if (!needUser(req, res)) return;
+ const list = store.definitions.list({ owner_subject: req.caller.subject });
+ page(req, res, {
+ title: 'Streams', canonicalPath: '/streams',
+ body: `<h1>Streams</h1>${list.length ? streamTable(list) : '<p class="muted">No streams yet.</p>'}
+<div class="card"><h2>New stream</h2>${form(req, '/streams', `
+<label>Title</label><input type="text" name="title" maxlength="140" required>
+<div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>
+<div><label>Recording visibility</label><select name="recording_visibility"><option>public</option><option>unlisted</option><option>private</option></select></div></div>
+<button type="submit">Create stream</button><p class="muted">The ingest key is shown once, on the next page.</p>`)}</div>`,
+ });
+ });
+
+ function keyPage(req, res, definition, key, heading) {
+ const ep = store.definitions.ingestEndpoints(definition);
+ res.set('Cache-Control', 'no-store');
+ page(req, res, {
+ title: heading, canonicalPath: `/streams/${definition.id}`,
+ body: `<h1>${esc(heading)}</h1>
+<div class="card"><p><strong>Copy the key now.</strong> OpenRe stores only a hash of it; this page is the only time it is shown.</p>
+<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
+<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
+<p><a href="/streams/${esc(definition.id)}">Continue to the stream</a></p>`,
+ });
+ }
+
+ post('/streams', (req, res) => {
+ const b = req.body || {};
+ const { definition, key } = store.definitions.create({
+ owner_subject: req.caller.subject, title: b.title, recording_mode: b.recording_mode,
+ recording_visibility: b.recording_visibility, created_by: req.caller.subject,
+ });
+ keyPage(req, res, definition, key, 'Stream created');
+ });
+
+ router.get('/streams/:id', (req, res) => {
+ if (!needUser(req, res)) return;
+ const d = ownDefinition(req, res, req.params.id);
+ if (!d) return;
+ const ep = store.definitions.ingestEndpoints(d);
+ const keys = store.definitions.keys(d.id).filter(k => k.status !== 'revoked');
+ const dests = store.outputs.destinations(d.id);
+ const sessions = store.sessions.list({ definition_id: d.id, limit: 15 });
+ const open = sessions.find(s => ['starting', 'live', 'ending'].includes(s.state));
+ const outputs = open ? store.outputs.outputsOfSession(open.id) : [];
+ const outFor = (destId) => outputs.find(o => o.destination_id === destId);
+ page(req, res, {
+ title: d.title, canonicalPath: `/streams/${d.id}`,
+ body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>
+<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>
+<div class="card"><h2>Ingest</h2>
+${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}
+<table><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>
+${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key stays valid for (seconds, 0 = revoke now)</label><input type="number" name="grace_seconds" min="0" max="604800" value="0"></div>
+<div><label><input type="checkbox" name="end_sessions" value="1"> also disconnect a running session</label></div></div><button type="submit" class="danger">Rotate key</button>`, { confirm: 'Issue a new key and retire the current one?' })}
+</div>
+<div class="card"><h2>Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>
+${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>
+<div class="card"><h2>Restream destinations</h2>
+${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${dests.map((x) => {
+ const o = outFor(x.id);
+ return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td>
+<td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td>
+<td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td>
+<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}
+<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;
+ }).join('')}</table>` : '<p class="muted">No destinations.</p>'}
+<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
+<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `
+<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">
+<div class="row"><div><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
+<div><label>Recording visibility</label><select name="recording_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.recording_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
+<div><label>Playback</label><select name="playback_visibility">${['public', 'unlisted', 'private'].map(m => `<option${d.playback_visibility === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
+<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
+<label><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Mirror sessions into my OpenVibe.Live channel (consent; Live also has to switch this slot to OpenRe)</label>
+<button type="submit">Save</button>`)}</div>
+<h2>Sessions</h2>${sessionTable(sessions)}`,
+ });
+ });
+
+ function destinationFields(x) {
+ const sel = (name, opts, cur) => `<select name="${name}">${opts.map(o => `<option${o === cur ? ' selected' : ''}>${o}</option>`).join('')}</select>`;
+ return `<div class="row"><div><label>Platform</label>${sel('platform', ['twitch', 'youtube', 'kick', 'custom'], x.platform)}</div>
+<div><label>Name</label><input type="text" name="name" maxlength="80" value="${esc(x.name || '')}"></div></div>
+<label>Server URL (rtmp://, rtmps:// or srt://)</label><input type="text" name="server_url" maxlength="2048" value="${esc(x.server_url || '')}" required>
+<label>Stream key ${x.id ? '(leave empty to keep the stored key)' : ''}</label><input type="password" name="stream_key" autocomplete="off" maxlength="512">
+<div class="row"><div><label>Quality (only used when re-encoding)</label>${sel('quality_preset', ['auto', 'low', 'medium', 'high', 'ultra', 'source'], x.quality_preset || 'auto')}</div>
+<div><label>SRT latency (ms)</label><input type="number" name="srt_latency_ms" min="20" max="8000" value="${esc(x.srt_latency_ms || '')}"></div>
+<div><label>SRT passphrase ${x.id ? '(empty = keep)' : ''}</label><input type="password" name="srt_passphrase" autocomplete="off" maxlength="79"></div></div>
+<label><input type="checkbox" name="enabled" value="1"${x.enabled === false ? '' : ' checked'}> enabled</label>
+<label><input type="checkbox" name="auto_start" value="1"${x.auto_start === false ? '' : ' checked'}> start automatically when the stream goes live</label>
+<button type="submit">${x.id ? 'Save destination' : 'Add destination'}</button>`;
+ }
+
+ function destInput(b, { editing }) {
+ const out = {
+ platform: b.platform, name: b.name, server_url: b.server_url, quality_preset: b.quality_preset,
+ enabled: b.enabled === '1', auto_start: b.auto_start === '1',
+ srt_latency_ms: b.srt_latency_ms === '' || b.srt_latency_ms == null ? null : b.srt_latency_ms,
+ };
+ if (!editing || b.stream_key) out.stream_key = b.stream_key || '';
+ if (!editing || b.srt_passphrase) out.srt_passphrase = b.srt_passphrase || '';
+ return out;
+ }
+
+ function sessionTable(list) {
+ if (!list.length) return '<p class="muted">No sessions yet.</p>';
+ return `<table><tr><th>Session</th><th>State</th><th>Started</th><th>Ended</th><th>Worker</th></tr>${list.map(s => `<tr><td><a class="mono" href="/sessions/${esc(s.id)}">${esc(s.id)}</a></td><td>${pill(s.state)}${s.failure_reason ? ` <span class="muted">${esc(s.failure_reason)}</span>` : ''}</td><td>${esc(iso(s.live_at || s.created_at))}</td><td>${esc(iso(s.ended_at))}</td><td>${esc(s.worker_kind)} #${esc(s.worker_generation)}</td></tr>`).join('')}</table>`;
+ }
+
+ post('/streams/:id', (req, res) => {
+ const d = ownDefinition(req, res, req.params.id);
+ if (!d) return;
+ const b = req.body || {};
+ store.definitions.update(d.id, { title: b.title, recording_mode: b.recording_mode, recording_visibility: b.recording_visibility, playback_visibility: b.playback_visibility, state: b.state, mirror_to_live: b.mirror_to_live === '1' });
+ res.redirect(303, `/streams/${d.id}`);
+ });
+
+ post('/streams/:id/rotate', (req, res) => {
+ const d = ownDefinition(req, res, req.params.id);
+ if (!d) return;
+ const r = store.definitions.rotateKey(d.id, { grace_seconds: Number(req.body.grace_seconds) || 0, rotated_by: req.caller.subject });
+ if (req.body.end_sessions === '1') for (const s of store.sessions.openFor(d.id)) store.sessions.requestEnd(s.id, `key_rotation:${req.caller.subject}`);
+ keyPage(req, res, store.definitions.get(d.id), r.key, 'New ingest key');
+ });
+
+ post('/streams/:id/destinations', (req, res) => {
+ const d = ownDefinition(req, res, req.params.id);
+ if (!d) return;
+ store.outputs.createDestination(d.id, destInput(req.body || {}, { editing: false }));
+ res.redirect(303, `/streams/${d.id}`);
+ });
+
+ router.get('/destinations/:id', (req, res) => {
+ if (!needUser(req, res)) return;
+ const own = ownDestination(req, res, req.params.id);
+ if (!own) return;
+ const x = store.outputs.publicDest(own.dest);
+ const logs = store.outputs.logsOfDestination(x.id, 50);
+ page(req, res, {
+ title: `Destination ${x.name || x.platform}`, canonicalPath: `/destinations/${x.id}`,
+ body: `<h1>${esc(x.name || x.platform)}</h1><p><a href="/streams/${esc(own.definition.id)}">← ${esc(own.definition.title)}</a></p>
+<div class="card">${form(req, `/destinations/${x.id}`, destinationFields(x))}</div>
+<h2>Log</h2>${logs.length ? `<table><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,
+ });
+ });
+
+ post('/destinations/:id', (req, res) => {
+ const own = ownDestination(req, res, req.params.id);
+ if (!own) return;
+ store.outputs.updateDestination(own.dest.id, destInput(req.body || {}, { editing: true }));
+ res.redirect(303, `/streams/${own.definition.id}`);
+ });
+
+ post('/destinations/:id/delete', (req, res) => {
+ const own = ownDestination(req, res, req.params.id);
+ if (!own) return;
+ store.outputs.deleteDestination(own.dest.id);
+ res.redirect(303, `/streams/${own.definition.id}`);
+ });
+
+ post('/destinations/:id/test', async (req, res) => {
+ const own = ownDestination(req, res, req.params.id);
+ if (!own) return;
+ const result = await testDestination(own.dest, { allowPrivate: config.outputs.allowPrivateHosts });
+ store.outputs.log(null, own.dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);
+ page(req, res, {
+ title: 'Destination test',
+ body: `<h1>Test ${result.ok ? pill('ok') : pill('failed')}</h1><table>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>
+<p class="muted">A passing test means the ingest is reachable. Whether the platform accepts the key shows once an output runs.</p><p><a href="/streams/${esc(own.definition.id)}">Back</a></p>`,
+ });
+ });
+
+ post('/destinations/:id/start', (req, res) => {
+ const own = ownDestination(req, res, req.params.id);
+ if (!own) return;
+ store.outputs.startDestination(own.dest.id);
+ res.redirect(303, `/streams/${own.definition.id}`);
+ });
+
+ post('/destinations/:id/stop', (req, res) => {
+ const own = ownDestination(req, res, req.params.id);
+ if (!own) return;
+ store.outputs.stopDestination(own.dest.id);
+ res.redirect(303, `/streams/${own.definition.id}`);
+ });
+
+ router.get('/sessions', (req, res) => {
+ if (!needUser(req, res)) return;
+ const all = req.caller.staff && req.query.all === '1';
+ page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `<h1>Sessions</h1>${req.caller.staff ? `<p class="muted"><a href="/sessions${all ? '' : '?all=1'}">${all ? 'Only mine' : 'Everyone (staff)'}</a></p>` : ''}${sessionTable(store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });
+ });
+
+ router.get('/sessions/:id', (req, res) => {
+ if (!needUser(req, res)) return;
+ const s = store.sessions.get(req.params.id);
+ const d = s ? store.definitions.row(s.definition_id) : null;
+ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);
+ const outputs = store.outputs.outputsOfSession(s.id);
+ const rec = store.recordings.bySession(s.id);
+ const pb = store.sessions.playback(s);
+ const open = ['starting', 'live', 'ending'].includes(s.state);
+ return page(req, res, {
+ title: `Session ${s.id}`, canonicalPath: `/sessions/${s.id}`,
+ body: `<h1>Session ${pill(s.state)}</h1><p class="mono">${esc(s.id)}</p><p><a href="/streams/${esc(d.id)}">← ${esc(d.title)}</a></p>
+<div class="card"><table>
+<tr><th>Protocol</th><td>${esc(s.protocol)}</td></tr>
+<tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr>
+<tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>
+<tr><th>Media</th><td>${s.media_info ? esc(`${s.media_info.video_codec || '?'} ${s.media_info.width || '?'}×${s.media_info.height || '?'} @${s.media_info.fps || '?'} · ${s.media_info.audio_codec || 'no audio'} · ${s.media_info.bitrate_kbps || '?'} kbit/s`) : '—'}</td></tr>
+<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}` : '<span class="muted">none</span>'}</td></tr>
+<tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr>
+</table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>
+<h2>Outputs</h2>${outputs.length ? `<table><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`).join('')}</table>` : '<p class="muted">No outputs.</p>'}
+<h2>Lifecycle</h2><table>${store.sessions.transitions(s.id).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,
+ });
+ });
+
+ post('/sessions/:id/end', (req, res) => {
+ const s = store.sessions.get(req.params.id);
+ const d = s ? store.definitions.row(s.definition_id) : null;
+ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);
+ store.sessions.requestEnd(s.id, req.caller.subject);
+ return res.redirect(303, `/sessions/${s.id}`);
+ });
+
+ return router;
+}
+
+module.exports = { createUiRouter, csrfFor };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+// ADR-009 acceptance: "Deploying the OpenRe API does not end worker-owned transports."
+// The API runs as its own process; a fake worker process owns a live session. The API is stopped
+// (SIGTERM, as systemd does on a deploy), the session stays live and its lease keeps being renewed
+// by the worker; a new API process starts and serves the same live session.
+const assert = require('assert');
+const path = require('path');
+const { tmpDir, testEnv, child, waitFor, request, userToken, freePort, sleep, suite, OWNER } = require('./helpers');
+const { load } = require('../server/config');
+const { openRuntime } = require('../server/store');
+
+const t = suite('api-restart');
+const dir = tmpDir();
+let env;
+let api;
+let fake;
+let sessionId;
+let workerId;
+const token = userToken({ subjectId: OWNER });
+
+async function startApi() {
+ const p = child('server/index.js', env);
+ const base = `http://127.0.0.1:${env.PORT}`;
+ await waitFor(async () => { try { return (await request(base, 'GET', '/api/ready')).status === 200; } catch { return false; } }, { what: 'openre-api ready' });
+ return { proc: p, base };
+}
+
+t('setup: API process + fake worker process holding a live session', async () => {
+ env = testEnv(dir, { PORT: String(await freePort()), OPENRE_WORKER_HEARTBEAT_MS: '200', OPENRE_WORKER_LEASE_MS: '1500' });
+ const rt = openRuntime({ config: load(env), log: { log() {}, warn() {}, error() {} } });
+ const { definition } = rt.store.definitions.create({ owner_subject: OWNER, title: 'Restart test' });
+ rt.db.close();
+ api = await startApi();
+ fake = child(path.join('test', 'fixtures', 'fake-worker.js'), { ...env, FAKE_STREAM_ID: definition.id });
+ await waitFor(() => /SESSION (ses_\S+) WORKER (wrk_\S+)/.test(fake.output), { what: 'fake worker session' });
+ [, sessionId, workerId] = /SESSION (ses_\S+) WORKER (wrk_\S+)/.exec(fake.output);
+ const r = await request(api.base, 'GET', `/api/v1/sessions/${sessionId}`, { token });
+ assert.strictEqual(r.body.session.state, 'live');
+});
+
+t('stopping the API (SIGTERM) leaves the worker process and its session alone', async () => {
+ const rt = openRuntime({ config: load(env), log: { log() {}, warn() {}, error() {} } });
+ const leaseBefore = rt.store.sessions.get(sessionId).lease_expires_at;
+ api.proc.kill('SIGTERM');
+ const exit = await api.proc.exited;
+ assert.strictEqual(exit.code, 0, 'the API exits cleanly');
+ // Longer than a lease: if anything depended on the API, the session would now be failing.
+ await sleep(2500);
+ assert.strictEqual(fake.exitCode, null, 'fake worker still running');
+ const s = rt.store.sessions.get(sessionId);
+ assert.strictEqual(s.state, 'live');
+ assert.ok(s.lease_expires_at > leaseBefore, 'the worker kept renewing its lease while the API was down');
+ assert.strictEqual(rt.store.workers.get(workerId).state, 'ready');
+ rt.db.close();
+});
+
+t('a new API process serves the same live session', async () => {
+ api = await startApi();
+ const r = await request(api.base, 'GET', `/api/v1/sessions/${sessionId}`, { token });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.session.state, 'live');
+ assert.strictEqual(r.body.session.worker.id, workerId);
+ assert.deepStrictEqual(r.body.session.transitions.map(x => x.to_state), ['starting', 'live'], 'no transition happened across the restart');
+});
+
+t('teardown', async () => {
+ api.proc.kill('SIGTERM');
+ fake.kill('SIGKILL');
+ await Promise.all([api.proc.exited, fake.exited]);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,158 @@
@@ -0,0 +1,158 @@
+'use strict';
+// openre-api: capability guards, owner scoping, keys shown once, destination secrets never
+// returned, problem+json, rotation and end requests over HTTP, the playback descriptor.
+const assert = require('assert');
+const { bootApi, request, serviceToken, userToken, suite, OWNER, OTHER } = require('./helpers');
+
+const t = suite('api');
+let api;
+const owner = userToken({ subjectId: OWNER });
+const other = userToken({ subjectId: OTHER });
+const admin = userToken({ subjectId: 'usr_01J0000000000000000000000C', role: 'admin' });
+const live = serviceToken('live', ['openre.stream.read', 'openre.stream.write', 'openre.key.rotate', 'openre.session.read', 'openre.session.end', 'openre.output.read', 'openre.output.write']);
+
+t('boot', async () => {
+ api = await bootApi();
+ const ready = await request(api.base, 'GET', '/api/ready');
+ assert.strictEqual(ready.status, 200);
+ assert.strictEqual(ready.body.checks.db, true);
+ assert.strictEqual(ready.body.checks.key, true);
+});
+
+t('anonymous and ungranted callers are refused with problem+json', async () => {
+ const r = await request(api.base, 'GET', '/api/v1/streams');
+ assert.strictEqual(r.status, 401);
+ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(r.body.code, 'token.missing');
+ const noCap = await request(api.base, 'GET', '/api/v1/streams', { token: serviceToken('tools', ['openre.session.read']) });
+ assert.strictEqual(noCap.status, 403);
+ assert.strictEqual(noCap.body.code, 'capability.denied');
+ const wrongAud = await request(api.base, 'GET', '/api/v1/streams', { token: serviceToken('live', ['openre.stream.read'], { aud: 'openvibe.media' }) });
+ assert.strictEqual(wrongAud.status, 401);
+ const badSubject = await request(api.base, 'GET', '/api/v1/streams', { token: live, headers: { 'X-OV-Subject': 'not-a-subject' } });
+ assert.strictEqual(badSubject.status, 400);
+});
+
+let streamId;
+let firstKey;
+
+t('an owner creates a stream: the key comes back once and never again', async () => {
+ const r = await request(api.base, 'POST', '/api/v1/streams', { token: owner, body: { title: 'Evening show', recording_mode: 'vod' } });
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.match(r.body.key.key, /^ork_[A-Za-z0-9_-]{43}$/);
+ assert.strictEqual(r.body.stream.owner.id, OWNER);
+ assert.strictEqual(r.body.stream.ingest.rtmp.url, `rtmp://127.0.0.1:1936/live`);
+ assert.strictEqual(r.body.stream.ingest.rtmp.key_hint, r.body.key.key.slice(-4));
+ streamId = r.body.stream.id;
+ firstKey = r.body.key.key;
+ for (const p of [`/api/v1/streams/${streamId}`, '/api/v1/streams', `/api/v1/streams/${streamId}/keys`]) {
+ const g = await request(api.base, 'GET', p, { token: owner });
+ assert.strictEqual(g.status, 200, p);
+ assert.ok(!g.text.includes(firstKey), `${p} must not return the key`);
+ assert.ok(!g.text.includes('key_hash'), `${p} must not return the hash`);
+ }
+});
+
+t('other owners cannot see or touch the stream; admin staff can read it', async () => {
+ assert.strictEqual((await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: other })).status, 404);
+ assert.strictEqual((await request(api.base, 'POST', `/api/v1/streams/${streamId}/keys/rotate`, { token: other, body: {} })).status, 404);
+ assert.deepStrictEqual((await request(api.base, 'GET', '/api/v1/streams', { token: other })).body.streams, []);
+ assert.strictEqual((await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: admin })).status, 200);
+});
+
+t('a service acting for a subject is limited to that owner; external refs resolve', async () => {
+ const created = await request(api.base, 'POST', '/api/v1/streams', {
+ token: live, headers: { 'X-OV-Subject': OTHER },
+ body: { title: 'Slot 44', external_refs: [{ service: 'live', type: 'managed_stream', id: '44' }, { service: 'live', type: 'user', id: '9' }], mirror_to_live: true },
+ });
+ assert.strictEqual(created.status, 201);
+ assert.strictEqual(created.body.stream.owner.id, OTHER);
+ const byRef = await request(api.base, 'GET', '/api/v1/streams?external_ref=live:managed_stream:44', { token: live });
+ assert.strictEqual(byRef.body.streams[0].id, created.body.stream.id);
+ const asOwner = await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: live, headers: { 'X-OV-Subject': OTHER } });
+ assert.strictEqual(asOwner.status, 404, 'acting for OTHER, the service cannot read OWNER\'s stream');
+ const unscoped = await request(api.base, 'GET', `/api/v1/streams/${streamId}`, { token: live });
+ assert.strictEqual(unscoped.status, 200, 'without a subject the grant covers every stream');
+});
+
+t('rotation over HTTP returns the new key once and the old one stops authenticating', async () => {
+ const r = await request(api.base, 'POST', `/api/v1/streams/${streamId}/keys/rotate`, { token: live, body: { grace_seconds: 0 } });
+ assert.strictEqual(r.status, 200);
+ assert.notStrictEqual(r.body.key.key, firstKey);
+ assert.strictEqual(r.body.retired[0].status, 'revoked');
+ assert.strictEqual(api.rt.store.definitions.resolveIngestKey(firstKey, 'rtmp').error, 'revoked_key');
+ assert.ok(api.rt.store.definitions.resolveIngestKey(r.body.key.key, 'rtmp').definition);
+});
+
+t('destinations: validated, masked, never returned in full; SSRF rules apply', async () => {
+ const bad = await request(api.base, 'POST', `/api/v1/streams/${streamId}/destinations`, { token: owner, body: { platform: 'custom', server_url: 'file:///etc/passwd', stream_key: 'x' } });
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.body.code, 'openre.invalid_destination_url');
+ const r = await request(api.base, 'POST', `/api/v1/streams/${streamId}/destinations`, { token: owner, body: { platform: 'youtube', name: 'YT', server_url: 'rtmp://a.rtmp.youtube.com/live2', stream_key: 'abcd-efgh-ijkl-mnop' } });
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual(r.body.destination.stream_key_hint, '****mnop');
+ const id = r.body.destination.id;
+ const list = await request(api.base, 'GET', `/api/v1/streams/${streamId}/destinations`, { token: owner });
+ assert.ok(!list.text.includes('abcd-efgh-ijkl-mnop'));
+ assert.ok(!list.text.includes('stream_key_enc'));
+ const patched = await request(api.base, 'PATCH', `/api/v1/destinations/${id}`, { token: owner, body: { name: 'YouTube main', auto_start: false } });
+ assert.strictEqual(patched.body.destination.name, 'YouTube main');
+ assert.strictEqual(patched.body.destination.stream_key_hint, '****mnop', 'omitted key is kept');
+ const notLive = await request(api.base, 'POST', `/api/v1/destinations/${id}/start`, { token: owner });
+ assert.strictEqual(notLive.status, 409);
+ assert.strictEqual(notLive.body.code, 'openre.not_live');
+ const del = await request(api.base, 'DELETE', `/api/v1/destinations/${id}`, { token: owner });
+ assert.strictEqual(del.status, 204);
+});
+
+t('the destination test reports URL, DNS and TCP checks without pushing media', async () => {
+ const net = require('net');
+ const srv = net.createServer((s) => s.destroy());
+ await new Promise(r => srv.listen(0, '127.0.0.1', r));
+ const r = await request(api.base, 'POST', `/api/v1/streams/${streamId}/destinations`, { token: owner, body: { platform: 'custom', server_url: `rtmp://127.0.0.1:${srv.address().port}/app`, stream_key: 'k12345' } });
+ const test = await request(api.base, 'POST', `/api/v1/destinations/${r.body.destination.id}/test`, { token: owner });
+ assert.strictEqual(test.status, 200);
+ assert.strictEqual(test.body.ok, true);
+ assert.deepStrictEqual(test.body.checks.map(c => c.check), ['url', 'dns', 'stream_key', 'connect']);
+ const logs = await request(api.base, 'GET', `/api/v1/destinations/${r.body.destination.id}/logs`, { token: owner });
+ assert.match(logs.body.logs[0].message, /^test passed/);
+ srv.close();
+});
+
+t('sessions: list, detail with playback descriptor, end request', async () => {
+ const rt = api.rt;
+ const w = rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: { publicPort: 1936, rtmpPlayPort: 19370, flvPort: 19371 } });
+ rt.store.workers.ready(w.id);
+ const def = rt.store.definitions.get(streamId);
+ const a = rt.store.sessions.admit({ definition: def, key: null, protocol: 'rtmp', worker: rt.store.workers.get(w.id) });
+ rt.store.sessions.transition(a.session.id, 'live');
+ const list = await request(api.base, 'GET', '/api/v1/sessions?state=live', { token: owner });
+ assert.deepStrictEqual(list.body.sessions.map(s => s.id), [a.session.id]);
+ const detail = await request(api.base, 'GET', `/api/v1/sessions/${a.session.id}`, { token: owner });
+ assert.strictEqual(detail.body.session.playback.flv.internal_url, `http://127.0.0.1:19371/live/${a.session.id}.flv`);
+ assert.strictEqual(detail.body.session.playback.flv.public_url, `${api.config.baseUrl}/play/${a.session.id}.flv`);
+ assert.deepStrictEqual(detail.body.session.transitions.map(x => x.to_state), ['starting', 'live']);
+ assert.strictEqual((await request(api.base, 'GET', `/api/v1/sessions/${a.session.id}`, { token: other })).status, 404);
+ const noEndCap = await request(api.base, 'POST', `/api/v1/sessions/${a.session.id}/end`, { token: serviceToken('live', ['openre.session.read']) });
+ assert.strictEqual(noEndCap.status, 403);
+ const end = await request(api.base, 'POST', `/api/v1/sessions/${a.session.id}/end`, { token: owner });
+ assert.strictEqual(end.status, 202);
+ assert.strictEqual(rt.store.sessions.get(a.session.id).desired_state, 'end');
+ // Archiving is refused while it is open.
+ const del = await request(api.base, 'DELETE', `/api/v1/streams/${streamId}`, { token: owner });
+ assert.strictEqual(del.status, 409);
+ rt.store.sessions.finish(a.session.id, { reason: 'test' });
+ assert.strictEqual((await request(api.base, 'GET', `/play/${a.session.id}.flv`)).status, 404, 'no playback for an ended session');
+});
+
+t('workers are visible to staff and services only', async () => {
+ assert.strictEqual((await request(api.base, 'GET', '/api/v1/workers', { token: owner })).status, 403);
+ const w = await request(api.base, 'GET', '/api/v1/workers', { token: admin });
+ assert.strictEqual(w.status, 200);
+ assert.ok(w.body.workers.length >= 1);
+});
+
+t('close', async () => { await api.close(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+// Session coordinator: generations and draining, lost workers, lease expiry, output assignment to
+// the newest restream generation, drain-deadline handover and the single-coordinator lease.
+const assert = require('assert');
+const { runtime, manualClock, suite, OWNER, outboxTypes, silent } = require('./helpers');
+const { createCoordinator } = require('../server/coordinator');
+const { createWorkerRuntime } = require('../workers/runtime');
+
+const t = suite('coordinator');
+
+function setup() {
+ const clock = manualClock();
+ const rt = runtime({ clock });
+ const coordinator = createCoordinator({ rt, media: null, log: silent });
+ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER });
+ const worker = (kind) => { const w = rt.store.workers.register({ kind, endpoints: { flvPort: 19361, rtmpPlayPort: 19360 } }); rt.store.workers.ready(w.id); return rt.store.workers.get(w.id); };
+ return { rt, clock, coordinator, definition, key, worker };
+}
+
+t('generations grow per kind; when a newer one is ready every older one drains with a deadline', () => {
+ const { rt, clock, coordinator, worker } = setup();
+ const g1 = worker('rtmp-ingest');
+ const r1 = worker('restream');
+ assert.strictEqual(g1.generation, 1);
+ assert.strictEqual(r1.generation, 1);
+ assert.strictEqual(coordinator.syncTick().drained, 0);
+ const g2 = worker('rtmp-ingest');
+ assert.strictEqual(g2.generation, 2);
+ assert.strictEqual(coordinator.syncTick().drained, 1);
+ const old = rt.store.workers.get(g1.id);
+ assert.strictEqual(old.state, 'draining');
+ assert.strictEqual(old.drain_deadline, clock.now() + rt.config.workers.drainMaxMs);
+ assert.strictEqual(rt.store.workers.get(r1.id).state, 'ready', 'other kinds are independent');
+ assert.strictEqual(rt.store.workers.newestReady('rtmp-ingest').id, g2.id);
+});
+
+t('a draining generation keeps its live session; new sessions only reach the newest generation', () => {
+ const { rt, coordinator, definition, key, worker } = setup();
+ const g1 = worker('rtmp-ingest');
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: g1 });
+ rt.store.sessions.transition(a.session.id, 'live');
+ worker('rtmp-ingest');
+ coordinator.syncTick();
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live');
+ assert.strictEqual(rt.store.workers.get(g1.id).state, 'draining');
+ // The worker runtime of the draining generation refuses to take sessions (rtmp-ingest checks
+ // runtime.draining / state === 'ready' in its publish handler).
+ const exits = [];
+ const wr = createWorkerRuntime({ rt, kind: 'rtmp-ingest', log: silent, exit: (c) => exits.push(c), hooks: { activeCount: () => 1 } });
+ wr.register({});
+ wr.ready();
+ wr.stop();
+});
+
+t('a worker without heartbeats is lost: its sessions fail (session.failed) and its outputs go back to pending', () => {
+ const { rt, clock, coordinator, definition, key, worker } = setup();
+ const ing = worker('rtmp-ingest');
+ const rs = worker('restream');
+ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/app', stream_key: 'abcd1' });
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: ing });
+ rt.store.sessions.transition(a.session.id, 'live');
+ const tick1 = coordinator.syncTick();
+ assert.strictEqual(tick1.outputsCreated, 1);
+ assert.strictEqual(tick1.outputsAssigned, 1);
+ const out = rt.store.outputs.outputsOfSession(a.session.id)[0];
+ assert.strictEqual(out.worker.id, rs.id);
+
+ // The restream worker dies; the ingest keeps heartbeating.
+ clock.advance(rt.config.workers.leaseMs + 1000);
+ rt.store.workers.heartbeat(ing.id);
+ const tick2 = coordinator.syncTick();
+ assert.strictEqual(tick2.lost, 1);
+ assert.strictEqual(rt.store.workers.get(rs.id).state, 'lost');
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live', 'a lost restream worker never ends the source session');
+ assert.strictEqual(rt.store.outputs.getOutput(out.id).state, 'pending');
+ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker, null);
+ // A new restream generation picks it up.
+ const rs2 = worker('restream');
+ assert.strictEqual(coordinator.syncTick().outputsAssigned, 1);
+ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker.id, rs2.id);
+
+ // Now the ingest worker dies too: its session fails.
+ clock.advance(rt.config.workers.leaseMs + 1000);
+ rt.store.workers.heartbeat(rs2.id);
+ coordinator.syncTick();
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'failed');
+ assert.strictEqual(rt.store.sessions.get(a.session.id).failure_reason, 'worker_lost');
+ assert.ok(outboxTypes(rt.db).includes('openre.session.failed'));
+ assert.strictEqual(rt.store.outputs.getOutput(out.id).desired, 'stop');
+});
+
+t('outputs are only created for enabled, auto-start destinations with a key and no cooldown', () => {
+ const { rt, clock, coordinator, definition, key, worker } = setup();
+ const ing = worker('rtmp-ingest');
+ worker('restream');
+ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/a', stream_key: 'k1111' });
+ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/b', stream_key: 'k2222', auto_start: false });
+ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/c', stream_key: 'k3333', enabled: false });
+ const cooling = rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/d', stream_key: 'k4444' });
+ rt.store.outputs.markDestinationFailure(cooling.id, 'dead');
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: ing });
+ rt.store.sessions.transition(a.session.id, 'live');
+ assert.strictEqual(coordinator.syncTick().outputsCreated, 1);
+ // A manual start ignores the cooldown (like Live's /start).
+ rt.store.outputs.startDestination(cooling.id);
+ assert.strictEqual(rt.store.outputs.outputsOfSession(a.session.id).length, 2);
+ clock.advance(1);
+});
+
+t('a restream generation at its drain deadline hands its outputs to the newest generation', () => {
+ const { rt, coordinator, definition, key, worker } = setup();
+ const ing = worker('rtmp-ingest');
+ const rs1 = worker('restream');
+ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/a', stream_key: 'k1111' });
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker: ing });
+ rt.store.sessions.transition(a.session.id, 'live');
+ coordinator.syncTick();
+ const out = rt.store.outputs.outputsOfSession(a.session.id)[0];
+ rt.store.outputs.report(out.id, { state: 'live' });
+ const rs2 = worker('restream');
+ coordinator.syncTick();
+ assert.strictEqual(rt.store.workers.get(rs1.id).state, 'draining');
+ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker.id, rs1.id, 'the running output stays on the draining generation');
+ assert.strictEqual(rt.store.outputs.release(rs1.id), 1); // what restream-worker does at its deadline
+ coordinator.syncTick();
+ assert.strictEqual(rt.store.outputs.getOutput(out.id).worker.id, rs2.id);
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live');
+});
+
+t('only one coordinator acts at a time', async () => {
+ const { rt } = setup();
+ const a = createCoordinator({ rt, media: null, log: silent, holder: 'a' });
+ const b = createCoordinator({ rt, media: null, log: silent, holder: 'b' });
+ assert.ok(!(await a.tick()).skipped);
+ assert.ok((await b.tick()).skipped);
+ rt.clock.advance(rt.config.workers.leaseMs + 1);
+ assert.ok(!(await b.tick()).skipped, 'an expired lease can be taken over');
+});
+
+t('the worker runtime exits by itself when a draining generation is idle, and on being lost', async () => {
+ const { rt, worker } = setup();
+ const exits = [];
+ let active = 1;
+ const wr = createWorkerRuntime({ rt, kind: 'restream', log: silent, exit: (c) => exits.push(c), hooks: { activeCount: () => active } });
+ wr.register({});
+ wr.ready();
+ worker('restream');
+ rt.store.workers.drain(wr.me.id, Date.now() + 60000);
+ wr.beat();
+ assert.ok(wr.draining);
+ assert.deepStrictEqual(exits, [], 'still carrying an output');
+ active = 0;
+ wr.beat();
+ await new Promise(r => setImmediate(r));
+ assert.deepStrictEqual(exits, [0]);
+ assert.strictEqual(rt.store.workers.get(wr.me.id).state, 'stopped');
+
+ const exits2 = [];
+ const wr2 = createWorkerRuntime({ rt, kind: 'rtmp-ingest', log: silent, exit: (c) => exits2.push(c), hooks: { activeCount: () => 1 } });
+ wr2.register({});
+ wr2.ready();
+ rt.store.workers.lose(wr2.me.id, 'test');
+ wr2.beat();
+ await new Promise(r => setImmediate(r));
+ assert.deepStrictEqual(exits2, [1]);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+/**
+ * A fake transport worker for test/api-restart.test.js: a separate process that registers an
+ * rtmp-ingest generation, admits a session for the stream named in FAKE_STREAM_ID, takes it live
+ * and keeps heartbeating (renewing its lease) until killed. It holds no socket: the point is that
+ * nothing the API does can reach into it.
+ */
+const { load } = require('../../server/config');
+const { openRuntime } = require('../../server/store');
+const { createWorkerRuntime } = require('../../workers/runtime');
+
+const rt = openRuntime({ config: load(), log: { log() {}, warn() {}, error: console.error } });
+const worker = createWorkerRuntime({ rt, kind: 'rtmp-ingest', hooks: { activeCount: () => 1 }, log: { log() {}, warn() {}, error: console.error } });
+worker.register({ publicPort: 0, rtmpPlayPort: 0, flvPort: 0 });
+worker.ready();
+const definition = rt.store.definitions.get(process.env.FAKE_STREAM_ID);
+const a = rt.store.sessions.admit({ definition, key: null, protocol: 'rtmp', worker: worker.me });
+rt.store.sessions.transition(a.session.id, 'live', { reason: 'fake_media' });
+console.log(`SESSION ${a.session.id} WORKER ${worker.me.id}`);
+setInterval(() => {}, 1 << 30);
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+/**
+ * Shared test fixtures: a generated Network signing key and token minting, temp databases, a
+ * manual clock, runtime/API boot helpers, child-process helpers and a tiny sequential runner.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+const { spawn, spawnSync } = require('child_process');
+const { serviceAuth } = require('openvibe-contracts');
+const { load } = require('../server/config');
+const { openRuntime } = require('../server/store');
+
+const ROOT = path.join(__dirname, '..');
+const ISSUER = 'https://openvibe.network';
+const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+});
+const SECRETS_KEY = crypto.randomBytes(32).toString('hex');
+const OWNER = 'usr_01J0000000000000000000000A';
+const OTHER = 'usr_01J0000000000000000000000B';
+
+const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
+
+function serviceToken(slug, cap, { aud = 'openvibe.openre', exp = Math.floor(Date.now() / 1000) + 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: ISSUER, sub: `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
+ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ }, privateKey);
+}
+
+function userToken({ subjectId = OWNER, role = 'user', aud = ['openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600 } = {}) {
+ return serviceAuth.signServiceToken({
+ sub: 57, id: 57, subject_id: subjectId, username: 'streamer', display_name: 'Streamer', role, iss: ISSUER, aud,
+ iat: Math.floor(Date.now() / 1000), exp,
+ }, privateKey);
+}
+
+function manualClock(t = Date.now()) {
+ return { t, now() { return this.t; }, advance(ms) { this.t += ms; return this.t; } };
+}
+
+const made = [];
+process.on('exit', () => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
+function tmpDir() {
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'openre-test-'));
+ made.push(d);
+ return d;
+}
+
+function testEnv(dir, extra = {}) {
+ return {
+ NODE_ENV: 'test',
+ PORT: '0',
+ OPENRE_DB_PATH: path.join(dir, 'openre.db'),
+ OPENRE_SECRETS_KEY: SECRETS_KEY,
+ OV_NETWORK_PUBLIC_KEY: publicKey,
+ OPENRE_DEST_ALLOW_PRIVATE: '1',
+ OPENRE_RTMP_PUBLIC_HOST: '127.0.0.1',
+ ...extra,
+ };
+}
+
+/** A runtime on a temp database (no network). */
+function runtime({ env = {}, clock, dir = tmpDir(), fetchImpl } = {}) {
+ const config = load(testEnv(dir, env));
+ return { ...openRuntime({ config, clock, log: silent, fetchImpl }), dir };
+}
+
+async function bootApi({ env = {}, dir = tmpDir(), clock, fetchImpl } = {}) {
+ const { start } = require('../server/index');
+ const config = load(testEnv(dir, env));
+ const h = await start({ config, clock, log: silent, fetchImpl });
+ const base = `http://127.0.0.1:${h.server.address().port}`;
+ return { ...h, base, dir };
+}
+
+async function request(base, method, p, { token, body, headers = {}, cookie, form } = {}) {
+ const h = { ...headers };
+ if (token) h.Authorization = `Bearer ${token}`;
+ if (cookie) h.Cookie = cookie;
+ let payload;
+ if (form) { h['Content-Type'] = 'application/x-www-form-urlencoded'; payload = new URLSearchParams(form).toString(); }
+ else if (body !== undefined) { h['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }
+ const res = await fetch(base + p, { method, headers: h, body: payload, redirect: 'manual' });
+ const text = await res.text();
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
+ return { status: res.status, body: json, text, headers: res.headers };
+}
+
+function freePort() {
+ return new Promise((resolve, reject) => {
+ const s = net.createServer();
+ s.once('error', reject);
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
+ });
+}
+
+/** A child process running one of the service's entry points with a test env. */
+function child(script, env, { onLine } = {}) {
+ const proc = spawn(process.execPath, [path.join(ROOT, script)], {
+ cwd: ROOT,
+ env: { ...process.env, ...env, OPENRE_ENV_FILE: '/nonexistent' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ proc.output = '';
+ const onData = (c) => {
+ const s = c.toString();
+ proc.output += s;
+ if (process.env.DEBUG) process.stderr.write(`[${path.basename(script)}] ${s}`);
+ if (onLine) for (const line of s.split('\n')) if (line) onLine(line);
+ };
+ proc.stdout.on('data', onData);
+ proc.stderr.on('data', onData);
+ proc.exited = new Promise(r => proc.on('exit', (code, signal) => r({ code, signal })));
+ return proc;
+}
+
+async function waitFor(pred, { timeoutMs = 10000, intervalMs = 50, what = 'condition' } = {}) {
+ const until = Date.now() + timeoutMs;
+ for (;;) {
+ const v = await pred();
+ if (v) return v;
+ if (Date.now() > until) throw new Error(`timed out waiting for ${what}`);
+ await sleep(intervalMs);
+ }
+}
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+function hasFfmpeg() {
+ try { return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; } catch { return false; }
+}
+
+/** Publish a lavfi test pattern over RTMP with the system ffmpeg. */
+function publish(url, { seconds } = {}) {
+ const args = ['-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=15', '-f', 'lavfi', '-i', 'sine=frequency=440',
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-g', '15', '-c:a', 'aac', '-b:a', '64k'];
+ if (seconds) args.push('-t', String(seconds));
+ args.push('-f', 'flv', url);
+ const p = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
+ p.err = '';
+ p.stderr.on('data', (c) => { p.err += c; });
+ p.exited = new Promise(r => p.on('exit', (code, signal) => r({ code, signal })));
+ return p;
+}
+
+/** An RTMP sink (ffmpeg -listen 1) that counts as a working restream destination. */
+function rtmpSink(port, key = 'sinkkey') {
+ const p = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-listen', '1', '-i', `rtmp://127.0.0.1:${port}/app/${key}`, '-f', 'null', '-'], { stdio: 'ignore' });
+ p.exited = new Promise(r => p.on('exit', (code, signal) => r({ code, signal })));
+ return p;
+}
+
+function suite(name) {
+ const tests = [];
+ const t = (n, fn) => tests.push([n, fn]);
+ t.run = async () => {
+ let failed = 0;
+ for (const [n, fn] of tests) {
+ try { await fn(); console.log(` ok ${n}`); } catch (err) { failed++; console.log(` FAIL ${n}\n${err.stack}`); }
+ }
+ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);
+ process.exit(failed ? 1 : 0);
+ };
+ return t;
+}
+
+/** The event types in the outbox, in order. */
+function outboxTypes(db) {
+ return db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope).event_type);
+}
+function outboxEnvelopes(db) {
+ return db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope));
+}
+
+module.exports = {
+ ROOT, ISSUER, privateKey, publicKey, SECRETS_KEY, OWNER, OTHER, silent, serviceToken, userToken, manualClock, tmpDir, testEnv,
+ runtime, bootApi, request, freePort, child, waitFor, sleep, hasFfmpeg, publish, rtmpSink, suite, outboxTypes, outboxEnvelopes,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// Ingest keys: stored hashed, shown once, rotation (immediate and with a grace period), revocation
+// by the coordinator, archive, and destination secrets sealed at rest.
+const assert = require('assert');
+const { runtime, manualClock, suite, OWNER, outboxEnvelopes } = require('./helpers');
+const { hashIngestKey, isIngestKeyShape, createBox } = require('../server/secrets');
+const { createCoordinator } = require('../server/coordinator');
+
+const t = suite('keys');
+
+t('a new definition issues one key; only its SHA-256 is stored', () => {
+ const rt = runtime();
+ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER, title: 'Main' });
+ assert.ok(isIngestKeyShape(key.key), 'ork_ + 43 base64url chars');
+ const rows = rt.db.prepare('SELECT * FROM ingest_keys').all();
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].key_hash, hashIngestKey(key.key));
+ assert.strictEqual(rows[0].hint, key.key.slice(-4));
+ // The plain key is nowhere in the database file's tables.
+ const dump = JSON.stringify(rt.db.prepare("SELECT * FROM ingest_keys").all()) + JSON.stringify(rt.db.prepare('SELECT * FROM stream_definitions').all());
+ assert.ok(!dump.includes(key.key));
+ const r = rt.store.definitions.resolveIngestKey(key.key, 'rtmp');
+ assert.strictEqual(r.definition.id, definition.id);
+ assert.ok(rt.db.prepare('SELECT last_used_at FROM ingest_keys').get().last_used_at > 0);
+});
+
+t('unknown, malformed and wrong-protocol keys are refused', () => {
+ const rt = runtime();
+ const { key } = rt.store.definitions.create({ owner_subject: OWNER });
+ assert.strictEqual(rt.store.definitions.resolveIngestKey('ork_' + 'A'.repeat(43), 'rtmp').error, 'unknown_key');
+ assert.strictEqual(rt.store.definitions.resolveIngestKey('a3f9c2', 'rtmp').error, 'malformed_key');
+ assert.strictEqual(rt.store.definitions.resolveIngestKey(key.key, 'whip').error, 'protocol_not_allowed');
+});
+
+t('rotate revokes the old key immediately and emits openre.key.rotated without any key', () => {
+ const rt = runtime();
+ const { definition, key: first } = rt.store.definitions.create({ owner_subject: OWNER, external_refs: [{ service: 'live', type: 'managed_stream', id: '7' }] });
+ const r = rt.store.definitions.rotateKey(definition.id, { rotated_by: OWNER });
+ assert.notStrictEqual(r.key.key, first.key);
+ assert.strictEqual(rt.store.definitions.resolveIngestKey(first.key, 'rtmp').error, 'revoked_key');
+ assert.strictEqual(rt.store.definitions.resolveIngestKey(r.key.key, 'rtmp').definition.id, definition.id);
+ const ev = outboxEnvelopes(rt.db).find(e => e.event_type === 'openre.key.rotated');
+ assert.ok(ev);
+ assert.deepStrictEqual(ev.subject, { type: 'stream', id: definition.id, revision: 1 });
+ assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });
+ const text = JSON.stringify(ev);
+ assert.ok(!text.includes(first.key) && !text.includes(r.key.key), 'no key in the event');
+ assert.strictEqual(ev.payload.retired_key_ids.length, 1);
+});
+
+t('rotate with a grace period keeps the old key for new publishes until the coordinator revokes it', async () => {
+ const clock = manualClock();
+ const rt = runtime({ clock });
+ const { definition, key: first } = rt.store.definitions.create({ owner_subject: OWNER });
+ const r = rt.store.definitions.rotateKey(definition.id, { grace_seconds: 600 });
+ assert.strictEqual(rt.store.definitions.resolveIngestKey(first.key, 'rtmp').key.status, 'grace');
+ assert.ok(rt.store.definitions.resolveIngestKey(r.key.key, 'rtmp').definition);
+ clock.advance(601 * 1000);
+ // Past the grace period the key no longer authenticates, even before the coordinator runs...
+ assert.strictEqual(rt.store.definitions.resolveIngestKey(first.key, 'rtmp').error, 'revoked_key');
+ // ...and the coordinator records the revocation.
+ const out = createCoordinator({ rt, media: null, log: { log() {}, warn() {}, error() {} } }).syncTick();
+ assert.strictEqual(out.keysRevoked, 1);
+ assert.strictEqual(rt.db.prepare("SELECT status FROM ingest_keys WHERE key_hash = ?").get(hashIngestKey(first.key)).status, 'revoked');
+});
+
+t('archiving revokes every key and is refused while a session is open', () => {
+ const rt = runtime();
+ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER });
+ const w = rt.store.workers.register({ kind: 'rtmp-ingest' });
+ rt.store.workers.ready(w.id);
+ const a = rt.store.sessions.admit({ definition, key: { id: 'k' }, protocol: 'rtmp', worker: rt.store.workers.get(w.id) });
+ assert.throws(() => rt.store.definitions.archive(definition.id), /end the live session/);
+ rt.store.sessions.finish(a.session.id, { reason: 'test' });
+ rt.store.definitions.archive(definition.id);
+ assert.strictEqual(rt.store.definitions.resolveIngestKey(key.key, 'rtmp').error, 'revoked_key');
+});
+
+t('external refs are unique across definitions', () => {
+ const rt = runtime();
+ rt.store.definitions.create({ owner_subject: OWNER, external_refs: [{ service: 'live', type: 'managed_stream', id: '9' }] });
+ assert.throws(() => rt.store.definitions.create({ owner_subject: OWNER, external_refs: [{ service: 'live', type: 'managed_stream', id: '9' }] }), /already belongs/);
+ assert.ok(rt.store.definitions.findByRef('live', 'managed_stream', '9'));
+});
+
+t('destination keys are sealed with AES-256-GCM and only the last four characters come back', () => {
+ const rt = runtime();
+ const { definition } = rt.store.definitions.create({ owner_subject: OWNER });
+ const d = rt.store.outputs.createDestination(definition.id, { platform: 'twitch', server_url: 'rtmps://live.twitch.tv/app', stream_key: 'live_1234567890_secret' });
+ assert.strictEqual(d.stream_key_hint, '****cret');
+ assert.ok(!JSON.stringify(d).includes('live_1234567890_secret'));
+ const row = rt.db.prepare('SELECT stream_key_enc FROM destinations').get();
+ assert.ok(row.stream_key_enc.startsWith('v1.') && !row.stream_key_enc.includes('secret'));
+ assert.strictEqual(rt.store.outputs.destinationForWorker(d.id).stream_key, 'live_1234567890_secret');
+ // A second key can take over: the previous one still opens old rows.
+ const box2 = createBox({ key: require('crypto').randomBytes(32).toString('hex'), previous: rt.config.secretsKey });
+ assert.strictEqual(box2.open(row.stream_key_enc), 'live_1234567890_secret');
+ // Without a key nothing is stored in the clear.
+ assert.throws(() => createBox({}).seal('x'), /OPENRE_SECRETS_KEY/);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
+'use strict';
+// End to end with real processes and real RTMP (system ffmpeg): openre-api, openre-rtmp-ingest,
+// openre-restream-worker and openre-session-coordinator as separate child processes, a stub
+// OpenVibe.Network (token endpoint), a stub OpenVibe.Events and a stub OpenVibe.Media.
+//
+// Proves: ingest with OpenRe's own keys (wrong and duplicate publishers refused), restream to a
+// working destination while a dead destination fails without touching the session, recording
+// requested from Media with a key-free loopback URL that Media can actually pull, an API restart
+// during the broadcast that ends nothing, HTTP-FLV playback through the new API, a worker
+// generation rollover (new sessions on the new generation, the old one drains and exits on its
+// own), and the events published to OpenVibe.Events.
+const assert = require('assert');
+const http = require('http');
+const { spawn } = require('child_process');
+const {
+ tmpDir, testEnv, child, waitFor, request, userToken, freePort, sleep, suite, hasFfmpeg, publish, rtmpSink, OWNER,
+} = require('./helpers');
+
+if (!hasFfmpeg()) {
+ console.log('rtmp-e2e: SKIPPED (no ffmpeg on PATH)');
+ process.exit(0);
+}
+
+const t = suite('rtmp-e2e');
+const token = userToken({ subjectId: OWNER });
+const procs = [];
+const published = [];
+const mediaCalls = [];
+let env;
+let api;
+let coordinator;
+let base;
+let rtmpPort;
+let ingest1;
+let stubs = [];
+
+function stubServer(handler) {
+ return new Promise((resolve) => {
+ const s = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => handler(req, res, body));
+ });
+ s.listen(0, '127.0.0.1', () => { stubs.push(s); resolve(`http://127.0.0.1:${s.address().port}`); });
+ });
+}
+
+const json = (res, status, obj) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
+
+function startChild(script, extra = {}) {
+ const p = child(script, { ...env, ...extra });
+ procs.push(p);
+ return p;
+}
+
+async function startApi() {
+ const p = startChild('server/index.js');
+ await waitFor(async () => { try { return (await request(base, 'GET', '/api/ready')).status === 200; } catch { return false; } }, { what: 'api ready' });
+ return p;
+}
+
+async function session(streamId, state = 'live') {
+ const r = await request(base, 'GET', `/api/v1/sessions?stream_id=${streamId}`, { token });
+ return (r.body.sessions || []).find(s => s.state === state) || null;
+}
+
+let streamA;
+let keyA;
+let pubA;
+let sessionA;
+let sink;
+
+t('setup: stubs and the four OpenRe processes', async () => {
+ const network = await stubServer((req, res, body) => {
+ if (req.url === '/oauth/token') {
+ const p = new URLSearchParams(body);
+ return json(res, 200, { access_token: `tok-${p.get('audience')}`, token_type: 'Bearer', expires_in: 300 });
+ }
+ return json(res, 404, {});
+ });
+ const events = await stubServer((req, res, body) => {
+ if (req.url === '/api/v1/events' && req.method === 'POST') {
+ assert.strictEqual(req.headers.authorization, 'Bearer tok-openvibe.events');
+ const parsed = JSON.parse(body);
+ const list = parsed.events || [parsed];
+ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });
+ return json(res, parsed.events ? 200 : 201, parsed.events ? { results } : results[0]);
+ }
+ return json(res, 404, {});
+ });
+ const media = await stubServer((req, res, body) => {
+ const call = { method: req.method, url: req.url, auth: req.headers.authorization, body: body ? JSON.parse(body) : null };
+ mediaCalls.push(call);
+ if (req.method === 'POST' && req.url === '/api/v1/live/vods') return json(res, 201, { id: 77 });
+ if (req.method === 'POST' && req.url === '/api/v1/live/vods/77/ingest/rtmp') {
+ // Behave like Media: pull the URL with ffmpeg (here for 2 s) and remember the result.
+ const pull = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-rw_timeout', '5000000', '-i', call.body.rtmp_url, '-t', '2', '-f', 'null', '-'], { stdio: 'ignore' });
+ procs.push(pull);
+ pull.on('exit', (code) => { call.pullExit = code; });
+ return json(res, 202, { id: 77, status: 'recording' });
+ }
+ if (req.method === 'POST' && req.url === '/api/v1/live/vods/77/finalize') return json(res, 200, { ok: true });
+ return json(res, 404, { error: 'no' });
+ });
+ rtmpPort = await freePort();
+ const dir = tmpDir();
+ env = testEnv(dir, {
+ PORT: String(await freePort()),
+ OPENRE_RTMP_PORT: String(rtmpPort),
+ OPENRE_RTMP_INTERNAL_PORT_MIN: String(20000 + Math.floor(Math.random() * 20000)),
+ OV_NETWORK_INTERNAL_URL: network,
+ OV_OAUTH_CLIENT_SECRET: 'test-secret',
+ EVENTS_URL: events,
+ EVENTS_RELAY_INTERVAL_MS: '200',
+ MEDIA_URL: media,
+ MEDIA_API_KEY: 'media-app-key',
+ OPENRE_RECORDING_START_DELAY_MS: '300',
+ OPENRE_WORKER_HEARTBEAT_MS: '250',
+ OPENRE_WORKER_LEASE_MS: '4000',
+ OPENRE_COORDINATOR_INTERVAL_MS: '250',
+ OPENRE_RESTREAM_POLL_MS: '250',
+ OPENRE_OUTPUT_START_DELAY_MS: '500',
+ OPENRE_RESTART_BASE_MS: '200',
+ OPENRE_MAX_RESTARTS: '3',
+ OPENRE_RAPID_CRASH_GIVEUP: '2',
+ });
+ env.OPENRE_RTMP_INTERNAL_PORT_MAX = String(Number(env.OPENRE_RTMP_INTERNAL_PORT_MIN) + 20);
+ base = `http://127.0.0.1:${env.PORT}`;
+ coordinator = startChild('workers/coordinator.js');
+ ingest1 = startChild('workers/rtmp-ingest.js');
+ startChild('workers/restream-worker.js');
+ api = await startApi();
+ await waitFor(() => /generation 1 ready/.test(ingest1.output), { what: 'rtmp worker ready' });
+});
+
+t('a stream with a working and a dead destination', async () => {
+ const r = await request(base, 'POST', '/api/v1/streams', { token, body: { title: 'E2E', recording_mode: 'vod', external_refs: [{ service: 'live', type: 'managed_stream', id: '5' }, { service: 'live', type: 'user', id: '42' }] } });
+ assert.strictEqual(r.status, 201);
+ streamA = r.body.stream.id;
+ keyA = r.body.key.key;
+ const sinkPort = await freePort();
+ sink = rtmpSink(sinkPort);
+ procs.push(sink);
+ const deadPort = await freePort();
+ assert.strictEqual((await request(base, 'POST', `/api/v1/streams/${streamA}/destinations`, { token, body: { platform: 'custom', name: 'sink', server_url: `rtmp://127.0.0.1:${sinkPort}/app`, stream_key: 'sinkkey' } })).status, 201);
+ assert.strictEqual((await request(base, 'POST', `/api/v1/streams/${streamA}/destinations`, { token, body: { platform: 'custom', name: 'dead', server_url: `rtmp://127.0.0.1:${deadPort}/app`, stream_key: 'deadkey' } })).status, 201);
+});
+
+t('a wrong key is refused; the right key goes live on generation 1', async () => {
+ const wrong = publish(`rtmp://127.0.0.1:${rtmpPort}/live/ork_${'x'.repeat(43)}`, { seconds: 3 });
+ procs.push(wrong);
+ const w = await wrong.exited;
+ assert.notStrictEqual(w.code, 0, 'ffmpeg with a wrong key fails');
+ pubA = publish(`rtmp://127.0.0.1:${rtmpPort}/live/${keyA}`);
+ procs.push(pubA);
+ sessionA = await waitFor(() => session(streamA), { what: 'session A live', timeoutMs: 15000 });
+ assert.strictEqual(sessionA.worker.generation, 1);
+});
+
+t('a second publisher with the same key is refused while the first is live', async () => {
+ const dup = publish(`rtmp://127.0.0.1:${rtmpPort}/live/${keyA}`, { seconds: 3 });
+ procs.push(dup);
+ const d = await dup.exited;
+ assert.notStrictEqual(d.code, 0);
+ assert.ok((await session(streamA)).id === sessionA.id);
+});
+
+t('the working destination goes live, the dead one fails, the session stays live', async () => {
+ const outputs = await waitFor(async () => {
+ const r = await request(base, 'GET', `/api/v1/sessions/${sessionA.id}/outputs`, { token });
+ const o = r.body.outputs || [];
+ return o.length === 2 && o.some(x => x.state === 'live') && o.some(x => x.state === 'failed') ? o : null;
+ }, { what: 'outputs live + failed', timeoutMs: 40000 });
+ const failed = outputs.find(o => o.state === 'failed');
+ assert.match(failed.last_error, /connect|closed|refused|rapid/i);
+ assert.strictEqual((await session(streamA)).id, sessionA.id, 'the source session is still live');
+ assert.strictEqual(pubA.exitCode, null, 'the encoder is still connected');
+});
+
+t('recording is requested from Media with a loopback URL that carries no key, and Media can pull it', async () => {
+ const ingestCall = await waitFor(() => mediaCalls.find(c => c.url.endsWith('/ingest/rtmp')), { what: 'media ingest call', timeoutMs: 10000 });
+ const create = mediaCalls.find(c => c.url === '/api/v1/live/vods');
+ assert.strictEqual(create.auth, 'Bearer media-app-key');
+ assert.strictEqual(create.body.user_id, 42);
+ assert.strictEqual(create.body.managed_stream_id, 5);
+ assert.strictEqual(create.body.meta.openre_session_id, sessionA.id);
+ assert.match(ingestCall.body.rtmp_url, new RegExp(`^rtmp://127\\.0\\.0\\.1:\\d+/live/${sessionA.id}$`));
+ assert.ok(!ingestCall.body.rtmp_url.includes(keyA));
+ await waitFor(() => ingestCall.pullExit !== undefined, { what: 'media pull', timeoutMs: 15000 });
+ assert.strictEqual(ingestCall.pullExit, 0, 'Media-side ffmpeg read 2 s from the loopback play URL');
+ const detail = await request(base, 'GET', `/api/v1/sessions/${sessionA.id}`, { token });
+ assert.strictEqual(detail.body.session.recording.state, 'recording');
+});
+
+t('restarting the API during the broadcast ends nothing; playback works through the new API', async () => {
+ api.kill('SIGTERM');
+ assert.strictEqual((await api.exited).code, 0);
+ await sleep(1500);
+ api = await startApi();
+ const s = await session(streamA);
+ assert.strictEqual(s && s.id, sessionA.id);
+ assert.strictEqual(pubA.exitCode, null);
+ const outputs = (await request(base, 'GET', `/api/v1/sessions/${sessionA.id}/outputs`, { token })).body.outputs;
+ assert.ok(outputs.some(o => o.state === 'live'), 'the restream kept running');
+ const head = await new Promise((resolve, reject) => {
+ http.get(`${base}/play/${sessionA.id}.flv`, (res) => {
+ assert.strictEqual(res.statusCode, 200);
+ assert.strictEqual(res.headers['content-type'], 'video/x-flv');
+ res.once('data', (c) => { resolve(c.subarray(0, 3).toString()); res.destroy(); });
+ }).on('error', reject);
+ });
+ assert.strictEqual(head, 'FLV');
+});
+
+let streamB;
+let pubB;
+let ingest2;
+
+t('a new rtmp-ingest generation takes new sessions; generation 1 drains but keeps session A', async () => {
+ ingest2 = startChild('workers/rtmp-ingest.js');
+ await waitFor(() => /generation 2 ready/.test(ingest2.output), { what: 'generation 2 ready' });
+ await waitFor(() => /draining/.test(ingest1.output) && /public listener closed/.test(ingest1.output), { what: 'generation 1 draining', timeoutMs: 10000 });
+ const r = await request(base, 'POST', '/api/v1/streams', { token, body: { title: 'B', recording_mode: 'none' } });
+ streamB = r.body.stream.id;
+ pubB = publish(`rtmp://127.0.0.1:${rtmpPort}/live/${r.body.key.key}`);
+ procs.push(pubB);
+ const sB = await waitFor(() => session(streamB), { what: 'session B live', timeoutMs: 15000 });
+ assert.strictEqual(sB.worker.generation, 2, 'new sessions reach the newest generation');
+ const sA = await session(streamA);
+ assert.strictEqual(sA.id, sessionA.id);
+ assert.strictEqual(sA.worker.generation, 1, 'the running session stays on its generation');
+ assert.strictEqual(ingest1.exitCode, null);
+});
+
+t('when session A ends, generation 1 exits by itself; Media is asked to finalise', async () => {
+ pubA.kill('SIGINT');
+ await waitFor(async () => {
+ const r = await request(base, 'GET', `/api/v1/sessions/${sessionA.id}`, { token });
+ return r.body.session.state === 'ended' ? r.body.session : null;
+ }, { what: 'session A ended', timeoutMs: 15000 });
+ const exit = await Promise.race([ingest1.exited, sleep(10000).then(() => ({ code: 'timeout' }))]);
+ assert.strictEqual(exit.code, 0, 'the drained generation exited cleanly');
+ await waitFor(() => mediaCalls.some(c => c.url.endsWith('/finalize')), { what: 'media finalize', timeoutMs: 10000 });
+ const sB = await session(streamB);
+ assert.ok(sB, 'session B on generation 2 is unaffected');
+});
+
+t('events reached OpenVibe.Events through the outbox relay, without keys', async () => {
+ const types = await waitFor(() => {
+ const ts = published.map(e => e.event_type);
+ return ['openre.session.started', 'openre.output.healthy', 'openre.output.failed', 'openre.recording.requested', 'openre.session.ended'].every(x => ts.includes(x)) ? ts : null;
+ }, { what: 'published events', timeoutMs: 10000 });
+ assert.strictEqual(types.filter(x => x === 'openre.session.started').length, 2);
+ assert.ok(!types.includes('openre.session.failed'));
+ const all = JSON.stringify(published);
+ assert.ok(!all.includes(keyA), 'no ingest key in any event');
+ assert.ok(!all.includes('sinkkey') && !all.includes('deadkey'), 'no destination key in any event');
+ const started = published.find(e => e.event_type === 'openre.session.started' && e.subject.id === sessionA.id);
+ assert.deepStrictEqual(started.payload.external_refs.map(r => `${r.service}:${r.type}:${r.id}`), ['live:managed_stream:5', 'live:user:42']);
+});
+
+t('no OpenRe process crashed along the way', () => {
+ assert.strictEqual(coordinator.exitCode, null, `coordinator exited:\n${coordinator.output}`);
+ assert.strictEqual(api.exitCode, null, `api exited:\n${api.output}`);
+ assert.strictEqual(ingest2.exitCode, null, `generation 2 exited:\n${ingest2.output}`);
+});
+
+t('teardown', async () => {
+ if (process.exitCode || process.env.DEBUG) for (const p of procs) if (p.output) console.log(p.output);
+ for (const p of procs) { try { p.kill('SIGKILL'); } catch { /* */ } }
+ for (const s of stubs) { s.closeAllConnections?.(); s.close(); }
+ stubs = [];
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
+ * if any of them fails. They use temp SQLite databases, generated RSA keys and stub subscribers
+ * on random ports; none of them needs the network or a running OpenVibe.Network. The RTMP end-to-end tests use
+ * the system ffmpeg and say SKIPPED when it is missing.
+ *
+ * npm test # everything
+ * npm test -- publish sse # 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 = 180000;
+
+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(32)} ${String(r.ms).padStart(6)}ms`);
+ }
+ 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(-60).join('\n'));
+ }
+ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
+ process.exit(failed.length ? 1 : 0);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+// Session state machine: allowed transitions, events emitted exactly per the lifecycle rules,
+// admission (one open session per stream), and outputs following their session down.
+const assert = require('assert');
+const { contracts } = { contracts: require('openvibe-contracts') };
+const { runtime, manualClock, suite, OWNER, outboxTypes, outboxEnvelopes } = require('./helpers');
+const { NEXT, canTransition } = require('../server/state-machine');
+
+const t = suite('sessions');
+
+function setup(clock = manualClock()) {
+ const rt = runtime({ clock });
+ const { definition, key } = rt.store.definitions.create({ owner_subject: OWNER, title: 'Show', external_refs: [{ service: 'live', type: 'managed_stream', id: '12' }] });
+ const w = rt.store.workers.register({ kind: 'rtmp-ingest', endpoints: { publicPort: 1936, rtmpPlayPort: 19360, flvPort: 19361 } });
+ rt.store.workers.ready(w.id);
+ return { rt, clock, definition, key, worker: rt.store.workers.get(w.id) };
+}
+
+t('the transition table is exactly starting→live→ending→ended with failed from any open state', () => {
+ assert.deepStrictEqual(NEXT, { starting: ['live', 'ending', 'failed'], live: ['ending', 'failed'], ending: ['ended', 'failed'], ended: [], failed: [] });
+ assert.ok(!canTransition('ended', 'live'));
+ assert.ok(!canTransition('live', 'starting'));
+ assert.ok(!canTransition('failed', 'ended'));
+});
+
+t('a clean session emits started then ended; envelopes are valid events.event-envelope@1', () => {
+ const { rt, clock, definition, key, worker } = setup();
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ assert.strictEqual(a.session.state, 'starting');
+ assert.deepStrictEqual(outboxTypes(rt.db), [], 'starting is internal');
+ assert.ok(rt.store.sessions.transition(a.session.id, 'live', { reason: 'media_flowing' }).ok);
+ clock.advance(90 * 1000);
+ const f = rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });
+ assert.ok(f.ok);
+ assert.strictEqual(f.session.state, 'ended');
+ assert.deepStrictEqual(outboxTypes(rt.db), ['openre.session.started', 'openre.session.ended']);
+ const [started, ended] = outboxEnvelopes(rt.db);
+ for (const ev of [started, ended]) {
+ const v = contracts.validate('events.event-envelope@1', ev);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(ev.source, 'openre');
+ assert.strictEqual(ev.subject.type, 'ingest_session');
+ assert.ok(!JSON.stringify(ev).includes(key.key), 'no ingest key in events');
+ }
+ assert.strictEqual(started.subject.revision, 2);
+ assert.strictEqual(ended.subject.revision, 4);
+ assert.deepStrictEqual(started.payload.external_refs, [{ service: 'live', type: 'managed_stream', id: '12', label: null }]);
+ assert.strictEqual(started.payload.playback.flv.internal_url, `http://127.0.0.1:19361/live/${a.session.id}.flv`);
+ assert.strictEqual(ended.payload.duration_seconds, 90);
+ assert.deepStrictEqual(rt.store.sessions.transitions(a.session.id).map(x => x.to_state), ['starting', 'live', 'ending', 'ended']);
+});
+
+t('a publisher that leaves during the handshake ends without events; failures always emit', () => {
+ const { rt, definition, key, worker } = setup();
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });
+ assert.deepStrictEqual(outboxTypes(rt.db), []);
+ const b = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ rt.store.sessions.transition(b.session.id, 'failed', { reason: 'worker_lost' });
+ const ev = outboxEnvelopes(rt.db).pop();
+ assert.strictEqual(ev.event_type, 'openre.session.failed');
+ assert.strictEqual(ev.payload.was_live, false);
+ assert.strictEqual(ev.payload.failure_reason, 'worker_lost');
+});
+
+t('invalid transitions change nothing (a worker and the coordinator racing)', () => {
+ const { rt, definition, key, worker } = setup();
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ rt.store.sessions.transition(a.session.id, 'live');
+ rt.store.sessions.transition(a.session.id, 'failed', { reason: 'lease_expired' });
+ const before = outboxTypes(rt.db).length;
+ const r = rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });
+ assert.strictEqual(r.ok, false);
+ assert.strictEqual(r.code, 'invalid_transition');
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'failed');
+ assert.strictEqual(outboxTypes(rt.db).length, before);
+});
+
+t('one open session per stream: a second publisher is refused while the first holds its lease', () => {
+ const { rt, clock, definition, key, worker } = setup();
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ assert.strictEqual(rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker }).error, 'duplicate_publisher');
+ // Lease expired (worker died): the stale session fails and the new publish is admitted.
+ clock.advance(rt.config.workers.leaseMs + 1);
+ const b = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ assert.ok(b.session);
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'failed');
+ assert.strictEqual(rt.store.sessions.get(a.session.id).failure_reason, 'lease_expired');
+});
+
+t('heartbeats renew the lease of every session the worker owns', () => {
+ const { rt, clock, definition, key, worker } = setup();
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ const lease1 = rt.store.sessions.get(a.session.id).lease_expires_at;
+ clock.advance(5000);
+ rt.store.workers.heartbeat(worker.id);
+ assert.strictEqual(rt.store.sessions.get(a.session.id).lease_expires_at, lease1 + 5000);
+});
+
+t('ending a session asks its outputs to stop; an output never moves the session', () => {
+ const { rt, definition, key, worker } = setup();
+ const dest = rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:1/app', stream_key: 'k1234' });
+ rt.store.outputs.createDestination(definition.id, { platform: 'custom', server_url: 'rtmp://127.0.0.1:2/app', stream_key: 'k5678' });
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ rt.store.sessions.transition(a.session.id, 'live');
+ assert.strictEqual(rt.store.outputs.ensureAutoOutputs(), 2);
+ const [out, other] = rt.store.outputs.outputsOfSession(a.session.id);
+ rt.store.outputs.report(other.id, { state: 'live' });
+ rt.store.outputs.report(out.id, { state: 'failed', last_error: 'boom' });
+ assert.strictEqual(rt.store.sessions.get(a.session.id).state, 'live', 'a failed output leaves the session live');
+ assert.ok(outboxTypes(rt.db).includes('openre.output.failed'));
+ rt.store.sessions.finish(a.session.id, { reason: 'publisher_disconnected' });
+ assert.strictEqual(rt.db.prepare('SELECT desired FROM outputs WHERE id = ?').get(other.id).desired, 'stop', 'the running output is told to stop');
+ assert.ok(dest.id);
+});
+
+t('an end request is recorded for the owning worker to act on', () => {
+ const { rt, definition, key, worker } = setup();
+ const a = rt.store.sessions.admit({ definition, key, protocol: 'rtmp', worker });
+ assert.ok(rt.store.sessions.requestEnd(a.session.id, OWNER));
+ assert.deepStrictEqual(rt.store.sessions.endRequestsFor(worker.id).map(s => s.id), [a.session.id]);
+});
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+/**
+ * openre-session-coordinator — leases, generations, output assignment, recording requests and the
+ * event relay (unit openre-session-coordinator.service). See server/coordinator.js.
+ *
+ * Restarting it never touches a transport: workers keep their sessions and heartbeats; a stopped
+ * coordinator only delays output assignment, recording requests and event publishing.
+ */
+const path = require('path');
+
+if (require.main === module) {
+ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
+ const { load } = require('../server/config');
+ const { openRuntime } = require('../server/store');
+ const { createCoordinator } = require('../server/coordinator');
+ const { createMediaClient } = require('../server/media-client');
+ const config = load();
+ const rt = openRuntime({ config });
+ const media = createMediaClient({ config });
+ const coordinator = createCoordinator({ rt, media });
+ coordinator.start();
+ console.log(`[coordinator] running (events relay ${rt.events.configured ? 'on' : 'off: EVENTS_URL/OV_OAUTH_CLIENT_SECRET not set'}, recordings ${media.configured ? `on → ${config.media.url} app ${config.media.appId}` : 'off'})`);
+ const prune = setInterval(() => { try { rt.events.outbox.prune(); } catch { /* next time */ } }, 6 * 60 * 60 * 1000);
+ prune.unref();
+ const shutdown = (sig) => {
+ console.log(`[coordinator] ${sig}: stopping`);
+ coordinator.stop().then(() => { rt.db.close(); process.exit(0); }, () => process.exit(1));
+ setTimeout(() => process.exit(1), 10000).unref();
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+'use strict';
+/** openre-jsmpeg: worker interface only, the transport is not ported yet (see unported-transport.js). */
+if (require.main === module) require('./unported-transport').main('jsmpeg');
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+/**
+ * openre-restream-worker — runs restream outputs (unit openre-restream-worker@<release>.service).
+ *
+ * The coordinator assigns each pending output to the newest ready restream generation; this
+ * process picks up the outputs assigned to it, runs one ffmpeg per output (OutputRunner, ported
+ * from Live's restream manager) and reports state, progress and logs. It pulls the source from the
+ * RTMP worker that owns the session (its loopback HTTP-FLV endpoint), so it never needs a key.
+ *
+ * Drain: a draining generation keeps its outputs running until their sessions end; at the drain
+ * deadline it stops them and releases them to the coordinator, which hands them to the newest
+ * generation (the destination sees one reconnect). Then the process exits.
+ */
+const path = require('path');
+const { OutputRunner } = require('./restream/output-runner');
+const { createWorkerRuntime } = require('./runtime');
+
+function createRestreamWorker({ rt, log = console, exit = (code) => process.exit(code), spawnImpl, lookup }) {
+ const { store, config } = rt;
+ const runners = new Map(); // output id → OutputRunner
+ let pollTimer = null;
+ let closing = false;
+
+ const runtime = createWorkerRuntime({
+ rt, kind: 'restream', log, exit,
+ hooks: {
+ onDrainDeadline: () => handOver(),
+ onLost: () => stopAll('worker lost'),
+ activeCount: () => runners.size,
+ onExit: () => { closing = true; clearTimeout(pollTimer); },
+ },
+ });
+
+ function inputUrlFor(session) {
+ if (session.protocol !== 'rtmp') return null;
+ const pb = store.sessions.playback(session);
+ return pb && pb.flv ? pb.flv.internal_url : null;
+ }
+
+ function poll() {
+ if (closing || !runtime.me) return;
+ const rows = store.outputs.forWorker(runtime.me.id);
+ const seen = new Set();
+ for (const row of rows) {
+ seen.add(row.id);
+ let runner = runners.get(row.id);
+ const session = store.sessions.get(row.session_id);
+ const shouldRun = row.desired === 'run' && session && session.state === 'live';
+ if (!shouldRun) {
+ if (runner) { runner.stop(row.desired === 'stop' ? 'stop requested' : 'session not live'); runners.delete(row.id); } else store.outputs.report(row.id, { state: 'stopped', ended_at: Date.now() });
+ continue;
+ }
+ if (runner && runner.stopped) { runners.delete(row.id); runner = null; continue; }
+ if (runner) continue;
+ if (runtime.draining) continue; // a draining generation starts nothing new
+ const input = inputUrlFor(session);
+ if (!input) { store.outputs.report(row.id, { state: 'failed', last_error: `restream from ${session.protocol} sessions is not supported by OpenRe yet`, ended_at: Date.now() }); continue; }
+ // Let the ingest settle before pulling (Live waits 3 s for node-media-server's FLV).
+ if (session.live_at && Date.now() - session.live_at < config.outputs.startDelayMs) continue;
+ runner = new OutputRunner({ outputId: row.id, destinationId: row.destination_id, inputUrl: input, store, config, log, spawnImpl, lookup });
+ runners.set(row.id, runner);
+ runner.start().catch((err) => runner.fail(err.message, { cooldown: false }));
+ }
+ for (const [id, runner] of runners) {
+ if (!seen.has(id)) { runner.stop('no longer assigned'); runners.delete(id); }
+ else if (runner.stopped && !runner.proc) runners.delete(id);
+ }
+ if (runtime.draining) runtime.exitWhenIdle();
+ }
+
+ function schedule() {
+ clearTimeout(pollTimer);
+ if (closing) return;
+ pollTimer = setTimeout(() => {
+ try { poll(); } catch (err) { log.error(`[restream] poll failed: ${err.stack || err}`); }
+ schedule();
+ }, config.workers.restreamPollMs);
+ }
+
+ function stopAll(reason) {
+ for (const [id, r] of runners) { r.stop(reason); runners.delete(id); }
+ }
+
+ /** Drain deadline: stop here and let the coordinator give the outputs to the newest generation. */
+ function handOver() {
+ for (const [id, r] of runners) { r.stopped = true; r.clearTimers(); if (r.proc) { try { r.proc.kill('SIGTERM'); } catch { /* */ } } runners.delete(id); }
+ const n = store.outputs.release(runtime.me.id);
+ log.log(`[restream] drain deadline: released ${n} output(s) to the newest generation`);
+ }
+
+ function start() {
+ runtime.register({});
+ runtime.ready();
+ schedule();
+ log.log(`[restream] generation ${runtime.me.generation} ready`);
+ return runtime.me;
+ }
+
+ return {
+ start,
+ poll,
+ runtime,
+ runners: () => runners,
+ drain: (reason) => runtime.drainNow(reason),
+ close() { closing = true; clearTimeout(pollTimer); stopAll('closing'); runtime.stop(); },
+ };
+}
+
+if (require.main === module) {
+ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
+ const { load } = require('../server/config');
+ const { openRuntime } = require('../server/store');
+ const rt = openRuntime({ config: load() });
+ const worker = createRestreamWorker({ rt });
+ worker.start();
+ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => worker.drain(sig));
+}
+
+module.exports = { createRestreamWorker };
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+/**
+ * FFmpeg argument building for restream outputs, ported from OpenVibe.Live
+ * server/streaming/restream-manager.js (quality presets, destination URL normalisation, SRT,
+ * output flags, friendly errors). Kept byte-for-byte equivalent where it matters so a stream that
+ * restreams correctly from Live restreams the same way from OpenRe.
+ *
+ * Only the RTMP source is ported: RTMP → destination is a codec copy (zero CPU). The JSMPEG
+ * (MPEG-TS on stdin) and WebRTC (mediasoup PlainRTP → SDP) sources stay in Live until those
+ * ingest protocols move; encodingArgs() is here for them and for a future re-encode option.
+ */
+
+const SRT_DEFAULT_LATENCY_MS = 120;
+
+const QUALITY_PRESETS = {
+ low: { label: 'Low (720p 1500k)', videoBitrate: '1500k', maxrate: '1800k', bufsize: '1500k', audioBitrate: '96k', preset: 'ultrafast', scale: '1280:720', fps: 30, gop: 60 },
+ medium: { label: 'Medium (720p 2500k)', videoBitrate: '2500k', maxrate: '3000k', bufsize: '2500k', audioBitrate: '128k', preset: 'ultrafast', scale: '1280:720', fps: 30, gop: 60 },
+ high: { label: 'High (720p 4000k)', videoBitrate: '4000k', maxrate: '4500k', bufsize: '4000k', audioBitrate: '160k', preset: 'superfast', scale: '1280:720', fps: 30, gop: 60 },
+ ultra: { label: 'Ultra (1080p 6000k)', videoBitrate: '6000k', maxrate: '6500k', bufsize: '6000k', audioBitrate: '192k', preset: 'veryfast', scale: null, fps: 30, gop: 60 },
+ source: { label: 'Source (native 8000k)', videoBitrate: '8000k', maxrate: '8500k', bufsize: '16000k', audioBitrate: '192k', preset: 'fast', scale: null, fps: 0, gop: 60 },
+};
+const PLATFORM_DEFAULT_PRESET = { twitch: 'medium', youtube: 'high', kick: 'medium', custom: 'medium' };
+const ENCODER_PRESETS = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow'];
+
+function isSrtUrl(url) { return /^srt:\/\//i.test(String(url || '')); }
+
+/**
+ * Full output URL from server_url + stream key. Twitch is normalised to RTMPS and Kick gets its
+ * required /app path, exactly as Live does. `dest` carries the decrypted stream_key/srt_passphrase.
+ */
+function buildDestUrl(dest) {
+ if (!dest.server_url || !dest.stream_key) return null;
+ if (!/^(rtmps?|srt):\/\/[^\s/]+/i.test(String(dest.server_url).trim())) return null;
+ let url = dest.server_url.replace(/\/+$/, '');
+ if (dest.platform === 'twitch' && url.startsWith('rtmp://')) url = url.replace(/^rtmp:\/\//, 'rtmps://');
+ if (dest.platform === 'kick' && !url.endsWith('/app')) url += '/app';
+ if (isSrtUrl(url)) return buildSrtUrl(url, dest);
+ return `${url}/${dest.stream_key}`;
+}
+
+function buildSrtUrl(base, dest) {
+ const u = new URL(base);
+ const q = u.searchParams;
+ if (dest.stream_key && !q.has('streamid')) q.set('streamid', dest.stream_key);
+ const latency = Number(dest.srt_latency_ms) > 0 ? Number(dest.srt_latency_ms) : SRT_DEFAULT_LATENCY_MS;
+ q.set('latency', String(latency * 1000));
+ if (dest.srt_passphrase) q.set('passphrase', dest.srt_passphrase);
+ if (!q.has('mode')) q.set('mode', 'caller');
+ if (!q.has('pkt_size')) q.set('pkt_size', '1316');
+ return u.toString();
+}
+
+function outputArgs(destUrl) {
+ const common = ['-muxdelay', '0', '-muxpreload', '0', '-flush_packets', '1', '-max_muxing_queue_size', '4096'];
+ if (isSrtUrl(destUrl)) return [...common, '-f', 'mpegts', '-mpegts_flags', '+resend_headers', destUrl];
+ return [...common, '-rtmp_live', 'live', '-f', 'flv', '-flvflags', 'no_duration_filesize', destUrl];
+}
+
+/** RTMP source → destination, codec copy (Live's _startRtmpRestream). */
+function rtmpCopyArgs(flvUrl, destUrl) {
+ return [
+ '-hide_banner',
+ '-loglevel', 'warning',
+ '-rw_timeout', '10000000',
+ '-i', flvUrl,
+ '-c', 'copy',
+ '-fflags', '+nobuffer+discardcorrupt',
+ ...outputArgs(destUrl),
+ ];
+}
+
+/** `-progress pipe:1 -stats_period 1` after the global flags: the live ACK and the health line. */
+function withProgress(args) {
+ return args[0] === '-hide_banner'
+ ? ['-hide_banner', '-progress', 'pipe:1', '-stats_period', '1', ...args.slice(1)]
+ : ['-progress', 'pipe:1', ...args];
+}
+
+function resolvePreset(destination) {
+ const key = destination && destination.quality_preset || 'auto';
+ if (key !== 'auto' && QUALITY_PRESETS[key]) return QUALITY_PRESETS[key];
+ return QUALITY_PRESETS[PLATFORM_DEFAULT_PRESET[destination && destination.platform || 'custom'] || 'medium'];
+}
+
+function customOverrides(d) {
+ const o = {};
+ if (d && d.custom_video_bitrate && Number.isFinite(Number(d.custom_video_bitrate))) o.videoBitrate = `${d.custom_video_bitrate}k`;
+ if (d && d.custom_audio_bitrate && Number.isFinite(Number(d.custom_audio_bitrate))) o.audioBitrate = `${d.custom_audio_bitrate}k`;
+ if (d && d.custom_fps && Number(d.custom_fps) > 0) o.fps = Number(d.custom_fps);
+ if (d && d.custom_encoder_preset && ENCODER_PRESETS.includes(d.custom_encoder_preset)) o.encoderPreset = d.custom_encoder_preset;
+ return o;
+}
+
+/** H.264/AAC CBR encode args (Live's _getEncodingArgs), for re-encoding sources. */
+function encodingArgs(preset, { hasAudio = true, overrides = {} } = {}) {
+ const videoBitrate = overrides.videoBitrate || preset.videoBitrate;
+ const audioBitrate = overrides.audioBitrate || preset.audioBitrate;
+ const fps = overrides.fps || preset.fps;
+ const encoderPreset = overrides.encoderPreset || preset.preset;
+ let maxrate = videoBitrate;
+ let bufsize = preset.bufsize;
+ if (overrides.videoBitrate) { const kbps = parseInt(overrides.videoBitrate, 10); maxrate = `${kbps}k`; bufsize = `${kbps}k`; }
+ const args = ['-map', '0:v:0', '-c:v', 'libx264', '-preset', encoderPreset, '-tune', 'zerolatency', '-b:v', videoBitrate,
+ '-minrate', videoBitrate, '-maxrate', maxrate, '-bufsize', bufsize, '-g', String(preset.gop), '-keyint_min', String(preset.gop),
+ '-sc_threshold', '0', '-flags', '+cgop', '-pix_fmt', 'yuv420p', '-threads', '2', '-x264-params', 'nal-hrd=cbr:force-cfr=1'];
+ if (preset.scale) args.push('-vf', `scale=${preset.scale}:force_original_aspect_ratio=decrease,pad=${preset.scale}:(ow-iw)/2:(oh-ih)/2`);
+ if (fps > 0) args.push('-r', String(fps), '-fps_mode', 'cfr');
+ if (hasAudio) args.push('-map', '0:a:0', '-c:a', 'aac', '-b:a', audioBitrate, '-ar', '48000', '-ac', '2');
+ return args;
+}
+
+/** ffmpeg stderr → something a streamer can act on (Live's friendlyFfmpegError). */
+function friendlyError(raw, platform = 'the destination') {
+ const t = String(raw || '');
+ const name = platform === 'custom' ? 'the destination' : platform.charAt(0).toUpperCase() + platform.slice(1);
+ if (/NetStream\.Publish\.BadName|Authentication|Unauthorized|403|Access denied|invalid stream key|bad name|not authorized|Publish rejected/i.test(t)) return `${name} rejected the stream key — check the key (and that it is a live key, not an expired one)`;
+ if (/Name or service not known|Temporary failure in name resolution|getaddrinfo|could not resolve/i.test(t)) return 'Could not resolve the ingest host name — check the server URL';
+ if (/Connection refused|No route to host|Network is unreachable|Connection timed out|timed out|Failed to connect|Cannot open connection/i.test(t)) return `Could not connect to ${name}'s ingest server — it may be down or the URL/port is wrong`;
+ if (/TLS|SSL|gnutls|handshake|certificate/i.test(t)) return `TLS handshake with ${name} failed — try the plain rtmp:// ingest URL or the platform's rtmps:// address`;
+ if (/Broken pipe|Connection reset|End of file|EOF|I\/O error|Input\/output error|Server closed/i.test(t)) return `${name} closed the connection — usually a rejected key, a second stream on the same key, or a platform-side hiccup (auto-retrying)`;
+ if (/srt|SRT/.test(t) && /passphrase|encrypt|crypto|Wrong password/i.test(t)) return 'SRT passphrase rejected — the passphrase (or its length, 10–79 characters) does not match the receiver';
+ if (/SRT.*(Connection setup failure|rejected|timeout)/i.test(t)) return 'SRT receiver did not accept the connection — check host, port, streamid and that the receiver is in listener mode';
+ if (/Unrecognized option|Invalid argument|Option .* not found/i.test(t)) return `Encoder configuration was rejected by ffmpeg (${t.slice(0, 80)})`;
+ if (/No such file|not found/i.test(t) && /ffmpeg/i.test(t)) return 'ffmpeg is not installed on the server';
+ if (!t.trim()) return 'Restream process stopped without a message';
+ return t.slice(0, 160);
+}
+
+/** Mask keys in URLs before anything is logged (rtmp path key, SRT streamid/passphrase). */
+function redactUrl(value) {
+ const s = String(value || '');
+ if (/^srt:\/\//i.test(s)) {
+ try {
+ const u = new URL(s);
+ for (const k of ['streamid', 'passphrase']) if (u.searchParams.has(k)) u.searchParams.set(k, '****');
+ return u.toString();
+ } catch { return 'srt://****'; }
+ }
+ return s.replace(/^(rtmps?:\/\/[^/\s]+(?:\/[^/\s]+)*)\/([^/\s]{5,})$/i, (m, base, key) => `${base}/****${key.slice(-4)}`);
+}
+
+module.exports = {
+ QUALITY_PRESETS, PLATFORM_DEFAULT_PRESET, ENCODER_PRESETS, SRT_DEFAULT_LATENCY_MS,
+ isSrtUrl, buildDestUrl, buildSrtUrl, outputArgs, rtmpCopyArgs, withProgress, resolvePreset, customOverrides, encodingArgs, friendlyError, redactUrl,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+/**
+ * One restream output: one ffmpeg process pushing one session to one destination, restarted with
+ * backoff and cut off by a circuit breaker. Ported from Live's RestreamManager._spawnFFmpeg and
+ * _scheduleRestart (the same timings, live-ACK rule and rapid-crash detection), with two changes:
+ * state is reported to the store (output row, logs, events) instead of an in-memory map, and a
+ * failure only ever ends this output — the source session is never touched from here.
+ */
+const { spawn } = require('child_process');
+const fs = require('fs');
+const { rtmpCopyArgs, withProgress, buildDestUrl, friendlyError, redactUrl } = require('./ffmpeg-args');
+const { validateDestinationUrl, checkResolvedHost } = require('../../server/destination-url');
+
+class OutputRunner {
+ constructor({ outputId, destinationId, inputUrl, store, config, log = console, spawnImpl = spawn, lookup }) {
+ this.outputId = outputId;
+ this.destinationId = destinationId;
+ this.inputUrl = inputUrl;
+ this.store = store;
+ this.config = config;
+ this.log = log;
+ this.spawnImpl = spawnImpl;
+ this.lookup = lookup;
+ this.o = config.outputs;
+ this.status = 'starting';
+ this.proc = null;
+ this.restartAttempts = 0;
+ this.restartDelay = this.o.restartBaseMs;
+ this.rapidCrashCount = 0;
+ this.everLive = false;
+ this.stopped = false;
+ this.timers = new Set();
+ this.lastProgressReport = 0;
+ this.platform = 'custom';
+ }
+
+ timer(fn, ms) {
+ const t = setTimeout(() => { this.timers.delete(t); fn(); }, ms);
+ this.timers.add(t);
+ return t;
+ }
+
+ report(change) { try { return this.store.outputs.report(this.outputId, change); } catch (err) { this.log.error(`[restream] report ${this.outputId}: ${err.message}`); return null; } }
+ note(level, message) { try { this.store.outputs.log(this.outputId, this.destinationId, level, message); } catch { /* logs are best effort */ } }
+
+ async start() {
+ if (this.stopped) return;
+ let dest;
+ try { dest = this.store.outputs.destinationForWorker(this.destinationId); } catch (err) {
+ return this.fail(`destination secrets unavailable: ${err.message}`, { cooldown: false });
+ }
+ if (!dest || !dest.enabled || dest.hold_reason) return this.stop('destination disabled');
+ this.platform = dest.platform || 'custom';
+ const v = validateDestinationUrl(dest.server_url, { allowPrivate: this.o.allowPrivateHosts });
+ if (!v.ok) return this.fail(v.error, { cooldown: false });
+ const resolved = await checkResolvedHost(v.host, { allowPrivate: this.o.allowPrivateHosts, ...(this.lookup ? { lookup: this.lookup } : {}) });
+ if (this.stopped) return;
+ if (!resolved.ok) return this.fail(`destination refused: ${resolved.error}`, { cooldown: false });
+ const destUrl = buildDestUrl(dest);
+ if (!destUrl) return this.fail('destination has no usable URL or stream key', { cooldown: false });
+ this.spawn(destUrl);
+ }
+
+ spawn(destUrl) {
+ const args = withProgress(rtmpCopyArgs(this.inputUrl, destUrl));
+ const rtmps = destUrl.startsWith('rtmps://');
+ const bin = rtmps && this.o.ffmpegOpenSslPath && fs.existsSync(this.o.ffmpegOpenSslPath) ? this.o.ffmpegOpenSslPath : this.o.ffmpegPath;
+ this.note('info', `starting ffmpeg → ${redactUrl(destUrl)}${bin !== this.o.ffmpegPath ? ' (openssl build)' : ''}`);
+ let proc;
+ try {
+ proc = this.spawnImpl(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+ } catch (err) {
+ this.onExit(null, `spawn failed: ${err.message}`);
+ return;
+ }
+ this.proc = proc;
+ const startedAt = Date.now();
+ this.startedAt = startedAt;
+ this.liveThisRun = false;
+ this.status = 'starting';
+ this.report({ state: 'starting', started_at: startedAt, restart_attempts: this.restartAttempts, next_restart_at: null });
+
+ let stderr = '';
+ let progressBuf = '';
+ let progressSeen = false;
+ const cur = {};
+ proc.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 4096) stderr = stderr.slice(-4096); });
+ proc.stdout.on('data', (d) => {
+ progressBuf += d.toString();
+ if (progressBuf.length > 8192) progressBuf = progressBuf.slice(-8192);
+ let nl;
+ while ((nl = progressBuf.indexOf('\n')) >= 0) {
+ const line = progressBuf.slice(0, nl).trim();
+ progressBuf = progressBuf.slice(nl + 1);
+ const eq = line.indexOf('=');
+ if (eq <= 0) continue;
+ const k = line.slice(0, eq);
+ const val = line.slice(eq + 1).trim();
+ if (k === 'frame') cur.frame = +val || 0;
+ else if (k === 'fps') cur.fps = +val || 0;
+ else if (k === 'bitrate') cur.bitrate_kbps = val.endsWith('kbits/s') ? +parseFloat(val) || 0 : null;
+ else if (k === 'speed') cur.speed = val === 'N/A' ? null : +parseFloat(val) || 0;
+ else if (k === 'drop_frames') cur.dropped = +val || 0;
+ else if (k === 'out_time_us' || k === 'out_time_ms') cur.out_ms = Math.round((+val || 0) / 1000);
+ else if (k === 'progress') {
+ progressSeen = true;
+ cur.at = Date.now();
+ if (!this.liveThisRun && ((cur.frame || 0) > 0 || (cur.out_ms || 0) > 0)) this.confirmLive(proc);
+ if (Date.now() - this.lastProgressReport > 2000) {
+ this.lastProgressReport = Date.now();
+ this.report({ progress: { ...cur } });
+ }
+ }
+ }
+ });
+ proc.on('error', (err) => { if (this.proc === proc) this.onExit(null, `ffmpeg error: ${err.message}`); });
+ proc.on('close', (code) => {
+ if (this.proc !== proc) return;
+ const lastLines = stderr.split('\n').filter(Boolean).slice(-5).join(' | ');
+ this.onExit(code, lastLines);
+ });
+ // No progress at all within the ACK window: the ingest never took our data.
+ this.timer(() => {
+ if (this.proc !== proc || this.liveThisRun || this.stopped) return;
+ if (progressSeen) { this.confirmLive(proc); return; }
+ this.ackTimeout = true;
+ this.note('warn', `no response from the ingest within ${this.o.liveAckTimeoutMs / 1000}s`);
+ try { proc.kill('SIGTERM'); } catch { /* */ }
+ }, this.o.liveAckTimeoutMs);
+ }
+
+ confirmLive(proc) {
+ if (this.proc !== proc || this.liveThisRun || this.stopped) return;
+ this.liveThisRun = true;
+ this.everLive = true;
+ this.rapidCrashCount = 0;
+ this.status = 'live';
+ try { this.store.outputs.clearDestinationCooldown(this.destinationId); } catch { /* */ }
+ this.report({ state: 'live', live_at: Date.now(), last_error: null });
+ this.note('info', 'output is live');
+ this.timer(() => {
+ if (this.proc === proc && this.status === 'live') {
+ this.restartAttempts = 0;
+ this.restartDelay = this.o.restartBaseMs;
+ this.rapidCrashCount = 0;
+ this.report({ restart_attempts: 0 });
+ }
+ }, this.o.stableMs);
+ }
+
+ onExit(code, raw) {
+ this.proc = null;
+ if (this.stopped) return;
+ const ran = Date.now() - (this.startedAt || Date.now());
+ if (code === 0) {
+ this.status = 'stopped';
+ this.note('info', `ffmpeg exited cleanly after ${(ran / 1000).toFixed(1)}s`);
+ // A clean exit means the source ended; the session transition stops the output.
+ this.report({ state: 'stopped', ended_at: Date.now() });
+ this.stopped = true;
+ return;
+ }
+ const message = this.ackTimeout
+ ? `No response from the ingest within ${this.o.liveAckTimeoutMs / 1000}s — ${friendlyError(raw, this.platform)}`
+ : friendlyError(raw || `ffmpeg exit code ${code}`, this.platform);
+ this.ackTimeout = false;
+ this.note('error', `ffmpeg exited (code ${code}) after ${(ran / 1000).toFixed(1)}s: ${message}`);
+ this.status = 'error';
+ this.report({ state: 'error', last_error: message });
+ this.scheduleRestart(ran, message);
+ }
+
+ scheduleRestart(ranMs, message) {
+ if (this.stopped) return;
+ if (this.restartAttempts >= this.o.maxRestarts) return this.fail(`${message} (gave up after ${this.o.maxRestarts} restarts)`, { cooldown: !this.everLive });
+ if (!this.liveThisRun && ranMs < this.o.rapidCrashMs) {
+ this.rapidCrashCount++;
+ this.restartDelay = Math.min(this.restartDelay * 2, this.o.restartMaxMs);
+ } else {
+ this.rapidCrashCount = 0;
+ }
+ if (this.rapidCrashCount >= this.o.rapidCrashGiveUp && !this.everLive) {
+ return this.fail(`${message} (${this.rapidCrashCount} rapid crashes without ever going live)`, { cooldown: true });
+ }
+ const delay = this.restartDelay;
+ this.restartDelay = Math.min(this.restartDelay * 1.5, this.o.restartMaxMs);
+ this.restartAttempts++;
+ this.report({ restart_attempts: this.restartAttempts, next_restart_at: Date.now() + delay });
+ this.timer(() => { this.start().catch(err => this.fail(err.message, { cooldown: false })); }, delay);
+ return undefined;
+ }
+
+ /** The circuit breaker: this output is done; the destination may cool down. */
+ fail(message, { cooldown }) {
+ if (this.stopped) return;
+ this.stopped = true;
+ this.clearTimers();
+ let cooldownMinutes = null;
+ if (cooldown) {
+ const r = this.store.outputs.markDestinationFailure(this.destinationId, message);
+ cooldownMinutes = r ? r.cooldownMinutes : null;
+ }
+ this.status = 'failed';
+ this.note('error', `output failed: ${message}${cooldownMinutes ? ` (destination cooling down ${cooldownMinutes} min)` : ''}`);
+ this.report({ state: 'failed', last_error: message, ended_at: Date.now(), next_restart_at: null, cooldown_minutes: cooldownMinutes });
+ }
+
+ stop(reason = 'stopped') {
+ if (this.stopped && !this.proc) return;
+ this.stopped = true;
+ this.clearTimers();
+ const proc = this.proc;
+ this.proc = null;
+ if (proc) {
+ try { proc.kill('SIGTERM'); } catch { /* */ }
+ const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* */ } }, 5000);
+ t.unref?.();
+ }
+ this.status = 'stopped';
+ this.note('info', `output stopped (${reason})`);
+ this.report({ state: 'stopped', ended_at: Date.now(), next_restart_at: null });
+ }
+
+ clearTimers() { for (const t of this.timers) clearTimeout(t); this.timers.clear(); }
+}
+
+module.exports = { OutputRunner };
new file mode 100644
new file mode 100644
@@ -0,0 +1,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+/**
+ * openre-rtmp-ingest — the RTMP transport worker (unit openre-rtmp-ingest@<release>.service).
+ *
+ * The same node-media-server 2.7.4 session code Live runs in-process (OpenVibe.Live
+ * server/streaming/rtmp-server.js), with the lifecycle and ownership moved here:
+ *
+ * public 0.0.0.0:OPENRE_RTMP_PORT (1936 until the RTMP cutover), SO_REUSEPORT so two
+ * generations can listen during a drain. Publish only; play is refused here.
+ * internal 127.0.0.1:<rtmpPlayPort> RTMP play for Media's recorder (rtmp://…/live/<session id>)
+ * 127.0.0.1:<flvPort> HTTP-FLV for the restream worker and the playback proxy
+ * Both ports are this generation's own (from OPENRE_RTMP_INTERNAL_PORT_MIN..MAX) and are
+ * published in its worker row, so consumers always reach the process that holds the
+ * publisher.
+ *
+ * Authentication happens inside the publish handshake, synchronously, against OpenRe's hashed
+ * keys only. An accepted publish is renamed from /live/<key> to /live/<session id> before
+ * node-media-server registers it, so the key never appears in any play URL, log or descriptor.
+ *
+ * Drain: when the coordinator marks this generation draining (a newer one is ready) the public
+ * listener closes — new encoder connections reach the new generation through the shared port —
+ * and running publishers stay until they leave or the drain deadline passes. Then the process
+ * exits by itself. SIGTERM starts the same drain; it never drops a live publisher.
+ */
+const http = require('http');
+const net = require('net');
+const path = require('path');
+const NodeRtmpSession = require('node-media-server/src/node_rtmp_session');
+const NodeFlvSession = require('node-media-server/src/node_flv_session');
+const context = require('node-media-server/src/node_core_ctx');
+const NmsLogger = require('node-media-server/src/node_core_logger');
+const { createWorkerRuntime } = require('./runtime');
+
+const SESSION_PATH_RE = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})$/;
+
+/** Bind the first free loopback port in [min, max] (skipping `skip`). */
+function listenInRange(server, min, max, skip = new Set()) {
+ return new Promise((resolve, reject) => {
+ let port = min;
+ const tryNext = () => {
+ while (skip.has(port)) port++;
+ if (port > max) { reject(new Error(`no free loopback port in ${min}-${max}`)); return; }
+ const onError = (err) => {
+ server.removeListener('listening', onListening);
+ if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { port++; tryNext(); } else reject(err);
+ };
+ const onListening = () => { server.removeListener('error', onError); resolve(server.address().port); };
+ server.once('error', onError);
+ server.once('listening', onListening);
+ server.listen(port, '127.0.0.1');
+ };
+ tryNext();
+ });
+}
+
+function createRtmpIngest({ rt, log = console, exit = (code) => process.exit(code) }) {
+ const { store, config } = rt;
+ NmsLogger.setLogType(1); // errors only: the library's info lines would print stream paths
+ const nmsConfig = {
+ logType: 1,
+ rtmp: { port: config.rtmp.port, chunk_size: config.rtmp.chunkSize, gop_cache: true, ping: 30, ping_timeout: 60 },
+ };
+ /** node-media-server session id → { sessionId, definitionId, endReason } */
+ const publishers = new Map();
+ let publicServer = null;
+ let playServer = null;
+ let flvServer = null;
+ let endpoints = null;
+ const handlers = [];
+
+ const runtime = createWorkerRuntime({
+ rt, kind: 'rtmp-ingest', log, exit,
+ hooks: {
+ onDrain: () => closePublic(),
+ onDrainDeadline: () => endAll('drain_deadline'),
+ onEndRequested: (s) => endSession(s.id, 'end_requested'),
+ onLost: () => { for (const [nmsId] of publishers) stopNms(nmsId); return closeAll(); },
+ onHeartbeat: () => refreshMediaInfo(),
+ activeCount: () => publishers.size,
+ onExit: () => closeAll(),
+ },
+ });
+
+ function nms(id) { return context.sessions.get(id); }
+ function stopNms(id) { const s = nms(id); if (s) { try { s.reject(); } catch { /* socket already gone */ } } }
+
+ function on(event, fn) {
+ const wrapped = (...args) => {
+ try { fn(...args); } catch (err) {
+ // A throw inside a node-media-server event is an uncaught exception, and that would
+ // take every publisher on this worker with it.
+ log.error(`[rtmp] ${event} handler failed: ${err.stack || err}`);
+ }
+ };
+ context.nodeEvent.on(event, wrapped);
+ handlers.push([event, wrapped]);
+ }
+
+ function isPublicSocket(session) {
+ return Boolean(session && session.socket && session.socket.localPort === config.rtmp.port);
+ }
+
+ on('prePublish', (id, streamPath) => {
+ const s = nms(id);
+ if (!s) return;
+ const reject = (why) => { log.log(`[rtmp] publish refused (${why}) from ${s.ip}`); s.reject(); };
+ if (!isPublicSocket(s)) return reject('not the public listener');
+ if (runtime.draining || !runtime.me || runtime.me.state !== 'ready') return reject('generation not taking sessions');
+ if (publishers.size >= config.rtmp.maxPublishersPerWorker) return reject('worker full');
+ const parts = String(streamPath).split('/');
+ if (parts.length !== 3 || parts[1] !== 'live') return reject('bad path');
+ const r = store.definitions.resolveIngestKey(parts[2], 'rtmp');
+ if (r.error) return reject(r.error);
+ const a = store.sessions.admit({ definition: r.definition, key: r.key, protocol: 'rtmp', worker: runtime.me });
+ if (a.error) return reject(a.error);
+ // Rename before node-media-server registers the publisher (it reads publishStreamPath
+ // right after this synchronous event): from here on the stream is /live/<session id>.
+ s.publishStreamPath = `/live/${a.session.id}`;
+ publishers.set(id, { sessionId: a.session.id, definitionId: r.definition.id, endReason: null });
+ log.log(`[rtmp] session ${a.session.id} accepted for ${r.definition.id} (key …${r.key.hint})`);
+ });
+
+ on('postPublish', (id) => {
+ const p = publishers.get(id);
+ if (!p) return;
+ const t = store.sessions.transition(p.sessionId, 'live', { reason: 'media_flowing', actor: `worker:${runtime.me.id}` });
+ if (!t.ok) {
+ // The coordinator failed it meanwhile (lease) — the transport must not outlive its record.
+ log.warn(`[rtmp] session ${p.sessionId} could not go live (${t.code}); closing`);
+ stopNms(id);
+ }
+ });
+
+ on('donePublish', (id) => {
+ const p = publishers.get(id);
+ if (!p) return;
+ publishers.delete(id);
+ store.sessions.finish(p.sessionId, { reason: p.endReason || 'publisher_disconnected', actor: `worker:${runtime.me.id}` });
+ log.log(`[rtmp] session ${p.sessionId} ended (${p.endReason || 'publisher_disconnected'})`);
+ if (runtime.draining) runtime.exitWhenIdle();
+ });
+
+ on('prePlay', (id, streamPath) => {
+ const s = nms(id);
+ if (!s) return;
+ // RTMP play only on the loopback play port, and only by session path.
+ if (s instanceof NodeRtmpSession && s.socket.localPort !== endpoints.rtmpPlayPort) { s.reject(); return; }
+ if (!SESSION_PATH_RE.test(String(streamPath))) s.reject();
+ });
+
+ function refreshMediaInfo() {
+ for (const [id, p] of publishers) {
+ const s = nms(id);
+ if (!s || !s.isPublishing) continue;
+ store.sessions.setMediaInfo(p.sessionId, {
+ video_codec: s.videoCodecName || null,
+ width: s.videoWidth || null,
+ height: s.videoHeight || null,
+ fps: s.videoFps || null,
+ audio_codec: s.audioCodecName || null,
+ audio_samplerate: s.audioSamplerate || null,
+ bitrate_kbps: s.bitrate || null,
+ });
+ }
+ }
+
+ function endSession(sessionId, reason) {
+ for (const [id, p] of publishers) {
+ if (p.sessionId === sessionId) { p.endReason = reason; stopNms(id); return true; }
+ }
+ // Not ours any more (publisher already gone): make sure the record is closed.
+ store.sessions.finish(sessionId, { reason, actor: `worker:${runtime.me.id}` });
+ return false;
+ }
+
+ function endAll(reason) {
+ for (const [id, p] of publishers) { p.endReason = reason; stopNms(id); }
+ }
+
+ function closePublic() {
+ if (!publicServer) return;
+ const srv = publicServer;
+ publicServer = null;
+ srv.close();
+ log.log('[rtmp] public listener closed; running publishers stay until they leave');
+ }
+
+ function closeAll() {
+ closePublic();
+ const closing = [];
+ for (const srv of [playServer, flvServer]) {
+ if (srv) closing.push(new Promise(r => { srv.closeAllConnections?.(); srv.close(() => r()); }));
+ }
+ playServer = null;
+ flvServer = null;
+ for (const [event, fn] of handlers.splice(0)) context.nodeEvent.removeListener(event, fn);
+ return Promise.all(closing);
+ }
+
+ function newRtmpServer() {
+ return net.createServer((socket) => {
+ const session = new NodeRtmpSession(nmsConfig, socket);
+ session.run();
+ });
+ }
+
+ async function start() {
+ // Internal endpoints first: they are how this generation is reached, so they go in the
+ // worker row before it can take a session.
+ playServer = newRtmpServer();
+ const rtmpPlayPort = await listenInRange(playServer, config.rtmp.internalPortMin, config.rtmp.internalPortMax);
+ flvServer = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ const m = /^\/live\/(ses_[0-9A-HJKMNP-TV-Z]{26})\.flv$/.exec(url.pathname);
+ if (req.method !== 'GET' || !m) { res.statusCode = 404; return res.end(); }
+ if (!context.publishers.has(`/live/${m[1]}`)) { res.statusCode = 404; return res.end(); }
+ res.setHeader('Content-Type', 'video/x-flv');
+ res.setHeader('Cache-Control', 'no-cache, no-store');
+ req.nmsConnectionType = 'http';
+ new NodeFlvSession(nmsConfig, req, res).run();
+ return undefined;
+ });
+ const flvPort = await listenInRange(flvServer, config.rtmp.internalPortMin, config.rtmp.internalPortMax, new Set([rtmpPlayPort]));
+ endpoints = { publicPort: config.rtmp.port, rtmpPlayPort, flvPort };
+ runtime.register(endpoints);
+
+ publicServer = newRtmpServer();
+ await new Promise((resolve, reject) => {
+ publicServer.once('error', reject);
+ publicServer.listen({ port: config.rtmp.port, host: config.rtmp.bindHost, reusePort: true }, () => resolve());
+ });
+ publicServer.on('error', (err) => log.error(`[rtmp] public listener: ${err.message}`));
+ runtime.ready();
+ log.log(`[rtmp] generation ${runtime.me.generation} ready: publish ${config.rtmp.bindHost}:${config.rtmp.port}, play 127.0.0.1:${rtmpPlayPort}, flv 127.0.0.1:${flvPort}`);
+ return endpoints;
+ }
+
+ return {
+ start,
+ runtime,
+ endpoints: () => endpoints,
+ publishers: () => [...publishers.values()],
+ drain: (reason) => runtime.drainNow(reason),
+ close: closeAll,
+ };
+}
+
+if (require.main === module) {
+ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
+ const { load } = require('../server/config');
+ const { openRuntime } = require('../server/store');
+ const rt = openRuntime({ config: load() });
+ const worker = createRtmpIngest({ rt });
+ worker.start().catch((err) => { console.error(`[rtmp] failed to start: ${err.stack || err}`); process.exit(1); });
+ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => worker.drain(sig));
+}
+
+module.exports = { createRtmpIngest, listenInRange };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * The worker interface every OpenRe transport worker implements (RTMP ingest, restream, and the
+ * WHIP/WebRTC/SFU/JSMPEG kinds as they are ported):
+ *
+ * const w = createWorkerRuntime({ rt, kind, log, hooks });
+ * w.register(endpoints) → a new generation of `kind` ('starting')
+ * w.ready() → takes new sessions; the coordinator drains older generations
+ * hooks.onDrain() → coordinator marked this generation draining: take nothing new
+ * hooks.onDrainDeadline() → the drain lifetime is over: end what is left (encoders reconnect
+ * to the newest generation)
+ * hooks.onEndRequested(s) → an owner/Live asked for session s to end
+ * hooks.onLost() → the coordinator gave this worker up (missed heartbeats): its
+ * sessions are already failed, so drop the transports and exit
+ * hooks.activeCount() → how many transports this process still carries
+ * w.exitWhenIdle() → when draining and activeCount() is 0: mark stopped and exit(0)
+ *
+ * The heartbeat (every heartbeatMs) is also the lease renewal for every session the worker owns.
+ * A worker never depends on openre-api being up; it needs only the database.
+ */
+function createWorkerRuntime({ rt, kind, log = console, hooks = {}, exit = (code) => process.exit(code) }) {
+ const { store, config } = rt;
+ let me = null;
+ let timer = null;
+ let draining = false;
+ let deadlineHandled = false;
+ let stopped = false;
+
+ function register(endpoints = {}) {
+ me = store.workers.register({ kind, endpoints });
+ log.log(`[${kind}] registered ${me.id} generation ${me.generation} (release ${me.release})`);
+ return me;
+ }
+
+ function ready() {
+ store.workers.ready(me.id);
+ me = store.workers.get(me.id);
+ schedule();
+ return me;
+ }
+
+ function beat() {
+ if (stopped || !me) return null;
+ const row = store.workers.heartbeat(me.id);
+ me = row;
+ if (!row) return null;
+ if (row.state === 'lost') {
+ log.error(`[${kind}] coordinator marked ${me.id} lost; dropping transports and exiting`);
+ stopped = true;
+ clearTimeout(timer);
+ Promise.resolve(hooks.onLost && hooks.onLost()).finally(() => exit(1));
+ return row;
+ }
+ if (row.state === 'draining' && !draining) {
+ draining = true;
+ log.log(`[${kind}] generation ${row.generation} draining (deadline ${new Date(row.drain_deadline).toISOString()})`);
+ try { hooks.onDrain && hooks.onDrain(row); } catch (err) { log.error(`[${kind}] onDrain: ${err.message}`); }
+ }
+ if (draining && row.drain_deadline && row.drain_deadline <= Date.now() && !deadlineHandled) {
+ deadlineHandled = true;
+ log.warn(`[${kind}] drain deadline reached; ending remaining transports`);
+ try { hooks.onDrainDeadline && hooks.onDrainDeadline(row); } catch (err) { log.error(`[${kind}] onDrainDeadline: ${err.message}`); }
+ }
+ if (hooks.onEndRequested) {
+ for (const s of store.sessions.endRequestsFor(me.id)) {
+ try { hooks.onEndRequested(s); } catch (err) { log.error(`[${kind}] onEndRequested: ${err.message}`); }
+ }
+ }
+ if (hooks.onHeartbeat) {
+ try { hooks.onHeartbeat(row); } catch (err) { log.error(`[${kind}] onHeartbeat: ${err.message}`); }
+ }
+ if (draining) exitWhenIdle();
+ return row;
+ }
+
+ function schedule() {
+ clearTimeout(timer);
+ if (stopped) return;
+ timer = setTimeout(() => {
+ try { beat(); } catch (err) { log.error(`[${kind}] heartbeat failed: ${err.message}`); }
+ schedule();
+ }, config.workers.heartbeatMs);
+ }
+
+ /** SIGTERM: the operator (or systemd) asks this generation to go away → same as a drain. */
+ function drainNow(reason = 'signal') {
+ if (!me || stopped) return;
+ if (!draining) {
+ store.workers.drain(me.id, Date.now() + config.workers.drainMaxMs);
+ log.log(`[${kind}] draining on ${reason}`);
+ }
+ beat();
+ }
+
+ function exitWhenIdle() {
+ const active = hooks.activeCount ? hooks.activeCount() : 0;
+ if (active > 0 || stopped) return false;
+ stopped = true;
+ clearTimeout(timer);
+ store.workers.stop(me.id, draining ? 'drained' : 'exit');
+ log.log(`[${kind}] generation ${me.generation} idle after drain; exiting`);
+ Promise.resolve(hooks.onExit && hooks.onExit()).finally(() => exit(0));
+ return true;
+ }
+
+ return {
+ register,
+ ready,
+ beat,
+ drainNow,
+ exitWhenIdle,
+ get me() { return me; },
+ get draining() { return draining; },
+ stop() { stopped = true; clearTimeout(timer); if (me) store.workers.stop(me.id, 'stopped'); },
+ };
+}
+
+module.exports = { createWorkerRuntime };
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+'use strict';
+/** openre-sfu: worker interface only, the transport is not ported yet (see unported-transport.js). */
+if (require.main === module) require('./unported-transport').main('sfu');
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+/**
+ * The worker interface for the transports OpenRe does not carry yet: WHIP/WebRTC ingest
+ * (werift), the mediasoup SFU and JSMPEG. Each registers a generation with the coordinator, takes
+ * part in the drain protocol and heartbeats like a real transport worker, and carries nothing:
+ * activeCount() is always 0, and no session of these protocols is ever admitted (their stream
+ * definitions may list the protocol, but no worker accepts it, so encoders keep using Live).
+ *
+ * This is the seam the port lands in: a ported transport replaces `admit nothing` with its
+ * session code and reports its sessions through store.sessions exactly like rtmp-ingest.js.
+ * Nothing in production runs these (deploy/ has no unit for them).
+ */
+const path = require('path');
+const { createWorkerRuntime } = require('./runtime');
+
+const PORT_STATUS = Object.freeze({
+ 'webrtc-ingest': 'WHIP/WebRTC ingest (Live: server/streaming/whip-handler.js, werift) — not ported',
+ sfu: 'WebRTC SFU (Live: server/streaming/webrtc-sfu.js + broadcast-server.js, mediasoup) — not ported',
+ jsmpeg: 'JSMPEG relay (Live: server/streaming/jsmpeg-relay.js) — not ported',
+});
+
+function createUnportedTransport({ rt, kind, log = console, exit = (code) => process.exit(code) }) {
+ if (!PORT_STATUS[kind]) throw new Error(`no unported transport "${kind}"`);
+ const runtime = createWorkerRuntime({ rt, kind, log, exit, hooks: { activeCount: () => 0 } });
+ return {
+ runtime,
+ start() {
+ runtime.register({ ported: false, note: PORT_STATUS[kind] });
+ runtime.ready();
+ log.log(`[${kind}] registered generation ${runtime.me.generation}: ${PORT_STATUS[kind]}`);
+ return runtime.me;
+ },
+ drain: (reason) => runtime.drainNow(reason),
+ };
+}
+
+function main(kind) {
+ require('dotenv').config({ path: process.env.OPENRE_ENV_FILE || path.join(process.cwd(), '.env') });
+ const { load } = require('../server/config');
+ const { openRuntime } = require('../server/store');
+ const w = createUnportedTransport({ rt: openRuntime({ config: load() }), kind });
+ w.start();
+ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => w.drain(sig));
+}
+
+module.exports = { createUnportedTransport, PORT_STATUS, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+'use strict';
+/** openre-webrtc-ingest: worker interface only, the transport is not ported yet (see unported-transport.js). */
+if (require.main === module) require('./unported-transport').main('webrtc-ingest');