openvibers/openvibe.ai Commits

AI service: providers, routing with fallback and circuit breaker, versioned templates/workflows/routes, runs, request log, citations, scoped cache, quotas and audit

OP OpenVibers committed 23 Sept 2026 · 8ecc674636 · 43 files, +7411 −0

The eleven record groups in SQLite; runs are created idempotently, served from a cache that never
crosses requester/actor/target/attribution scope, checked against quotas before any provider call,
and executed by a workflow engine that validates versioned input/output schemas and fails
explicitly instead of inventing output. Providers: the deterministic synthetic stub, OpenAI-
compatible and Anthropic adapters ported from Live's llm.js/ai-provider.js (same setting names),
the local HTTP seam and whisper.cpp. Seeded workflows: the twelve product workflows, Live's
features (structured and passthrough) and the direct chat/generate/summarize/classify/extract/
enrich/embed operations. Media inputs are fetched only from allow-listed OpenVibe hosts.

Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..86baf5c
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1880 @@
++{
++ "name": "openvibe-ai",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-ai",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "better-sqlite3": "^11.10.0",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ },
++ "optionalDependencies": {
++ "sharp": "^0.33.5"
++ }
++ },
++ "node_modules/@emnapi/runtime": {
++ "version": "1.11.3",
++ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
++ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
++ "license": "MIT",
++ "optional": true,
++ "dependencies": {
++ "tslib": "^2.4.0"
++ }
++ },
++ "node_modules/@img/sharp-darwin-arm64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
++ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-arm64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-darwin-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
++ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-darwin-x64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-libvips-darwin-arm64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
++ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-darwin-x64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
++ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-arm": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
++ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-arm64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
++ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-s390x": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
++ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linux-x64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
++ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
++ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
++ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-linux-arm": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
++ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
++ "cpu": [
++ "arm"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm": "1.0.5"
++ }
++ },
++ "node_modules/@img/sharp-linux-arm64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
++ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-arm64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-s390x": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
++ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
++ "cpu": [
++ "s390x"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-s390x": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linux-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
++ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linux-x64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linuxmusl-arm64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
++ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-linuxmusl-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
++ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "Apache-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
++ }
++ },
++ "node_modules/@img/sharp-wasm32": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
++ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
++ "cpu": [
++ "wasm32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
++ "optional": true,
++ "dependencies": {
++ "@emnapi/runtime": "^1.2.0"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-ia32": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
++ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/@img/sharp-win32-x64": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
++ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "Apache-2.0 AND LGPL-3.0-or-later",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/color": {
++ "version": "4.2.3",
++ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
++ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
++ "license": "MIT",
++ "optional": true,
++ "dependencies": {
++ "color-convert": "^2.0.1",
++ "color-string": "^1.9.0"
++ },
++ "engines": {
++ "node": ">=12.5.0"
++ }
++ },
++ "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",
++ "optional": true,
++ "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",
++ "optional": true
++ },
++ "node_modules/color-string": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
++ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
++ "license": "MIT",
++ "optional": true,
++ "dependencies": {
++ "color-name": "^1.0.0",
++ "simple-swizzle": "^0.2.2"
++ }
++ },
++ "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-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/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-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/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/is-arrayish": {
++ "version": "0.3.4",
++ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
++ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
++ "license": "MIT",
++ "optional": true
++ },
++ "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/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.6.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0",
++ "integrity": "sha512-bRN/XG+mlvg0lnOc+9kVa5HgxBWTXy/xRfDn8Gnz5k41FT73zg6i463Le7yWkcLKhq6HI3NKlpBf3C/NRpSX9g==",
++ "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/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/sharp": {
++ "version": "0.33.5",
++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
++ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
++ "hasInstallScript": true,
++ "license": "Apache-2.0",
++ "optional": true,
++ "dependencies": {
++ "color": "^4.2.3",
++ "detect-libc": "^2.0.3",
++ "semver": "^7.6.3"
++ },
++ "engines": {
++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
++ },
++ "funding": {
++ "url": "https://opencollective.com/libvips"
++ },
++ "optionalDependencies": {
++ "@img/sharp-darwin-arm64": "0.33.5",
++ "@img/sharp-darwin-x64": "0.33.5",
++ "@img/sharp-libvips-darwin-arm64": "1.0.4",
++ "@img/sharp-libvips-darwin-x64": "1.0.4",
++ "@img/sharp-libvips-linux-arm": "1.0.5",
++ "@img/sharp-libvips-linux-arm64": "1.0.4",
++ "@img/sharp-libvips-linux-s390x": "1.0.4",
++ "@img/sharp-libvips-linux-x64": "1.0.4",
++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
++ "@img/sharp-linux-arm": "0.33.5",
++ "@img/sharp-linux-arm64": "0.33.5",
++ "@img/sharp-linux-s390x": "0.33.5",
++ "@img/sharp-linux-x64": "0.33.5",
++ "@img/sharp-linuxmusl-arm64": "0.33.5",
++ "@img/sharp-linuxmusl-x64": "0.33.5",
++ "@img/sharp-wasm32": "0.33.5",
++ "@img/sharp-win32-ia32": "0.33.5",
++ "@img/sharp-win32-x64": "0.33.5"
++ }
++ },
++ "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/simple-swizzle": {
++ "version": "0.2.4",
++ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
++ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
++ "license": "MIT",
++ "optional": true,
++ "dependencies": {
++ "is-arrayish": "^0.3.1"
++ }
++ },
++ "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/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/tslib": {
++ "version": "2.8.1",
++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
++ "license": "0BSD",
++ "optional": true
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..bf56d0a
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,30 @@
++{
++ "name": "openvibe-ai",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.AI: providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit for every OpenVibe product. Extracted from OpenVibe.Live's AI subsystem.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "import-from-live": "node scripts/import-from-live.js"
++ },
++ "keywords": ["openvibe", "ai", "llm", "workflows", "whisper"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.AI",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "better-sqlite3": "^11.10.0",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.6.0"
++ },
++ "optionalDependencies": {
++ "sharp": "^0.33.5"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++}
ddiff --git a/server/api/admin.js b/server/api/admin.js
nnew file mode 100644
iindex 0000000..d555ad8
---- /dev/null
++++ b/server/api/admin.js
@@@ -0,0 +1,145 @@
++'use strict';
++/**
++ * Registry and admin API. Provider responses never include secret values — only the secret
++ * reference name and whether it resolves.
++ *
++ * GET /api/v1/status summary (ai.usage.read)
++ * GET /api/v1/providers | /providers/:key (ai.provider.manage | ai.usage.read)
++ * POST /api/v1/providers create/replace (ai.provider.manage)
++ * PATCH /api/v1/providers/:key update (ai.provider.manage)
++ * POST /api/v1/providers/:key/{disable,enable,reset} status / circuit reset (ai.provider.manage)
++ * GET /api/v1/models POST /api/v1/models PATCH /api/v1/models/:provider/:model
++ * GET /api/v1/routes[/:key] POST /api/v1/routes/:key/versions POST /routes/:key/versions/:v/status
++ * GET /api/v1/templates[/:key] POST /api/v1/templates/:key/versions POST /templates/:key/versions/:v/status
++ * GET /api/v1/workflows[/:key] POST /api/v1/workflows/:key/versions POST /workflows/:key/versions/:v/status
++ * (routes/templates/workflows: read with ai.workflow.manage, ai.run.create or ai.usage.read; write with ai.workflow.manage)
++ * GET /api/v1/quotas POST /api/v1/quotas (ai.provider.manage)
++ * GET /api/v1/usage, /api/v1/requests, /api/v1/audit, /api/v1/cache (ai.usage.read)
++ * DELETE /api/v1/cache[?workflow=] purge (ai.provider.manage)
++ */
++const express = require('express');
++const { CAPS } = require('../auth');
++const { AiError } = require('../util');
++const { sendError } = require('./runs');
++
++function adminRouter({ db, registry, pool, quotas, cache, runs, auth, log = console }) {
++ const r = express.Router();
++ const provRead = auth.requireCap(CAPS.providerManage, CAPS.usageRead);
++ const provWrite = auth.requireCap(CAPS.providerManage);
++ const defRead = auth.requireCap(CAPS.workflowManage, CAPS.runCreate, CAPS.usageRead);
++ const defWrite = auth.requireCap(CAPS.workflowManage);
++ const usageRead = auth.requireCap(CAPS.usageRead);
++ const wrap = (fn) => (req, res) => { try { const out = fn(req, res); if (out !== undefined) res.json(out); } catch (err) { sendError(res, err, req.ov, log); } };
++ const actor = (req) => ({ actor: req.principal.sub, trace: req.ov.traceId });
++ const view = (p) => registry.publicProvider(p, pool.health(p.key));
++ const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
++
++ r.get('/api/v1/status', usageRead, wrap(() => {
++ const counts = Object.fromEntries(db.prepare('SELECT status, COUNT(*) n FROM runs GROUP BY status').all().map(x => [x.status, x.n]));
++ const today = new Date().toISOString().slice(0, 10);
++ const spend = db.prepare('SELECT COALESCE(SUM(cost_usd),0) cost, COALESCE(SUM(requests),0) requests FROM usage_daily WHERE day = ?').get(today);
++ return {
++ providers: registry.listProviders().map(p => ({ key: p.key, kind: p.kind, status: p.status, credentials: view(p).credentials, health: pool.health(p.key).state })),
++ runs: counts, today: { day: today, requests: spend.requests, cost_usd: spend.cost },
++ workflows: registry.listWorkflows().length, templates: registry.listTemplates().length, routes: registry.listRoutes().length,
++ inflight: runs.inflight.size, quotas: quotas.counters(),
++ };
++ }));
++
++ // Providers
++ r.get('/api/v1/providers', provRead, wrap(() => ({ providers: registry.listProviders().map(view) })));
++ r.get('/api/v1/providers/:key', provRead, wrap((req) => {
++ const p = registry.getProvider(req.params.key);
++ if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
++ return { provider: view(p), models: registry.listModels({ provider: p.key }), stats: pool.stats[p.key] || null };
++ }));
++ r.post('/api/v1/providers', provWrite, wrap((req, res) => {
++ const b = body(req);
++ if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');
++ res.status(201);
++ return { provider: view(registry.upsertProvider(b, { ...actor(req), origin: 'admin' })) };
++ }));
++ r.patch('/api/v1/providers/:key', provWrite, wrap((req) => {
++ const b = body(req);
++ if ('api_key' in b || 'secret' in b) throw new AiError(422, 'ai.invalid', 'send secret_ref (env:NAME), never a secret value');
++ if (!registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');
++ return { provider: view(registry.upsertProvider({ ...b, key: req.params.key }, { ...actor(req), origin: 'admin' })) };
++ }));
++ for (const [action, status] of [['disable', 'disabled'], ['enable', 'active']]) {
++ r.post(`/api/v1/providers/:key/${action}`, provWrite, wrap((req) => {
++ if (!registry.getProvider(req.params.key)) throw new AiError(404, 'ai.not_found', 'no such provider');
++ return { provider: view(registry.upsertProvider({ key: req.params.key, status }, { ...actor(req), origin: 'admin' })) };
++ }));
++ }
++ r.post('/api/v1/providers/:key/reset', provWrite, wrap((req) => {
++ pool.resetHealth(req.params.key);
++ registry.audit(req.principal.sub, 'provider.circuit_reset', 'provider', req.params.key, { trace: req.ov.traceId });
++ return { health: pool.health(req.params.key) };
++ }));
++
++ // Models
++ r.get('/api/v1/models', provRead, wrap((req) => ({ models: registry.listModels({ provider: req.query.provider }) })));
++ r.post('/api/v1/models', provWrite, wrap((req, res) => { res.status(201); return { model: registry.upsertModel(body(req), actor(req)) }; }));
++ r.patch('/api/v1/models/:provider/:model', provWrite, wrap((req) => {
++ if (!registry.getModel(req.params.provider, req.params.model)) throw new AiError(404, 'ai.not_found', 'no such model');
++ return { model: registry.upsertModel({ ...body(req), provider_key: req.params.provider, model_key: req.params.model }, actor(req)) };
++ }));
++
++ // Versioned definitions
++ const kinds = {
++ routes: { list: registry.listRoutes, get: registry.getRoute, create: registry.createRouteVersion, one: 'route' },
++ templates: { list: registry.listTemplates, get: registry.getTemplate, create: registry.createTemplateVersion, one: 'template' },
++ workflows: { list: registry.listWorkflows, get: registry.getWorkflow, create: registry.createWorkflowVersion, one: 'workflow' },
++ };
++ for (const [plural, k] of Object.entries(kinds)) {
++ r.get(`/api/v1/${plural}`, defRead, wrap((req) => ({ [plural]: k.list({ history: req.query.history === '1', namespace: req.query.namespace }) })));
++ r.get(`/api/v1/${plural}/:key`, defRead, wrap((req) => {
++ const latest = k.get(req.params.key, req.query.version ? Number(req.query.version) : undefined);
++ if (!latest) throw new AiError(404, 'ai.not_found', `no ${k.one} ${req.params.key}`);
++ const versions = k.list({ history: true }).filter(x => x.key === req.params.key).map(x => ({ version: x.version, status: x.status, created_by: x.created_by, created_at: x.created_at }));
++ return { [k.one]: latest, versions };
++ }));
++ // An edit is always a new version; the previous one stays readable.
++ r.post(`/api/v1/${plural}/:key/versions`, defWrite, wrap((req, res) => {
++ try {
++ const created = k.create(req.params.key, body(req), actor(req));
++ res.status(201);
++ return { [k.one]: created };
++ } catch (err) {
++ if (err instanceof AiError) throw err;
++ throw new AiError(422, 'ai.invalid', err.message);
++ }
++ }));
++ r.post(`/api/v1/${plural}/:key/versions/:version/status`, defWrite, wrap((req) => ({
++ [k.one]: registry.setStatus(k.one, req.params.key, Number(req.params.version), body(req).status, actor(req)),
++ })));
++ }
++
++ // Quotas, usage, logs, audit, cache
++ r.get('/api/v1/quotas', usageRead, wrap(() => ({ quotas: quotas.list(), current: quotas.counters() })));
++ r.post('/api/v1/quotas', provWrite, wrap((req, res) => { res.status(201); return { quota: quotas.upsert(body(req), { ...actor(req), origin: 'admin' }) }; }));
++ r.get('/api/v1/usage', usageRead, wrap((req) => ({ usage: quotas.usage({ from: req.query.from, to: req.query.to, requester: req.query.requester }) })));
++ r.get('/api/v1/requests', usageRead, wrap((req) => {
++ const where = [];
++ const args = [];
++ if (req.query.provider) { where.push('provider_key = ?'); args.push(req.query.provider); }
++ if (req.query.status) { where.push('status = ?'); args.push(req.query.status); }
++ if (req.query.run) { where.push('run_id = ?'); args.push(req.query.run); }
++ if (req.query.fallback === '1') where.push('fallback = 1');
++ // Raw debug fields only for provider managers who ask for them (and only exist when opted in).
++ const debug = req.query.debug === '1' && auth.principalHas(req, CAPS.providerManage);
++ const cols = debug ? '*' : 'id, run_id, seq, operation, provider_key, model_key, route_key, route_version, status, skip_reason, fallback, prompt_hash, input_hash, output_hash, tokens_in, tokens_out, tokens_cached, tokens_estimated, cost_usd, latency_ms, error, created_at';
++ return { requests: db.prepare(`SELECT ${cols} FROM requests ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(500, Number(req.query.limit) || 100)) };
++ }));
++ r.get('/api/v1/audit', usageRead, wrap((req) => ({ audit: registry.listAudit({ action: req.query.action, targetType: req.query.target_type, targetId: req.query.target_id, limit: Number(req.query.limit) || 100, before: req.query.before }) })));
++ r.get('/api/v1/cache', usageRead, wrap(() => ({ cache: cache.stats() })));
++ r.delete('/api/v1/cache', provWrite, wrap((req) => {
++ const n = cache.purge({ workflow: req.query.workflow });
++ registry.audit(req.principal.sub, 'cache.purge', 'cache', req.query.workflow || '*', { trace: req.ov.traceId, metadata: { removed: n } });
++ return { removed: n };
++ }));
++
++ return r;
++}
++
++module.exports = { adminRouter };
ddiff --git a/server/api/runs.js b/server/api/runs.js
nnew file mode 100644
iindex 0000000..b9a8afe
---- /dev/null
++++ b/server/api/runs.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * Run API.
++ *
++ * POST /api/v1/runs create (ai.run.create); ?wait=ms waits for the result
++ * GET /api/v1/runs the caller's runs (ai.run.read)
++ * GET /api/v1/runs/:id one run (+ citations, request log metadata)
++ * POST /api/v1/runs/:id/cancel (ai.run.create, owner)
++ * POST /api/v1/runs/:id/retry (ai.run.create, owner) -> a new run with retry_of
++ * GET /api/v1/runs/:id/citations (ai.run.read)
++ * POST /api/v1/runs/:id/citations attach sources/citations to a finished run (ai.run.create, owner)
++ * POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}
++ * direct operations: a run of workflow ai.<op>, waited for
++ *
++ * Responses: 201 when a new run finished (or was served from cache) within the wait, 202 while it
++ * is still queued/running (poll GET /runs/:id), 200 for an idempotent replay. Errors are
++ * problem+json; a quota refusal is 429 with Retry-After.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { AiError } = require('../util');
++const { CAPS, namespaceAllowed } = require('../auth');
++
++function sendError(res, err, ctx, log = console) {
++ if (err instanceof AiError) {
++ const extra = err.extra && typeof err.extra === 'object' ? { ...err.extra } : undefined;
++ const errors = extra && Array.isArray(extra.errors) ? extra.errors : undefined;
++ if (extra) delete extra.errors;
++ if (err.status === 429 && extra && extra.retry_after_seconds) res.setHeader('Retry-After', String(extra.retry_after_seconds));
++ return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx, errors, extra });
++ }
++ log.error(`[api] ${err && err.stack || err}`);
++ return http.sendProblem(res, 500, 'ai.internal', { detail: 'internal error', ctx });
++}
++
++function runsRouter({ runs, registry, auth, config, log = console }) {
++ const r = express.Router();
++ const create = auth.requireCap(CAPS.runCreate);
++ const read = auth.requireCap(CAPS.runRead, CAPS.runCreate, CAPS.usageRead);
++ const has = (req) => (id) => auth.principalHas(req, id);
++
++ function checkNamespace(req, workflowKey) {
++ if (!namespaceAllowed(req.principal, workflowKey)) throw new AiError(403, 'capability.namespace_denied', `this token may not run ${workflowKey}`);
++ }
++
++ async function createAndRespond(req, res, body, waitMs) {
++ checkNamespace(req, String(body.workflow || ''));
++ const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
++ const created = runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });
++ const run = await runs.wait(created, waitMs);
++ const status = !created.created ? 200 : (runs.TERMINAL.has(run.status) ? 201 : 202);
++ if (status === 202) res.setHeader('Location', `/api/v1/runs/${run.id}`);
++ return res.status(status).json({ run, replayed: !created.created || undefined });
++ }
++
++ r.post('/api/v1/runs', create, async (req, res) => {
++ try {
++ const wait = Math.max(0, Math.min(Number(req.query.wait) || 0, config.runs.maxWaitMs));
++ await createAndRespond(req, res, req.body || {}, wait);
++ } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
++ r.get('/api/v1/runs', read, (req, res) => {
++ const all = req.query.all === '1' && auth.principalHas(req, CAPS.usageRead);
++ res.json({ runs: runs.list(req.principal, { status: req.query.status, workflow: req.query.workflow, target: req.query.target, limit: req.query.limit, before: req.query.before, all }) });
++ });
++
++ function owned(req) {
++ const run = runs.get(req.params.id);
++ if (!run) throw new AiError(404, 'run.not_found', 'no such run');
++ const s = req.principal.subject;
++ const mine = run.requester.type === s.type && run.requester.id === s.id;
++ if (!mine && !auth.principalHas(req, CAPS.usageRead)) throw new AiError(404, 'run.not_found', 'no such run');
++ return run;
++ }
++
++ r.get('/api/v1/runs/:id', read, (req, res) => {
++ try {
++ const run = owned(req);
++ res.json({ run, citations: runs.citations(run.id), requests: runs.requestsFor(run.id) });
++ } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
++ r.post('/api/v1/runs/:id/cancel', create, (req, res) => {
++ try { res.json({ run: runs.cancel(req.params.id, req.principal, { trace: req.ov.traceId, principalHas: has(req) }) }); } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
++ r.post('/api/v1/runs/:id/retry', create, async (req, res) => {
++ try {
++ const created = runs.retry(req.params.id, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, principalHas: has(req) });
++ const wait = Math.max(0, Math.min(Number(req.query.wait) || 0, config.runs.maxWaitMs));
++ const run = await runs.wait(created, wait);
++ res.status(runs.TERMINAL.has(run.status) ? 201 : 202).json({ run });
++ } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
++ r.get('/api/v1/runs/:id/citations', read, (req, res) => {
++ try { const run = owned(req); res.json({ citations: runs.citations(run.id) }); } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
++ r.post('/api/v1/runs/:id/citations', create, (req, res) => {
++ try {
++ const run = owned(req);
++ const s = req.principal.subject;
++ if (run.requester.type !== s.type || run.requester.id !== s.id) throw new AiError(403, 'capability.denied', 'only the requester may attach citations');
++ const list = Array.isArray(req.body && req.body.citations) ? req.body.citations : null;
++ if (!list || !list.length || list.length > 50) throw new AiError(422, 'input.invalid', 'citations: 1-50 items');
++ for (const c of list) {
++ if (!c || typeof c.source_type !== 'string' || !/^[a-z][a-z0-9_.-]{1,63}$/.test(c.source_type)) throw new AiError(422, 'input.invalid', 'every citation needs a source_type');
++ for (const k of ['url', 'title', 'author', 'snippet', 'source_id']) if (c[k] != null && String(c[k]).length > 4000) throw new AiError(422, 'input.invalid', `${k} too long`);
++ }
++ runs.addCitations(run.id, list.map(c => ({ ...c, ordinal: null, provenance: { ...(c.provenance || {}), attached: true } })), req.principal.sub);
++ registry.audit(req.principal.sub, 'run.citations.attach', 'run', run.id, { trace: req.ov.traceId, metadata: { count: list.length } });
++ res.status(201).json({ citations: runs.citations(run.id) });
++ } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
++ // Direct operations — each is a run of workflow ai.<op>, waited for (default: the max wait).
++ for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed']) {
++ r.post(`/api/v1/${op}`, create, async (req, res) => {
++ try {
++ const body = req.body || {};
++ const { idempotency_key: idem, target, attribution, on_behalf_of: obo, options, ...input } = body;
++ const wait = req.query.wait != null ? Math.max(0, Math.min(Number(req.query.wait) || 0, config.runs.maxWaitMs)) : config.runs.maxWaitMs;
++ await createAndRespond(req, res, { workflow: `ai.${op}`, input, idempotency_key: idem, target, attribution, on_behalf_of: obo, options }, wait);
++ } catch (err) { sendError(res, err, req.ov, log); }
++ });
++ }
++
++ return r;
++}
++
++module.exports = { runsRouter, sendError };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..7b54f75
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,70 @@
++'use strict';
++/** Express app: request context, health/readiness, the v1 run API and the admin/registry API. */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { runsRouter } = require('./api/runs');
++const { adminRouter } = require('./api/admin');
++const pkg = require('../package.json');
++
++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, log = console }) {
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', 'loopback');
++ app.use(http.middleware());
++ app.use((req, res, next) => {
++ res.setHeader('X-Content-Type-Options', 'nosniff');
++ res.setHeader('Cache-Control', 'no-store');
++ next();
++ });
++ // Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
++ app.use('/api', express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
++
++ app.get('/api/health', (_req, res) => {
++ res.json({ status: 'ok', service: 'openvibe-ai', version: pkg.version });
++ });
++
++ app.get('/api/ready', (_req, res) => {
++ let dbOk = false;
++ try { dbOk = db.prepare('SELECT 1 AS x').get().x === 1; } catch { dbOk = false; }
++ const checks = { db: dbOk, key: keys.loaded(), workflows: dbOk && registry.listWorkflows().length > 0 };
++ const ready = Object.values(checks).every(Boolean);
++ const providers = dbOk ? registry.listProviders().map(p => ({ key: p.key, kind: p.kind, status: p.status, health: pool.health(p.key).state })) : [];
++ res.status(ready ? 200 : 503).json({ status: ready ? 'ready' : 'not_ready', checks, providers, inflight: runs.inflight.size });
++ });
++
++ app.use(runsRouter({ runs, registry, auth, config, log }));
++ app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, log }));
++
++ app.get('/', (_req, res) => {
++ res.type('text/plain').send([
++ 'OpenVibe.AI: providers, models, routing, versioned prompt templates and workflows, runs, citations, scoped cache, quotas and audit.',
++ '',
++ 'POST /api/v1/runs {workflow, input, target?, idempotency_key} run a workflow (?wait=ms)',
++ 'GET /api/v1/runs/:id POST /api/v1/runs/:id/cancel|retry',
++ 'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
++ 'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
++ 'GET /api/health, /api/ready',
++ '',
++ 'Callers authenticate with OpenVibe.Network service tokens (audience openvibe.ai).',
++ 'AI output is a draft/evidence package attributed to a workflow, model and run, never to a person.',
++ '',
++ 'Source: https://github.com/OpenVibers/OpenVibe.AI',
++ '',
++ ].join('\n'));
++ });
++
++ app.use((req, res) => http.sendProblem(res, 404, 'ai.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 http.sendProblem(res, 400, 'input.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });
++ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'input.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 http.sendProblem(res, 500, 'ai.internal', { detail: 'internal error', ctx: req.ov });
++ });
++
++ return app;
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth.js b/server/auth.js
nnew file mode 100644
iindex 0000000..1973fcf
---- /dev/null
++++ b/server/auth.js
@@@ -0,0 +1,143 @@
++'use strict';
++/**
++ * Authentication: callers present OpenVibe.Network service tokens (RS256 client-credentials JWTs,
++ * audience openvibe.ai), verified with openvibe-contracts serviceAuth.verifyServiceToken.
++ *
++ * Capabilities ai.run.create / ai.run.read / ai.workflow.manage / ai.provider.manage / ai.usage.read
++ * are proposed in docs/capabilities-proposal/ and are not in openvibe-contracts yet. Until they
++ * ship, allows() decides with the contracts' own grant rule (the exact id, or a `family.*` grant);
++ * once contracts knows an id, contracts decides.
++ *
++ * Namespaces: a token whose `ns` claim is non-empty may only run workflows inside those namespaces
++ * (the same matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate').
++ */
++const crypto = require('crypto');
++const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++
++const CAPS = Object.freeze({
++ runCreate: 'ai.run.create',
++ runRead: 'ai.run.read',
++ workflowManage: 'ai.workflow.manage',
++ providerManage: 'ai.provider.manage',
++ usageRead: 'ai.usage.read',
++});
++
++/** Loads the Network signing key from /api/.well-known/jwks (retrying), or uses a configured PEM. */
++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, 30 * 1000);
++ retryTimer.unref?.();
++ }
++ return k;
++ };
++ refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);
++ refreshTimer.unref?.();
++ return attempt();
++ }
++
++ function stop() { clearTimeout(retryTimer); clearInterval(refreshTimer); }
++
++ return { get: () => key, loaded: () => Boolean(key), start, stop, fetchOnce };
++}
++
++/** Exact id or `family.*` grant (the rule openvibe-contracts applies to the capabilities it knows). */
++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` };
++ if (!capabilities.get(id)) return { allowed: true, code: null, reason: null }; // not in contracts yet: local rule decided
++ const c = capabilities.check(claims, id);
++ return c;
++}
++
++function namespaceAllowed(principal, workflowKey) {
++ const ns = principal && Array.isArray(principal.ns) ? principal.ns : [];
++ if (!ns.length) return true;
++ return capabilities.namespaceAllowed(ns, workflowKey);
++}
++
++function bearer(req) {
++ const h = String(req.headers.authorization || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++/** svc:live -> { type: 'service', id: 'live' }; app:/mod: principals keep their type. */
++function principalSubject(sub) {
++ const m = /^(svc|app|mod):(.+)$/.exec(String(sub || ''));
++ if (!m) return null;
++ return { type: m[1] === 'svc' ? 'service' : m[1], id: m[2] };
++}
++
++function createAuth({ config, keys }) {
++ function verify(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 });
++ }
++
++ /**
++ * Express guard. `anyOf` lists capability ids; one granted is enough.
++ * Sets req.principal = { sub, subject: {type,id}, cap, ns, jti }.
++ */
++ function requireCap(...anyOf) {
++ return function capGuard(req, res, next) {
++ const ctx = req.ov;
++ const token = bearer(req);
++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token (audience openvibe.ai) is required', ctx });
++ const r = verify(token);
++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
++ let decision = null;
++ for (const id of anyOf) {
++ decision = allows(r.claims, id);
++ if (decision.allowed) break;
++ }
++ if (!decision.allowed) return http.sendProblem(res, 403, decision.code, { detail: anyOf.length > 1 ? `one of ${anyOf.join(', ')} is required` : decision.reason, ctx });
++ const subject = principalSubject(r.claims.sub);
++ req.principal = { sub: r.claims.sub, subject, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti, claims: r.claims };
++ return next();
++ };
++ }
++
++ /** Does the authenticated principal also hold `id`? (for owner-or-admin checks) */
++ function principalHas(req, id) { return Boolean(req.principal && allows(req.principal.claims, id).allowed); }
++
++ return { verify, requireCap, principalHas };
++}
++
++module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, namespaceAllowed, principalSubject, bearer };
ddiff --git a/server/cache.js b/server/cache.js
nnew file mode 100644
iindex 0000000..9a542d3
---- /dev/null
++++ b/server/cache.js
@@@ -0,0 +1,61 @@
++'use strict';
++/**
++ * The run cache. A cached output is only ever served to a request with the same scope:
++ *
++ * private requester principal + on_behalf_of actor + target + attribution (the default)
++ * service requester principal only — for workflows whose inputs are public text and whose
++ * outputs are the same for every user of that service (translation of a public chat
++ * line, footer copy). Still never shared across services.
++ * none never cached
++ *
++ * The scope is part of the hashed key AND stored beside it, and get() compares both, so an entry
++ * written for actor A cannot be read by actor B even on a hash collision. The key also covers the
++ * workflow, template and route versions and the input hash, so any new version misses cleanly.
++ * Synthetic (stub) outputs are never cached.
++ */
++const { sha256, iso, parseJson } = require('./util');
++
++function createCache(db, { clock = { now: () => Date.now() } } = {}) {
++ function scopeFor(mode, ctx) {
++ if (mode === 'none') return null;
++ const req = `${ctx.requesterType}:${ctx.requesterId}`;
++ if (mode === 'service') return `service|${req}`;
++ return `private|${req}|actor:${ctx.actorKey || '-'}|target:${ctx.targetKey || '-'}|attr:${ctx.attributionKey || '-'}`;
++ }
++
++ function keyFor({ scope, workflowKey, workflowVersion, templateVersion, routeKey, routeVersion, inputHash }) {
++ return sha256({ scope, w: workflowKey, wv: workflowVersion, tv: templateVersion || null, r: routeKey || null, rv: routeVersion || null, i: inputHash });
++ }
++
++ function get(key, scope) {
++ const row = db.prepare('SELECT * FROM cache_entries WHERE cache_key = ?').get(key);
++ if (!row) return null;
++ if (row.scope !== scope) return null; // belt and braces: never cross scope
++ if (row.expires_at <= clock.now()) { db.prepare('DELETE FROM cache_entries WHERE cache_key = ?').run(key); return null; }
++ db.prepare('UPDATE cache_entries SET hits = hits + 1 WHERE cache_key = ?').run(key);
++ return { ...row, output: parseJson(row.output, null) };
++ }
++
++ function put({ key, scope, privacy, workflowKey, workflowVersion, templateVersion, routeKey, routeVersion, model, inputHash, output, runId, ttlSec }) {
++ db.prepare(`INSERT INTO cache_entries (cache_key, scope, workflow_key, workflow_version, template_version, route_key, route_version, model_key, input_hash, privacy, output, run_id, hits, created_at, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
++ ON CONFLICT(cache_key) DO UPDATE SET output = excluded.output, run_id = excluded.run_id, model_key = excluded.model_key, created_at = excluded.created_at, expires_at = excluded.expires_at`)
++ .run(key, scope, workflowKey, workflowVersion, templateVersion || null, routeKey || null, routeVersion || null, model || null, inputHash, privacy,
++ JSON.stringify(output), runId, iso(clock.now()), clock.now() + ttlSec * 1000);
++ }
++
++ function stats() {
++ return db.prepare('SELECT workflow_key, privacy, COUNT(*) AS entries, SUM(hits) AS hits FROM cache_entries WHERE expires_at > ? GROUP BY workflow_key, privacy ORDER BY entries DESC').all(clock.now());
++ }
++
++ function purge({ workflow } = {}) {
++ const r = workflow ? db.prepare('DELETE FROM cache_entries WHERE workflow_key = ?').run(workflow) : db.prepare('DELETE FROM cache_entries').run();
++ return r.changes;
++ }
++
++ function prune() { return db.prepare('DELETE FROM cache_entries WHERE expires_at <= ?').run(clock.now()).changes; }
++
++ return { scopeFor, keyFor, get, put, stats, purge, prune };
++}
++
++module.exports = { createCache };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..4e8b2c8
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * OpenVibe.AI configuration. Everything comes from the environment (.env in development,
++ * /etc/openvibe/ai.env in production); .env.example documents the list.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ *
++ * Provider settings keep the names OpenVibe.Live used for them (its admin site settings
++ * ai_provider / ai_base_url / ai_api_key / ai_model / ai_model_<role> / ai_pricing_json /
++ * ai_max_cost_usd_per_day, upper-cased here) and Live's own environment names for whisper.cpp
++ * (WHISPER_*), so the same keys work on both sides of the move. Secrets are only ever read by
++ * NAME through secretRef(); nothing here logs or returns a value.
++ */
++
++const int = (v, d) => {
++ const n = parseInt(v, 10);
++ return Number.isFinite(n) ? n : d;
++};
++const float = (v, d) => {
++ const n = parseFloat(v);
++ 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 trimUrl = (v) => String(v || '').trim().replace(/\/+$/, '');
++
++/** Roles Live's llm.js routes by (one route per role; see server/workflows/seed.js). */
++const LIVE_ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4700);
++ const aiProvider = String(env.AI_PROVIDER || '').trim().toLowerCase();
++ const aiBaseUrl = trimUrl(env.AI_BASE_URL);
++ const roleModels = {};
++ for (const r of LIVE_ROLES) {
++ const v = env[`AI_MODEL_${r.toUpperCase()}`];
++ if (v && String(v).trim()) roleModels[r] = String(v).trim();
++ }
++ let pricing = {};
++ if (env.AI_PRICING_JSON) {
++ try { pricing = JSON.parse(env.AI_PRICING_JSON) || {}; } catch { throw new Error('AI_PRICING_JSON is not valid JSON'); }
++ }
++ return {
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl: trimUrl(env.BASE_URL || (isProduction ? 'https://ai.openvibe.network' : `http://localhost:${port}`)),
++
++ // Identity: OpenVibe.Network signs the service tokens callers present (audience openvibe.ai).
++ networkUrl: trimUrl(env.OV_NETWORK_URL || 'https://openvibe.network'),
++ networkInternalUrl: trimUrl(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ issuer: trimUrl(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.ai',
++
++ dbPath: env.AI_DB_PATH || './data/ai.db',
++
++ // ── The shared provider (Live's "shared key") ──
++ // AI_ENABLED is Live's ai_enabled master switch. Unset means "on when a key or a
++ // self-hosted base URL is configured".
++ shared: {
++ enabled: bool(env.AI_ENABLED, true),
++ provider: aiProvider, // anthropic | openai | openrouter | groq | ollama | '' (OpenAI-shaped)
++ baseUrl: aiBaseUrl,
++ apiKeyRef: 'env:AI_API_KEY', // secret reference, never the value
++ model: String(env.AI_MODEL || '').trim(),
++ roleModels,
++ timeoutMs: int(env.AI_PROVIDER_TIMEOUT_MS, 30000),
++ },
++ // Optional second OpenAI-compatible provider used as the fallback of every route.
++ fallback: {
++ baseUrl: trimUrl(env.AI_FALLBACK_BASE_URL),
++ apiKeyRef: 'env:AI_FALLBACK_API_KEY',
++ model: String(env.AI_FALLBACK_MODEL || '').trim(),
++ kind: String(env.AI_FALLBACK_PROVIDER || '').trim().toLowerCase(),
++ },
++ // Configurable local HTTP seam: POST {operation, request} JSON to this URL.
++ httpSeamUrl: trimUrl(env.AI_HTTP_SEAM_URL),
++ // Let routes fall back to the deterministic stub when every real provider fails.
++ // Off in production: synthetic output must never stand in for a real answer there.
++ stubFallback: bool(env.AI_STUB_FALLBACK, !isProduction),
++
++ pricing: {
++ table: pricing,
++ inputPerMtok: float(env.AI_INPUT_COST_PER_MTOK, 3),
++ outputPerMtok: float(env.AI_OUTPUT_COST_PER_MTOK, 15),
++ },
++
++ // ── Local speech-to-text (whisper.cpp), Live's names ──
++ whisper: {
++ bin: env.WHISPER_BIN || null,
++ model: env.WHISPER_MODEL || null,
++ modelLive: env.WHISPER_MODEL_LIVE || null,
++ modelMulti: env.WHISPER_MODEL_MULTI || null,
++ vadModel: env.WHISPER_VAD_MODEL || null,
++ vad: env.WHISPER_VAD !== '0',
++ threads: Math.max(2, Math.min(8, int(env.WHISPER_THREADS, 4))),
++ beam: Math.max(1, Math.min(8, int(env.WHISPER_BEAM, 1))),
++ maxConcurrent: Math.max(1, int(env.WHISPER_MAX_CONCURRENT, 1)),
++ },
++
++ // ── Quotas seeded at boot (admin API edits them afterwards) ──
++ quotas: {
++ // Live's ai_max_cost_usd_per_day: one global daily spend cap across every caller.
++ globalCostPerDay: float(env.AI_MAX_COST_USD_PER_DAY, 0),
++ serviceRequestsPerMinute: int(env.AI_QUOTA_SERVICE_RPM, 600),
++ serviceRequestsPerDay: int(env.AI_QUOTA_SERVICE_RPD, 50000),
++ },
++
++ // ── Runs ──
++ runs: {
++ maxWaitMs: int(env.AI_MAX_WAIT_MS, 60000),
++ maxConcurrent: Math.max(1, int(env.AI_MAX_CONCURRENT_RUNS, 4)),
++ defaultCacheTtlSec: int(env.AI_CACHE_TTL_SEC, 7 * 24 * 3600),
++ maxInputBytes: int(env.AI_MAX_INPUT_BYTES, 6 * 1024 * 1024),
++ retentionDays: int(env.AI_RUN_RETENTION_DAYS, 30),
++ },
++ // Raw prompt/response logging is opt-in debugging only, and only for callers that ask.
++ debugRawLog: bool(env.AI_DEBUG_RAW_LOG, false),
++
++ // ── Circuit breaker ──
++ breaker: {
++ failureThreshold: Math.max(1, int(env.AI_BREAKER_FAILURES, 3)),
++ cooldownMs: int(env.AI_BREAKER_COOLDOWN_MS, 60000),
++ },
++ // One retry on a transient failure (timeout, 429, 5xx, connection reset) before the
++ // router moves on to the fallback — Live's rule.
++ providerRetries: Math.max(0, int(env.AI_PROVIDER_RETRIES, 1)),
++ retryDelayMs: Math.max(0, int(env.AI_PROVIDER_RETRY_DELAY_MS, 800)),
++
++ // ── Media inputs: only these hosts are ever fetched (SSRF guard) ──
++ media: {
++ publicUrl: trimUrl(env.OV_MEDIA_URL || 'https://openvibe.media'),
++ internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
++ // https hostnames (exact, or *.suffix) that media/image URLs may point at.
++ allowHosts: list(env.AI_FETCH_ALLOW_HOSTS, ['openvibe.media', 'openvibe.live', 'openvibe.network', '*.openvibe.network', 'openvibe.community', 'openvibe.tools', 'openvibe.games']),
++ maxBytes: int(env.AI_FETCH_MAX_BYTES, 512 * 1024 * 1024),
++ maxImageBytes: int(env.AI_FETCH_MAX_IMAGE_BYTES, 12 * 1024 * 1024),
++ timeoutMs: int(env.AI_FETCH_TIMEOUT_MS, 120000),
++ },
++ };
++}
++
++module.exports = { load, LIVE_ROLES };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..233fc09
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,312 @@
++'use strict';
++/**
++ * The AI authority's SQLite database (better-sqlite3). One table family per canonical record
++ * group of the plan (§12.13 / §15.14):
++ *
++ * 1 providers (+ provider_health) 6 runs
++ * 2 models 7 requests (request/completion log)
++ * 3 routes (versioned) 8 citations (sources/citations)
++ * 4 templates (versioned) 9 cache_entries
++ * 5 workflows (versioned) 10 quotas + usage_counters
++ * 11 audit_log
++ *
++ * Templates, workflows and routes are append-only per key: an edit inserts version n+1 and the
++ * previous version stays readable, so every run can say exactly which versions produced it.
++ * import_ledger / import_holds belong to scripts/import-from-live.js.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS providers (
++ key TEXT PRIMARY KEY,
++ display_name TEXT NOT NULL,
++ kind TEXT NOT NULL, -- stub | openai | anthropic | http | whisper
++ status TEXT NOT NULL DEFAULT 'active', -- active | disabled
++ base_url TEXT,
++ auth_mode TEXT NOT NULL DEFAULT 'none', -- none | bearer | x-api-key
++ secret_ref TEXT, -- 'env:NAME' — never a value
++ default_model TEXT,
++ capabilities TEXT NOT NULL DEFAULT '[]', -- features supports() answers true for
++ timeout_ms INTEGER NOT NULL DEFAULT 30000,
++ priority INTEGER NOT NULL DEFAULT 100,
++ metadata TEXT NOT NULL DEFAULT '{}',
++ origin TEXT NOT NULL DEFAULT 'seed', -- seed (from env at boot) | admin | import
++ created_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL
++);
++CREATE TABLE IF NOT EXISTS provider_health (
++ provider_key TEXT PRIMARY KEY,
++ state TEXT NOT NULL DEFAULT 'closed', -- closed (healthy) | open (skipped) | half_open (one probe)
++ consecutive_failures INTEGER NOT NULL DEFAULT 0,
++ opened_at INTEGER,
++ last_error TEXT,
++ last_success_at INTEGER,
++ last_failure_at INTEGER
++);
++CREATE TABLE IF NOT EXISTS models (
++ provider_key TEXT NOT NULL,
++ model_key TEXT NOT NULL,
++ display_name TEXT,
++ type TEXT NOT NULL DEFAULT 'chat', -- chat | vision | embedding | stt
++ status TEXT NOT NULL DEFAULT 'active',
++ context_window INTEGER,
++ max_output INTEGER,
++ cost_in_per_mtok REAL,
++ cost_out_per_mtok REAL,
++ cost_cached_per_mtok REAL,
++ supports_json INTEGER NOT NULL DEFAULT 1,
++ supports_tools INTEGER NOT NULL DEFAULT 0,
++ supports_streaming INTEGER NOT NULL DEFAULT 0,
++ supports_vision INTEGER NOT NULL DEFAULT 0,
++ metadata TEXT NOT NULL DEFAULT '{}',
++ created_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL,
++ PRIMARY KEY (provider_key, model_key)
++);
++CREATE TABLE IF NOT EXISTS routes (
++ key TEXT NOT NULL,
++ version INTEGER NOT NULL,
++ status TEXT NOT NULL DEFAULT 'active', -- active | disabled
++ primary_provider TEXT NOT NULL,
++ primary_model TEXT,
++ fallbacks TEXT NOT NULL DEFAULT '[]', -- [{provider, model}]
++ options TEXT NOT NULL DEFAULT '{}', -- temperature and friends
++ max_output_tokens INTEGER,
++ response_format TEXT NOT NULL DEFAULT 'text', -- text | json
++ timeout_ms INTEGER,
++ alias_of TEXT, -- historical key kept as an explicit alias
++ created_by TEXT,
++ created_at TEXT NOT NULL,
++ PRIMARY KEY (key, version)
++);
++CREATE TABLE IF NOT EXISTS templates (
++ key TEXT NOT NULL,
++ version INTEGER NOT NULL,
++ name TEXT NOT NULL,
++ description TEXT,
++ input_schema TEXT NOT NULL DEFAULT '{}',
++ output_schema TEXT NOT NULL DEFAULT '{}',
++ system_prompt TEXT NOT NULL DEFAULT '',
++ user_prompt TEXT NOT NULL DEFAULT '',
++ default_route TEXT,
++ owner TEXT NOT NULL DEFAULT 'ai',
++ visibility TEXT NOT NULL DEFAULT 'internal', -- public | first-party | internal
++ status TEXT NOT NULL DEFAULT 'active', -- draft | active | deprecated | archived
++ metadata TEXT NOT NULL DEFAULT '{}',
++ created_by TEXT,
++ created_at TEXT NOT NULL,
++ PRIMARY KEY (key, version)
++);
++CREATE TABLE IF NOT EXISTS workflows (
++ key TEXT NOT NULL,
++ version INTEGER NOT NULL,
++ name TEXT NOT NULL,
++ description TEXT,
++ namespace TEXT NOT NULL, -- owning product: live, network, wiki, ...
++ input_schema TEXT NOT NULL,
++ output_schema TEXT NOT NULL,
++ steps TEXT NOT NULL, -- [{kind, template?, operation?, ...}]
++ default_route TEXT,
++ cache_mode TEXT NOT NULL DEFAULT 'private', -- none | private | service
++ cache_ttl_sec INTEGER,
++ status TEXT NOT NULL DEFAULT 'active', -- draft | active | deprecated | archived
++ metadata TEXT NOT NULL DEFAULT '{}',
++ created_by TEXT,
++ created_at TEXT NOT NULL,
++ PRIMARY KEY (key, version)
++);
++CREATE TABLE IF NOT EXISTS runs (
++ id TEXT PRIMARY KEY, -- run_<ULID>
++ workflow_key TEXT NOT NULL,
++ workflow_version INTEGER NOT NULL,
++ template_key TEXT,
++ template_version INTEGER,
++ route_key TEXT,
++ route_version INTEGER,
++ status TEXT NOT NULL, -- queued | running | succeeded | failed | cancelled | cached
++ requester_type TEXT NOT NULL, -- service | app | mod (the token principal)
++ requester_id TEXT NOT NULL,
++ on_behalf_of TEXT, -- SubjectRef JSON (optional)
++ attribution TEXT, -- EntityRef JSON the spend is attributed to (optional)
++ source_service TEXT,
++ target TEXT, -- EntityRef JSON (optional)
++ target_key TEXT, -- service:type:id for lookups
++ input TEXT NOT NULL,
++ input_hash TEXT NOT NULL,
++ output TEXT,
++ citations_count INTEGER NOT NULL DEFAULT 0,
++ error_code TEXT,
++ error_detail TEXT,
++ synthetic INTEGER NOT NULL DEFAULT 0, -- produced by the stub provider
++ provider_key TEXT,
++ model_key TEXT,
++ fallback_used INTEGER NOT NULL DEFAULT 0,
++ attempts INTEGER NOT NULL DEFAULT 0,
++ tokens_in INTEGER NOT NULL DEFAULT 0,
++ tokens_out INTEGER NOT NULL DEFAULT 0,
++ cost_usd REAL NOT NULL DEFAULT 0,
++ cache_key TEXT,
++ cached_from TEXT, -- run id whose output a cached run reused
++ retry_of TEXT,
++ idempotency_key TEXT,
++ trace_id TEXT,
++ request_id TEXT,
++ options TEXT NOT NULL DEFAULT '{}',
++ created_at TEXT NOT NULL,
++ started_at TEXT,
++ finished_at TEXT
++);
++CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_idem ON runs(requester_type, requester_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
++CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status, created_at);
++CREATE INDEX IF NOT EXISTS idx_runs_workflow ON runs(workflow_key, created_at);
++CREATE INDEX IF NOT EXISTS idx_runs_target ON runs(target_key, created_at);
++CREATE INDEX IF NOT EXISTS idx_runs_requester ON runs(requester_type, requester_id, created_at);
++CREATE TABLE IF NOT EXISTS requests (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ run_id TEXT,
++ seq INTEGER NOT NULL DEFAULT 0,
++ operation TEXT NOT NULL, -- chat | generate | summarize | classify | extract | enrich | embed | transcribe
++ provider_key TEXT NOT NULL,
++ model_key TEXT,
++ route_key TEXT,
++ route_version INTEGER,
++ status TEXT NOT NULL, -- ok | error | timeout | skipped | cancelled
++ skip_reason TEXT, -- disabled | circuit_open | unsupported | no_credentials
++ fallback INTEGER NOT NULL DEFAULT 0, -- 1 when this was not the route's primary
++ prompt_hash TEXT,
++ input_hash TEXT,
++ output_hash TEXT,
++ tokens_in INTEGER NOT NULL DEFAULT 0,
++ tokens_out INTEGER NOT NULL DEFAULT 0,
++ tokens_cached INTEGER NOT NULL DEFAULT 0,
++ tokens_estimated INTEGER NOT NULL DEFAULT 0,
++ cost_usd REAL NOT NULL DEFAULT 0,
++ latency_ms INTEGER,
++ error TEXT,
++ debug_prompt TEXT, -- only with AI_DEBUG_RAW_LOG and a caller opt-in
++ debug_response TEXT,
++ created_at TEXT NOT NULL
++);
++CREATE INDEX IF NOT EXISTS idx_requests_run ON requests(run_id, seq);
++CREATE INDEX IF NOT EXISTS idx_requests_provider ON requests(provider_key, created_at);
++CREATE TABLE IF NOT EXISTS citations (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ run_id TEXT NOT NULL,
++ ordinal INTEGER NOT NULL DEFAULT 0,
++ source_type TEXT NOT NULL,
++ source_id TEXT,
++ url TEXT,
++ title TEXT,
++ author TEXT,
++ published_at TEXT,
++ retrieved_at TEXT,
++ snippet TEXT,
++ content_hash TEXT,
++ trust TEXT NOT NULL DEFAULT '{}',
++ provenance TEXT NOT NULL DEFAULT '{}',
++ attached_by TEXT, -- workflow | <principal sub>
++ created_at TEXT NOT NULL
++);
++CREATE INDEX IF NOT EXISTS idx_citations_run ON citations(run_id, ordinal);
++CREATE TABLE IF NOT EXISTS cache_entries (
++ cache_key TEXT PRIMARY KEY, -- sha256 over everything below
++ scope TEXT NOT NULL, -- who may read it (see server/cache.js)
++ workflow_key TEXT NOT NULL,
++ workflow_version INTEGER NOT NULL,
++ template_version INTEGER,
++ route_key TEXT,
++ route_version INTEGER,
++ model_key TEXT,
++ input_hash TEXT NOT NULL,
++ privacy TEXT NOT NULL, -- private | service
++ output TEXT NOT NULL,
++ run_id TEXT NOT NULL,
++ hits INTEGER NOT NULL DEFAULT 0,
++ created_at TEXT NOT NULL,
++ expires_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS idx_cache_expiry ON cache_entries(expires_at);
++CREATE INDEX IF NOT EXISTS idx_cache_workflow ON cache_entries(workflow_key);
++CREATE TABLE IF NOT EXISTS quotas (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ scope_type TEXT NOT NULL, -- global | service | actor | attribution
++ scope_id TEXT NOT NULL, -- '*' | live | user:usr_… | live:user:42
++ window TEXT NOT NULL, -- minute | hour | day
++ max_requests INTEGER,
++ max_tokens INTEGER,
++ max_cost_usd REAL,
++ workflow_prefix TEXT, -- optional: only runs of workflows with this prefix
++ status TEXT NOT NULL DEFAULT 'active',
++ origin TEXT NOT NULL DEFAULT 'admin', -- seed | admin | import
++ created_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS idx_quotas_unique ON quotas(scope_type, scope_id, window, COALESCE(workflow_prefix, ''));
++CREATE TABLE IF NOT EXISTS usage_counters (
++ scope_type TEXT NOT NULL,
++ scope_id TEXT NOT NULL,
++ window TEXT NOT NULL,
++ window_start INTEGER NOT NULL, -- epoch seconds, UTC
++ workflow_prefix TEXT NOT NULL DEFAULT '',
++ requests INTEGER NOT NULL DEFAULT 0,
++ tokens INTEGER NOT NULL DEFAULT 0,
++ cost_usd REAL NOT NULL DEFAULT 0,
++ PRIMARY KEY (scope_type, scope_id, window, window_start, workflow_prefix)
++);
++CREATE TABLE IF NOT EXISTS usage_daily (
++ day TEXT NOT NULL, -- YYYY-MM-DD (UTC)
++ requester TEXT NOT NULL, -- service:live
++ attribution TEXT NOT NULL DEFAULT '', -- live:user:42
++ workflow_key TEXT NOT NULL,
++ provider_key TEXT NOT NULL,
++ model_key TEXT NOT NULL DEFAULT '',
++ requests INTEGER NOT NULL DEFAULT 0,
++ tokens_in INTEGER NOT NULL DEFAULT 0,
++ tokens_out INTEGER NOT NULL DEFAULT 0,
++ tokens_cached INTEGER NOT NULL DEFAULT 0,
++ cost_usd REAL NOT NULL DEFAULT 0,
++ PRIMARY KEY (day, requester, attribution, workflow_key, provider_key, model_key)
++);
++CREATE TABLE IF NOT EXISTS audit_log (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ at TEXT NOT NULL,
++ actor TEXT NOT NULL, -- principal sub (svc:live) or 'system'
++ action TEXT NOT NULL, -- run.create | template.version | provider.update | ...
++ target_type TEXT,
++ target_id TEXT,
++ trace_id TEXT,
++ metadata TEXT NOT NULL DEFAULT '{}'
++);
++CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_type, target_id, id);
++CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action, id);
++CREATE TABLE IF NOT EXISTS import_ledger (
++ source TEXT NOT NULL, -- live.ai_usage, live.translations, ...
++ source_key TEXT NOT NULL,
++ target TEXT NOT NULL, -- where it went (table:key)
++ imported_at TEXT NOT NULL,
++ PRIMARY KEY (source, source_key)
++);
++CREATE TABLE IF NOT EXISTS import_holds (
++ source TEXT NOT NULL,
++ source_key TEXT NOT NULL,
++ reason TEXT NOT NULL,
++ detail TEXT,
++ held_at TEXT NOT NULL,
++ PRIMARY KEY (source, source_key)
++);
++`;
++
++function openDb(dbPath) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('synchronous = NORMAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ db.exec(SCHEMA);
++ return db;
++}
++
++module.exports = { openDb, SCHEMA };
ddiff --git a/server/fetcher.js b/server/fetcher.js
nnew file mode 100644
iindex 0000000..279be38
---- /dev/null
++++ b/server/fetcher.js
@@@ -0,0 +1,219 @@
++'use strict';
++/**
++ * Media and image inputs, fetched SSRF-safely.
++ *
++ * Workflows take media by URL or MediaRef, and the service fetches ONLY from allow-listed OpenVibe
++ * hosts. The rules, all enforced here:
++ * - https only, to a hostname on the allow-list (exact, or '*.suffix'); the one exception is the
++ * operator-configured Media internal base (OV_MEDIA_INTERNAL_URL), matched by exact origin
++ * - the address actually connected to must be public unicast: the DNS answer is checked inside
++ * the connection's own lookup (no second resolution, so no DNS rebinding), and IPv4-mapped
++ * IPv6 spellings of internal addresses are caught too
++ * - redirects are followed by hand (max 3) and every hop is judged by the same rules
++ * - a byte cap, a timeout and cancellation on every transfer
++ * data: URLs (images a caller sends inline) are decoded locally with a size cap — never fetched.
++ * A MediaRef resolves to the public Media URL for the legacy kinds; med_ ids are an explicit
++ * `media.unresolvable` until the Wave 4 object API exists.
++ */
++const dns = require('dns');
++const net = require('net');
++const http = require('http');
++const https = require('https');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { AiError } = require('./util');
++
++const blocked = new net.BlockList();
++for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24],
++ ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked.addSubnet(a, p, 'ipv4');
++for (const [a, p] of [['::', 128], ['::1', 128], ['fe80::', 10], ['fec0::', 10], ['fc00::', 7], ['ff00::', 8], ['100::', 64], ['2001:db8::', 32], ['2001::', 23]]) blocked.addSubnet(a, p, 'ipv6');
++
++function isPublicAddress(ip) {
++ if (net.isIPv4(ip)) return !blocked.check(ip, 'ipv4');
++ if (net.isIPv6(ip)) {
++ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
++ if (m) return !blocked.check(m[1], 'ipv4');
++ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ip);
++ if (hex) {
++ const n = (parseInt(hex[1], 16) << 16) >>> 0 | parseInt(hex[2], 16);
++ const v4 = [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
++ return !blocked.check(v4, 'ipv4');
++ }
++ return !blocked.check(ip, 'ipv6');
++ }
++ return false;
++}
++
++/** dns.lookup replacement used at connect time: fails when any answer is not public unicast. */
++function safeLookup(hostname, options, cb) {
++ if (typeof options === 'function') { cb = options; options = {}; }
++ dns.lookup(hostname, { ...options, all: true }, (err, addrs) => {
++ if (err) return cb(err);
++ const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: options.family || 4 }];
++ const bad = list.find(a => !isPublicAddress(a.address));
++ if (bad || !list.length) {
++ const e = new Error(`refused: ${hostname} resolves to a non-public address`);
++ e.code = 'EADDRNOTPUBLIC';
++ return cb(e);
++ }
++ if (options.all) return cb(null, list);
++ return cb(null, list[0].address, list[0].family);
++ });
++}
++
++function hostAllowed(hostname, allowHosts) {
++ const h = String(hostname || '').toLowerCase().replace(/\.$/, '');
++ return allowHosts.some((pat) => {
++ const p = String(pat).toLowerCase();
++ if (p.startsWith('*.')) return h.endsWith(p.slice(1)) && h.length > p.length - 1;
++ return h === p;
++ });
++}
++
++function createFetcher(config) {
++ const m = config.media;
++ const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;
++
++ /** Judge one URL. Returns { url, internal } or throws AiError 422 fetch.refused. */
++ function judge(raw) {
++ let u;
++ try { u = new URL(String(raw)); } catch { throw new AiError(422, 'fetch.refused', 'not a valid URL'); }
++ if (u.username || u.password) throw new AiError(422, 'fetch.refused', 'credentials in URLs are not allowed');
++ if (internalOrigin && u.origin === internalOrigin) return { url: u, internal: true };
++ if (u.protocol !== 'https:') throw new AiError(422, 'fetch.refused', `only https URLs on allow-listed OpenVibe hosts are fetched (got ${u.protocol})`);
++ if (net.isIP(u.hostname.replace(/^\[|\]$/g, ''))) throw new AiError(422, 'fetch.refused', 'IP-literal hosts are not fetched');
++ if (!hostAllowed(u.hostname, m.allowHosts)) throw new AiError(422, 'fetch.refused', `host ${u.hostname} is not on the allow-list`);
++ return { url: u, internal: false };
++ }
++
++ function once(target, { signal, maxBytes, timeoutMs, toFile }) {
++ return new Promise((resolve, reject) => {
++ const { url, internal } = target;
++ const mod = url.protocol === 'https:' ? https : http;
++ const req = mod.request(url, {
++ method: 'GET',
++ headers: { 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.network)', Accept: '*/*' },
++ // Internal Media is operator-configured (usually loopback); everything else must be public.
++ lookup: internal ? undefined : safeLookup,
++ timeout: timeoutMs,
++ });
++ const fail = (err) => { try { req.destroy(); } catch { /* */ } reject(err); };
++ const onAbort = () => fail(new AiError(409, 'run.cancelled', 'cancelled'));
++ if (signal) { if (signal.aborted) return onAbort(); signal.addEventListener('abort', onAbort, { once: true }); }
++ const timer = setTimeout(() => fail(new AiError(502, 'source.unavailable', `fetch timed out after ${timeoutMs}ms`)), timeoutMs);
++ const done = (fn) => { clearTimeout(timer); if (signal) signal.removeEventListener('abort', onAbort); fn(); };
++ req.on('timeout', () => done(() => fail(new AiError(502, 'source.unavailable', 'fetch timed out'))));
++ req.on('error', (err) => done(() => reject(err.code === 'EADDRNOTPUBLIC'
++ ? new AiError(422, 'fetch.refused', err.message)
++ : new AiError(502, 'source.unavailable', `fetch failed: ${err.message}`))));
++ req.on('response', (res) => {
++ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
++ res.resume();
++ return done(() => resolve({ redirect: new URL(res.headers.location, url).toString() }));
++ }
++ if (res.statusCode !== 200) {
++ res.resume();
++ return done(() => reject(new AiError(502, 'source.unavailable', `source answered HTTP ${res.statusCode}`)));
++ }
++ const declared = Number(res.headers['content-length']);
++ if (Number.isFinite(declared) && declared > maxBytes) { res.resume(); return done(() => reject(new AiError(413, 'source.too_large', `source is larger than ${maxBytes} bytes`))); }
++ let bytes = 0;
++ const chunks = [];
++ const out = toFile ? fs.createWriteStream(toFile) : null;
++ res.on('data', (c) => {
++ bytes += c.length;
++ if (bytes > maxBytes) { res.destroy(); if (out) out.destroy(); return done(() => reject(new AiError(413, 'source.too_large', `source is larger than ${maxBytes} bytes`))); }
++ if (out) out.write(c); else chunks.push(c);
++ });
++ res.on('error', (err) => done(() => reject(new AiError(502, 'source.unavailable', err.message))));
++ res.on('end', () => {
++ const finish = () => done(() => resolve({ contentType: String(res.headers['content-type'] || ''), bytes, buffer: out ? null : Buffer.concat(chunks), file: toFile || null }));
++ if (out) out.end(finish); else finish();
++ });
++ });
++ req.end();
++ });
++ }
++
++ async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null } = {}) {
++ let target = judge(raw);
++ for (let hop = 0; hop < 4; hop++) {
++ const r = await once(target, { signal, maxBytes, timeoutMs, toFile });
++ if (!r.redirect) return { ...r, url: target.url.toString() };
++ if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');
++ target = judge(r.redirect);
++ }
++ throw new AiError(502, 'source.unavailable', 'too many redirects');
++ }
++
++ /** A MediaRef -> the public URL of that object (legacy kinds only for now). */
++ function mediaRefUrl(ref) {
++ const id = ref && ref.media_id;
++ const mm = /^legacy:([a-z][a-z0-9-]{1,39}):(vod|clip|file|paste|thumbnail|avatar):([A-Za-z0-9._/-]{1,200})$/.exec(String(id || ''));
++ if (!mm) throw new AiError(422, 'media.unresolvable', `media ${id} cannot be resolved by this service yet`);
++ const [, , kind, key] = mm;
++ const base = m.publicUrl;
++ if (key.includes('..')) throw new AiError(422, 'media.unresolvable', 'bad media key');
++ switch (kind) {
++ case 'vod': return `${base}/v/${encodeURIComponent(key)}`;
++ case 'clip': return `${base}/c/${encodeURIComponent(key)}`;
++ case 'thumbnail': return `${base}/t/${encodeURIComponent(key)}`;
++ case 'paste': return `${base}/p/${encodeURIComponent(key)}/raw`;
++ case 'file': return `${base}/f/${key.split('/').map(encodeURIComponent).join('/')}`;
++ default: throw new AiError(422, 'media.unresolvable', `media kind ${kind} is not fetched`);
++ }
++ }
++
++ /** Image input { url } | { data_url } | { media: MediaRef } -> { mediaType, base64 }. */
++ async function loadImage(input, { signal } = {}) {
++ if (!input) return null;
++ let buf; let type = '';
++ if (input.data_url) {
++ const mm = /^data:(image\/[a-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(input.data_url));
++ if (!mm) throw new AiError(422, 'input.invalid', 'image.data_url must be a base64 data:image/* URL');
++ buf = Buffer.from(mm[2], 'base64');
++ type = mm[1];
++ if (buf.length > m.maxImageBytes) throw new AiError(413, 'source.too_large', 'inline image too large');
++ } else {
++ const url = input.url || (input.media ? mediaRefUrl(input.media) : null);
++ if (!url) throw new AiError(422, 'input.invalid', 'image needs url, data_url or media');
++ const r = await fetchUrl(url, { signal, maxBytes: m.maxImageBytes, timeoutMs: Math.min(m.timeoutMs, 30000) });
++ buf = r.buffer;
++ type = r.contentType.split(';')[0].trim();
++ if (!/^image\//i.test(type)) throw new AiError(422, 'source.invalid', `expected an image, got ${type || 'unknown type'}`);
++ }
++ return downscale(buf, type, input.max_width || 1024);
++ }
++
++ /** Media input -> a temp file path (caller deletes it). */
++ async function loadMediaToFile(input, { signal } = {}) {
++ const url = input.media_url || (input.media ? mediaRefUrl(input.media) : null);
++ if (!url) throw new AiError(422, 'input.invalid', 'media needs media_url or media (MediaRef)');
++ const file = path.join(os.tmpdir(), `openvibe-ai-media-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
++ try {
++ await fetchUrl(url, { signal, toFile: file });
++ } catch (e) {
++ try { fs.unlinkSync(file); } catch { /* */ }
++ throw e;
++ }
++ return { file, url };
++ }
++
++ return { judge, fetchUrl, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };
++}
++
++/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */
++async function downscale(buf, type, maxWidth) {
++ try {
++ const sharp = require('sharp');
++ const out = await sharp(buf, { failOn: 'none', animated: false }).rotate()
++ .resize({ width: maxWidth, height: maxWidth, fit: 'inside', withoutEnlargement: true })
++ .jpeg({ quality: 78 }).toBuffer();
++ return { mediaType: 'image/jpeg', base64: out.toString('base64') };
++ } catch {
++ return { mediaType: type || 'image/jpeg', base64: buf.toString('base64') };
++ }
++}
++
++module.exports = { createFetcher, isPublicAddress, safeLookup, hostAllowed };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..9b0eb97
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * OpenVibe.AI entry point.
++ *
++ * node server/index.js (systemd: openvibe-ai.service)
++ *
++ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable
++ * clock/fetch/env/log, and returns handles to every part so they can be driven directly.
++ */
++const { load } = require('./config');
++const { openDb } = require('./db');
++const { createRegistry } = require('./registry');
++const { createProviderPool } = require('./providers');
++const { createFetcher } = require('./fetcher');
++const { createEngine } = require('./workflows/engine');
++const { createCache } = require('./cache');
++const { createQuotas } = require('./quota');
++const { createRuns } = require('./runs');
++const { seed } = require('./workflows/seed');
++const { createKeyStore, createAuth } = require('./auth');
++const { createApp } = require('./app');
++
++async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true } = {}) {
++ config = config || load(env);
++ const db = openDb(config.dbPath);
++ const registry = createRegistry(db, { clock, env });
++ const quotas = createQuotas(db, { clock, registry });
++ const cache = createCache(db, { clock });
++ const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log });
++ const fetcher = createFetcher(config);
++ const engine = createEngine({ registry, pool, fetcher });
++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log });
++ seed({ registry, quotas, config, env, db });
++ const interrupted = runs.recoverInterrupted();
++ if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
++
++ const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
++ const auth = createAuth({ config, keys });
++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, log });
++ const keyLoaded = keys.start().catch(() => null);
++
++ const housekeeping = setInterval(() => {
++ try {
++ const pruned = runs.prune();
++ const expired = cache.prune();
++ if (pruned || expired) log.log(`[ai] retention: ${pruned} old run(s), ${expired} expired cache entr${expired === 1 ? 'y' : 'ies'}`);
++ } catch (err) { log.error(`[ai] retention failed: ${err.message}`); }
++ }, 60 * 60 * 1000);
++ housekeeping.unref?.();
++
++ 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);
++ });
++ server.keepAliveTimeout = 65000;
++ server.headersTimeout = 66000;
++ server.requestTimeout = config.runs.maxWaitMs + 30000;
++ log.log(`[ai] listening on http://${config.host}:${server.address().port} (${config.nodeEnv})`);
++ const shared = registry.getProvider('shared');
++ log.log(`[ai] shared provider: ${shared.kind} ${shared.status}, credentials ${registry.publicProvider(shared).credentials}; stub fallback ${config.stubFallback ? 'on' : 'off'}`);
++ }
++
++ async function close() {
++ clearInterval(housekeeping);
++ keys.stop();
++ if (server) {
++ server.closeAllConnections?.();
++ await new Promise(resolve => server.close(() => resolve()));
++ }
++ await runs.drain();
++ const w = pool.adapter('whisper');
++ if (w && w.adapter.killActive) w.adapter.killActive();
++ db.close();
++ }
++
++ return { config, db, registry, pool, quotas, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, close };
++}
++
++if (require.main === module) {
++ require('dotenv').config();
++ start().then((handles) => {
++ const shutdown = (sig) => {
++ console.log(`[ai] ${sig}: shutting down`);
++ handles.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(`[ai] failed to start: ${err.stack || err}`);
++ process.exit(1);
++ });
++}
++
++module.exports = { start };
ddiff --git a/server/providers/anthropic.js b/server/providers/anthropic.js
nnew file mode 100644
iindex 0000000..d0df553
---- /dev/null
++++ b/server/providers/anthropic.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * Anthropic Messages API adapter, ported from OpenVibe.Live server/ai/llm.js
++ * (_anthropicBody/_callAnthropic): real system blocks with cache_control on the stable ones
++ * (prompt caching), images as base64 blocks, structured output through a forced tool.
++ * No embeddings or transcription on this API — supports() says so and the router skips it.
++ */
++const { postJson } = require('./common');
++
++const DEFAULT_BASE_URL = 'https://api.anthropic.com/v1';
++const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
++
++function buildBody({ model, system, messages, image, json, maxTokens, temperature }) {
++ const body = { model, max_tokens: Math.max(1, maxTokens) };
++ if (system && system.length) {
++ body.system = system.map(x => (x.cache ? { type: 'text', text: x.text, cache_control: { type: 'ephemeral' } } : { type: 'text', text: x.text }));
++ }
++ body.messages = messages.map((m, i) => {
++ const last = i === messages.length - 1;
++ const content = Array.isArray(m.content)
++ ? m.content.map(c => (c.type === 'text' ? { type: 'text', text: c.text } : c))
++ : [{ type: 'text', text: String(m.content || '') }];
++ if (last && image && m.role === 'user') content.push({ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.base64 } });
++ return { role: m.role, content };
++ });
++ if (temperature != null) body.temperature = temperature;
++ if (json) {
++ body.tools = [{ name: json.name || 'result', description: json.description || 'Return the result.', input_schema: json.schema }];
++ body.tool_choice = { type: 'tool', name: json.name || 'result' };
++ }
++ return body;
++}
++
++function createAnthropicProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
++ const baseUrl = String(record.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '');
++ const caps = new Set(record.capabilities && record.capabilities.length ? record.capabilities.filter(f => f !== 'embed' && f !== 'transcribe') : [...TEXT_OPS, 'json', 'vision']);
++
++ async function chat(req) {
++ const j = await postJson(`${baseUrl}/messages`, { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, buildBody(req), { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
++ const content = j.content || [];
++ let text = content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
++ let jsonOut = null;
++ const tool = content.find(c => c.type === 'tool_use');
++ if (tool && tool.input) { jsonOut = tool.input; if (!text) text = JSON.stringify(tool.input); }
++ const u = j.usage || {};
++ const cached = u.cache_read_input_tokens || 0;
++ return { text, json: jsonOut, model: j.model || req.model, usage: { input: (u.input_tokens || 0) + cached + (u.cache_creation_input_tokens || 0), output: u.output_tokens || 0, cached } };
++ }
++
++ const adapter = { key: record.key, kind: 'anthropic', supports: (f) => caps.has(f), chat };
++ for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat;
++ return adapter;
++}
++
++module.exports = { createAnthropicProvider, buildBody, DEFAULT_BASE_URL };
ddiff --git a/server/providers/common.js b/server/providers/common.js
nnew file mode 100644
iindex 0000000..3c56d59
---- /dev/null
++++ b/server/providers/common.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * Transport helpers shared by the HTTP provider adapters (ported from OpenVibe.Live
++ * server/ai/llm.js): JSON POST with a hard timeout and cancellation, an error type that keeps the
++ * HTTP status, the transient-failure test used for the one retry, and the token estimate for
++ * self-hosted servers that omit usage.
++ */
++
++class ProviderError extends Error {
++ constructor(message, { status = null, code = 'provider.error', body = null } = {}) {
++ super(message);
++ this.status = status;
++ this.code = code;
++ this.body = body;
++ }
++}
++
++/** An AbortSignal that fires on the caller's signal OR after timeoutMs. */
++function deadline(signal, timeoutMs) {
++ const t = AbortSignal.timeout(Math.max(1, timeoutMs));
++ return signal ? AbortSignal.any([signal, t]) : t;
++}
++
++function describeAbort(err, signal, timeoutMs) {
++ if (signal && signal.aborted) return new ProviderError('cancelled', { code: 'provider.cancelled' });
++ if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) return new ProviderError(`no response within ${Math.round(timeoutMs / 1000)}s`, { code: 'provider.timeout' });
++ return null;
++}
++
++async function postJson(url, headers, body, { signal, timeoutMs = 30000, fetchImpl = globalThis.fetch } = {}) {
++ let res;
++ try {
++ res = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal: deadline(signal, timeoutMs) });
++ } catch (err) {
++ throw describeAbort(err, signal, timeoutMs) || new ProviderError(`connection failed: ${err.message}`, { code: 'provider.unreachable' });
++ }
++ let text;
++ try { text = await res.text(); } catch (err) {
++ throw describeAbort(err, signal, timeoutMs) || new ProviderError(`read failed: ${err.message}`, { code: 'provider.unreachable' });
++ }
++ let j = {};
++ try { j = text ? JSON.parse(text) : {}; } catch { j = { raw: text }; }
++ if (!res.ok) {
++ const msg = (j.error && (j.error.message || j.error)) || `HTTP ${res.status}: ${String(text).slice(0, 200)}`;
++ throw new ProviderError(typeof msg === 'string' ? msg : JSON.stringify(msg), { status: res.status, code: res.status === 401 || res.status === 403 ? 'provider.auth' : 'provider.http', body: j });
++ }
++ return j;
++}
++
++/** Transient failures get one more try on the same provider (Live's rule). */
++function retryable(e) {
++ if (!e) return false;
++ if (e.code === 'provider.cancelled') return false;
++ if (e.code === 'provider.timeout' || e.code === 'provider.unreachable') return true;
++ if (e.status) return e.status === 429 || e.status >= 500;
++ return /fetch failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket/i.test(e.message || '');
++}
++
++const approxTokens = (t) => Math.ceil(String(t || '').length / 4);
++
++/** Self-hosted servers often omit usage — estimate so quotas and budgets still mean something. */
++function estimateUsage(req, text) {
++ const sys = (req.system || []).map(x => x.text).join('');
++ const msgs = (req.messages || []).reduce((n, m) => n + approxTokens(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0);
++ return { input: approxTokens(sys) + msgs + (req.image ? 800 : 0), output: approxTokens(text), cached: 0, estimated: true };
++}
++
++function sleep(ms, signal) {
++ return new Promise((resolve, reject) => {
++ const t = setTimeout(resolve, ms);
++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); reject(new ProviderError('cancelled', { code: 'provider.cancelled' })); }, { once: true });
++ });
++}
++
++module.exports = { ProviderError, deadline, describeAbort, postJson, retryable, approxTokens, estimateUsage, sleep };
ddiff --git a/server/providers/http.js b/server/providers/http.js
nnew file mode 100644
iindex 0000000..6b613ce
---- /dev/null
++++ b/server/providers/http.js
@@@ -0,0 +1,47 @@
++'use strict';
++/**
++ * The configurable local HTTP seam. Any process that speaks this tiny protocol can be a provider
++ * (a local model server, a test double, an experimental pipeline):
++ *
++ * POST <base_url>
++ * { "operation": "chat|generate|summarize|classify|extract|enrich|embed",
++ * "model": "...", "system": [{text}], "messages": [{role, content}], "json": {name, schema}|null,
++ * "max_tokens": n, "temperature": t, "input": [...] (embed) }
++ * -> { "text": "...", "json": {...}|null, "vectors": [[...]] (embed), "model": "...",
++ * "usage": { "input": n, "output": n, "cached": n }, "synthetic": false }
++ *
++ * The base URL is operator-configured (provider record), never caller-supplied.
++ */
++const { postJson } = require('./common');
++
++const OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed'];
++
++function createHttpSeamProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
++ const url = String(record.base_url || '').replace(/\/+$/, '');
++ const caps = new Set(record.capabilities && record.capabilities.length ? record.capabilities : ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json']);
++ const headers = () => (apiKey ? { Authorization: `Bearer ${apiKey}` } : {});
++
++ async function call(operation, req) {
++ const body = {
++ operation, model: req.model || null, system: req.system || [], messages: req.messages || [], json: req.json || null,
++ max_tokens: req.maxTokens || null, temperature: req.temperature ?? null, input: req.input || null,
++ image: req.image ? { media_type: req.image.mediaType, base64: req.image.base64 } : null,
++ };
++ const j = await postJson(url, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
++ const u = j.usage || {};
++ return {
++ text: typeof j.text === 'string' ? j.text : (j.json ? JSON.stringify(j.json) : ''),
++ json: j.json && typeof j.json === 'object' ? j.json : null,
++ vectors: Array.isArray(j.vectors) ? j.vectors : undefined,
++ model: j.model || req.model,
++ synthetic: j.synthetic === true,
++ usage: { input: Number(u.input) || 0, output: Number(u.output) || 0, cached: Number(u.cached) || 0 },
++ };
++ }
++
++ const adapter = { key: record.key, kind: 'http', supports: (f) => caps.has(f) };
++ for (const op of OPS) adapter[op] = (req) => call(op, req);
++ return adapter;
++}
++
++module.exports = { createHttpSeamProvider };
ddiff --git a/server/providers/index.js b/server/providers/index.js
nnew file mode 100644
iindex 0000000..e6bb799
---- /dev/null
++++ b/server/providers/index.js
@@@ -0,0 +1,199 @@
++'use strict';
++/**
++ * Provider pool and router.
++ *
++ * pool.adapter(key) the adapter for a provider record (rebuilt when the record changes)
++ * pool.health(key) circuit-breaker state
++ * pool.execute(route, op, req, ctx)
++ * tries the route's primary, then each fallback, skipping candidates that are disabled,
++ * circuit-open, missing credentials or that do not support the operation; applies a strict
++ * timeout and cancellation to every call and one retry on a transient failure; records one
++ * request-log row per attempt or skip (fallback = 1 for every non-primary candidate) and
++ * returns { result, provider, model, fallbackUsed }. When nothing answers it throws
++ * AiError 503 provider.unavailable — an explicit state, never a made-up answer.
++ *
++ * Circuit breaker per provider: `failureThreshold` consecutive failures open it for `cooldownMs`;
++ * after that one half-open probe decides whether it closes again.
++ */
++const { AiError, resolveSecret, sha256 } = require('../util');
++const { retryable, sleep, ProviderError } = require('./common');
++const { createStubProvider } = require('./stub');
++const { createOpenAiProvider } = require('./openai');
++const { createAnthropicProvider } = require('./anthropic');
++const { createHttpSeamProvider } = require('./http');
++const { createWhisperProvider } = require('./whisper');
++
++function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console }) {
++ const cache = new Map(); // key -> { stamp, adapter }
++ const stats = {}; // key -> { calls, failures, ... } (in memory; tests + /api/ready)
++
++ function statFor(key) { return stats[key] || (stats[key] = { calls: 0, ok: 0, failures: 0, skipped: 0 }); }
++
++ function build(p) {
++ const apiKey = resolveSecret(p.secret_ref, env);
++ switch (p.kind) {
++ case 'stub': return createStubProvider(p, { stats: statFor(p.key) });
++ case 'openai': return createOpenAiProvider(p, { apiKey, fetchImpl });
++ case 'anthropic': return createAnthropicProvider(p, { apiKey, fetchImpl });
++ case 'http': return createHttpSeamProvider(p, { apiKey, fetchImpl });
++ case 'whisper': return createWhisperProvider(p, { config });
++ default: throw new Error(`unknown provider kind ${p.kind}`);
++ }
++ }
++
++ function adapter(key) {
++ const p = registry.getProvider(key);
++ if (!p) return null;
++ const stamp = `${p.updated_at}|${p.secret_ref}|${resolveSecret(p.secret_ref, env) ? 1 : 0}`;
++ const hit = cache.get(key);
++ if (hit && hit.stamp === stamp) return { record: p, adapter: hit.adapter };
++ const a = build(p);
++ cache.set(key, { stamp, adapter: a });
++ return { record: p, adapter: a };
++ }
++
++ // ── Circuit breaker ────────────────────────────────────
++ const getHealth = db.prepare('SELECT * FROM provider_health WHERE provider_key = ?');
++ const putHealth = db.prepare(`INSERT INTO provider_health (provider_key, state, consecutive_failures, opened_at, last_error, last_success_at, last_failure_at)
++ VALUES (@provider_key, @state, @consecutive_failures, @opened_at, @last_error, @last_success_at, @last_failure_at)
++ ON CONFLICT(provider_key) DO UPDATE SET state = excluded.state, consecutive_failures = excluded.consecutive_failures, opened_at = excluded.opened_at,
++ last_error = excluded.last_error, last_success_at = excluded.last_success_at, last_failure_at = excluded.last_failure_at`);
++ function health(key) {
++ const h = getHealth.get(key) || { provider_key: key, state: 'closed', consecutive_failures: 0, opened_at: null, last_error: null, last_success_at: null, last_failure_at: null };
++ if (h.state === 'open' && clock.now() - (h.opened_at || 0) >= config.breaker.cooldownMs) return { ...h, state: 'half_open' };
++ return h;
++ }
++ function recordSuccess(key) {
++ const h = health(key);
++ if (h.state !== 'closed') registry.audit('system', 'provider.circuit_closed', 'provider', key, { metadata: { after_failures: h.consecutive_failures } });
++ putHealth.run({ ...h, provider_key: key, state: 'closed', consecutive_failures: 0, opened_at: null, last_success_at: clock.now() });
++ }
++ function recordFailure(key, err) {
++ const h = health(key);
++ const failures = (h.consecutive_failures || 0) + 1;
++ const open = h.state === 'half_open' || failures >= config.breaker.failureThreshold;
++ if (open && h.state !== 'open') registry.audit('system', 'provider.circuit_opened', 'provider', key, { metadata: { failures, error: String(err && err.message || err).slice(0, 200) } });
++ putHealth.run({ ...h, provider_key: key, state: open ? 'open' : 'closed', consecutive_failures: failures, opened_at: open ? clock.now() : h.opened_at, last_error: String(err && err.message || err).slice(0, 500), last_failure_at: clock.now() });
++ }
++ function resetHealth(key) { db.prepare('DELETE FROM provider_health WHERE provider_key = ?').run(key); }
++
++ // ── Pricing (Live's rules: model row -> AI_PRICING_JSON longest prefix -> flat rates) ──
++ function priceFor(providerKey, model) {
++ const p = registry.getProvider(providerKey);
++ if (!p || p.kind === 'stub' || p.kind === 'whisper') return { in: 0, out: 0, cached: 0 };
++ const row = model ? registry.getModel(providerKey, model) : null;
++ if (row && row.cost && Number.isFinite(row.cost.in_per_mtok) && row.cost.in_per_mtok !== null) {
++ const inRate = Number(row.cost.in_per_mtok);
++ return { in: inRate, out: Number(row.cost.out_per_mtok) || 0, cached: row.cost.cached_per_mtok != null ? Number(row.cost.cached_per_mtok) : inRate * 0.1 };
++ }
++ const table = config.pricing.table || {};
++ const m = String(model || '').toLowerCase();
++ let best = null; let bestLen = -1;
++ for (const [k, v] of Object.entries(table)) {
++ const key = String(k).toLowerCase();
++ if (key === 'default') continue;
++ if (m.startsWith(key) && key.length > bestLen && v && typeof v === 'object') { best = v; bestLen = key.length; }
++ }
++ if (!best && table.default && typeof table.default === 'object') best = table.default;
++ const inRate = best && Number.isFinite(Number(best.in)) ? Number(best.in) : config.pricing.inputPerMtok;
++ const outRate = best && Number.isFinite(Number(best.out)) ? Number(best.out) : config.pricing.outputPerMtok;
++ const cachedRate = best && Number.isFinite(Number(best.cached)) ? Number(best.cached) : inRate * 0.1;
++ return { in: inRate, out: outRate, cached: cachedRate };
++ }
++ function costOf(providerKey, model, usage) {
++ const pr = priceFor(providerKey, model);
++ const input = Math.max(0, (usage.input || 0) - (usage.cached || 0));
++ return (input / 1e6) * pr.in + ((usage.cached || 0) / 1e6) * pr.cached + ((usage.output || 0) / 1e6) * pr.out;
++ }
++
++ // ── Routing ────────────────────────────────────────────
++ function candidates(route) {
++ const list = [route.primary, ...(route.fallbacks || [])].filter(c => c && c.provider);
++ if (config.stubFallback && !list.some(c => c.provider === 'stub') && registry.getProvider('stub')) list.push({ provider: 'stub', model: null, auto: true });
++ return list;
++ }
++
++ function skipReason(p, a, features) {
++ if (!p || !a) return 'unknown_provider';
++ if (p.status !== 'active') return 'disabled';
++ if (p.auth_mode !== 'none' && !resolveSecret(p.secret_ref, env)) return 'no_credentials';
++ if (features.some(f => !a.supports(f))) return 'unsupported';
++ if (health(p.key).state === 'open') return 'circuit_open';
++ return null;
++ }
++
++ /**
++ * ctx: { runId, routeKey, routeVersion, signal, logRequest(entry), debugRaw }
++ * req: canonical request (system, messages, image, json, maxTokens, temperature, cacheKey, input, filePath, ...)
++ */
++ async function execute(route, operation, req, ctx = {}) {
++ const features = [operation];
++ if (req.image) features.push('vision');
++ if (req.json) features.push('json');
++ const tried = [];
++ const list = candidates(route);
++ for (let i = 0; i < list.length; i++) {
++ const c = list[i];
++ const entry = adapter(c.provider);
++ const p = entry && entry.record;
++ const a = entry && entry.adapter;
++ const model = c.model || (p && p.default_model) || null;
++ const fallback = i > 0;
++ const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
++ const why = skipReason(p, a, features);
++ if (why) {
++ statFor(c.provider).skipped++;
++ tried.push({ provider: c.provider, skipped: why });
++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
++ continue;
++ }
++ const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || route.timeout_ms || p.timeout_ms || 30000, p.timeout_ms || 600000));
++ const retries = config.providerRetries;
++ let lastErr = null;
++ for (let attempt = 0; attempt <= retries; attempt++) {
++ if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
++ const started = clock.now();
++ const t0 = Date.now();
++ try {
++ const fn = a[operation];
++ if (typeof fn !== 'function') throw new ProviderError(`${operation} not implemented`, { code: 'provider.unsupported' });
++ statFor(c.provider).calls += p.kind === 'stub' ? 0 : 1; // stub counts its own calls
++ const result = await fn({ ...req, model, signal: ctx.signal, timeoutMs });
++ const latency = Date.now() - t0;
++ const usage = result.usage || { input: 0, output: 0, cached: 0 };
++ const cost = costOf(c.provider, result.model || model, usage);
++ statFor(c.provider).ok++;
++ recordSuccess(c.provider);
++ if (ctx.logRequest) {
++ ctx.logRequest({
++ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || result.vectors || result.segments || ''),
++ tokens_in: usage.input || 0, tokens_out: usage.output || 0, tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0,
++ cost_usd: cost, latency_ms: latency,
++ debug_prompt: ctx.debugRaw ? JSON.stringify({ system: req.system, messages: req.messages }).slice(0, 200000) : null,
++ debug_response: ctx.debugRaw ? String(result.text || '').slice(0, 200000) : null,
++ });
++ }
++ return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback, usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started };
++ } catch (err) {
++ lastErr = err;
++ if (ctx.signal && ctx.signal.aborted) {
++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'cancelled', latency_ms: Date.now() - t0, error: 'cancelled' });
++ throw new AiError(409, 'run.cancelled', 'run was cancelled');
++ }
++ statFor(c.provider).failures++;
++ if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
++ if (attempt < retries && retryable(err)) { await sleep(config.retryDelayMs + Math.floor(Math.random() * Math.min(700, config.retryDelayMs)), ctx.signal).catch(() => {}); continue; }
++ break;
++ }
++ }
++ recordFailure(c.provider, lastErr);
++ tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });
++ log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);
++ }
++ throw new AiError(503, 'provider.unavailable', 'no provider on this route could answer', { tried });
++ }
++
++ return { adapter, health, resetHealth, execute, priceFor, costOf, stats, statFor, candidates };
++}
++
++module.exports = { createProviderPool };
ddiff --git a/server/providers/openai.js b/server/providers/openai.js
nnew file mode 100644
iindex 0000000..b2474e6
---- /dev/null
++++ b/server/providers/openai.js
@@@ -0,0 +1,119 @@
++'use strict';
++/**
++ * OpenAI-compatible adapter, ported from OpenVibe.Live (server/ai/llm.js _openaiBody/_callOpenAI
++ * and server/ai/ai-provider.js transcribe). Covers OpenAI, OpenRouter, Groq, Together and
++ * self-hosted llama.cpp / LM Studio / Ollama (OpenAI mode) — anything with the OpenAI REST shape:
++ *
++ * chat and every text operation POST {base}/chat/completions
++ * embed POST {base}/embeddings
++ * transcribe POST {base}/audio/transcriptions (Whisper-style)
++ *
++ * Structured output steps down json_schema -> json_object -> prose for gateways that lack it
++ * (Live's behaviour); prose JSON is repaired by the engine.
++ */
++const fs = require('fs');
++const path = require('path');
++const { ProviderError, postJson, deadline, describeAbort } = require('./common');
++
++const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
++const TEXT_OPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
++
++function isReasoningModel(m) { return /^(gpt-5|o\d)/i.test(m || ''); }
++
++function buildBody({ model, system, messages, image, json, maxTokens, temperature, cacheKey, baseUrl }, jsonMode) {
++ const msgs = [];
++ const sysText = (system || []).map(x => x.text).join('\n\n');
++ if (sysText) msgs.push({ role: 'system', content: sysText });
++ messages.forEach((m, i) => {
++ const last = i === messages.length - 1;
++ if (last && image && m.role === 'user') {
++ const parts = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content || '') }];
++ msgs.push({ role: 'user', content: [...parts, { type: 'image_url', image_url: { url: `data:${image.mediaType};base64,${image.base64}` } }] });
++ } else msgs.push({ role: m.role, content: Array.isArray(m.content) ? m.content : String(m.content || '') });
++ });
++ const body = { model, messages: msgs };
++ if (isReasoningModel(model)) {
++ body.max_completion_tokens = Math.max(maxTokens, 256) + 512;
++ body.reasoning_effort = /^gpt-5/i.test(model) ? 'minimal' : 'low';
++ } else {
++ body.max_tokens = maxTokens;
++ if (temperature != null) body.temperature = temperature;
++ }
++ if (json && jsonMode === 'schema') body.response_format = { type: 'json_schema', json_schema: { name: json.name || 'result', schema: json.schema, strict: json.strict !== false } };
++ else if (json && jsonMode === 'object') body.response_format = { type: 'json_object' };
++ if (cacheKey && /api\.openai\.com/i.test(baseUrl)) body.prompt_cache_key = String(cacheKey).slice(0, 64);
++ return body;
++}
++
++function createOpenAiProvider(record, { apiKey = '', fetchImpl = globalThis.fetch } = {}) {
++ const baseUrl = String(record.base_url || DEFAULT_BASE_URL).replace(/\/+$/, '');
++ const headers = () => (apiKey ? { Authorization: `Bearer ${apiKey}` } : {});
++ const caps = new Set(record.capabilities && record.capabilities.length ? record.capabilities : [...TEXT_OPS, 'json', 'vision', 'embed', 'transcribe']);
++
++ async function chat(req) {
++ let jsonMode = req.json ? 'schema' : null;
++ for (let attempt = 0; attempt < 3; attempt++) {
++ const body = buildBody({ ...req, baseUrl }, jsonMode);
++ try {
++ const j = await postJson(`${baseUrl}/chat/completions`, headers(), body, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
++ const msg = j.choices && j.choices[0] && j.choices[0].message;
++ const text = (msg && typeof msg.content === 'string' ? msg.content : '').trim();
++ const u = j.usage || {};
++ return { text, json: null, model: j.model || req.model, usage: { input: u.prompt_tokens || 0, output: u.completion_tokens || 0, cached: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 } };
++ } catch (e) {
++ // Gateways without structured-output support: schema -> json_object -> prose.
++ if (e instanceof ProviderError && e.status === 400 && jsonMode && /response_format|json_schema|schema|strict/i.test(e.message)) {
++ jsonMode = jsonMode === 'schema' ? 'object' : null;
++ continue;
++ }
++ throw e;
++ }
++ }
++ throw new ProviderError('structured output unsupported', { code: 'provider.unsupported' });
++ }
++
++ async function embed(req) {
++ const j = await postJson(`${baseUrl}/embeddings`, headers(), { model: req.model, input: req.input }, { signal: req.signal, timeoutMs: req.timeoutMs, fetchImpl });
++ const vectors = (j.data || []).sort((a, b) => a.index - b.index).map(d => d.embedding);
++ const u = j.usage || {};
++ return { vectors, model: j.model || req.model, usage: { input: u.prompt_tokens || u.total_tokens || 0, output: 0, cached: 0 } };
++ }
++
++ async function transcribe(req) {
++ if (!req.filePath || !fs.existsSync(req.filePath)) throw new ProviderError('transcription audio file missing', { code: 'provider.input' });
++ const form = new FormData();
++ form.append('file', new Blob([fs.readFileSync(req.filePath)], { type: 'audio/wav' }), path.basename(req.filePath));
++ form.append('model', req.model || 'whisper-1');
++ form.append('response_format', 'verbose_json');
++ if (req.language && req.language !== 'auto') form.append('language', req.language);
++ let res;
++ try {
++ res = await fetchImpl(`${baseUrl}/audio/transcriptions`, { method: 'POST', headers: headers(), body: form, signal: deadline(req.signal, req.timeoutMs) });
++ } catch (err) {
++ throw describeAbort(err, req.signal, req.timeoutMs) || new ProviderError(`connection failed: ${err.message}`, { code: 'provider.unreachable' });
++ }
++ const raw = await res.text();
++ if (!res.ok) throw new ProviderError(`Transcribe HTTP ${res.status}: ${raw.slice(0, 300)}`, { status: res.status, code: 'provider.http' });
++ // verbose_json has segments; some providers still answer plain text.
++ try {
++ const j = JSON.parse(raw);
++ const segments = Array.isArray(j.segments) ? j.segments.map(s => ({ start: Number(s.start) || 0, end: Number(s.end) || 0, text: String(s.text || '').trim() })) : [];
++ return { text: String(j.text || j.transcript || '').trim(), segments, model: req.model || 'whisper-1', usage: { input: 0, output: 0, cached: 0 } };
++ } catch {
++ return { text: String(raw || '').trim(), segments: [], model: req.model || 'whisper-1', usage: { input: 0, output: 0, cached: 0 } };
++ }
++ }
++
++ const adapter = {
++ key: record.key,
++ kind: 'openai',
++ supports: (feature) => caps.has(feature),
++ chat,
++ embed,
++ transcribe,
++ };
++ for (const op of TEXT_OPS) if (op !== 'chat') adapter[op] = chat; // the engine shapes the prompt per operation
++ return adapter;
++}
++
++module.exports = { createOpenAiProvider, buildBody, DEFAULT_BASE_URL };
ddiff --git a/server/providers/stub.js b/server/providers/stub.js
nnew file mode 100644
iindex 0000000..b2d828e
---- /dev/null
++++ b/server/providers/stub.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * The deterministic no-key stub provider (mandatory for local and test operation).
++ *
++ * Every answer is a pure function of the request: the same request always yields the same output,
++ * so tests are exact and browsers can exercise Wiki/Blog/News flows without keys or network.
++ * Outputs are realistically SHAPED — a JSON answer follows the requested schema (enums, bounds,
++ * arrays, nested objects), text has sentences, embeddings are unit vectors, transcripts have
++ * timed segments — and are clearly marked synthetic: free-text strings start with "(synthetic)"
++ * and every result carries synthetic: true, which the run records and returns. Downstream
++ * products treat a synthetic run as "no real answer" (Live does; the publication gate keeps it
++ * noindex).
++ *
++ * Drill modes (provider metadata, for fallback/breaker drills): { fail: 'error' | 'timeout' }.
++ */
++const crypto = require('crypto');
++const { ProviderError, approxTokens, sleep } = require('./common');
++
++const WORDS = ['signal', 'stream', 'moment', 'chat', 'overview', 'context', 'detail', 'summary', 'topic', 'source',
++ 'evidence', 'draft', 'highlight', 'session', 'update', 'note', 'theme', 'story', 'entity', 'record'];
++
++function seeded(seedText) {
++ let h = crypto.createHash('sha256').update(seedText).digest();
++ let i = 0;
++ return function next() {
++ if (i + 4 > h.length) { h = crypto.createHash('sha256').update(h).digest(); i = 0; }
++ const v = h.readUInt32BE(i);
++ i += 4;
++ return v / 0x100000000;
++ };
++}
++
++function lastUserText(req) {
++ const msgs = req.messages || [];
++ for (let i = msgs.length - 1; i >= 0; i--) {
++ const c = msgs[i].content;
++ if (msgs[i].role === 'user') return typeof c === 'string' ? c : JSON.stringify(c);
++ }
++ return '';
++}
++
++function phrase(rnd, n) {
++ const out = [];
++ for (let i = 0; i < n; i++) out.push(WORDS[Math.floor(rnd() * WORDS.length)]);
++ return out.join(' ');
++}
++
++/** A value shaped like `schema`, drawn from rnd. Strings are marked synthetic. */
++function synthesize(schema, rnd, depth = 0, name = 'value') {
++ if (!schema || typeof schema !== 'object' || depth > 6) return null;
++ if (schema.const !== undefined) return schema.const;
++ if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[Math.floor(rnd() * schema.enum.length)];
++ const alt = schema.oneOf || schema.anyOf;
++ if (Array.isArray(alt) && alt.length) return synthesize(alt[0], rnd, depth + 1, name);
++ let type = schema.type;
++ if (Array.isArray(type)) type = type.find(t => t !== 'null') || 'null';
++ if (!type) type = schema.properties ? 'object' : schema.items ? 'array' : 'string';
++ switch (type) {
++ case 'object': {
++ const out = {};
++ const props = schema.properties || {};
++ for (const [k, sub] of Object.entries(props)) out[k] = synthesize(sub, rnd, depth + 1, k);
++ return out;
++ }
++ case 'array': {
++ const max = Number.isInteger(schema.maxItems) ? schema.maxItems : 3;
++ const min = Number.isInteger(schema.minItems) ? schema.minItems : Math.min(1, max);
++ const n = Math.max(min, Math.min(max, min + Math.floor(rnd() * 3)));
++ return Array.from({ length: n }, (_, i) => synthesize(schema.items || { type: 'string' }, rnd, depth + 1, `${name}_${i + 1}`));
++ }
++ case 'integer': {
++ const min = Number.isFinite(schema.minimum) ? schema.minimum : 0;
++ const max = Number.isFinite(schema.maximum) ? schema.maximum : min + 100;
++ return Math.floor(min + rnd() * (max - min + 1));
++ }
++ case 'number': {
++ const min = Number.isFinite(schema.minimum) ? schema.minimum : 0;
++ const max = Number.isFinite(schema.maximum) ? schema.maximum : 1;
++ return Math.round((min + rnd() * (max - min)) * 1000) / 1000;
++ }
++ case 'boolean': return rnd() < 0.5;
++ case 'null': return null;
++ default: {
++ let s = `(synthetic) ${name.replace(/_/g, ' ')}: ${phrase(rnd, 4 + Math.floor(rnd() * 5))}`;
++ if (Number.isInteger(schema.maxLength)) s = s.slice(0, Math.max(0, schema.maxLength));
++ if (Number.isInteger(schema.minLength) && s.length < schema.minLength) s = s.padEnd(schema.minLength, '.');
++ return s;
++ }
++ }
++}
++
++function textFor(op, req, rnd) {
++ const src = lastUserText(req).replace(/\s+/g, ' ').trim();
++ const firstWords = src.split(' ').slice(0, 10).join(' ');
++ switch (op) {
++ case 'summarize': return `(synthetic) Summary: ${firstWords ? `${firstWords}…` : 'no input text'} — ${phrase(rnd, 6)}.`;
++ case 'classify': return `(synthetic) ${phrase(rnd, 1)}`;
++ case 'extract': return `(synthetic) extracted ${phrase(rnd, 3)}`;
++ case 'enrich': return `(synthetic) enriched ${phrase(rnd, 5)}`;
++ default: return `(synthetic) ${phrase(rnd, 8 + Math.floor(rnd() * 8))}. ${phrase(rnd, 6)}.`;
++ }
++}
++
++function createStubProvider(record = { key: 'stub', metadata: {} }, { stats = null } = {}) {
++ const fail = record.metadata && record.metadata.fail;
++
++ async function run(op, req) {
++ if (stats) stats.calls = (stats.calls || 0) + 1;
++ if (req.signal && req.signal.aborted) throw new ProviderError('cancelled', { code: 'provider.cancelled' });
++ if (fail === 'error') throw new ProviderError('stub drill: provider error', { status: 503, code: 'provider.http' });
++ if (fail === 'timeout') { await sleep(Math.min(req.timeoutMs || 1000, 60000), req.signal); throw new ProviderError('stub drill: timeout', { code: 'provider.timeout' }); }
++ if (record.metadata && Number(record.metadata.delay_ms) > 0) await sleep(Number(record.metadata.delay_ms), req.signal);
++ const seed = JSON.stringify({ op, m: req.model || null, s: (req.system || []).map(x => x.text), u: req.messages, j: req.json ? req.json.schema : null, i: req.input || null, img: req.image ? req.image.base64.length : 0 });
++ const rnd = seeded(seed);
++ const model = req.model || 'stub-1';
++ if (op === 'embed') {
++ const inputs = Array.isArray(req.input) ? req.input : [String(req.input || '')];
++ const vectors = inputs.map((t) => {
++ const r = seeded(`embed:${t}`);
++ const v = Array.from({ length: 64 }, () => r() * 2 - 1);
++ const norm = Math.sqrt(v.reduce((n, x) => n + x * x, 0)) || 1;
++ return v.map(x => Math.round((x / norm) * 1e6) / 1e6);
++ });
++ return { vectors, model, synthetic: true, usage: { input: inputs.reduce((n, t) => n + approxTokens(t), 0), output: 0, cached: 0, estimated: true } };
++ }
++ if (op === 'transcribe') {
++ const n = 2 + Math.floor(rnd() * 3);
++ const segments = Array.from({ length: n }, (_, i) => ({ start: i * 4, end: i * 4 + 3.5, text: `(synthetic) ${phrase(rnd, 5)}` }));
++ return { text: segments.map(s => s.text).join(' '), segments, model, synthetic: true, usage: { input: 0, output: 0, cached: 0, estimated: true } };
++ }
++ let json = null;
++ let text;
++ if (req.json && req.json.schema) {
++ json = synthesize(req.json.schema, rnd, 0, req.json.name || 'result');
++ text = JSON.stringify(json);
++ } else {
++ text = textFor(op, req, rnd);
++ }
++ const inTok = approxTokens((req.system || []).map(x => x.text).join('')) + approxTokens(lastUserText(req)) + (req.image ? 800 : 0);
++ return { text, json, model, synthetic: true, usage: { input: inTok, output: approxTokens(text), cached: 0, estimated: true } };
++ }
++
++ const adapter = {
++ key: record.key,
++ kind: 'stub',
++ synthetic: true,
++ supports: () => true,
++ transcribe: (req) => run('transcribe', req),
++ embed: (req) => run('embed', req),
++ };
++ for (const op of ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich']) adapter[op] = (req) => run(op, req);
++ return adapter;
++}
++
++module.exports = { createStubProvider, synthesize, seeded };
ddiff --git a/server/providers/whisper.js b/server/providers/whisper.js
nnew file mode 100644
iindex 0000000..9bf3f96
---- /dev/null
++++ b/server/providers/whisper.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * Local speech-to-text through whisper.cpp — ported from OpenVibe.Live server/ai/transcribe.js with
++ * its hard-won behaviour intact:
++ * - JSON output (-oj) for per-segment timestamps; greedy decoding unless WHISPER_BEAM > 1
++ * - Silero VAD when the model is installed (decode only regions with a voice)
++ * - the hallucination filter (stock phrases over silence, subtitle boilerplate, URL lines,
++ * filler words when there is no VAD) and collapsing of looped repeats
++ * - a multilingual model (WHISPER_MODEL_MULTI) for non-English audio, a faster live model
++ * (WHISPER_MODEL_LIVE), two independent lanes (live never waits on batch), nice -n 15
++ * Same environment names as Live, so the same install works on either host.
++ *
++ * Input is a local media file the engine already fetched through the SSRF-safe fetcher; ffmpeg
++ * turns it into 16 kHz mono WAV first.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawn } = require('child_process');
++const { ProviderError } = require('./common');
++
++const HALLUCINATIONS = new Set([
++ 'thank you', 'thank you.', 'thanks for watching', 'thanks for watching!', 'thanks for watching.', 'please subscribe', 'subscribe',
++ 'like and subscribe', 'thank you for watching', 'thank you very much', 'thank you so much', 'silence', 'music', 'applause',
++]);
++const FILLER_WORDS = new Set(['you', 'bye', 'bye.', 'bye bye', 'okay', 'ok', 'oh', 'uh', 'um', 'hmm', 'mm', 'mhm', 'the', 'so', 'yeah', 'i\'m sorry']);
++
++function isNoise(t, hasVad) {
++ const s = (t || '').replace(/\s+/g, ' ').trim();
++ if (!s) return true;
++ if (/^[\s.\-–—_*]+$/.test(s)) return true;
++ if (/^[[(♪].*[\])♪]?$/.test(s)) return true;
++ if (/^♪/.test(s) || /♪$/.test(s)) return true;
++ const norm = s.toLowerCase().replace(/[.!?,…]+$/g, '').trim();
++ if (HALLUCINATIONS.has(norm)) return true;
++ if (/\b(?:www\.|https?:\/\/)\S+/i.test(norm) && norm.split(/\s+/).length <= 12) return true;
++ if (/subtitle[sd]?\s+(?:by|from)|amara\.org|subscribe to|closed caption/i.test(norm)) return true;
++ if (/^(?:for more info(?:rmation)?|visit our website|see you (?:next time|in the next video))/i.test(norm)) return true;
++ if (!hasVad && FILLER_WORDS.has(norm)) return true;
++ return false;
++}
++
++function cleanSegments(segsRaw, hasVad) {
++ const out = [];
++ let last = '';
++ for (const seg of segsRaw) {
++ const text = (seg.text || '').replace(/\s+/g, ' ').trim();
++ if (isNoise(text, hasVad)) continue;
++ const norm = text.toLowerCase();
++ if (norm === last) continue;
++ last = norm;
++ out.push({ start: Math.round(seg.start * 100) / 100, end: Math.round(seg.end * 100) / 100, text });
++ }
++ return out;
++}
++
++function createWhisperProvider(record, { config }) {
++ const HOME = os.homedir();
++ const w = config.whisper;
++ const bins = [w.bin, path.join(HOME, 'whisper.cpp/build/bin/whisper-cli'), path.join(HOME, 'whisper.cpp/build/bin/main'), path.join(HOME, 'whisper.cpp/main')].filter(Boolean);
++ const MODEL = w.model || path.join(HOME, 'whisper.cpp/models/ggml-base.en.bin');
++ const MODEL_LIVE = w.modelLive || MODEL;
++ const MODEL_MULTI = w.modelMulti || path.join(HOME, 'whisper.cpp/models/ggml-base.bin');
++ const VAD_MODEL = w.vadModel || path.join(HOME, 'whisper.cpp/models/ggml-silero-v5.1.2.bin');
++ const exists = (p) => { try { return Boolean(p) && fs.existsSync(p); } catch { return false; } };
++ const bin = () => bins.find(exists) || null;
++ const vadModel = () => (w.vad && exists(VAD_MODEL) ? VAD_MODEL : null);
++ const multi = () => (exists(MODEL_MULTI) ? MODEL_MULTI : null);
++ const available = () => Boolean(bin()) && exists(MODEL);
++
++ const lanes = { live: { max: 1, running: 0, waiters: [] }, batch: { max: w.maxConcurrent, running: 0, waiters: [] } };
++ const acquire = (lane) => { const L = lanes[lane]; if (L.running < L.max) { L.running++; return Promise.resolve(); } return new Promise(r => L.waiters.push(r)); };
++ const release = (lane) => { const L = lanes[lane]; const n = L.waiters.shift(); if (n) n(); else L.running = Math.max(0, L.running - 1); };
++ const active = new Set();
++
++ function spawnTracked(cmd, args) {
++ const ch = spawn(cmd, args, { stdio: 'ignore' });
++ active.add(ch);
++ const drop = () => active.delete(ch);
++ ch.on('close', drop); ch.on('error', drop);
++ return ch;
++ }
++
++ function toWav(src, seconds, signal, timeoutMs) {
++ return new Promise((resolve, reject) => {
++ const wav = path.join(os.tmpdir(), `openvibe-ai-tx-${Date.now()}-${Math.floor(Math.random() * 1e6)}.wav`);
++ const args = ['-y', '-nostdin', '-i', src];
++ if (seconds > 0) args.push('-t', String(seconds));
++ args.push('-vn', '-ac', '1', '-ar', '16000', '-f', 'wav', wav);
++ let ff;
++ try { ff = spawnTracked('ffmpeg', args); } catch (e) { return reject(new ProviderError(`ffmpeg: ${e.message}`, { code: 'provider.unavailable' })); }
++ const kill = () => { try { ff.kill('SIGKILL'); } catch { /* */ } };
++ const timer = setTimeout(kill, timeoutMs);
++ if (signal) signal.addEventListener('abort', kill, { once: true });
++ ff.on('close', (code) => {
++ clearTimeout(timer);
++ if (signal && signal.aborted) { try { fs.unlinkSync(wav); } catch { /* */ } return reject(new ProviderError('cancelled', { code: 'provider.cancelled' })); }
++ if (code !== 0) { try { fs.unlinkSync(wav); } catch { /* */ } return reject(new ProviderError(`ffmpeg exited ${code}`, { code: 'provider.error' })); }
++ resolve(wav);
++ });
++ ff.on('error', (e) => { clearTimeout(timer); reject(new ProviderError(`ffmpeg: ${e.message}`, { code: 'provider.unavailable' })); });
++ });
++ }
++
++ function decode(wavPath, { language = 'en', live = false, offsetSec = 0, signal, timeoutMs }) {
++ return new Promise((resolve, reject) => {
++ const lang = language && language !== 'en' && multi() ? String(language) : 'en';
++ const model = lang !== 'en' ? multi() : (live && exists(MODEL_LIVE) ? MODEL_LIVE : MODEL);
++ const threads = live ? Math.max(1, Math.min(2, w.threads)) : w.threads;
++ const outBase = `${wavPath}.out`;
++ const jsonPath = `${outBase}.json`;
++ const args = ['-m', model, '-f', wavPath, '-oj', '-of', outBase, '-t', String(threads), '-l', lang];
++ if (w.beam > 1) args.push('-bs', String(w.beam));
++ const vm = vadModel();
++ if (vm) args.push('--vad', '-vm', vm);
++ let ch;
++ try { ch = spawnTracked('nice', ['-n', '15', bin(), ...args]); } catch (e) { return reject(new ProviderError(e.message, { code: 'provider.unavailable' })); }
++ const kill = () => { try { ch.kill('SIGKILL'); } catch { /* */ } };
++ const timer = setTimeout(kill, timeoutMs);
++ if (signal) signal.addEventListener('abort', kill, { once: true });
++ const done = (fn) => { clearTimeout(timer); try { fs.existsSync(jsonPath) && fs.unlinkSync(jsonPath); } catch { /* */ } fn(); };
++ ch.on('close', (code) => {
++ if (signal && signal.aborted) return done(() => reject(new ProviderError('cancelled', { code: 'provider.cancelled' })));
++ if (code !== 0) return done(() => reject(new ProviderError(`whisper exited ${code}`, { code: 'provider.error' })));
++ let parsed;
++ try { parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); } catch (e) { return done(() => reject(new ProviderError(`parse: ${e.message}`, { code: 'provider.error' }))); }
++ const items = Array.isArray(parsed && parsed.transcription) ? parsed.transcription : [];
++ const segments = cleanSegments(items.map(it => ({
++ start: offsetSec + ((it.offsets && it.offsets.from) || 0) / 1000,
++ end: offsetSec + ((it.offsets && it.offsets.to) || 0) / 1000,
++ text: it.text || '',
++ })), Boolean(vm));
++ done(() => resolve({ text: segments.map(s => s.text).join(' ').replace(/\s+/g, ' ').trim(), segments, language: lang, model: path.basename(model) }));
++ });
++ ch.on('error', (e) => done(() => reject(new ProviderError(e.message, { code: 'provider.unavailable' }))));
++ });
++ }
++
++ async function transcribe(req) {
++ if (!available()) throw new ProviderError('whisper.cpp is not installed on this host', { code: 'provider.unavailable' });
++ const lane = req.live ? 'live' : 'batch';
++ const timeoutMs = req.timeoutMs || 300000;
++ const wav = await toWav(req.filePath, req.seconds || 0, req.signal, timeoutMs);
++ try {
++ await acquire(lane);
++ try {
++ const r = await decode(wav, { language: req.language, live: req.live, offsetSec: req.offsetSec || 0, signal: req.signal, timeoutMs });
++ return { ...r, usage: { input: 0, output: 0, cached: 0 } };
++ } finally { release(lane); }
++ } finally { try { fs.unlinkSync(wav); } catch { /* */ } }
++ }
++
++ return {
++ key: record.key,
++ kind: 'whisper',
++ supports: (f) => f === 'transcribe' && available(),
++ transcribe,
++ describe: () => ({ available: available(), model: path.basename(MODEL), multilingual: Boolean(multi()), vad: Boolean(vadModel()), lanes: Object.fromEntries(Object.entries(lanes).map(([k, L]) => [k, { running: L.running, queued: L.waiters.length }])) }),
++ killActive() { for (const ch of active) { try { ch.kill('SIGKILL'); } catch { /* */ } } active.clear(); },
++ };
++}
++
++module.exports = { createWhisperProvider, cleanSegments, isNoise };
ddiff --git a/server/quota.js b/server/quota.js
nnew file mode 100644
iindex 0000000..367d1db
---- /dev/null
++++ b/server/quota.js
@@@ -0,0 +1,161 @@
++'use strict';
++/**
++ * Quotas and usage.
++ *
++ * A quota limits requests, tokens and/or cost for one scope in one fixed window:
++ * scope_type global (scope_id '*') | service (live) | actor (user:usr_…) | attribution (live:user:42)
++ * window minute | hour | day (UTC-aligned)
++ * workflow_prefix (optional) narrows it to workflows whose key starts with the prefix
++ *
++ * reserve() runs BEFORE any provider call: in one transaction it checks every applicable quota and,
++ * only if all pass, counts the request against each. A refusal throws AiError 429 quota.exceeded
++ * with retry_after_seconds (the end of the tightest exceeded window), so the caller can back off.
++ * account() adds the real tokens and cost afterwards (tokens/cost limits therefore bound the NEXT
++ * request once a window's spend has reached them, the same way Live's daily USD cap worked).
++ */
++const { AiError, iso } = require('./util');
++
++const WINDOWS = { minute: 60, hour: 3600, day: 86400 };
++
++function createQuotas(db, { clock = { now: () => Date.now() }, registry } = {}) {
++ const nowSec = () => Math.floor(clock.now() / 1000);
++ const windowStart = (w, t = nowSec()) => Math.floor(t / WINDOWS[w]) * WINDOWS[w];
++
++ function scopesFor(ctx) {
++ const s = [['global', '*']];
++ if (ctx.requesterType === 'service') s.push(['service', ctx.requesterId]);
++ else s.push(['service', `${ctx.requesterType}:${ctx.requesterId}`]);
++ if (ctx.actorKey) s.push(['actor', ctx.actorKey]);
++ if (ctx.attributionKey) s.push(['attribution', ctx.attributionKey]);
++ return s;
++ }
++
++ /**
++ * Quotas that apply to this request, each with the counter id it counts under. A quota whose
++ * scope_id is '*' on a non-global scope applies to EACH principal of that type separately
++ * (e.g. service '*' = every service gets its own window).
++ */
++ function applicable(ctx) {
++ const scopes = scopesFor(ctx);
++ const rows = db.prepare("SELECT * FROM quotas WHERE status = 'active'").all();
++ const out = [];
++ for (const q of rows) {
++ if (q.workflow_prefix && !String(ctx.workflowKey || '').startsWith(q.workflow_prefix)) continue;
++ const hit = scopes.find(([t, id]) => q.scope_type === t && (q.scope_id === id || (q.scope_id === '*' && t !== 'global')));
++ if (hit) out.push({ q, cid: q.scope_id === '*' && q.scope_type !== 'global' ? hit[1] : q.scope_id });
++ }
++ return out;
++ }
++
++ const getCounter = db.prepare('SELECT * FROM usage_counters WHERE scope_type = ? AND scope_id = ? AND window = ? AND window_start = ? AND workflow_prefix = ?');
++ const bump = db.prepare(`INSERT INTO usage_counters (scope_type, scope_id, window, window_start, workflow_prefix, requests, tokens, cost_usd)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT(scope_type, scope_id, window, window_start, workflow_prefix)
++ DO UPDATE SET requests = requests + excluded.requests, tokens = tokens + excluded.tokens, cost_usd = cost_usd + excluded.cost_usd`);
++
++ const reserveTx = db.transaction((ctx) => {
++ const qs = applicable(ctx);
++ const t = nowSec();
++ let worst = null;
++ for (const { q, cid } of qs) {
++ const ws = windowStart(q.window, t);
++ const c = getCounter.get(q.scope_type, cid, q.window, ws, q.workflow_prefix || '') || { requests: 0, tokens: 0, cost_usd: 0 };
++ let reason = null;
++ if (q.max_requests != null && c.requests + 1 > q.max_requests) reason = `${q.max_requests} requests per ${q.window}`;
++ else if (q.max_tokens != null && c.tokens >= q.max_tokens) reason = `${q.max_tokens} tokens per ${q.window}`;
++ else if (q.max_cost_usd != null && c.cost_usd >= q.max_cost_usd) reason = `$${q.max_cost_usd} per ${q.window}`;
++ if (reason) {
++ const retryAfter = ws + WINDOWS[q.window] - t;
++ if (!worst || retryAfter > worst.retryAfter) worst = { q, cid, reason, retryAfter };
++ }
++ }
++ if (worst) return { ok: false, ...worst };
++ // Count this request in EVERY scope x window (not only where a quota exists today), so a
++ // quota added later sees the spend that already happened in its window — Live's daily cap
++ // read today's total the same way. Workflow-prefix quotas keep their own counters.
++ const keys = new Map();
++ for (const [st, sid] of scopesFor(ctx)) {
++ for (const w of Object.keys(WINDOWS)) keys.set(`${st}|${sid}|${w}|`, { scope_type: st, scope_id: sid, window: w, workflow_prefix: '', ws: windowStart(w, t) });
++ }
++ for (const { q, cid } of qs) {
++ if (q.workflow_prefix) keys.set(`${q.scope_type}|${cid}|${q.window}|${q.workflow_prefix}`, { scope_type: q.scope_type, scope_id: cid, window: q.window, workflow_prefix: q.workflow_prefix, ws: windowStart(q.window, t) });
++ }
++ const counted = [...keys.values()];
++ for (const k of counted) bump.run(k.scope_type, k.scope_id, k.window, k.ws, k.workflow_prefix, 1, 0, 0);
++ return { ok: true, quotas: counted };
++ });
++
++ /** Check + count one request. Throws 429 quota.exceeded before any provider is touched. */
++ function reserve(ctx) {
++ const r = reserveTx(ctx);
++ if (!r.ok) {
++ throw new AiError(429, 'quota.exceeded', `quota exceeded for ${r.q.scope_type} ${r.cid}: ${r.reason}`, {
++ retry_after_seconds: Math.max(1, r.retryAfter), quota: { id: r.q.id, scope_type: r.q.scope_type, scope_id: r.cid, window: r.q.window },
++ });
++ }
++ return r.quotas;
++ }
++
++ const insDaily = db.prepare(`INSERT INTO usage_daily (day, requester, attribution, workflow_key, provider_key, model_key, requests, tokens_in, tokens_out, tokens_cached, cost_usd)
++ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
++ ON CONFLICT(day, requester, attribution, workflow_key, provider_key, model_key) DO UPDATE SET requests = requests + 1,
++ tokens_in = tokens_in + excluded.tokens_in, tokens_out = tokens_out + excluded.tokens_out, tokens_cached = tokens_cached + excluded.tokens_cached, cost_usd = cost_usd + excluded.cost_usd`);
++
++ /** Add real usage to the reserved windows and the daily usage table. */
++ function account(ctx, reserved, { provider, model, tokensIn = 0, tokensOut = 0, tokensCached = 0, cost = 0 }) {
++ const tokens = tokensIn + tokensOut;
++ db.transaction(() => {
++ for (const r of reserved || []) bump.run(r.scope_type, r.scope_id, r.window, r.ws, r.workflow_prefix, 0, tokens, cost);
++ insDaily.run(iso(clock.now()).slice(0, 10), `${ctx.requesterType}:${ctx.requesterId}`, ctx.attributionKey || '', ctx.workflowKey, provider || '', model || '', tokensIn, tokensOut, tokensCached, cost);
++ })();
++ }
++
++ // ── Admin ──
++ function list() { return db.prepare('SELECT * FROM quotas ORDER BY scope_type, scope_id, window').all(); }
++ function upsert(q, { actor = 'system', origin = 'admin', trace = null } = {}) {
++ if (!['global', 'service', 'actor', 'attribution'].includes(q.scope_type)) throw new AiError(422, 'ai.invalid', 'scope_type must be global, service, actor or attribution');
++ if (!WINDOWS[q.window]) throw new AiError(422, 'ai.invalid', 'window must be minute, hour or day');
++ const scopeId = q.scope_type === 'global' ? '*' : String(q.scope_id || ''); // '*' on other scopes = each one separately
++ if (!scopeId || scopeId.length > 200) throw new AiError(422, 'ai.invalid', 'scope_id required');
++ const num = (v) => (v == null || v === '' ? null : Number(v));
++ for (const k of ['max_requests', 'max_tokens', 'max_cost_usd']) if (q[k] != null && !(Number(q[k]) >= 0)) throw new AiError(422, 'ai.invalid', `${k} must be >= 0`);
++ const status = q.status || 'active';
++ if (!['active', 'disabled'].includes(status)) throw new AiError(422, 'ai.invalid', 'status must be active or disabled');
++ const t = iso(clock.now());
++ const prefix = q.workflow_prefix || null;
++ const prev = db.prepare("SELECT * FROM quotas WHERE scope_type = ? AND scope_id = ? AND window = ? AND COALESCE(workflow_prefix, '') = ?").get(q.scope_type, scopeId, q.window, prefix || '');
++ if (prev) {
++ db.prepare('UPDATE quotas SET max_requests = ?, max_tokens = ?, max_cost_usd = ?, status = ?, updated_at = ? WHERE id = ?')
++ .run(num(q.max_requests), num(q.max_tokens), num(q.max_cost_usd), status, t, prev.id);
++ } else {
++ db.prepare('INSERT INTO quotas (scope_type, scope_id, window, max_requests, max_tokens, max_cost_usd, workflow_prefix, status, origin, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(q.scope_type, scopeId, q.window, num(q.max_requests), num(q.max_tokens), num(q.max_cost_usd), prefix, status, origin, t, t);
++ }
++ const row = db.prepare("SELECT * FROM quotas WHERE scope_type = ? AND scope_id = ? AND window = ? AND COALESCE(workflow_prefix, '') = ?").get(q.scope_type, scopeId, q.window, prefix || '');
++ if (registry) registry.audit(actor, prev ? 'quota.update' : 'quota.create', 'quota', row.id, { trace, metadata: { scope_type: row.scope_type, scope_id: row.scope_id, window: row.window, max_requests: row.max_requests, max_tokens: row.max_tokens, max_cost_usd: row.max_cost_usd, status: row.status, origin } });
++ return row;
++ }
++
++ function usage({ from, to, requester } = {}) {
++ const where = [];
++ const args = [];
++ if (from) { where.push('day >= ?'); args.push(from); }
++ if (to) { where.push('day <= ?'); args.push(to); }
++ if (requester) { where.push('requester = ?'); args.push(requester); }
++ return db.prepare(`SELECT * FROM usage_daily ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY day DESC, cost_usd DESC LIMIT 1000`).all(...args);
++ }
++
++ function counters(ctxLike) {
++ const qs = ctxLike ? applicable(ctxLike) : list().filter(q => q.status === 'active' && (q.scope_type === 'global' || q.scope_id !== '*')).map(q => ({ q, cid: q.scope_id }));
++ const t = nowSec();
++ return qs.map(({ q, cid }) => {
++ const ws = windowStart(q.window, t);
++ const c = getCounter.get(q.scope_type, cid, q.window, ws, q.workflow_prefix || '') || { requests: 0, tokens: 0, cost_usd: 0 };
++ return { quota: q, scope_id: cid, window_start: iso(ws * 1000), resets_in_seconds: ws + WINDOWS[q.window] - t, used: { requests: c.requests, tokens: c.tokens, cost_usd: c.cost_usd } };
++ });
++ }
++
++ return { reserve, account, list, upsert, usage, counters, WINDOWS };
++}
++
++module.exports = { createQuotas, WINDOWS };
ddiff --git a/server/registry.js b/server/registry.js
nnew file mode 100644
iindex 0000000..7cf26f3
---- /dev/null
++++ b/server/registry.js
@@@ -0,0 +1,361 @@
++'use strict';
++/**
++ * Registry: providers, models, routing profiles, prompt templates and workflow definitions,
++ * plus the audit log every change writes to.
++ *
++ * Versioning: routes, templates and workflows are append-only per key. createXVersion() inserts
++ * version n+1; the previous version stays readable forever so a run can always be traced to the
++ * exact definition that produced it. The version a run uses is the newest one whose status is
++ * 'active'. Lifecycle changes (deprecate, archive, disable) are status updates on one version,
++ * audited, and never change its content.
++ */
++const { sha256, parseJson, iso, AiError, secretRefValid, resolveSecret } = require('./util');
++const schemas = require('./schemas');
++
++const PROVIDER_KINDS = ['stub', 'openai', 'anthropic', 'http', 'whisper'];
++const FEATURES = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'vision', 'json', 'transcribe'];
++const LIFECYCLE = ['draft', 'active', 'deprecated', 'archived'];
++const KEY_RE = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$/;
++
++function createRegistry(db, { clock = { now: () => Date.now() }, env = process.env } = {}) {
++ const now = () => iso(clock.now());
++
++ // ── Audit ──────────────────────────────────────────────
++ const insAudit = db.prepare('INSERT INTO audit_log (at, actor, action, target_type, target_id, trace_id, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)');
++ function audit(actor, action, targetType, targetId, { trace = null, metadata = {} } = {}) {
++ insAudit.run(now(), actor || 'system', action, targetType || null, targetId == null ? null : String(targetId), trace, JSON.stringify(metadata || {}));
++ }
++ function listAudit({ action, targetType, targetId, limit = 100, before } = {}) {
++ const where = [];
++ const args = [];
++ if (action) { where.push('action = ?'); args.push(action); }
++ if (targetType) { where.push('target_type = ?'); args.push(targetType); }
++ if (targetId) { where.push('target_id = ?'); args.push(String(targetId)); }
++ if (before) { where.push('id < ?'); args.push(Number(before)); }
++ const rows = db.prepare(`SELECT * FROM audit_log ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(500, limit));
++ return rows.map(r => ({ ...r, metadata: parseJson(r.metadata, {}) }));
++ }
++
++ // ── Providers ──────────────────────────────────────────
++ function decodeProvider(r) {
++ if (!r) return null;
++ return {
++ key: r.key, display_name: r.display_name, kind: r.kind, status: r.status, base_url: r.base_url,
++ auth_mode: r.auth_mode, secret_ref: r.secret_ref, default_model: r.default_model,
++ capabilities: parseJson(r.capabilities, []), timeout_ms: r.timeout_ms, priority: r.priority,
++ metadata: parseJson(r.metadata, {}), origin: r.origin, created_at: r.created_at, updated_at: r.updated_at,
++ };
++ }
++ function getProvider(key) { return decodeProvider(db.prepare('SELECT * FROM providers WHERE key = ?').get(key)); }
++ function listProviders() { return db.prepare('SELECT * FROM providers ORDER BY priority, key').all().map(decodeProvider); }
++
++ /** Public view: the secret reference name and whether it resolves — never the value. */
++ function publicProvider(p, health) {
++ if (!p) return null;
++ const hasSecret = p.auth_mode === 'none' ? null : Boolean(resolveSecret(p.secret_ref, env));
++ return { ...p, credentials: hasSecret === null ? 'not_required' : hasSecret ? 'configured' : 'missing', health: health || null };
++ }
++
++ function checkProvider(p) {
++ if (!KEY_RE.test(p.key || '')) throw new AiError(422, 'ai.invalid', 'provider key must be lowercase dotted/dashed');
++ if (!PROVIDER_KINDS.includes(p.kind)) throw new AiError(422, 'ai.invalid', `provider kind must be one of ${PROVIDER_KINDS.join(', ')}`);
++ if (!['active', 'disabled'].includes(p.status)) throw new AiError(422, 'ai.invalid', 'provider status must be active or disabled');
++ if (!['none', 'bearer', 'x-api-key'].includes(p.auth_mode)) throw new AiError(422, 'ai.invalid', 'auth_mode must be none, bearer or x-api-key');
++ if (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' (a reference, never a secret value)");
++ if (p.base_url && !/^https?:\/\/[^\s]+$/i.test(p.base_url)) throw new AiError(422, 'ai.invalid', 'base_url must be an http(s) URL');
++ if (!Array.isArray(p.capabilities) || p.capabilities.some(f => !FEATURES.includes(f))) throw new AiError(422, 'ai.invalid', `capabilities must be a subset of ${FEATURES.join(', ')}`);
++ }
++
++ function upsertProvider(input, { actor = 'system', origin = 'admin', trace = null } = {}) {
++ const prev = getProvider(input.key);
++ const p = {
++ key: input.key,
++ display_name: input.display_name || (prev && prev.display_name) || input.key,
++ kind: input.kind || (prev && prev.kind),
++ status: input.status || (prev && prev.status) || 'active',
++ base_url: input.base_url !== undefined ? (input.base_url || null) : (prev ? prev.base_url : null),
++ auth_mode: input.auth_mode || (prev && prev.auth_mode) || 'none',
++ secret_ref: input.secret_ref !== undefined ? (input.secret_ref || null) : (prev ? prev.secret_ref : null),
++ default_model: input.default_model !== undefined ? (input.default_model || null) : (prev ? prev.default_model : null),
++ capabilities: input.capabilities || (prev && prev.capabilities) || [],
++ timeout_ms: Number.isFinite(Number(input.timeout_ms)) ? Math.max(1000, Math.min(600000, Number(input.timeout_ms))) : (prev ? prev.timeout_ms : 30000),
++ priority: Number.isFinite(Number(input.priority)) ? Number(input.priority) : (prev ? prev.priority : 100),
++ metadata: input.metadata || (prev && prev.metadata) || {},
++ };
++ checkProvider(p);
++ const t = now();
++ db.prepare(`INSERT INTO providers (key, display_name, kind, status, base_url, auth_mode, secret_ref, default_model, capabilities, timeout_ms, priority, metadata, origin, created_at, updated_at)
++ VALUES (@key, @display_name, @kind, @status, @base_url, @auth_mode, @secret_ref, @default_model, @capabilities, @timeout_ms, @priority, @metadata, @origin, @t, @t)
++ ON CONFLICT(key) DO UPDATE SET display_name = excluded.display_name, kind = excluded.kind, status = excluded.status, base_url = excluded.base_url,
++ auth_mode = excluded.auth_mode, secret_ref = excluded.secret_ref, default_model = excluded.default_model, capabilities = excluded.capabilities,
++ timeout_ms = excluded.timeout_ms, priority = excluded.priority, metadata = excluded.metadata, origin = excluded.origin, updated_at = excluded.updated_at`)
++ .run({ ...p, capabilities: JSON.stringify(p.capabilities), metadata: JSON.stringify(p.metadata), origin, t });
++ audit(actor, prev ? 'provider.update' : 'provider.create', 'provider', p.key, { trace, metadata: { kind: p.kind, status: p.status, base_url: p.base_url, secret_ref: p.secret_ref, origin } });
++ return getProvider(p.key);
++ }
++
++ // ── Models ─────────────────────────────────────────────
++ function decodeModel(r) {
++ if (!r) return null;
++ return {
++ provider_key: r.provider_key, model_key: r.model_key, display_name: r.display_name, type: r.type, status: r.status,
++ context_window: r.context_window, max_output: r.max_output,
++ cost: { in_per_mtok: r.cost_in_per_mtok, out_per_mtok: r.cost_out_per_mtok, cached_per_mtok: r.cost_cached_per_mtok },
++ supports: { json: !!r.supports_json, tools: !!r.supports_tools, streaming: !!r.supports_streaming, vision: !!r.supports_vision },
++ metadata: parseJson(r.metadata, {}), created_at: r.created_at, updated_at: r.updated_at,
++ };
++ }
++ function getModel(providerKey, modelKey) { return decodeModel(db.prepare('SELECT * FROM models WHERE provider_key = ? AND model_key = ?').get(providerKey, modelKey)); }
++ function listModels({ provider } = {}) {
++ const rows = provider ? db.prepare('SELECT * FROM models WHERE provider_key = ? ORDER BY model_key').all(provider) : db.prepare('SELECT * FROM models ORDER BY provider_key, model_key').all();
++ return rows.map(decodeModel);
++ }
++ function upsertModel(input, { actor = 'system', trace = null } = {}) {
++ if (!getProvider(input.provider_key)) throw new AiError(404, 'ai.not_found', `no provider ${input.provider_key}`);
++ if (!input.model_key || String(input.model_key).length > 200) throw new AiError(422, 'ai.invalid', 'model_key required');
++ const prev = getModel(input.provider_key, input.model_key);
++ const type = input.type || (prev && prev.type) || 'chat';
++ if (!['chat', 'vision', 'embedding', 'stt'].includes(type)) throw new AiError(422, 'ai.invalid', 'model type must be chat, vision, embedding or stt');
++ const status = input.status || (prev && prev.status) || 'active';
++ if (!['active', 'disabled'].includes(status)) throw new AiError(422, 'ai.invalid', 'model status must be active or disabled');
++ const cost = input.cost || (prev && prev.cost) || {};
++ const sup = input.supports || (prev && prev.supports) || {};
++ const t = now();
++ db.prepare(`INSERT INTO models (provider_key, model_key, display_name, type, status, context_window, max_output, cost_in_per_mtok, cost_out_per_mtok, cost_cached_per_mtok,
++ supports_json, supports_tools, supports_streaming, supports_vision, metadata, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
++ ON CONFLICT(provider_key, model_key) DO UPDATE SET display_name = excluded.display_name, type = excluded.type, status = excluded.status,
++ context_window = excluded.context_window, max_output = excluded.max_output, cost_in_per_mtok = excluded.cost_in_per_mtok,
++ cost_out_per_mtok = excluded.cost_out_per_mtok, cost_cached_per_mtok = excluded.cost_cached_per_mtok, supports_json = excluded.supports_json,
++ supports_tools = excluded.supports_tools, supports_streaming = excluded.supports_streaming, supports_vision = excluded.supports_vision,
++ metadata = excluded.metadata, updated_at = excluded.updated_at`)
++ .run(input.provider_key, input.model_key, input.display_name || (prev && prev.display_name) || input.model_key, type, status,
++ input.context_window ?? (prev && prev.context_window) ?? null, input.max_output ?? (prev && prev.max_output) ?? null,
++ cost.in_per_mtok ?? null, cost.out_per_mtok ?? null, cost.cached_per_mtok ?? null,
++ sup.json === false ? 0 : 1, sup.tools ? 1 : 0, sup.streaming ? 1 : 0, sup.vision ? 1 : 0,
++ JSON.stringify(input.metadata || (prev && prev.metadata) || {}), t, t);
++ audit(actor, prev ? 'model.update' : 'model.create', 'model', `${input.provider_key}/${input.model_key}`, { trace, metadata: { status, type } });
++ return getModel(input.provider_key, input.model_key);
++ }
++
++ // ── Versioned records (routes, templates, workflows) ───
++ function nextVersion(table, key) {
++ const r = db.prepare(`SELECT MAX(version) AS v FROM ${table} WHERE key = ?`).get(key);
++ return (r && r.v ? r.v : 0) + 1;
++ }
++ function latestAny(table, key) {
++ return db.prepare(`SELECT * FROM ${table} WHERE key = ? ORDER BY version DESC LIMIT 1`).get(key);
++ }
++ function latestActive(table, key) {
++ return db.prepare(`SELECT * FROM ${table} WHERE key = ? AND status = 'active' ORDER BY version DESC LIMIT 1`).get(key);
++ }
++
++ // Routes
++ function decodeRoute(r) {
++ if (!r) return null;
++ return {
++ key: r.key, version: r.version, status: r.status, primary: { provider: r.primary_provider, model: r.primary_model },
++ fallbacks: parseJson(r.fallbacks, []), options: parseJson(r.options, {}), max_output_tokens: r.max_output_tokens,
++ response_format: r.response_format, timeout_ms: r.timeout_ms, alias_of: r.alias_of, created_by: r.created_by, created_at: r.created_at,
++ };
++ }
++ const routeContent = (r) => sha256({ p: r.primary, f: r.fallbacks, o: r.options, m: r.max_output_tokens, rf: r.response_format, t: r.timeout_ms, a: r.alias_of, s: r.status });
++ function createRouteVersion(key, input, { actor = 'system', trace = null } = {}) {
++ if (!KEY_RE.test(key || '')) throw new AiError(422, 'ai.invalid', 'route key must be lowercase dotted');
++ const prev = decodeRoute(latestAny('routes', key));
++ const r = {
++ primary: input.primary || (prev && prev.primary),
++ fallbacks: input.fallbacks !== undefined ? input.fallbacks : (prev ? prev.fallbacks : []),
++ options: input.options !== undefined ? input.options : (prev ? prev.options : {}),
++ max_output_tokens: input.max_output_tokens !== undefined ? input.max_output_tokens : (prev ? prev.max_output_tokens : null),
++ response_format: input.response_format || (prev && prev.response_format) || 'text',
++ timeout_ms: input.timeout_ms !== undefined ? input.timeout_ms : (prev ? prev.timeout_ms : null),
++ alias_of: input.alias_of !== undefined ? input.alias_of : (prev ? prev.alias_of : null),
++ status: input.status || 'active',
++ };
++ if (!r.alias_of) {
++ if (!r.primary || !r.primary.provider) throw new AiError(422, 'ai.invalid', 'route needs primary.provider');
++ for (const c of [r.primary, ...(r.fallbacks || [])]) {
++ if (!c || !getProvider(c.provider)) throw new AiError(422, 'ai.invalid', `route references unknown provider ${c && c.provider}`);
++ }
++ } else if (!latestAny('routes', r.alias_of)) throw new AiError(422, 'ai.invalid', `alias target ${r.alias_of} does not exist`);
++ if (!Array.isArray(r.fallbacks)) throw new AiError(422, 'ai.invalid', 'fallbacks must be an array');
++ if (!['text', 'json'].includes(r.response_format)) throw new AiError(422, 'ai.invalid', 'response_format must be text or json');
++ if (!['active', 'disabled'].includes(r.status)) throw new AiError(422, 'ai.invalid', 'route status must be active or disabled');
++ const version = nextVersion('routes', key);
++ db.prepare(`INSERT INTO routes (key, version, status, primary_provider, primary_model, fallbacks, options, max_output_tokens, response_format, timeout_ms, alias_of, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(key, version, r.status, r.alias_of ? (r.primary && r.primary.provider) || 'stub' : r.primary.provider, r.primary ? r.primary.model || null : null,
++ JSON.stringify(r.fallbacks || []), JSON.stringify(r.options || {}), r.max_output_tokens, r.response_format, r.timeout_ms, r.alias_of, actor, now());
++ audit(actor, 'route.version', 'route', key, { trace, metadata: { version, previous: prev ? prev.version : null, status: r.status, alias_of: r.alias_of } });
++ return getRoute(key, version);
++ }
++ function getRoute(key, version) {
++ return decodeRoute(version ? db.prepare('SELECT * FROM routes WHERE key = ? AND version = ?').get(key, version) : latestAny('routes', key));
++ }
++ /** The route a run uses: newest version, which must be active; aliases are followed (max 3 hops). */
++ function resolveRoute(key) {
++ let k = key;
++ for (let hop = 0; hop < 4; hop++) {
++ const r = decodeRoute(latestAny('routes', k));
++ if (!r) return null;
++ if (r.status !== 'active') return { ...r, disabled: true };
++ if (!r.alias_of) return r;
++ k = r.alias_of;
++ }
++ return null;
++ }
++ function listRoutes({ history = false } = {}) {
++ const rows = history ? db.prepare('SELECT * FROM routes ORDER BY key, version').all()
++ : db.prepare('SELECT r.* FROM routes r JOIN (SELECT key, MAX(version) v FROM routes GROUP BY key) m ON m.key = r.key AND m.v = r.version ORDER BY r.key').all();
++ return rows.map(decodeRoute);
++ }
++
++ // Templates
++ function decodeTemplate(r) {
++ if (!r) return null;
++ return {
++ key: r.key, version: r.version, name: r.name, description: r.description,
++ input_schema: parseJson(r.input_schema, {}), output_schema: parseJson(r.output_schema, {}),
++ system_prompt: r.system_prompt, user_prompt: r.user_prompt, default_route: r.default_route,
++ owner: r.owner, visibility: r.visibility, status: r.status, metadata: parseJson(r.metadata, {}),
++ created_by: r.created_by, created_at: r.created_at,
++ };
++ }
++ const templateContent = (t) => sha256({ n: t.name, d: t.description, i: t.input_schema, o: t.output_schema, s: t.system_prompt, u: t.user_prompt, r: t.default_route, v: t.visibility, m: t.metadata });
++ function createTemplateVersion(key, input, { actor = 'system', trace = null } = {}) {
++ if (!KEY_RE.test(key || '')) throw new AiError(422, 'ai.invalid', 'template key must be lowercase dotted');
++ const prev = decodeTemplate(latestAny('templates', key));
++ const t = {
++ name: input.name || (prev && prev.name) || key,
++ description: input.description !== undefined ? input.description : (prev ? prev.description : null),
++ input_schema: input.input_schema || (prev && prev.input_schema) || { type: 'object' },
++ output_schema: input.output_schema || (prev && prev.output_schema) || {},
++ system_prompt: input.system_prompt !== undefined ? String(input.system_prompt) : (prev ? prev.system_prompt : ''),
++ user_prompt: input.user_prompt !== undefined ? String(input.user_prompt) : (prev ? prev.user_prompt : ''),
++ default_route: input.default_route !== undefined ? input.default_route : (prev ? prev.default_route : null),
++ owner: input.owner || (prev && prev.owner) || 'ai',
++ visibility: input.visibility || (prev && prev.visibility) || 'internal',
++ status: input.status || 'active',
++ metadata: input.metadata || (prev && prev.metadata) || {},
++ };
++ schemas.assertSchema(t.input_schema, 'input_schema');
++ schemas.assertSchema(t.output_schema, 'output_schema');
++ if (!LIFECYCLE.includes(t.status)) throw new AiError(422, 'ai.invalid', `template status must be one of ${LIFECYCLE.join(', ')}`);
++ if (!['public', 'first-party', 'internal'].includes(t.visibility)) throw new AiError(422, 'ai.invalid', 'visibility must be public, first-party or internal');
++ const version = nextVersion('templates', key);
++ db.prepare(`INSERT INTO templates (key, version, name, description, input_schema, output_schema, system_prompt, user_prompt, default_route, owner, visibility, status, metadata, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(key, version, t.name, t.description, JSON.stringify(t.input_schema), JSON.stringify(t.output_schema), t.system_prompt, t.user_prompt,
++ t.default_route, t.owner, t.visibility, t.status, JSON.stringify(t.metadata), actor, now());
++ audit(actor, 'template.version', 'template', key, { trace, metadata: { version, previous: prev ? prev.version : null, status: t.status, content_hash: templateContent(t) } });
++ return getTemplate(key, version);
++ }
++ function getTemplate(key, version) {
++ return decodeTemplate(version ? db.prepare('SELECT * FROM templates WHERE key = ? AND version = ?').get(key, version) : latestAny('templates', key));
++ }
++ function activeTemplate(key) { return decodeTemplate(latestActive('templates', key)); }
++ function listTemplates({ history = false } = {}) {
++ const rows = history ? db.prepare('SELECT * FROM templates ORDER BY key, version').all()
++ : db.prepare('SELECT t.* FROM templates t JOIN (SELECT key, MAX(version) v FROM templates GROUP BY key) m ON m.key = t.key AND m.v = t.version ORDER BY t.key').all();
++ return rows.map(decodeTemplate);
++ }
++
++ // Workflows
++ function decodeWorkflow(r) {
++ if (!r) return null;
++ return {
++ key: r.key, version: r.version, name: r.name, description: r.description, namespace: r.namespace,
++ input_schema: parseJson(r.input_schema, {}), output_schema: parseJson(r.output_schema, {}),
++ steps: parseJson(r.steps, []), default_route: r.default_route, cache_mode: r.cache_mode, cache_ttl_sec: r.cache_ttl_sec,
++ status: r.status, metadata: parseJson(r.metadata, {}), created_by: r.created_by, created_at: r.created_at,
++ };
++ }
++ const workflowContent = (w) => sha256({ n: w.name, d: w.description, ns: w.namespace, i: w.input_schema, o: w.output_schema, s: w.steps, r: w.default_route, c: w.cache_mode, ttl: w.cache_ttl_sec, m: w.metadata });
++ function createWorkflowVersion(key, input, { actor = 'system', trace = null } = {}) {
++ if (!KEY_RE.test(key || '') || key.split('.').length < 2) throw new AiError(422, 'ai.invalid', 'workflow key must be <namespace>.<name>');
++ const prev = decodeWorkflow(latestAny('workflows', key));
++ const w = {
++ name: input.name || (prev && prev.name) || key,
++ description: input.description !== undefined ? input.description : (prev ? prev.description : null),
++ namespace: input.namespace || (prev && prev.namespace) || key.split('.')[0],
++ input_schema: input.input_schema || (prev && prev.input_schema),
++ output_schema: input.output_schema || (prev && prev.output_schema),
++ steps: input.steps || (prev && prev.steps),
++ default_route: input.default_route !== undefined ? input.default_route : (prev ? prev.default_route : null),
++ cache_mode: input.cache_mode || (prev && prev.cache_mode) || 'private',
++ cache_ttl_sec: input.cache_ttl_sec !== undefined ? input.cache_ttl_sec : (prev ? prev.cache_ttl_sec : null),
++ status: input.status || 'active',
++ metadata: input.metadata || (prev && prev.metadata) || {},
++ };
++ schemas.assertSchema(w.input_schema, 'input_schema');
++ schemas.assertSchema(w.output_schema, 'output_schema');
++ if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
++ for (const s of w.steps) {
++ if (!s || !['llm', 'passthrough', 'transcribe', 'embed'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, transcribe or embed');
++ if (s.kind === 'llm' && !(s.template && latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
++ }
++ if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
++ if (!LIFECYCLE.includes(w.status)) throw new AiError(422, 'ai.invalid', `workflow status must be one of ${LIFECYCLE.join(', ')}`);
++ const version = nextVersion('workflows', key);
++ db.prepare(`INSERT INTO workflows (key, version, name, description, namespace, input_schema, output_schema, steps, default_route, cache_mode, cache_ttl_sec, status, metadata, created_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(key, version, w.name, w.description, w.namespace, JSON.stringify(w.input_schema), JSON.stringify(w.output_schema), JSON.stringify(w.steps),
++ w.default_route, w.cache_mode, w.cache_ttl_sec, w.status, JSON.stringify(w.metadata), actor, now());
++ audit(actor, 'workflow.version', 'workflow', key, { trace, metadata: { version, previous: prev ? prev.version : null, status: w.status, content_hash: workflowContent(w) } });
++ return getWorkflow(key, version);
++ }
++ function getWorkflow(key, version) {
++ return decodeWorkflow(version ? db.prepare('SELECT * FROM workflows WHERE key = ? AND version = ?').get(key, version) : latestAny('workflows', key));
++ }
++ function activeWorkflow(key) { return decodeWorkflow(latestActive('workflows', key)); }
++ function listWorkflows({ history = false, namespace } = {}) {
++ let rows = history ? db.prepare('SELECT * FROM workflows ORDER BY key, version').all()
++ : db.prepare('SELECT w.* FROM workflows w JOIN (SELECT key, MAX(version) v FROM workflows GROUP BY key) m ON m.key = w.key AND m.v = w.version ORDER BY w.key').all();
++ if (namespace) rows = rows.filter(r => r.namespace === namespace);
++ return rows.map(decodeWorkflow);
++ }
++
++ /** Lifecycle change on one version of a template/workflow/route (status only; audited). */
++ function setStatus(kind, key, version, status, { actor = 'system', trace = null } = {}) {
++ const table = { template: 'templates', workflow: 'workflows', route: 'routes' }[kind];
++ if (!table) throw new AiError(400, 'ai.invalid', 'unknown kind');
++ const allowed = kind === 'route' ? ['active', 'disabled'] : LIFECYCLE;
++ if (!allowed.includes(status)) throw new AiError(422, 'ai.invalid', `status must be one of ${allowed.join(', ')}`);
++ const row = db.prepare(`SELECT status FROM ${table} WHERE key = ? AND version = ?`).get(key, version);
++ if (!row) throw new AiError(404, 'ai.not_found', `no ${kind} ${key} v${version}`);
++ db.prepare(`UPDATE ${table} SET status = ? WHERE key = ? AND version = ?`).run(status, key, version);
++ audit(actor, `${kind}.status`, kind, key, { trace, metadata: { version, from: row.status, to: status } });
++ return { key, version, status };
++ }
++
++ /**
++ * Seeding: insert a code-defined record when it is missing, or add a new version when the code
++ * changed it and nobody edited it through the admin API since (admin edits always win).
++ */
++ function seedVersioned(kind, key, def) {
++ const [table, create, decode, content] = {
++ route: ['routes', createRouteVersion, decodeRoute, routeContent],
++ template: ['templates', createTemplateVersion, decodeTemplate, templateContent],
++ workflow: ['workflows', createWorkflowVersion, decodeWorkflow, workflowContent],
++ }[kind];
++ const latest = decode(latestAny(table, key));
++ if (!latest) return create(key, def, { actor: 'seed' });
++ if (latest.created_by !== 'seed') return latest;
++ const merged = { ...latest, ...def };
++ if (content(merged) === content(latest) && (def.status || 'active') === latest.status) return latest;
++ return create(key, def, { actor: 'seed' });
++ }
++
++ return {
++ audit, listAudit,
++ getProvider, listProviders, upsertProvider, publicProvider,
++ getModel, listModels, upsertModel,
++ createRouteVersion, getRoute, resolveRoute, listRoutes,
++ createTemplateVersion, getTemplate, activeTemplate, listTemplates,
++ createWorkflowVersion, getWorkflow, activeWorkflow, listWorkflows,
++ setStatus, seedVersioned,
++ };
++}
++
++module.exports = { createRegistry, PROVIDER_KINDS, FEATURES, LIFECYCLE };
ddiff --git a/server/runs.js b/server/runs.js
nnew file mode 100644
iindex 0000000..95b5961
---- /dev/null
++++ b/server/runs.js
@@@ -0,0 +1,372 @@
++'use strict';
++/**
++ * Runs: create (idempotent), cache, quota, execute (sync with wait, or queued), cancel, retry.
++ *
++ * Order of a create, which is what the guarantees rest on:
++ * 1. the workflow's newest ACTIVE version is resolved and the input validated against its schema
++ * 2. idempotency: the same (requester, idempotency_key) returns the existing run; a different
++ * input under the same key is a 409 idempotency.conflict
++ * 3. cache: a hit in THIS request's scope becomes a run with status 'cached' (no provider call)
++ * 4. quota: every applicable quota is checked and counted — a refusal is a 429 before any
++ * provider is touched
++ * 5. the run is stored 'queued' and executed (inline up to ?wait=ms, else by the worker)
++ * Every create, cancel and retry writes an audit row. Output is attributable to workflow + version
++ * + template version + route version + provider/model + run id — never to a person.
++ */
++const { ids } = require('openvibe-contracts');
++const { AiError, sha256, stableStringify, parseJson, iso } = require('./util');
++const schemas = require('./schemas');
++
++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'cached']);
++const newRunId = () => `run_${ids.ulid()}`;
++
++function entityKey(ref) { return ref ? `${ref.service}:${ref.type}:${ref.id}` : null; }
++function subjectKey(ref) { return ref ? `${ref.type}:${ref.id}` : null; }
++
++/**
++ * What a run record keeps of its input. Inline images are replaced by their hash and size (they
++ * would bloat every row and are not needed after the call). A passthrough workflow's input IS the
++ * caller's raw prompt, so it is not kept at all unless raw debug logging is on and the caller
++ * opted in — only its hash (the run still has input_hash). Such runs cannot be retried.
++ */
++function retainedInput(input, wf, debugRaw) {
++ const passthrough = wf.metadata && (wf.metadata.passthrough || wf.metadata.retain_input === false);
++ if (passthrough && !debugRaw) return { value: { not_retained: 'passthrough prompt; see input_hash' }, full: false };
++ let redacted = false;
++ const walk = (v) => {
++ if (Array.isArray(v)) return v.map(walk);
++ if (v && typeof v === 'object') {
++ const out = {};
++ for (const [k, x] of Object.entries(v)) {
++ if (k === 'data_url' && typeof x === 'string' && x.length > 256) {
++ redacted = true;
++ out.data_url_sha256 = sha256(x);
++ out.data_url_bytes = x.length;
++ } else out[k] = walk(x);
++ }
++ return out;
++ }
++ return v;
++ };
++ const value = walk(input);
++ return { value, full: !redacted };
++}
++
++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console }) {
++ const inflight = new Map(); // run id -> { controller, promise }
++ const queue = []; // run ids waiting for the worker
++ let active = 0;
++ let shuttingDown = false;
++
++ function decode(r) {
++ if (!r) return null;
++ return {
++ id: r.id, status: r.status,
++ workflow: { key: r.workflow_key, version: r.workflow_version },
++ template: r.template_key ? { key: r.template_key, version: r.template_version } : null,
++ route: r.route_key ? { key: r.route_key, version: r.route_version } : null,
++ requester: { type: r.requester_type, id: r.requester_id },
++ on_behalf_of: parseJson(r.on_behalf_of, null),
++ attribution: parseJson(r.attribution, null),
++ source_service: r.source_service,
++ target: parseJson(r.target, null),
++ input: parseJson(r.input, null),
++ output: parseJson(r.output, null),
++ error: r.error_code ? { code: r.error_code, detail: r.error_detail } : null,
++ synthetic: Boolean(r.synthetic),
++ provenance: {
++ origin: 'ai', workflow: r.workflow_key, workflow_version: r.workflow_version, template_version: r.template_version, route: r.route_key, route_version: r.route_version,
++ provider: r.provider_key, model: r.model_key, fallback_used: Boolean(r.fallback_used), run_id: r.id, cached_from: r.cached_from, synthetic: Boolean(r.synthetic),
++ },
++ usage: { tokens_in: r.tokens_in, tokens_out: r.tokens_out, cost_usd: r.cost_usd, attempts: r.attempts },
++ citations_count: r.citations_count,
++ retry_of: r.retry_of,
++ idempotency_key: r.idempotency_key,
++ trace_id: r.trace_id,
++ created_at: r.created_at, started_at: r.started_at, finished_at: r.finished_at,
++ };
++ }
++ const getRow = (id) => db.prepare('SELECT * FROM runs WHERE id = ?').get(id);
++ const get = (id) => decode(getRow(id));
++
++ function citations(runId) {
++ return db.prepare('SELECT * FROM citations WHERE run_id = ? ORDER BY ordinal, id').all(runId)
++ .map(c => ({ ...c, trust: parseJson(c.trust, {}), provenance: parseJson(c.provenance, {}) }));
++ }
++ function addCitations(runId, list, attachedBy) {
++ const ins = db.prepare(`INSERT INTO citations (run_id, ordinal, source_type, source_id, url, title, author, published_at, retrieved_at, snippet, content_hash, trust, provenance, attached_by, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
++ const base = db.prepare('SELECT COUNT(*) AS n FROM citations WHERE run_id = ?').get(runId).n;
++ db.transaction(() => {
++ list.forEach((c, i) => ins.run(runId, c.ordinal != null ? c.ordinal : base + i, c.source_type, c.source_id || null, c.url || null, c.title || null, c.author || null,
++ c.published_at || null, c.retrieved_at || null, c.snippet || null, c.content_hash || null, JSON.stringify(c.trust || {}), JSON.stringify(c.provenance || {}), attachedBy, iso(clock.now())));
++ db.prepare('UPDATE runs SET citations_count = (SELECT COUNT(*) FROM citations WHERE run_id = ?) WHERE id = ?').run(runId, runId);
++ })();
++ }
++
++ const insReq = db.prepare(`INSERT INTO requests (run_id, seq, operation, provider_key, model_key, route_key, route_version, status, skip_reason, fallback, prompt_hash, input_hash, output_hash,
++ tokens_in, tokens_out, tokens_cached, tokens_estimated, cost_usd, latency_ms, error, debug_prompt, debug_response, created_at)
++ VALUES (@run_id, @seq, @operation, @provider_key, @model_key, @route_key, @route_version, @status, @skip_reason, @fallback, @prompt_hash, @input_hash, @output_hash,
++ @tokens_in, @tokens_out, @tokens_cached, @tokens_estimated, @cost_usd, @latency_ms, @error, @debug_prompt, @debug_response, @created_at)`);
++
++ // ── Create ─────────────────────────────────────────────
++ function validateRefs(body) {
++ const { validate } = require('openvibe-contracts');
++ if (body.target != null) {
++ const v = validate('common.entity-ref@1', body.target);
++ if (!v.valid) throw new AiError(422, 'input.invalid', `target is not an EntityRef: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ }
++ if (body.attribution != null) {
++ const v = validate('common.entity-ref@1', body.attribution);
++ if (!v.valid) throw new AiError(422, 'input.invalid', `attribution is not an EntityRef: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`);
++ }
++ if (body.on_behalf_of != null) {
++ const v = validate('identity.subject-ref@1', body.on_behalf_of);
++ if (!v.valid) throw new AiError(422, 'input.invalid', 'on_behalf_of is not a SubjectRef');
++ }
++ }
++
++ /**
++ * body: { workflow, version?, input, target?, attribution?, on_behalf_of?, idempotency_key?, options?: { cache, debug } }
++ * principal: { sub, subject: {type, id} }
++ * Returns { run, created, promise? }.
++ */
++ function create(body, principal, { trace = null, requestId = null, retryOf = null } = {}) {
++ if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
++ const wfKey = String(body.workflow || '');
++ const wf = body.version ? registry.getWorkflow(wfKey, Number(body.version)) : registry.activeWorkflow(wfKey);
++ if (!wf) throw new AiError(404, 'workflow.not_found', `no active workflow ${wfKey}${body.version ? ` v${body.version}` : ''}`);
++ if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);
++ validateRefs(body);
++ const input = body.input === undefined ? {} : body.input;
++ const inputJson = stableStringify(input);
++ if (Buffer.byteLength(inputJson) > config.runs.maxInputBytes) throw new AiError(413, 'input.too_large', `input is larger than ${config.runs.maxInputBytes} bytes`);
++ const v = schemas.validate(wf.input_schema, input);
++ if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });
++ const inputHash = sha256(inputJson);
++ const requester = principal.subject;
++ const idem = body.idempotency_key != null ? String(body.idempotency_key) : null;
++ if (idem !== null && !/^[A-Za-z0-9._:-]{8,128}$/.test(idem)) throw new AiError(422, 'input.invalid', 'idempotency_key must be 8-128 of [A-Za-z0-9._:-]');
++
++ if (idem) {
++ const prev = db.prepare('SELECT * FROM runs WHERE requester_type = ? AND requester_id = ? AND idempotency_key = ?').get(requester.type, requester.id, idem);
++ if (prev) {
++ if (prev.input_hash !== inputHash || prev.workflow_key !== wf.key) throw new AiError(409, 'idempotency.conflict', 'this idempotency_key was used with a different workflow or input');
++ return { run: decode(prev), created: false, promise: inflight.get(prev.id) ? inflight.get(prev.id).promise : null };
++ }
++ }
++
++ const llmStep = wf.steps.find(s => s.kind === 'llm');
++ const tpl = llmStep ? registry.activeTemplate(llmStep.template) : null;
++ const routeKey = (wf.steps[0] && wf.steps[0].route) || (tpl && tpl.default_route) || wf.default_route || null;
++ const route = routeKey && !String(routeKey).includes('{') ? registry.resolveRoute(routeKey) : null;
++ const options = body.options && typeof body.options === 'object' ? body.options : {};
++ const ctx = {
++ requesterType: requester.type, requesterId: requester.id,
++ actorKey: subjectKey(body.on_behalf_of), targetKey: entityKey(body.target), attributionKey: entityKey(body.attribution), workflowKey: wf.key,
++ };
++ const now = iso(clock.now());
++ const id = newRunId();
++ const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));
++ const base = {
++ id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,
++ route_key: route && !route.disabled ? route.key : routeKey, route_version: route && !route.disabled ? route.version : null,
++ requester_type: requester.type, requester_id: requester.id,
++ on_behalf_of: body.on_behalf_of ? JSON.stringify(body.on_behalf_of) : null,
++ attribution: body.attribution ? JSON.stringify(body.attribution) : null,
++ source_service: requester.type === 'service' ? requester.id : null,
++ target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
++ input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
++ options: JSON.stringify({ cache: options.cache !== false, debug: Boolean(options.debug), input_retained: kept.full }), created_at: now,
++ };
++ base.input = stableStringify(kept.value);
++
++ // Cache (scoped; never crosses requester/actor/target/attribution)
++ const scope = options.cache === false ? null : cache.scopeFor(wf.cache_mode, ctx);
++ let cacheKey = null;
++ if (scope) {
++ cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
++ const hit = cache.get(cacheKey, scope);
++ if (hit) {
++ db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
++ source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, created_at, started_at, finished_at, citations_count)
++ VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,
++ @source_service, @target, @target_key, @input, @input_hash, @output, @model_key, @cache_key, @cached_from, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @created_at, @created_at, @created_at, 0)`)
++ .run({ ...base, output: JSON.stringify(hit.output), model_key: hit.model_key, cache_key: cacheKey, cached_from: hit.run_id });
++ const src = citations(hit.run_id);
++ if (src.length) addCitations(id, src.map(c => ({ ...c, provenance: { ...c.provenance, via_cache: hit.run_id } })), 'cache');
++ registry.audit(principal.sub, 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, status: 'cached', cached_from: hit.run_id } });
++ return { run: get(id), created: true, promise: null };
++ }
++ }
++
++ // Quota, before any provider call.
++ const reserved = quotas.reserve(ctx);
++
++ db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
++ source_service, target, target_key, input, input_hash, cache_key, retry_of, idempotency_key, trace_id, request_id, options, created_at)
++ VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'queued', @requester_type, @requester_id, @on_behalf_of, @attribution,
++ @source_service, @target, @target_key, @input, @input_hash, @cache_key, @retry_of, @idempotency_key, @trace_id, @request_id, @options, @created_at)`)
++ .run({ ...base, cache_key: cacheKey });
++ registry.audit(principal.sub, retryOf ? 'run.retry' : 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, target: ctx.targetKey, retry_of: retryOf } });
++
++ const controller = new AbortController();
++ let release;
++ const gate = new Promise((r) => { release = r; });
++ const promise = gate.then(() => execute(id, { controller, reserved, scope, ctx, input })).catch((err) => { log.error(`[runs] ${id}: ${err.stack || err}`); });
++ inflight.set(id, { controller, promise, release });
++ queue.push(id);
++ pump();
++ return { run: get(id), created: true, promise };
++ }
++
++ function pump() {
++ while (active < config.runs.maxConcurrent && queue.length) {
++ const id = queue.shift();
++ const h = inflight.get(id);
++ if (!h) continue;
++ active++;
++ h.promise.finally(() => { active--; pump(); });
++ h.release();
++ }
++ }
++
++ async function execute(id, { controller, reserved, scope, ctx, input }) {
++ const row = getRow(id);
++ if (!row || row.status !== 'queued') { inflight.delete(id); return; }
++ db.prepare("UPDATE runs SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'").run(iso(clock.now()), id);
++ const wf = registry.getWorkflow(row.workflow_key, row.workflow_version);
++ const opts = parseJson(row.options, {});
++ let seq = 0;
++ let attempts = 0;
++ const logRequest = (e) => {
++ if (e.status !== 'skipped') attempts++;
++ insReq.run({
++ run_id: id, seq: seq++, operation: e.operation, provider_key: e.provider_key, model_key: e.model_key || null, route_key: e.route_key || null, route_version: e.route_version || null,
++ status: e.status, skip_reason: e.skip_reason || null, fallback: e.fallback || 0, prompt_hash: e.prompt_hash || null, input_hash: row.input_hash, output_hash: e.output_hash || null,
++ tokens_in: e.tokens_in || 0, tokens_out: e.tokens_out || 0, tokens_cached: e.tokens_cached || 0, tokens_estimated: e.tokens_estimated || 0, cost_usd: e.cost_usd || 0,
++ latency_ms: e.latency_ms == null ? null : e.latency_ms, error: e.error || null,
++ debug_prompt: config.debugRawLog && opts.debug ? e.debug_prompt || null : null, debug_response: config.debugRawLog && opts.debug ? e.debug_response || null : null,
++ created_at: iso(clock.now()),
++ });
++ };
++ try {
++ const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash });
++ if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
++ const done = db.prepare(`UPDATE runs SET status = 'succeeded', output = ?, synthetic = ?, provider_key = ?, model_key = ?, fallback_used = ?, attempts = ?, tokens_in = ?, tokens_out = ?, cost_usd = ?,
++ route_key = COALESCE(?, route_key), route_version = COALESCE(?, route_version), finished_at = ? WHERE id = ? AND status = 'running'`)
++ .run(JSON.stringify(r.output), r.synthetic ? 1 : 0, r.provider, r.model, r.fallbackUsed ? 1 : 0, attempts, r.usage.input, r.usage.output, r.cost,
++ r.route ? r.route.key : null, r.route ? r.route.version : null, iso(clock.now()), id);
++ if (!done.changes) return; // cancelled while finishing
++ if (r.citations.length) addCitations(id, r.citations, 'workflow');
++ if (r.provider) quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost });
++ if (r.fallbackUsed) registry.audit('system', 'run.fallback', 'run', id, { trace: row.trace_id, metadata: { workflow: row.workflow_key, provider: r.provider, route: r.route && r.route.key } });
++ if (scope && row.cache_key && !r.synthetic && wf.cache_mode !== 'none') {
++ cache.put({ key: row.cache_key, scope, privacy: wf.cache_mode, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: row.template_version, routeKey: row.route_key, routeVersion: row.route_version, model: r.model, inputHash: row.input_hash, output: r.output, runId: id, ttlSec: wf.cache_ttl_sec || config.runs.defaultCacheTtlSec });
++ }
++ } catch (err) {
++ const interrupted = shuttingDown && controller.signal.aborted;
++ const cancelled = !interrupted && (controller.signal.aborted || (err && err.code === 'run.cancelled'));
++ const code = interrupted ? 'run.interrupted' : cancelled ? 'run.cancelled' : (err instanceof AiError ? err.code : 'run.internal');
++ const detail = interrupted ? 'the service shut down while this run was in progress' : cancelled ? 'cancelled' : (err instanceof AiError ? err.detail : 'internal error');
++ if (!(err instanceof AiError) && !cancelled) log.error(`[runs] ${id} crashed: ${err && err.stack || err}`);
++ const extra = err instanceof AiError && err.extra ? JSON.stringify(err.extra).slice(0, 2000) : null;
++ db.prepare(`UPDATE runs SET status = ?, error_code = ?, error_detail = ?, attempts = ?, finished_at = ? WHERE id = ? AND status IN ('queued', 'running')`)
++ .run(cancelled ? 'cancelled' : 'failed', code, extra ? `${detail} ${extra}` : detail, attempts, iso(clock.now()), id);
++ const spent = db.prepare("SELECT COALESCE(SUM(tokens_in),0) ti, COALESCE(SUM(tokens_out),0) tout, COALESCE(SUM(cost_usd),0) c FROM requests WHERE run_id = ? AND status = 'ok'").get(id);
++ if (spent && (spent.ti || spent.tout)) quotas.account(ctx, reserved, { provider: 'mixed', model: '', tokensIn: spent.ti, tokensOut: spent.tout, cost: spent.c });
++ } finally {
++ inflight.delete(id);
++ }
++ }
++
++ /** Wait for a run to reach a terminal state, up to ms. */
++ async function wait(created, ms) {
++ if (!created.promise || ms <= 0) return get(created.run.id);
++ let timer;
++ await Promise.race([created.promise, new Promise((r) => { timer = setTimeout(r, Math.min(ms, config.runs.maxWaitMs)); timer.unref?.(); })]);
++ clearTimeout(timer);
++ return get(created.run.id);
++ }
++
++ function assertOwner(row, principal, adminCap, principalHas) {
++ const s = principal.subject;
++ if (row.requester_type === s.type && row.requester_id === s.id) return;
++ if (adminCap && principalHas && principalHas(adminCap)) return;
++ throw new AiError(404, 'run.not_found', 'no such run'); // do not reveal other callers' runs
++ }
++
++ function cancel(id, principal, { trace = null, principalHas } = {}) {
++ const row = getRow(id);
++ if (!row) throw new AiError(404, 'run.not_found', 'no such run');
++ assertOwner(row, principal, 'ai.workflow.manage', principalHas);
++ if (TERMINAL.has(row.status)) throw new AiError(409, 'run.terminal', `run is already ${row.status}`);
++ const h = inflight.get(id);
++ const r = db.prepare("UPDATE runs SET status = 'cancelled', error_code = 'run.cancelled', error_detail = 'cancelled by caller', finished_at = ? WHERE id = ? AND status IN ('queued', 'running')").run(iso(clock.now()), id);
++ if (h) {
++ h.controller.abort();
++ const i = queue.indexOf(id);
++ if (i >= 0) { queue.splice(i, 1); h.release(); }
++ }
++ registry.audit(principal.sub, 'run.cancel', 'run', id, { trace, metadata: { from: row.status, changed: r.changes } });
++ return get(id);
++ }
++
++ function retry(id, principal, { trace = null, requestId = null, principalHas } = {}) {
++ const row = getRow(id);
++ if (!row) throw new AiError(404, 'run.not_found', 'no such run');
++ assertOwner(row, principal, 'ai.workflow.manage', principalHas);
++ if (!['failed', 'cancelled'].includes(row.status)) throw new AiError(409, 'run.not_retryable', `only failed or cancelled runs can be retried (this one is ${row.status})`);
++ if (parseJson(row.options, {}).input_retained === false) throw new AiError(409, 'run.input_not_retained', 'this run did not keep its input (inline media or a passthrough prompt); submit it again');
++ return create({
++ workflow: row.workflow_key, input: parseJson(row.input, {}), target: parseJson(row.target, null) || undefined,
++ attribution: parseJson(row.attribution, null) || undefined, on_behalf_of: parseJson(row.on_behalf_of, null) || undefined,
++ options: { ...parseJson(row.options, {}), cache: false },
++ }, principal, { trace, requestId, retryOf: id });
++ }
++
++ function list(principal, { status, workflow, target, limit = 50, before, all = false } = {}) {
++ const where = [];
++ const args = [];
++ if (!all) { where.push('requester_type = ? AND requester_id = ?'); args.push(principal.subject.type, principal.subject.id); }
++ if (status) { where.push('status = ?'); args.push(status); }
++ if (workflow) { where.push('workflow_key = ?'); args.push(workflow); }
++ if (target) { where.push('target_key = ?'); args.push(target); }
++ if (before) { where.push('id < ?'); args.push(before); }
++ return db.prepare(`SELECT * FROM runs ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(200, Number(limit) || 50)).map(decode);
++ }
++
++ function requestsFor(runId) {
++ return db.prepare('SELECT id, seq, operation, provider_key, model_key, route_key, route_version, status, skip_reason, fallback, prompt_hash, input_hash, output_hash, tokens_in, tokens_out, tokens_cached, tokens_estimated, cost_usd, latency_ms, error, created_at FROM requests WHERE run_id = ? ORDER BY seq').all(runId);
++ }
++
++ /** Boot: runs interrupted by a restart are failed explicitly (retry is one call away). */
++ function recoverInterrupted() {
++ const r = db.prepare("UPDATE runs SET status = 'failed', error_code = 'run.interrupted', error_detail = 'the service restarted while this run was in progress', finished_at = ? WHERE status IN ('queued', 'running')").run(iso(clock.now()));
++ return r.changes;
++ }
++
++ function prune(days = config.runs.retentionDays) {
++ const cutoff = iso(clock.now() - days * 86400000);
++ const old = db.prepare('SELECT id FROM runs WHERE finished_at IS NOT NULL AND finished_at < ?').all(cutoff).map(r => r.id);
++ db.transaction(() => {
++ for (const id of old) {
++ db.prepare('DELETE FROM requests WHERE run_id = ?').run(id);
++ db.prepare('DELETE FROM citations WHERE run_id = ?').run(id);
++ db.prepare('DELETE FROM runs WHERE id = ?').run(id);
++ }
++ })();
++ return old.length;
++ }
++
++ /** Shutdown: in-flight runs end as failed/run.interrupted (retryable), never as a silent loss. */
++ async function drain() {
++ shuttingDown = true;
++ for (const h of inflight.values()) { h.controller.abort(); h.release(); }
++ await Promise.allSettled([...inflight.values()].map(h => h.promise));
++ }
++
++ return { create, wait, get, list, cancel, retry, citations, addCitations, requestsFor, recoverInterrupted, prune, drain, inflight, TERMINAL, decode };
++}
++
++module.exports = { createRuns, entityKey, subjectKey };
ddiff --git a/server/schemas.js b/server/schemas.js
nnew file mode 100644
iindex 0000000..6c3e46c
---- /dev/null
++++ b/server/schemas.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * JSON Schema (2020-12) validation for workflow/template input and output schemas. Compiled
++ * validators are cached by schema hash, so a new version of a workflow compiles once.
++ */
++const Ajv2020 = require('ajv/dist/2020');
++const { sha256 } = require('./util');
++
++const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true, validateFormats: false });
++const compiled = new Map();
++
++function compile(schema) {
++ const key = sha256(schema || {});
++ let fn = compiled.get(key);
++ if (!fn) {
++ fn = ajv.compile(schema || {});
++ compiled.set(key, fn);
++ }
++ return fn;
++}
++
++/** Throws when the schema itself is not a valid JSON Schema. */
++function assertSchema(schema, label = 'schema') {
++ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) throw new Error(`${label} must be a JSON Schema object`);
++ try { compile(schema); } catch (e) { throw new Error(`${label} does not compile: ${e.message}`); }
++}
++
++/** { valid, errors: [{path, message}] } */
++function validate(schema, value) {
++ const fn = compile(schema);
++ const valid = fn(value);
++ return { valid: Boolean(valid), errors: valid ? [] : (fn.errors || []).slice(0, 20).map(e => ({ path: e.instancePath || '/', message: e.message })) };
++}
++
++module.exports = { compile, assertSchema, validate };
ddiff --git a/server/templates.js b/server/templates.js
nnew file mode 100644
iindex 0000000..fce3c05
---- /dev/null
++++ b/server/templates.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * Prompt template rendering — a deliberately small, logic-less subset of Mustache:
++ *
++ * {{path.to.value}} the value (objects/arrays as JSON, missing as '')
++ * {{json path}} JSON.stringify(value)
++ * {{#path}} … {{/path}} section, rendered when the value is truthy (non-empty for arrays/strings)
++ * {{^path}} … {{/path}} inverted section
++ *
++ * No HTML escaping (these are prompts, not pages) and no code: anything that needs logic lives in
++ * a named prepare hook (server/workflows/hooks.js) that turns the run input into template vars.
++ */
++
++function lookup(vars, path) {
++ if (path === '.') return vars;
++ let cur = vars;
++ for (const part of String(path).split('.')) {
++ if (cur == null || typeof cur !== 'object') return undefined;
++ cur = cur[part];
++ }
++ return cur;
++}
++
++function truthy(v) {
++ if (Array.isArray(v)) return v.length > 0;
++ if (typeof v === 'string') return v.trim().length > 0;
++ return Boolean(v);
++}
++
++function show(v) {
++ if (v == null) return '';
++ if (typeof v === 'object') return JSON.stringify(v);
++ return String(v);
++}
++
++const SECTION_RE = /\{\{([#^])\s*([\w.]+)\s*\}\}([\s\S]*?)\{\{\/\s*\2\s*\}\}/g;
++const TAG_RE = /\{\{\s*(json\s+)?([\w.]+)\s*\}\}/g;
++
++function render(template, vars = {}) {
++ let out = String(template || '');
++ // Sections (innermost last is fine: templates here do not nest the same key).
++ for (let i = 0; i < 5 && SECTION_RE.test(out); i++) {
++ SECTION_RE.lastIndex = 0;
++ out = out.replace(SECTION_RE, (_, kind, path, body) => {
++ const on = truthy(lookup(vars, path));
++ return (kind === '#' ? on : !on) ? body : '';
++ });
++ SECTION_RE.lastIndex = 0;
++ }
++ return out.replace(TAG_RE, (_, json, path) => {
++ const v = lookup(vars, path);
++ return json ? JSON.stringify(v === undefined ? null : v) : show(v);
++ });
++}
++
++module.exports = { render, lookup };
ddiff --git a/server/util.js b/server/util.js
nnew file mode 100644
iindex 0000000..c7957ed
---- /dev/null
++++ b/server/util.js
@@@ -0,0 +1,67 @@
++'use strict';
++/** Small helpers shared by every module: hashing, stable JSON, row decoding, errors. */
++const crypto = require('crypto');
++
++/** JSON with sorted object keys, so equal inputs always hash equal. */
++function stableStringify(value) {
++ if (value === undefined) return 'null';
++ if (value === null || typeof value !== 'object') return JSON.stringify(value);
++ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
++ const keys = Object.keys(value).filter(k => value[k] !== undefined).sort();
++ return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
++}
++
++function sha256(value) {
++ const s = typeof value === 'string' ? value : stableStringify(value);
++ return crypto.createHash('sha256').update(s).digest('hex');
++}
++
++function parseJson(text, fallback = null) {
++ if (text == null || text === '') return fallback;
++ try { return JSON.parse(text); } catch { return fallback; }
++}
++
++const iso = (ms = Date.now()) => new Date(ms).toISOString();
++
++/** An error that the API layer turns into a problem+json response. */
++class AiError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.status = status;
++ this.code = code;
++ this.detail = detail;
++ this.extra = extra;
++ }
++}
++
++/** 'env:NAME' -> the value of process.env.NAME (read by name; never logged). */
++function resolveSecret(ref, env = process.env) {
++ if (!ref) return '';
++ const m = /^env:([A-Z][A-Z0-9_]{0,63})$/.exec(String(ref));
++ if (!m) return '';
++ return env[m[1]] ? String(env[m[1]]) : '';
++}
++
++function secretRefValid(ref) {
++ return ref == null || ref === '' || /^env:[A-Z][A-Z0-9_]{0,63}$/.test(String(ref));
++}
++
++/** Loose JSON repair, ported from Live's llm.parseJsonLoose (models emit slightly broken JSON). */
++function parseJsonLoose(text) {
++ if (!text) return null;
++ try { return JSON.parse(text); } catch { /* */ }
++ const m = String(text).match(/\{[\s\S]*\}/);
++ if (!m) return null;
++ const raw = m[0];
++ try { return JSON.parse(raw); } catch { /* repair */ }
++ try {
++ let t = raw.replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/,\s*([}\]])/g, '$1');
++ const osq = (t.match(/\[/g) || []).length, csq = (t.match(/\]/g) || []).length;
++ if (osq > csq) { t = t.replace(/\}\s*$/, ''); t += ']'.repeat(osq - csq); }
++ const ocb = (t.match(/\{/g) || []).length, ccb = (t.match(/\}/g) || []).length;
++ if (ocb > ccb) t += '}'.repeat(ocb - ccb);
++ return JSON.parse(t);
++ } catch { return null; }
++}
++
++module.exports = { stableStringify, sha256, parseJson, iso, AiError, resolveSecret, secretRefValid, parseJsonLoose };
ddiff --git a/server/workflows/common-schemas.js b/server/workflows/common-schemas.js
nnew file mode 100644
iindex 0000000..b3e7ff2
---- /dev/null
++++ b/server/workflows/common-schemas.js
@@@ -0,0 +1,46 @@
++'use strict';
++/** JSON Schema fragments shared by the seeded workflow definitions. */
++
++const MEDIA_REF = {
++ type: 'object', additionalProperties: false, required: ['media_id'],
++ properties: {
++ media_id: { type: 'string', pattern: '^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$' },
++ role: { type: 'string' }, variant: { type: 'string' },
++ },
++};
++
++/** An image by allow-listed URL, inline data URL, or MediaRef. */
++const IMAGE = {
++ type: 'object', additionalProperties: false, minProperties: 1,
++ properties: {
++ url: { type: 'string', maxLength: 2048 },
++ data_url: { type: 'string', maxLength: 16000000 },
++ media: MEDIA_REF,
++ max_width: { type: 'integer', minimum: 64, maximum: 2048 },
++ },
++};
++
++const SOURCE = {
++ type: 'object', additionalProperties: false, required: ['source_type'],
++ properties: {
++ source_type: { type: 'string', pattern: '^[a-z][a-z0-9_.-]{1,63}$' },
++ source_id: { type: 'string', maxLength: 256 },
++ url: { type: 'string', maxLength: 2048 },
++ title: { type: 'string', maxLength: 500 },
++ author: { type: 'string', maxLength: 200 },
++ published_at: { type: 'string', maxLength: 40 },
++ retrieved_at: { type: 'string', maxLength: 40 },
++ observed_at: { type: 'string', maxLength: 40 },
++ snippet: { type: 'string', maxLength: 4000 },
++ content: { type: 'string', maxLength: 20000 },
++ trust: { type: 'object' },
++ provenance: { type: 'object' },
++ },
++};
++const SOURCES = (min = 0, max = 50) => ({ type: 'array', minItems: min, maxItems: max, items: SOURCE });
++
++/** citations: indices into input.sources (the engine narrows the bounds per run). */
++const CITES = { type: 'array', items: { type: 'integer', minimum: 0 }, maxItems: 50 };
++const GAPS = { type: 'array', items: { type: 'string', maxLength: 300 }, maxItems: 20, description: 'what the sources do not support' };
++
++module.exports = { MEDIA_REF, IMAGE, SOURCE, SOURCES, CITES, GAPS };
ddiff --git a/server/workflows/core.js b/server/workflows/core.js
nnew file mode 100644
iindex 0000000..c2e3d45
---- /dev/null
++++ b/server/workflows/core.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * The direct operations (POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}).
++ * Each one IS a workflow (ai.<op>) so a direct call gets the same run record, quota, cache, audit
++ * and attribution as any product workflow — the convenience routes are thin, not a second engine.
++ */
++const { PASSTHROUGH_OUTPUT } = require('./live');
++
++const STR = (n) => ({ type: 'string', maxLength: n });
++const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, required, properties: props });
++const COMMON = { max_tokens: { type: 'integer', minimum: 1, maximum: 16000 }, temperature: { type: ['number', 'null'], minimum: 0, maximum: 2 } };
++
++const templates = [
++ {
++ key: 'ai.generate', name: 'Generate text', input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '{{system}}', user_prompt: '{{prompt}}', default_route: 'default.chat',
++ },
++ {
++ key: 'ai.summarize', name: 'Summarize text', input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: 'You summarize text faithfully and plainly. Never add facts, names, numbers or claims that are not in the text.',
++ user_prompt: 'Summarize the following{{#style}} ({{style}}){{/style}} in at most {{max_words}} words.\n\n---\n{{text}}',
++ default_route: 'default.chat',
++ },
++ {
++ key: 'ai.classify', name: 'Classify text into given labels', input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: 'You classify text into exactly one of the given labels. If none fits, choose the closest and give a low confidence. Output only JSON.',
++ user_prompt: 'Labels: {{json labels}}{{#instructions}}\nInstructions: {{instructions}}{{/instructions}}\n\nText:\n{{text}}',
++ default_route: 'default.json', metadata: { json_name: 'classification' },
++ },
++ {
++ key: 'ai.extract', name: 'Extract structured data', input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: 'You extract structured data from text. Use only what the text states; use null for anything it does not state. Never guess. Output only JSON matching the schema.',
++ user_prompt: '{{#instructions}}Instructions: {{instructions}}\n\n{{/instructions}}Text:\n{{text}}',
++ default_route: 'default.json', metadata: { json_name: 'extraction' },
++ },
++ {
++ key: 'ai.enrich', name: 'Enrich a record', input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: 'You enrich a record by normalising and filling fields that follow directly from the record itself. Never invent facts that are not derivable from the record; leave them null and say so in notes. Output only JSON.',
++ user_prompt: 'Instructions: {{instructions}}\n\nRecord:\n{{json record}}',
++ default_route: 'default.json', metadata: { json_name: 'enrichment' },
++ },
++];
++
++const workflows = [
++ {
++ key: 'ai.chat', name: 'Chat completion', namespace: 'ai',
++ input_schema: OBJ({ system: STR(200000), messages: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'object', required: ['role', 'content'], properties: { role: { enum: ['user', 'assistant'] }, content: STR(200000) } } }, ...COMMON }, ['messages']),
++ output_schema: PASSTHROUGH_OUTPUT,
++ steps: [{ kind: 'passthrough', route: 'default.chat' }], cache_mode: 'none',
++ },
++ {
++ key: 'ai.generate', name: 'Generate text', namespace: 'ai',
++ input_schema: OBJ({ prompt: STR(200000), system: STR(200000), ...COMMON }, ['prompt']),
++ output_schema: OBJ({ text: { type: 'string' } }, ['text']),
++ steps: [{ kind: 'llm', template: 'ai.generate', operation: 'generate', output: 'text', wrap: 'text', params: { max_tokens: 800 } }], cache_mode: 'private',
++ },
++ {
++ key: 'ai.summarize', name: 'Summarize text', namespace: 'ai',
++ input_schema: OBJ({ text: STR(400000), max_words: { type: 'integer', minimum: 10, maximum: 2000 }, style: STR(100), ...COMMON }, ['text']),
++ output_schema: OBJ({ summary: { type: 'string' } }, ['summary']),
++ steps: [{ kind: 'llm', template: 'ai.summarize', operation: 'summarize', output: 'text', wrap: 'summary', prepare: 'ai.summarize', params: { max_tokens: 600, temperature: 0.2 } }], cache_mode: 'private',
++ },
++ {
++ key: 'ai.classify', name: 'Classify text', namespace: 'ai',
++ input_schema: OBJ({ text: STR(100000), labels: { type: 'array', minItems: 2, maxItems: 50, items: STR(80), uniqueItems: true }, instructions: STR(2000), ...COMMON }, ['text', 'labels']),
++ output_schema: OBJ({ label: STR(80), confidence: { type: 'number', minimum: 0, maximum: 1 }, rationale: STR(1000) }, ['label', 'confidence']),
++ steps: [{ kind: 'llm', template: 'ai.classify', operation: 'classify', output: 'json', prepare: 'ai.classify', postprocess: 'ai.classify', params: { max_tokens: 200, temperature: 0 } }], cache_mode: 'private',
++ },
++ {
++ key: 'ai.extract', name: 'Extract structured data', namespace: 'ai',
++ input_schema: OBJ({ text: STR(200000), schema: { type: 'object' }, instructions: STR(2000), ...COMMON }, ['text', 'schema']),
++ output_schema: OBJ({ data: {} }, ['data']),
++ steps: [{ kind: 'llm', template: 'ai.extract', operation: 'extract', output: 'json', wrap: 'data', prepare: 'ai.extract', params: { max_tokens: 1200, temperature: 0 } }], cache_mode: 'private',
++ },
++ {
++ key: 'ai.enrich', name: 'Enrich a record', namespace: 'ai',
++ input_schema: OBJ({ record: { type: 'object' }, instructions: STR(4000), schema: { type: 'object' }, ...COMMON }, ['record', 'instructions']),
++ output_schema: OBJ({ data: { type: 'object' }, notes: { type: 'array', items: { type: 'string' } } }, ['data']),
++ steps: [{ kind: 'llm', template: 'ai.enrich', operation: 'enrich', output: 'json', prepare: 'ai.enrich', params: { max_tokens: 1200, temperature: 0.2 } }], cache_mode: 'private',
++ },
++ {
++ key: 'ai.embed', name: 'Embed text', namespace: 'ai',
++ input_schema: OBJ({ input: { anyOf: [STR(32000), { type: 'array', minItems: 1, maxItems: 64, items: STR(32000) }] } }, ['input']),
++ output_schema: OBJ({ vectors: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, dimensions: { type: 'integer' } }, ['vectors', 'dimensions']),
++ steps: [{ kind: 'embed', route: 'default.embedding' }], cache_mode: 'private',
++ },
++];
++
++module.exports = { templates, workflows };
ddiff --git a/server/workflows/engine.js b/server/workflows/engine.js
nnew file mode 100644
iindex 0000000..1adfd32
---- /dev/null
++++ b/server/workflows/engine.js
@@@ -0,0 +1,222 @@
++'use strict';
++/**
++ * The workflow engine: executes a workflow version's steps for one run.
++ *
++ * Step kinds
++ * llm render a versioned template (prepare hook -> vars), call the route's provider for
++ * the step's operation (chat/generate/summarize/classify/extract/enrich), parse the
++ * answer (structured JSON, or prose repaired like Live did), postprocess hook
++ * passthrough the caller supplies system/messages (Live features whose prompt Live still renders);
++ * routed by role (route_prefix.role) or a fixed route
++ * transcribe fetch the media (allow-listed hosts only) and run speech-to-text
++ * embed embeddings
++ *
++ * The final output is validated against the workflow's output schema. An empty or invalid answer
++ * fails the run (output.empty / output.invalid); nothing is ever filled in. input.sources become
++ * citation rows, marked cited when the output's `citations` arrays point at them.
++ */
++const fs = require('fs');
++const { AiError, sha256, parseJsonLoose } = require('../util');
++const { render } = require('../templates');
++const schemas = require('../schemas');
++const { PREPARE, POSTPROCESS } = require('./hooks');
++
++const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
++
++/** Deep copy of a schema with every `citations` array narrowed to valid source indices. */
++function narrowCitations(schema, count) {
++ if (!schema || typeof schema !== 'object') return schema;
++ if (Array.isArray(schema)) return schema.map(s => narrowCitations(s, count));
++ const out = {};
++ for (const [k, v] of Object.entries(schema)) {
++ if (k === 'properties' && v && typeof v === 'object') {
++ out.properties = {};
++ for (const [pk, pv] of Object.entries(v)) {
++ if (pk === 'citations' && pv && pv.type === 'array') {
++ out.properties.citations = count > 0
++ ? { ...pv, items: { type: 'integer', minimum: 0, maximum: count - 1 } }
++ : { ...pv, items: { type: 'integer', minimum: 0 }, maxItems: 0 };
++ } else out.properties[pk] = narrowCitations(pv, count);
++ }
++ } else out[k] = narrowCitations(v, count);
++ }
++ return out;
++}
++
++function collectCited(value, set = new Set()) {
++ if (Array.isArray(value)) { for (const v of value) collectCited(v, set); return set; }
++ if (value && typeof value === 'object') {
++ for (const [k, v] of Object.entries(value)) {
++ if (k === 'citations' && Array.isArray(v)) v.forEach(i => Number.isInteger(i) && set.add(i));
++ else collectCited(v, set);
++ }
++ }
++ return set;
++}
++
++function normMessages(messages, user) {
++ const out = [];
++ for (const m of messages || []) {
++ if (!m || !m.role) continue;
++ out.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content });
++ }
++ if (user) out.push({ role: 'user', content: user });
++ if (!out.length) out.push({ role: 'user', content: '' });
++ return out;
++}
++
++function createEngine({ registry, pool, fetcher }) {
++ function routeFor(key) {
++ const r = registry.resolveRoute(key);
++ if (!r) throw new AiError(503, 'route.unavailable', `no route ${key}`);
++ if (r.disabled) throw new AiError(503, 'route.unavailable', `route ${key} is disabled`);
++ return r;
++ }
++
++ async function llmStep(step, input, wf, run, ctx) {
++ // The run pinned its template version when it was created; later steps use the active one.
++ const template = run.template_key === step.template && run.template_version
++ ? registry.getTemplate(step.template, run.template_version)
++ : registry.activeTemplate(step.template);
++ if (!template) throw new AiError(500, 'workflow.broken', `template ${step.template} is missing`);
++ const prep = step.prepare ? PREPARE[step.prepare](input) : { vars: input };
++ if (!prep) throw new AiError(500, 'workflow.broken', `prepare hook ${step.prepare} is missing`);
++ if (prep.output !== undefined) {
++ if (prep.output === null) throw new AiError(422, 'input.insufficient', 'the input has nothing this workflow can work from');
++ return { output: prep.output, noProvider: true, template };
++ }
++ const vars = prep.vars || {};
++ const system = render(template.system_prompt, vars).trim();
++ const user = render(template.user_prompt, vars);
++ const routeKey = step.route || template.default_route || wf.default_route || 'default.chat';
++ const route = routeFor(routeKey);
++ const sources = Array.isArray(input.sources) ? input.sources : [];
++ let jsonSchema = null;
++ if (step.output === 'json') {
++ const base = prep.jsonSchema || (template.output_schema && template.output_schema.properties ? template.output_schema : wf.output_schema);
++ jsonSchema = step.cite ? narrowCitations(base, sources.length) : base;
++ if (prep.jsonSchema) {
++ try { schemas.assertSchema(jsonSchema, 'schema'); } catch (e) { throw new AiError(422, 'input.invalid', e.message); }
++ }
++ }
++ const image = step.image && input[step.image] ? await fetcher.loadImage({ ...input[step.image], max_width: input[step.image].max_width || step.image_max_width || 1024 }, { signal: ctx.signal }) : null;
++ const p = { ...(route.options || {}), ...(step.params || {}), ...(prep.params || {}) };
++ const req = {
++ system: system ? [{ text: system, cache: true }] : [],
++ messages: [{ role: 'user', content: user }],
++ image,
++ json: jsonSchema ? { name: (template.metadata && template.metadata.json_name) || 'result', schema: jsonSchema, strict: !prep.jsonSchema } : null,
++ maxTokens: Math.max(1, Math.round((prep.params && prep.params.max_tokens) || input.max_tokens || p.max_tokens || route.max_output_tokens || 400)),
++ temperature: input.temperature !== undefined ? input.temperature : (p.temperature !== undefined ? p.temperature : null),
++ timeoutMs: p.timeout_ms || route.timeout_ms || 30000,
++ cacheKey: `${wf.key}:${wf.version}`,
++ };
++ const exec = await pool.execute(route, step.operation || 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: system, u: user, i: image ? image.base64.length : 0 }) });
++ const r = { text: exec.result.text || '', json: exec.result.json || null };
++ if (step.output === 'json' && !r.json) r.json = parseJsonLoose(r.text);
++ let output;
++ if (step.postprocess) {
++ const post = POSTPROCESS[step.postprocess];
++ if (!post) throw new AiError(500, 'workflow.broken', `postprocess hook ${step.postprocess} is missing`);
++ output = post(r, input);
++ } else if (step.wrap) {
++ const v = step.output === 'json' ? r.json : r.text;
++ output = v == null || v === '' ? null : { [step.wrap]: v };
++ } else {
++ output = step.output === 'json' ? r.json : (r.text ? { text: r.text } : null);
++ }
++ if (output == null) throw new AiError(502, 'output.empty', 'the provider answered, but not with anything usable');
++ return { output, exec, template, route, jsonSchema };
++ }
++
++ async function passthroughStep(step, input, wf, run, ctx) {
++ const routeKey = step.route || `${step.route_prefix || 'live'}.${input.role || 'legacy'}`;
++ const route = routeFor(routeKey);
++ const sys = typeof input.system === 'string' ? (input.system.trim() ? [{ text: input.system, cache: false }] : [])
++ : Array.isArray(input.system) ? input.system.filter(x => x && String(x.text || '').trim()).map(x => ({ text: String(x.text), cache: Boolean(x.cache) })) : [];
++ const messages = normMessages(input.messages, input.user);
++ const image = input.image ? await fetcher.loadImage({ ...input.image, max_width: input.image.max_width || 1024 }, { signal: ctx.signal }) : null;
++ const req = {
++ system: sys, messages, image,
++ json: input.json ? { name: input.json.name || 'result', schema: input.json.schema, strict: input.json.strict !== false, description: input.json.description } : null,
++ maxTokens: Math.max(1, Math.round(input.max_tokens || 400)),
++ temperature: input.temperature == null ? null : input.temperature,
++ timeoutMs: input.timeout_ms || route.timeout_ms || ROLE_TIMEOUT[input.role] || 25000,
++ cacheKey: input.cache_key || null,
++ };
++ const exec = await pool.execute(route, 'chat', req, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: sha256({ s: sys, m: messages }) });
++ let json = exec.result.json || null;
++ if (input.json && !json) json = parseJsonLoose(exec.result.text);
++ return { output: { text: exec.result.text || '', json }, exec, route };
++ }
++
++ async function transcribeStep(step, input, wf, run, ctx) {
++ const route = routeFor(step.route || 'live.stt');
++ const { file } = await fetcher.loadMediaToFile(input, { signal: ctx.signal });
++ try {
++ const exec = await pool.execute(route, 'transcribe', { filePath: file, language: input.language || 'en', seconds: input.seconds || 0, offsetSec: input.offset_sec || 0, timeoutMs: 3600000 }, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: null });
++ const r = exec.result;
++ return { output: { text: r.text || '', language: r.language || input.language || 'en', segments: r.segments || [] }, exec, route };
++ } finally {
++ try { fs.unlinkSync(file); } catch { /* */ }
++ }
++ }
++
++ async function embedStep(step, input, wf, run, ctx) {
++ const route = routeFor(step.route || 'default.embedding');
++ const inputs = Array.isArray(input.input) ? input.input : [input.input];
++ const exec = await pool.execute(route, 'embed', { input: inputs, timeoutMs: route.timeout_ms || 30000 }, { ...ctx, routeKey: route.key, routeVersion: route.version, promptHash: null });
++ const vectors = exec.result.vectors || [];
++ if (vectors.length !== inputs.length) throw new AiError(502, 'output.invalid', 'embedding count does not match the input');
++ return { output: { vectors, dimensions: vectors[0] ? vectors[0].length : 0 }, exec, route };
++ }
++
++ const STEP = { llm: llmStep, passthrough: passthroughStep, transcribe: transcribeStep, embed: embedStep };
++
++ /**
++ * Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
++ * Returns { output, citations, synthetic, provider, model, fallbackUsed, usage, cost, template, route }.
++ */
++ async function execute(wf, run, input, ctx) {
++ let last = null;
++ const usage = { input: 0, output: 0, cached: 0 };
++ let cost = 0;
++ let exec = null;
++ let jsonSchema = null;
++ for (const step of wf.steps) {
++ const fn = STEP[step.kind];
++ if (!fn) throw new AiError(500, 'workflow.broken', `unknown step kind ${step.kind}`);
++ last = await fn(step, input, wf, run, ctx);
++ if (last.exec) {
++ exec = last.exec;
++ usage.input += exec.usage.input || 0;
++ usage.output += exec.usage.output || 0;
++ usage.cached += exec.usage.cached || 0;
++ cost += exec.cost || 0;
++ }
++ if (last.jsonSchema) jsonSchema = last.jsonSchema;
++ }
++ const sources = Array.isArray(input.sources) ? input.sources : [];
++ const citeStep = wf.steps.some(s => s.cite);
++ const outSchema = citeStep ? narrowCitations(wf.output_schema, sources.length) : wf.output_schema;
++ const v = schemas.validate(outSchema, last.output);
++ if (!v.valid) throw new AiError(502, 'output.invalid', `output does not match ${wf.key} v${wf.version}: ${v.errors.map(e => `${e.path} ${e.message}`).join('; ')}`, { errors: v.errors });
++ const cited = collectCited(last.output);
++ const citations = sources.map((s, i) => ({
++ ordinal: i, source_type: s.source_type, source_id: s.source_id || null, url: s.url || null, title: s.title || null, author: s.author || null,
++ published_at: s.published_at || null, retrieved_at: s.retrieved_at || s.observed_at || null, snippet: s.snippet ? String(s.snippet).slice(0, 1000) : null,
++ content_hash: s.content ? sha256(String(s.content)) : null, trust: s.trust || {}, provenance: { ...(s.provenance || {}), cited: citeStep ? cited.has(i) : null },
++ }));
++ return {
++ output: last.output, citations, jsonSchema,
++ synthetic: Boolean(exec && exec.synthetic),
++ provider: exec ? exec.provider : null, model: exec ? exec.model : null, fallbackUsed: Boolean(exec && exec.fallbackUsed),
++ usage, cost,
++ route: last.route || null,
++ };
++ }
++
++ return { execute, narrowCitations };
++}
++
++module.exports = { createEngine, narrowCitations, collectCited };
ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
nnew file mode 100644
iindex 0000000..c564658
---- /dev/null
++++ b/server/workflows/hooks.js
@@@ -0,0 +1,223 @@
++'use strict';
++/**
++ * Named prepare/postprocess hooks. Template TEXT is data (versioned in the templates table); the
++ * little bits of logic Live's prompts needed — formatting a timeline, picking a translation
++ * direction phrase, repairing a model's almost-JSON — are code, referenced from workflow steps by
++ * name and versioned with the service.
++ *
++ * prepare(input) -> { vars, params?, output? } output short-circuits the provider call
++ * postprocess(result, input) -> output object, or null when the answer is unusable (the run then
++ * fails with output.empty — an explicit state, never a placeholder)
++ *
++ * The Live hooks are ported from OpenVibe.Live: server/i18n/translate.js, server/ai/ai-analysis.js,
++ * server/ai/media-analysis.js, server/recap/recap.js and server/internal/routes.js.
++ */
++const { parseJsonLoose } = require('../util');
++
++// ── Live: shared bits ────────────────────────────────────
++const LANG_NAMES = {
++ en: 'English', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', ar: 'Arabic',
++ th: 'Thai', he: 'Hebrew', el: 'Greek', hi: 'Hindi', es: 'Spanish', pt: 'Portuguese', fr: 'French',
++ de: 'German', it: 'Italian', tr: 'Turkish', vi: 'Vietnamese', id: 'Indonesian', pl: 'Polish', nl: 'Dutch',
++};
++const langName = (c) => LANG_NAMES[c] || c || 'English';
++
++const CATEGORIES = ['outdoors', 'travel', 'building', 'music', 'gaming', 'robot', 'desktop', 'irl', 'other'];
++function normalizeCategory(c) {
++ const t = String(c || '').toLowerCase().trim();
++ if (!t) return null;
++ if (CATEGORIES.includes(t)) return t;
++ const map = { 'just chatting': 'irl', chatting: 'irl', talk: 'irl', coding: 'desktop', programming: 'desktop', software: 'desktop', tech: 'desktop', art: 'building', craft: 'building', diy: 'building', cooking: 'irl', hiking: 'outdoors', driving: 'travel', 'road trip': 'travel', robotics: 'robot', game: 'gaming', games: 'gaming', dj: 'music' };
++ return map[t] || null;
++}
++
++/** Live's _extractDescription: lift a description out of almost-JSON, never store a raw blob. */
++function extractDescription(text, maxLen) {
++ if (!text) return '';
++ const j = parseJsonLoose(text);
++ if (j && j.description) return String(j.description).slice(0, maxLen);
++ const dm = text.match(/"description"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
++ if (dm) { try { return JSON.parse(`"${dm[1]}"`).slice(0, maxLen); } catch { return dm[1].slice(0, maxLen); } }
++ const t = text.trim();
++ return /^[{[]/.test(t) ? '' : t.slice(0, maxLen);
++}
++function extractTags(text, maxTags) {
++ const j = parseJsonLoose(text);
++ if (j && Array.isArray(j.tags)) return j.tags.slice(0, maxTags).map(String);
++ const tm = text && text.match(/"tags"\s*:\s*\[([^\]]*)/i);
++ if (tm) return tm[1].split(',').map(s => s.replace(/["'\s]/g, '')).filter(Boolean).slice(0, maxTags);
++ return [];
++}
++const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
++const clean = (v, n) => String(v == null ? '' : v).replace(/[<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
++
++/** Live's translatable(): is there anything worth translating (letters, not emotes/links)? */
++function translatable(text) {
++ const s = String(text || '').trim();
++ if (s.length < 2 || s.length > 4000) return false;
++ const stripped = s.replace(/https?:\/\/\S+/gi, '').replace(/:[a-z0-9_]+:/gi, '').replace(/[!/]\w+/g, '').replace(/@\w+/g, '');
++ return /\p{L}{2,}/u.test(stripped);
++}
++
++// ── Product workflows: numbered sources ──────────────────
++function sourcesBlock(sources) {
++ return (sources || []).map((s, i) => {
++ const head = [`[${i}]`, s.title || s.source_type, s.author ? `by ${s.author}` : '', s.published_at ? `(published ${s.published_at})` : '', s.url || ''].filter(Boolean).join(' ');
++ const body = String(s.content || s.snippet || '').slice(0, 6000);
++ return body ? `${head}\n${body}` : head;
++ }).join('\n\n');
++}
++
++const PREPARE = {
++ 'live.translate'(input) {
++ const text = String(input.text || '').trim();
++ const from = input.from || 'auto';
++ const to = input.to || 'en';
++ if (!translatable(text)) return { output: { text: null, from, to, unchanged: false, reason: 'not_translatable' } };
++ if (from === to) return { output: { text: null, from, to, unchanged: true, reason: 'same_language' } };
++ const context = input.context || 'chat';
++ const kind = context === 'bio' ? 'a streamer\'s profile bio'
++ : context === 'speech' ? 'lines of live-stream speech (a casual gamer talking to his chat)'
++ : 'a live-stream chat message';
++ return {
++ vars: { text, kind, src: from === 'auto' ? 'the source language' : langName(from), dst: langName(to), speech: context === 'speech' },
++ params: { max_tokens: Math.max(40, Math.min(input.max_tokens || 600, Math.round(text.length * 2.2) + 60)) },
++ };
++ },
++ 'live.paste.describe_image'(input) {
++ return { vars: { title: String(input.title || '').slice(0, 120) } };
++ },
++ 'live.paste.summarize_text'(input) {
++ return { vars: { title: String(input.title || '').slice(0, 120), snippet: String(input.content || '').slice(0, 6000) } };
++ },
++ 'live.stream.summarize'(input) {
++ const lines = (input.observations || []).slice(-80).map(d => `- ${d}`).join('\n');
++ if (!lines) return { output: null };
++ let audio = '';
++ const speech = (input.speech || []).slice(-200)
++ .map(r => `[${mmss(Number(r.start_sec) || 0)}] ${String(r.text || '').replace(/\s+/g, ' ').trim().slice(0, 200)}`)
++ .filter(l => l.length > 8).join('\n');
++ if (speech) audio += `\n\nWHAT THE STREAMER SAID (timestamped):\n${speech}`;
++ const sounds = (input.sounds || []).slice(-60).map(r => `[${mmss(Number(r.start_sec) || 0)}] ${r.label} (${Number(r.confidence || 0).toFixed(2)})`).join('\n');
++ if (sounds) audio += `\n\nNOTABLE SOUNDS HEARD:\n${sounds}`;
++ return { vars: { lines, audio_block: audio, categories: CATEGORIES.join(', ') } };
++ },
++ 'live.streamer.overview'(input) {
++ const st = input.streamer || {};
++ const mem = (input.memories || []).slice(0, 40).map(d => `- ${d}`).filter(l => l.length > 2);
++ const vods = (input.vods || []).map(v => `- ${v.title || 'Untitled VOD'}${v.category ? ` [${v.category}]` : ''}`);
++ const pastes = (input.pastes || []).filter(p => p.summary).map(p => `- "${p.title || 'paste'}": ${p.summary}`);
++ if (!mem.length && !vods.length && !pastes.length) return { output: null };
++ const ctx = [
++ `Streamer: ${st.display_name || st.username} (@${st.username})`,
++ st.bio ? `Bio: ${String(st.bio).slice(0, 400)}` : '',
++ st.category ? `Usual category (${st.category_inferred ? 'inferred by AI from their streams' : 'self-selected'}): ${st.category}` : '',
++ mem.length ? `\nLive-stream observations (across sessions):\n${mem.join('\n')}` : '',
++ vods.length ? `\nRecent VODs:\n${vods.join('\n')}` : '',
++ pastes.length ? `\nPaste summaries:\n${pastes.join('\n')}` : '',
++ ].filter(Boolean).join('\n');
++ return { vars: { ctx } };
++ },
++ 'live.media.overview'(input) {
++ const frames = input.frames || [];
++ const transcript = String(input.transcript || '');
++ if (!frames.length && !transcript) return { output: null };
++ const parts = [];
++ if (frames.length) parts.push(`Visual observations across the video (in order):\n${frames.map(f => `- ${f}`).join('\n')}`);
++ if (transcript) parts.push(`Audio transcript (sampled from the recording):\n"${transcript.slice(0, 4000)}"`);
++ return { vars: { parts: parts.join('\n\n') } };
++ },
++ 'network.site_copy'(input) {
++ const sites = (input.sites || []).slice(0, 24).map(x => ({ id: clean(x.id, 40), name: clean(x.name, 60), what: clean(x.what, 400), popular: (Array.isArray(x.popular) ? x.popular : []).slice(0, 8).map(v => clean(v, 60)) }));
++ const links = (input.links || []).slice(0, 80).map(l => ({ id: clean(l.id, 60), name: clean(l.name, 60), about: clean(l.about, 120) }));
++ return { vars: { payload: { sites, links } } };
++ },
++ sources(input) {
++ return { vars: { ...input, sources_block: sourcesBlock(input.sources), source_count: (input.sources || []).length } };
++ },
++ // ── Direct operations ──
++ 'ai.summarize'(input) {
++ return { vars: { ...input, max_words: input.max_words || 120 } };
++ },
++ 'ai.classify'(input) {
++ return {
++ vars: input,
++ jsonSchema: {
++ type: 'object', additionalProperties: false, required: ['label', 'confidence', 'rationale'],
++ properties: { label: { type: 'string', enum: input.labels }, confidence: { type: 'number', minimum: 0, maximum: 1 }, rationale: { type: 'string', maxLength: 1000 } },
++ },
++ };
++ },
++ 'ai.extract'(input) {
++ return { vars: input, jsonSchema: input.schema };
++ },
++ 'ai.enrich'(input) {
++ return {
++ vars: input,
++ jsonSchema: {
++ type: 'object', additionalProperties: false, required: ['data', 'notes'],
++ properties: { data: input.schema || { type: 'object' }, notes: { type: 'array', items: { type: 'string', maxLength: 500 }, maxItems: 20 } },
++ },
++ };
++ },
++};
++
++const POSTPROCESS = {
++ 'ai.classify'(r, input) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || !input.labels.includes(j.label)) return null; // a label outside the given set is not an answer
++ return { label: j.label, confidence: Math.max(0, Math.min(1, Number(j.confidence) || 0)), rationale: String(j.rationale || '').slice(0, 1000) };
++ },
++ 'live.translate'(r, input) {
++ const out = typeof r.text === 'string' ? r.text.trim().replace(/^["“]|["”]$/g, '') : '';
++ if (!out) return null;
++ const src = String(input.text || '').trim();
++ if (out === src) return { text: null, from: input.from || 'auto', to: input.to || 'en', unchanged: true, reason: 'already_target_language' };
++ return { text: out, from: input.from || 'auto', to: input.to || 'en', unchanged: false };
++ },
++ 'live.paste.describe_image'(r) {
++ const description = extractDescription(r.text, 600);
++ return description ? { description, tags: extractTags(r.text, 8) } : null;
++ },
++ 'live.paste.summarize_text'(r) {
++ return r.text ? { description: r.text.slice(0, 300), tags: [] } : null;
++ },
++ 'live.stream.describe_frame'(r) {
++ const description = extractDescription(r.text, 400);
++ if (!description) return null;
++ const j = parseJsonLoose(r.text) || {};
++ return { description, tags: extractTags(r.text, 6), worthy: j.worthy === true, title: j.worthy === true ? String(j.title || '').replace(/^["'\s]+|["'\s]+$/g, '').slice(0, 80) : '' };
++ },
++ 'live.stream.summarize'(r) {
++ if (!r.text) return null;
++ const j = parseJsonLoose(r.text);
++ const overview = j && j.overview ? String(j.overview).slice(0, 2000) : (extractDescription(r.text, 2000) || r.text.slice(0, 2000));
++ return { overview, category: normalizeCategory(j && j.category), tags: j && Array.isArray(j.tags) ? j.tags.map(String).slice(0, 6) : [] };
++ },
++ 'live.streamer.overview'(r) { return r.text ? { overview: r.text.slice(0, 4000) } : null; },
++ 'live.media.overview'(r) { return r.text ? { overview: r.text.slice(0, 2000) } : null; },
++ 'live.stream.recap'(r) {
++ const out = r.json || parseJsonLoose(r.text);
++ if (!out || !out.headline || !out.summary) return null;
++ return {
++ headline: String(out.headline).trim().slice(0, 90), summary: String(out.summary).trim().slice(0, 500), moment: String(out.moment || '').trim().slice(0, 200),
++ tags: (Array.isArray(out.tags) ? out.tags : []).map(t => String(t).trim().toLowerCase()).filter(Boolean).slice(0, 4),
++ grade: ['S', 'A', 'B', 'C'].includes(out.grade) ? out.grade : 'B',
++ };
++ },
++ 'network.site_copy'(r, input) {
++ const out = r.json || parseJsonLoose(r.text);
++ if (!out || !Array.isArray(out.sites)) return null;
++ // The model PICKS link ids from the allow-list; anything else is dropped, so nothing it
++ // says can become a link or a tag on another site.
++ const linkIds = new Set((input.links || []).map(l => clean(l.id, 60)));
++ const siteIds = new Set((input.sites || []).map(s => clean(s.id, 40)));
++ return {
++ sites: out.sites.filter(s => s && siteIds.has(String(s.id))).map(s => ({
++ id: String(s.id), blurb: clean(s.blurb, 300), picks: (Array.isArray(s.picks) ? s.picks : []).map(String).filter(p => linkIds.has(p) && p !== String(s.id)).slice(0, 4),
++ })),
++ };
++ },
++};
++
++module.exports = { PREPARE, POSTPROCESS, CATEGORIES, normalizeCategory, translatable, sourcesBlock, LANG_NAMES };
ddiff --git a/server/workflows/live.js b/server/workflows/live.js
nnew file mode 100644
iindex 0000000..16011d8
---- /dev/null
++++ b/server/workflows/live.js
@@@ -0,0 +1,266 @@
++'use strict';
++/**
++ * OpenVibe.Live's AI features as workflows, moved from Live with minimal change.
++ *
++ * Two shapes:
++ * - STRUCTURED workflows own their prompt here (versioned templates) and take structured input:
++ * translation, paste image/text analysis, stream-frame description (moments), stream summary
++ * (stream memory / overview + inferred category), streamer overview, VOD/clip media overview,
++ * stream recap, speech-to-text, and Network's footer copy (the dependency the plan reverses).
++ * - PASSTHROUGH workflows carry a prompt Live still renders itself (chat AI viewers, chat
++ * insights, moment ranking, slogans, easter eggs, arena, home star …). Each Live feature has
++ * its own workflow key so every run stays attributable to a workflow + model + run (§33);
++ * their prompts move into templates feature by feature (docs/migration.md).
++ *
++ * Prompt text below is copied verbatim from Live (server/i18n/translate.js, server/ai/ai-analysis.js,
++ * server/ai/media-analysis.js, server/recap/recap.js, server/internal/routes.js).
++ */
++const { IMAGE, MEDIA_REF, SOURCES } = require('./common-schemas');
++
++const ROLES = ['chat', 'vision', 'director', 'summary', 'legacy'];
++const LANG = { type: 'string', pattern: '^(auto|[a-z]{2})$' };
++const STR = (n) => ({ type: 'string', maxLength: n });
++
++// ── Templates ────────────────────────────────────────────
++const templates = [
++ {
++ key: 'live.translate', name: 'Live: translate chat, bio or speech',
++ description: 'From server/i18n/translate.js. Keeps tone, slang, emotes and mentions; speech keeps line count.',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: 'You translate {{kind}} from {{src}} to {{dst}}. Rules: keep the tone, slang, jokes, profanity and emoji as they are; keep names, URLs, :emotes:, !commands and @mentions unchanged; never add explanations, notes, quotes or brackets; if it is already in the target language, return it unchanged.{{#speech}} Input may contain several lines separated by newlines — return exactly the same number of lines, in order.{{/speech}} Output ONLY the translation.',
++ user_prompt: '{{text}}',
++ default_route: 'live.chat', owner: 'live',
++ },
++ {
++ key: 'live.paste.describe_image', name: 'Live: describe an image paste or moment frame',
++ description: 'From ai-analysis.analyzeImagePaste (paste_image, moment_frame).',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '',
++ user_prompt: 'You are describing an uploaded image/screenshot for a paste titled "{{title}}".\nReply ONLY with compact JSON: {"description":"1-2 sentence description of what the image shows","tags":["3-6","short","lowercase","tags"]}.',
++ default_route: 'live.vision', owner: 'live',
++ },
++ {
++ key: 'live.paste.summarize_text', name: 'Live: summarize a text paste',
++ description: 'From ai-analysis.analyzeTextPaste (paste_text).',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '',
++ user_prompt: 'Summarize what this pasted text is about in one short sentence (max 200 chars), plainly. Title: "{{title}}".\n\n---\n{{snippet}}',
++ default_route: 'live.legacy', owner: 'live',
++ },
++ {
++ key: 'live.stream.describe_frame', name: 'Live: describe a live-stream frame',
++ description: 'From ai-analysis.analyzeStreamFrame (stream_memory): memory description, tags, and a screenshot-worthiness verdict + caption for live pastes, in one vision call.',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '',
++ user_prompt: 'This is a frame from a live stream. Reply ONLY with compact JSON: {"description":"one concise sentence describing what is happening on screen right now","tags":["2-5","short","tags"],"worthy":<true only if this exact frame is genuinely screenshot-worthy on its own: a face/reaction, a visual gag, something unusual or funny on screen — false for ordinary gameplay/desktop/chat/talking-head frames>,"title":"<if worthy: a punchy, funny 3-7 word caption for it, else empty>"}.',
++ default_route: 'live.vision', owner: 'live',
++ },
++ {
++ key: 'live.stream.summarize', name: 'Live: stream overview + inferred category',
++ description: 'From ai-analysis.summarizeStreamMemories: whole-session overview from timestamped observations and the audio timeline; category is inferred, never defaulted.',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '',
++ user_prompt: 'These are timestamped observations from a live stream, in order since it started. Reply ONLY with compact JSON: {"overview":"a thorough overview (2-6 sentences) of what this stream has been about overall — the main activities, topics, and vibe across the whole session (not just the latest moment)","category":"<exactly one of: {{categories}} — what this stream mostly IS, judged from the observations; desktop = screen/software/coding, gaming = playing games, irl = camera on a person talking/doing things indoors, outdoors/travel = out in the world>","tags":["3-6","short","lowercase","tags"]}\n{{lines}}{{audio_block}}',
++ default_route: 'live.legacy', owner: 'live',
++ },
++ {
++ key: 'live.streamer.overview', name: 'Live: internal streamer profile for staff',
++ description: 'From ai-analysis.generateStreamerOverview.',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '',
++ user_prompt: 'You are building an internal profile of a livestreamer for site staff, using aggregated signals across their streams, VODs, and pastes. Write a concise overview (4-8 sentences) covering: what they stream / their content niche, recurring themes or activities, tone/vibe, and anything notable for moderation. Be factual and neutral; do NOT invent specifics that aren\'t supported by the signals below.\n\n{{ctx}}',
++ default_route: 'live.legacy', owner: 'live',
++ },
++ {
++ key: 'live.media.overview', name: 'Live: VOD/clip overview from frames + transcript',
++ description: 'From media-analysis.analyzeMedia (media_overview).',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: '',
++ user_prompt: 'You are writing an AI overview of a recorded video. Using ONLY the signals below (be concrete, don\'t invent), summarize what the video is about in 2-5 sentences — the main activities, topics, and vibe.\n\n{{parts}}',
++ default_route: 'live.legacy', owner: 'live',
++ },
++ {
++ key: 'live.stream.recap', name: 'Live: after-show report',
++ description: 'From recap/recap.js aiWriteup (stream_recap).',
++ input_schema: { type: 'object' },
++ output_schema: {
++ type: 'object', additionalProperties: false, required: ['headline', 'summary', 'moment', 'tags', 'grade'], properties: {
++ headline: { type: 'string', description: '≤ 70 chars, like a sports-page headline about this stream, no quotes, no emojis' },
++ summary: { type: 'string', description: '2–3 sentences, ≤ 420 chars, what happened and how it went, concrete, warm, second person is fine' },
++ moment: { type: 'string', description: '≤ 160 chars, the single moment of the night (from the mic lines, chat spike, a clip title or the transcript) — or empty string' },
++ tags: { type: 'array', items: { type: 'string' }, description: '3 short vibe tags, 1–2 words each, lowercase' },
++ grade: { type: 'string', enum: ['S', 'A', 'B', 'C'], description: 'S = legendary night, A = great, B = solid, C = quiet' },
++ },
++ },
++ system_prompt: 'You write the "after-show report" for a live stream on OpenVibe.Live (a scrappy, open-source, community-run streaming site). Voice: sports-page energy, warm, specific, a little funny, never mocking the streamer or the viewers. Use the REAL numbers and names given. Small streams are fine — a 4-viewer night can still be an A if it was fun. Output only JSON.',
++ user_prompt: '{{json facts}}',
++ default_route: 'live.summary', owner: 'live', metadata: { json_name: 'stream_recap' },
++ },
++ {
++ key: 'network.site_copy', name: 'Network: shared footer copy',
++ description: 'Was Live\'s POST /internal/ai/site-copy (server/internal/routes.js). The model writes blurbs and PICKS link ids from an allow-list; it never returns URLs or markup.',
++ input_schema: { type: 'object' },
++ output_schema: {
++ type: 'object', additionalProperties: false, required: ['sites'], properties: {
++ sites: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['id', 'blurb', 'picks'], properties: { id: { type: 'string' }, blurb: { type: 'string' }, picks: { type: 'array', items: { type: 'string' } } } } },
++ },
++ },
++ system_prompt: 'You write footer copy for OpenVibe, an open source, community-run network of sites (live streaming, online tools, community pastes, games, media). Voice: plain, confident, specific, a little playful. Never claim anything is free, costs $0, or has no ads. No hype words, no emoji, no markup, no URLs. For each site write one blurb of at most 150 characters saying what a visitor can do there right now, and choose 4 link ids from the provided list that a visitor of that site would most likely want next (prefer other sites and popular tools; never the site itself).',
++ user_prompt: '{{json payload}}',
++ default_route: 'live.summary', owner: 'network', metadata: { json_name: 'site_copy' },
++ },
++];
++
++// ── Structured workflows ─────────────────────────────────
++const TEXT_OUT = (props, required) => ({ type: 'object', additionalProperties: false, required, properties: props });
++
++const structured = [
++ {
++ key: 'live.translate', name: 'Translate a chat line, bio or speech lines', namespace: 'live',
++ description: 'Direction is chosen by the caller (Live picks it from the script and the channel language). Cached per service: translations of public text are the same for every Live user.',
++ input_schema: TEXT_OUT({ text: STR(4000), from: LANG, to: LANG, context: { enum: ['chat', 'bio', 'speech'] }, max_tokens: { type: 'integer', minimum: 40, maximum: 1200 } }, ['text', 'to']),
++ output_schema: TEXT_OUT({ text: { type: ['string', 'null'] }, from: STR(8), to: STR(8), unchanged: { type: 'boolean' }, reason: STR(40) }, ['text', 'unchanged']),
++ steps: [{ kind: 'llm', template: 'live.translate', operation: 'generate', output: 'text', prepare: 'live.translate', postprocess: 'live.translate', params: { temperature: 0.2, timeout_ms: 15000 } }],
++ cache_mode: 'service', cache_ttl_sec: 30 * 24 * 3600,
++ },
++ {
++ key: 'live.paste.describe_image', name: 'Describe an image paste / moment frame', namespace: 'live',
++ input_schema: TEXT_OUT({ title: STR(500), image: IMAGE, sources: SOURCES(0, 5) }, ['image']),
++ output_schema: TEXT_OUT({ description: STR(600), tags: { type: 'array', items: STR(200), maxItems: 8 } }, ['description', 'tags']),
++ steps: [{ kind: 'llm', template: 'live.paste.describe_image', operation: 'generate', output: 'text', image: 'image', image_max_width: 1280, prepare: 'live.paste.describe_image', postprocess: 'live.paste.describe_image', params: { max_tokens: 300 } }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'live.paste.summarize_text', name: 'Summarize a text paste', namespace: 'live',
++ input_schema: TEXT_OUT({ title: STR(500), content: STR(200000), sources: SOURCES(0, 5) }, ['content']),
++ output_schema: TEXT_OUT({ description: STR(300), tags: { type: 'array', maxItems: 0 } }, ['description', 'tags']),
++ steps: [{ kind: 'llm', template: 'live.paste.summarize_text', operation: 'summarize', output: 'text', prepare: 'live.paste.summarize_text', postprocess: 'live.paste.summarize_text', params: { max_tokens: 120 } }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'live.stream.describe_frame', name: 'Describe a live-stream frame (moment detection)', namespace: 'live',
++ input_schema: TEXT_OUT({ image: IMAGE, sources: SOURCES(0, 5) }, ['image']),
++ output_schema: TEXT_OUT({ description: STR(400), tags: { type: 'array', items: STR(200), maxItems: 6 }, worthy: { type: 'boolean' }, title: STR(80) }, ['description', 'tags', 'worthy', 'title']),
++ steps: [{ kind: 'llm', template: 'live.stream.describe_frame', operation: 'generate', output: 'text', image: 'image', image_max_width: 768, postprocess: 'live.stream.describe_frame', params: { max_tokens: 240 } }],
++ cache_mode: 'none',
++ },
++ {
++ key: 'live.stream.summarize', name: 'Stream overview + inferred category (stream memory)', namespace: 'live',
++ input_schema: TEXT_OUT({
++ observations: { type: 'array', items: STR(1000), minItems: 1, maxItems: 400 },
++ speech: { type: 'array', maxItems: 1000, items: { type: 'object', required: ['start_sec', 'text'], properties: { start_sec: { type: 'number' }, text: STR(2000) } } },
++ sounds: { type: 'array', maxItems: 500, items: { type: 'object', required: ['start_sec', 'label'], properties: { start_sec: { type: 'number' }, label: STR(120), confidence: { type: 'number' } } } },
++ sources: SOURCES(0, 50),
++ }, ['observations']),
++ output_schema: TEXT_OUT({ overview: STR(2000), category: { type: ['string', 'null'], enum: ['outdoors', 'travel', 'building', 'music', 'gaming', 'robot', 'desktop', 'irl', 'other', null] }, tags: { type: 'array', items: STR(200), maxItems: 6 } }, ['overview', 'category', 'tags']),
++ steps: [{ kind: 'llm', template: 'live.stream.summarize', operation: 'summarize', output: 'text', prepare: 'live.stream.summarize', postprocess: 'live.stream.summarize', params: { max_tokens: 560 } }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'live.streamer.overview', name: 'Internal streamer overview for staff', namespace: 'live',
++ input_schema: TEXT_OUT({
++ streamer: { type: 'object', required: ['username'], properties: { username: STR(80), display_name: STR(120), bio: STR(2000), category: STR(40), category_inferred: { type: 'boolean' } } },
++ memories: { type: 'array', items: STR(1000), maxItems: 100 },
++ vods: { type: 'array', maxItems: 50, items: { type: 'object', properties: { title: STR(300), category: STR(40) } } },
++ pastes: { type: 'array', maxItems: 50, items: { type: 'object', properties: { title: STR(300), summary: STR(600) } } },
++ sources: SOURCES(0, 50),
++ }, ['streamer']),
++ output_schema: TEXT_OUT({ overview: STR(4000) }, ['overview']),
++ steps: [{ kind: 'llm', template: 'live.streamer.overview', operation: 'summarize', output: 'text', prepare: 'live.streamer.overview', postprocess: 'live.streamer.overview', params: { max_tokens: 550 } }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'live.media.overview', name: 'VOD/clip overview from frames and transcript', namespace: 'live',
++ input_schema: TEXT_OUT({ frames: { type: 'array', items: STR(1000), maxItems: 60 }, transcript: STR(200000), sources: SOURCES(0, 50) }, []),
++ output_schema: TEXT_OUT({ overview: STR(2000) }, ['overview']),
++ steps: [{ kind: 'llm', template: 'live.media.overview', operation: 'summarize', output: 'text', prepare: 'live.media.overview', postprocess: 'live.media.overview', params: { max_tokens: 400 } }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'live.stream.recap', name: 'After-show report for a finished stream', namespace: 'live',
++ input_schema: TEXT_OUT({ facts: { type: 'object' }, sources: SOURCES(0, 50) }, ['facts']),
++ output_schema: TEXT_OUT({ headline: STR(90), summary: STR(500), moment: STR(200), tags: { type: 'array', items: STR(200), maxItems: 4 }, grade: { enum: ['S', 'A', 'B', 'C'] } }, ['headline', 'summary', 'moment', 'tags', 'grade']),
++ steps: [{ kind: 'llm', template: 'live.stream.recap', operation: 'generate', output: 'json', postprocess: 'live.stream.recap', params: { max_tokens: 500, temperature: 0.8, timeout_ms: 40000 } }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'live.media.transcribe', name: 'Speech-to-text for a VOD, clip or audio file', namespace: 'live',
++ description: 'whisper.cpp on this host (Live\'s transcribe.js), media fetched only from allow-listed OpenVibe hosts.',
++ input_schema: TEXT_OUT({ media: MEDIA_REF, media_url: STR(2048), language: LANG, seconds: { type: 'integer', minimum: 0, maximum: 36000 }, offset_sec: { type: 'number', minimum: 0 } }, []),
++ output_schema: TEXT_OUT({ text: { type: 'string' }, language: STR(8), segments: { type: 'array', items: { type: 'object', required: ['start', 'end', 'text'], properties: { start: { type: 'number' }, end: { type: 'number' }, text: { type: 'string' } } } } }, ['text', 'segments']),
++ steps: [{ kind: 'transcribe', route: 'live.stt' }],
++ cache_mode: 'private',
++ },
++ {
++ key: 'network.site_copy', name: 'Footer copy for the shared network chrome', namespace: 'network',
++ description: 'Reverses the dependency: OpenVibe.Network used to call Live\'s /internal/ai/site-copy for this.',
++ input_schema: TEXT_OUT({
++ sites: { type: 'array', minItems: 1, maxItems: 24, items: { type: 'object', required: ['id'], properties: { id: STR(40), name: STR(60), what: STR(400), popular: { type: 'array', items: STR(200), maxItems: 8 } } } },
++ links: { type: 'array', maxItems: 80, items: { type: 'object', required: ['id'], properties: { id: STR(60), name: STR(60), about: STR(120) } } },
++ }, ['sites']),
++ output_schema: TEXT_OUT({ sites: { type: 'array', items: { type: 'object', required: ['id', 'blurb', 'picks'], properties: { id: STR(40), blurb: STR(300), picks: { type: 'array', items: STR(60), maxItems: 4 } } } } }, ['sites']),
++ steps: [{ kind: 'llm', template: 'network.site_copy', operation: 'generate', output: 'json', prepare: 'network.site_copy', postprocess: 'network.site_copy', params: { max_tokens: 2200, temperature: 0.7, timeout_ms: 60000 } }],
++ cache_mode: 'service', cache_ttl_sec: 12 * 3600,
++ },
++];
++
++// ── Passthrough workflows (prompt still rendered by Live) ──
++const PASSTHROUGH_INPUT = {
++ type: 'object', additionalProperties: false,
++ properties: {
++ role: { enum: ROLES },
++ kind: STR(64),
++ source: STR(64),
++ system: { anyOf: [STR(400000), { type: 'array', maxItems: 12, items: { type: 'object', required: ['text'], additionalProperties: false, properties: { text: STR(400000), cache: { type: 'boolean' } } } }] },
++ messages: { type: 'array', maxItems: 200, items: { type: 'object', required: ['role', 'content'], properties: { role: { enum: ['user', 'assistant'] }, content: {} } } },
++ user: STR(800000),
++ image: IMAGE,
++ json: { type: 'object', required: ['schema'], properties: { name: STR(64), schema: { type: 'object' }, strict: { type: 'boolean' }, description: STR(500) } },
++ max_tokens: { type: 'integer', minimum: 1, maximum: 16000 },
++ temperature: { type: ['number', 'null'], minimum: 0, maximum: 2 },
++ timeout_ms: { type: 'integer', minimum: 1000, maximum: 120000 },
++ cache_key: STR(128),
++ },
++ anyOf: [{ required: ['user'] }, { required: ['messages'] }],
++};
++const PASSTHROUGH_OUTPUT = { type: 'object', required: ['text', 'json'], properties: { text: { type: 'string' }, json: { type: ['object', 'array', 'null'] } } };
++
++const PASSTHROUGH = [
++ ['live.complete', 'Any other Live completion', 'Fallback for a Live llm.complete() kind without its own workflow yet.'],
++ ['live.viewers.plan', 'AI viewers: director plan', 'viewers/director.js plan() — ai_viewers_director.'],
++ ['live.viewers.reply', 'AI viewers: fast streamer reply', 'viewers/director.js quickReply() — ai_viewers_reply.'],
++ ['live.viewers.line', 'AI viewers: single line', 'viewers/budget.js generate() — ai_viewers (chat reply).'],
++ ['live.viewers.fold', 'AI viewers: memory fold', 'viewers/fold.js — ai_viewers_fold.'],
++ ['live.chat.insight', 'Chat insights and session overviews', 'chat-ai.js and chat-ai-routes.js — chat_global, chat_user, chat_relay, chat_anon, combined_overview, session_titles.'],
++ ['live.moments.rank', 'Moments: rank VOD moments', 'ai-moments-job.js — moment_vod_rank.'],
++ ['live.moments.pick', 'Moments: pick a moment', 'ai-moments-job.js — moment_pick.'],
++ ['live.clips.confirm', 'Auto-clip confirmation', 'auto-clip-job.js — auto_clip_confirm.'],
++ ['live.hero.slogans', 'Home hero slogans', 'slogan-job.js — hero_slogans.'],
++ ['live.easter_egg', 'Easter eggs', 'easter-egg-job.js — easter_egg.'],
++ ['live.home.star', 'Star of OpenVibe write-up', 'home/star-job.js — home_star.'],
++ ['live.arena.persona', 'Arena: persona', 'arena-service.js — arena_persona.'],
++ ['live.arena.quotes', 'Arena: quotes', 'arena-service.js — arena_quotes.'],
++ ['live.arena.scene', 'Arena: scene description', 'arena-service.js — arena_scene.'],
++ ['live.arena.judge', 'Arena: beef and mic judging', 'arena/listener.js — arena_beef_judge, arena_mic_judge.'],
++ ['live.arena.headline', 'Arena: headline', 'arena/beef.js — arena_headline.'],
++ ['live.status_check', 'Provider probe', 'ai-analysis.testStatus / llm.testProvider — status_check.'],
++];
++
++const passthrough = PASSTHROUGH.map(([key, name, description]) => ({
++ key, name, namespace: 'live', description: `${description} Prompt rendered by Live (passthrough); the run records only its hash.`,
++ input_schema: PASSTHROUGH_INPUT, output_schema: PASSTHROUGH_OUTPUT,
++ steps: [{ kind: 'passthrough', route_prefix: 'live' }],
++ cache_mode: 'none',
++ metadata: { passthrough: true },
++}));
++
++/** Live llm.complete() `kind` -> workflow key (the Live client uses the same table). */
++const KIND_TO_WORKFLOW = {
++ ai_viewers_director: 'live.viewers.plan', ai_viewers_reply: 'live.viewers.reply', ai_viewers: 'live.viewers.line', ai_viewers_fold: 'live.viewers.fold',
++ chat_global: 'live.chat.insight', chat_user: 'live.chat.insight', chat_relay: 'live.chat.insight', chat_anon: 'live.chat.insight', combined_overview: 'live.chat.insight', session_titles: 'live.chat.insight',
++ moment_vod_rank: 'live.moments.rank', moment_pick: 'live.moments.pick', auto_clip_confirm: 'live.clips.confirm', hero_slogans: 'live.hero.slogans', easter_egg: 'live.easter_egg', home_star: 'live.home.star',
++ arena_persona: 'live.arena.persona', arena_quotes: 'live.arena.quotes', arena_scene: 'live.arena.scene', arena_beef_judge: 'live.arena.judge', arena_mic_judge: 'live.arena.judge', arena_headline: 'live.arena.headline',
++ status_check: 'live.status_check',
++};
++
++module.exports = { templates, workflows: [...structured, ...passthrough], KIND_TO_WORKFLOW, ROLES, PASSTHROUGH_INPUT, PASSTHROUGH_OUTPUT };
ddiff --git a/server/workflows/products.js b/server/workflows/products.js
nnew file mode 100644
iindex 0000000..dced1ae
---- /dev/null
++++ b/server/workflows/products.js
@@@ -0,0 +1,165 @@
++'use strict';
++/**
++ * Seed workflows the publication products consume (plan §15.13). Every one returns a DRAFT /
++ * EVIDENCE package — never publication truth — with citations that index into the numbered
++ * input.sources, plus `gaps` for what the sources do not support. The owning product (Wiki,
++ * Blog, News, Reviews, Deals, Coupons, Trade, Codes, Games) decides what to publish.
++ *
++ * Hard rules carried into the prompts and the schemas:
++ * - no invented facts, prices, ratings, dates, quotes (Reviews has no rating field at all;
++ * Deals' price is null unless cited; Coupons' unknown expiry stays unknown)
++ * - Trade is informational only (fixed disclaimer, observation timestamps, stale sources flagged)
++ * - moderation follows OpenVibe policy: profanity is allowed; threats, minors and doxxing are not
++ */
++const { SOURCES, CITES, GAPS } = require('./common-schemas');
++
++const STR = (n) => ({ type: 'string', maxLength: n });
++const OBJ = (props, required) => ({ type: 'object', additionalProperties: false, required: required || Object.keys(props), properties: props });
++const ARR = (items, max = 20) => ({ type: 'array', items, maxItems: max });
++const CLAIM = OBJ({ text: STR(600), citations: CITES });
++
++const RULES = 'You prepare a DRAFT evidence package for OpenVibe editors — never publication truth. Use ONLY the numbered sources provided. Every claim must cite the source numbers it rests on in its `citations` array (integers, e.g. [0, 2]). If the sources do not support something, leave it out and list it in `gaps`. Never invent facts, names, numbers, prices, ratings, dates or quotes. Output only JSON matching the schema.';
++
++function productTemplate(key, name, task, route, extraUser = '') {
++ return {
++ key, name, description: `${name}. Draft/evidence package with citations.`,
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: `${RULES}\n\nTask: ${task}`,
++ user_prompt: `${extraUser}{{#sources_block}}Sources ({{source_count}}):\n\n{{sources_block}}{{/sources_block}}{{^sources_block}}No sources were provided.{{/sources_block}}`,
++ default_route: route, owner: key.split('.')[0], metadata: { json_name: key.replace(/\./g, '_') },
++ };
++}
++
++const templates = [
++ productTemplate('wiki.generate_space', 'Wiki: propose a space', 'Propose a wiki space for the topic: a title, a one-paragraph summary and a page tree (page titles with short outlines), grounded in the sources.', 'wiki.generate',
++ 'Topic: {{topic}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
++ productTemplate('wiki.generate_page', 'Wiki: draft a page', 'Draft one wiki page: summary, sections and infobox values, each cited.', 'wiki.generate',
++ 'Page title: {{title}}{{#space}}\nSpace: {{space}}{{/space}}{{#outline}}\nRequested outline: {{json outline}}{{/outline}}\n\n'),
++ productTemplate('blog.draft_post', 'Blog: draft a post', 'Draft a blog post in Markdown for the brief. Claims of fact must cite sources; opinion and framing need none. If there are no sources, write only what needs no facts and list the facts you would need in gaps.', 'blog.draft',
++ 'Topic: {{topic}}{{#brief}}\nBrief: {{brief}}{{/brief}}{{#tone}}\nTone: {{tone}}{{/tone}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
++ productTemplate('news.summarize_story', 'News: summarize a story', 'Write a neutral, factual summary of the story the sources report: a headline, a summary, key points and a timeline, all cited. Attribute contested claims to their source.', 'news.summarize',
++ '{{#topic}}Topic: {{topic}}\n\n{{/topic}}'),
++ productTemplate('news.compare_perspectives', 'News: compare perspectives', 'Group the sources into distinct perspectives on the question, summarize each fairly in its own terms, and list where they agree and disagree. Do not pick a winner.', 'news.summarize',
++ 'Question or topic: {{topic}}\n\n'),
++ productTemplate('reviews.summarize_entity', 'Reviews: summarize an entity', 'Summarize what the review signals say about the entity: themes, pros and cons, each cited, and how strong the coverage is. Never produce or imply a star rating or aggregate score.', 'reviews.summarize',
++ 'Entity: {{entity.name}}{{#entity.type}} ({{entity.type}}){{/entity.type}}\n\n'),
++ productTemplate('deals.enrich_deal', 'Deals: enrich a deal', 'Normalise the offer: product identity, category, price, shipping, condition and availability — only what the sources state, with the observation time of the price. A missing price stays null.', 'deals.enrich',
++ 'Offer as submitted:\n{{json offer}}\n\n'),
++ productTemplate('coupons.extract_coupon', 'Coupons: extract coupons', 'Extract every coupon code the sources state, with its restrictions and expiry. If the expiry is not stated, expires_at is null and expiry_known is false. Never mark a code as working; validity is decided by reports, not by you.', 'coupons.extract',
++ '{{#merchant}}Merchant: {{json merchant}}\n\n{{/merchant}}'),
++ productTemplate('trade.summarize_market_context', 'Trade: market context', 'Summarize the sourced market and filing context for the instrument. Every observation carries the observation time from its source. Flag sources older than the staleness window as stale. Informational only: no advice, no predictions, no recommendations.', 'trade.summarize',
++ 'Instrument: {{json instrument}}{{#stale_after_hours}}\nStaleness window: {{stale_after_hours}} hours{{/stale_after_hours}}\n\n'),
++ productTemplate('codes.generate_docs', 'Codes: generate documentation', 'Write developer documentation for the project from the provided source files: an overview and sections in Markdown. Cite the files each section describes. Do not document behaviour the files do not show.', 'codes.generate_docs',
++ 'Project: {{project}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
++ productTemplate('games.generate_lore', 'Games: generate lore', 'Write lore for the world that stays consistent with the canon sources (cite them where you rely on them), and list any contradiction with canon you had to resolve. Invented fiction is expected here; invented canon citations are not.', 'games.generate_lore',
++ 'World: {{world}}\nRequest: {{prompt}}\n\n'),
++ productTemplate('moderation.classify', 'Moderation: classify content', 'Classify the content under OpenVibe policy. Profanity, insults, crude jokes and trash talk are ALLOWED and are not violations. Flag only: credible threats of violence, any sexual content involving minors, doxxing (sharing private personal information), targeted harassment campaigns, spam/scams, sexual content where not allowed, and self-harm encouragement. Quote the exact spans you flag. When unsure, choose review, not block.', 'moderation.classify',
++ '{{#context}}Context: {{context}}\n\n{{/context}}Content to classify:\n"""\n{{text}}\n"""\n\n'),
++];
++
++const workflows = [
++ {
++ key: 'wiki.generate_space', name: 'Propose a wiki space', namespace: 'wiki',
++ input_schema: OBJ({ topic: STR(300), audience: STR(300), sources: SOURCES(1) }, ['topic', 'sources']),
++ output_schema: OBJ({ title: STR(200), summary: STR(2000), pages: ARR(OBJ({ title: STR(200), slug_hint: STR(120), outline: ARR(STR(300), 20), citations: CITES }), 40), citations: CITES, gaps: GAPS }),
++ default_route: 'wiki.generate',
++ },
++ {
++ key: 'wiki.generate_page', name: 'Draft a wiki page', namespace: 'wiki',
++ input_schema: OBJ({ title: STR(300), space: STR(300), outline: ARR(STR(300), 30), sources: SOURCES(1) }, ['title', 'sources']),
++ output_schema: OBJ({ title: STR(300), summary: STR(2000), sections: ARR(OBJ({ heading: STR(200), body: STR(20000), citations: CITES }), 40), infobox: ARR(OBJ({ key: STR(100), value: STR(500), citations: CITES }), 30), citations: CITES, gaps: GAPS }),
++ default_route: 'wiki.generate',
++ },
++ {
++ key: 'blog.draft_post', name: 'Draft a blog post', namespace: 'blog',
++ input_schema: OBJ({ topic: STR(300), brief: STR(4000), tone: STR(200), audience: STR(300), sources: SOURCES(0) }, ['topic']),
++ output_schema: OBJ({ title: STR(200), dek: STR(400), body_markdown: STR(40000), tags: ARR(STR(40), 10), citations: CITES, gaps: GAPS }),
++ default_route: 'blog.draft',
++ },
++ {
++ key: 'news.summarize_story', name: 'Summarize a news story', namespace: 'news',
++ input_schema: OBJ({ topic: STR(300), sources: SOURCES(1) }, ['sources']),
++ output_schema: OBJ({ headline: STR(200), summary: STR(3000), key_points: ARR(CLAIM, 20), timeline: ARR(OBJ({ when: STR(60), what: STR(500), citations: CITES }), 30), citations: CITES, gaps: GAPS }),
++ default_route: 'news.summarize',
++ },
++ {
++ key: 'news.compare_perspectives', name: 'Compare perspectives on a story', namespace: 'news',
++ input_schema: OBJ({ topic: STR(300), sources: SOURCES(2) }, ['topic', 'sources']),
++ output_schema: OBJ({ question: STR(300), perspectives: ARR(OBJ({ label: STR(120), summary: STR(2000), citations: CITES }), 10), agreements: ARR(CLAIM, 10), disagreements: ARR(CLAIM, 10), citations: CITES, gaps: GAPS }),
++ default_route: 'news.summarize',
++ },
++ {
++ key: 'reviews.summarize_entity', name: 'Summarize review signals for an entity', namespace: 'reviews',
++ input_schema: OBJ({ entity: OBJ({ name: STR(200), type: STR(80) }, ['name']), sources: SOURCES(1) }, ['entity', 'sources']),
++ output_schema: OBJ({ summary: STR(2000), themes: ARR(STR(80), 12), pros: ARR(CLAIM, 12), cons: ARR(CLAIM, 12), coverage: { enum: ['weak', 'moderate', 'strong'] }, citations: CITES, gaps: GAPS }),
++ default_route: 'reviews.summarize',
++ },
++ {
++ key: 'deals.enrich_deal', name: 'Enrich a deal', namespace: 'deals',
++ input_schema: OBJ({ offer: { type: 'object', required: ['title'], properties: { title: STR(300), url: STR(2048), price: {}, currency: STR(8), merchant: STR(200) } }, sources: SOURCES(1) }, ['offer', 'sources']),
++ output_schema: OBJ({
++ title: STR(300),
++ product: OBJ({ name: STR(300), brand: { type: ['string', 'null'], maxLength: 120 }, model: { type: ['string', 'null'], maxLength: 120 } }),
++ category: STR(80),
++ price: OBJ({ amount: { type: ['number', 'null'] }, currency: { type: ['string', 'null'], maxLength: 8 }, observed_at: { type: ['string', 'null'], maxLength: 40 }, citations: CITES }),
++ shipping: { type: ['string', 'null'], maxLength: 300 },
++ condition: { enum: ['new', 'used', 'refurbished', 'unknown'] },
++ availability: { enum: ['in_stock', 'limited', 'out_of_stock', 'unknown'] },
++ summary: STR(1000), citations: CITES, gaps: GAPS,
++ }),
++ default_route: 'deals.enrich',
++ },
++ {
++ key: 'coupons.extract_coupon', name: 'Extract coupons', namespace: 'coupons',
++ input_schema: OBJ({ merchant: { type: 'object', properties: { name: STR(200), domain: STR(253) } }, sources: SOURCES(1) }, ['sources']),
++ output_schema: OBJ({
++ coupons: ARR(OBJ({ code: STR(64), description: STR(500), restrictions: ARR(STR(300), 10), expires_at: { type: ['string', 'null'], maxLength: 40 }, expiry_known: { type: 'boolean' }, min_spend: { type: ['string', 'null'], maxLength: 60 }, citations: CITES }), 30),
++ citations: CITES, gaps: GAPS,
++ }),
++ default_route: 'coupons.extract',
++ },
++ {
++ key: 'trade.summarize_market_context', name: 'Summarize market context (informational)', namespace: 'trade',
++ input_schema: OBJ({ instrument: { type: 'object', required: ['symbol'], properties: { symbol: STR(32), name: STR(200), exchange: STR(40) } }, stale_after_hours: { type: 'integer', minimum: 1, maximum: 8760 }, sources: SOURCES(1) }, ['instrument', 'sources']),
++ output_schema: OBJ({
++ summary: STR(3000),
++ observations: ARR(OBJ({ text: STR(600), observed_at: { type: ['string', 'null'], maxLength: 40 }, citations: CITES }), 30),
++ stale_sources: { type: 'array', items: { type: 'integer', minimum: 0 }, maxItems: 50 },
++ disclaimer: { const: 'Informational only; not investment advice.' },
++ citations: CITES, gaps: GAPS,
++ }),
++ default_route: 'trade.summarize',
++ },
++ {
++ key: 'codes.generate_docs', name: 'Generate documentation from code', namespace: 'codes',
++ input_schema: OBJ({ project: STR(200), audience: STR(200), sources: SOURCES(1) }, ['project', 'sources']),
++ output_schema: OBJ({ title: STR(200), overview: STR(4000), sections: ARR(OBJ({ heading: STR(200), body_markdown: STR(20000), citations: CITES }), 40), citations: CITES, gaps: GAPS }),
++ default_route: 'codes.generate_docs',
++ },
++ {
++ key: 'games.generate_lore', name: 'Generate game lore', namespace: 'games',
++ input_schema: OBJ({ world: STR(200), prompt: STR(4000), sources: SOURCES(0) }, ['world', 'prompt']),
++ output_schema: OBJ({ title: STR(200), lore_markdown: STR(20000), entities: ARR(OBJ({ name: STR(120), kind: STR(60), description: STR(1000) }), 30), contradictions: ARR(STR(500), 10), citations: CITES, gaps: GAPS }),
++ default_route: 'games.generate_lore',
++ },
++ {
++ key: 'moderation.classify', name: 'Classify content for moderation', namespace: 'moderation',
++ input_schema: OBJ({ text: STR(8000), context: STR(1000), sources: SOURCES(0) }, ['text']),
++ output_schema: OBJ({
++ labels: { type: 'array', maxItems: 8, items: { enum: ['threat', 'minors', 'doxxing', 'harassment', 'spam', 'sexual', 'self_harm', 'none'] } },
++ action: { enum: ['allow', 'review', 'block'] },
++ severity: { enum: ['none', 'low', 'medium', 'high'] },
++ rationale: STR(1000),
++ spans: ARR(OBJ({ quote: STR(500), label: STR(40) }), 20),
++ citations: CITES,
++ }),
++ default_route: 'moderation.classify', cache_mode: 'private',
++ },
++].map(w => ({
++ cache_mode: 'private',
++ ...w,
++ steps: [{ kind: 'llm', template: w.key, operation: w.key === 'moderation.classify' ? 'classify' : 'generate', output: 'json', prepare: 'sources', cite: true, params: { max_tokens: 2400, temperature: 0.3 } }],
++ metadata: { draft: true, seed: true },
++}));
++
++module.exports = { templates, workflows };
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
nnew file mode 100644
iindex 0000000..3825771
---- /dev/null
++++ b/server/workflows/seed.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * Boot-time seeding from configuration and code. Idempotent: every boot re-applies it.
++ *
++ * providers stub (always), shared (Live's shared key: AI_PROVIDER / AI_BASE_URL / AI_API_KEY /
++ * AI_MODEL), fallback (AI_FALLBACK_*), http-seam (AI_HTTP_SEAM_URL), whisper (WHISPER_*)
++ * — records whose origin is 'seed' follow the environment; admin-edited ones are left alone
++ * models the configured default and per-role models
++ * routes live.<role> for Live's roles (AI_MODEL_<ROLE> overrides), live.stt, default.chat|json|
++ * embedding, and the historical product route keys as explicit aliases of default.json
++ * templates, workflows from server/workflows/{live,core,products}.js — a code change becomes a
++ * new version unless an admin has versioned that key since (admin edits win)
++ * quotas AI_MAX_COST_USD_PER_DAY (global/day, Live's ai_max_cost_usd_per_day) and a per-service
++ * request rate for every service
++ */
++const live = require('./live');
++const core = require('./core');
++const products = require('./products');
++const { resolveSecret } = require('../util');
++
++const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
++const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify'];
++
++function sharedProviderRecord(config, env) {
++ const s = config.shared;
++ const base = s.baseUrl;
++ // Live's rule: Anthropic only when explicitly chosen and not pointed at an OpenAI-compatible
++ // gateway; everything else (openai, openrouter, groq, ollama, blank) is OpenAI-shaped.
++ const kind = s.provider === 'anthropic' && (!base || /anthropic\.com/i.test(base)) ? 'anthropic' : 'openai';
++ const hasKey = Boolean(resolveSecret(s.apiKeyRef, env));
++ const selfHosted = Boolean(base) && !/api\.openai\.com|anthropic\.com/i.test(base);
++ const auth = kind === 'anthropic' ? 'x-api-key' : (hasKey || !selfHosted ? 'bearer' : 'none');
++ return {
++ key: 'shared', display_name: 'Shared provider (Live\'s shared key)', kind,
++ status: s.enabled ? 'active' : 'disabled',
++ base_url: base || (kind === 'anthropic' ? 'https://api.anthropic.com/v1' : 'https://api.openai.com/v1'),
++ auth_mode: auth, secret_ref: s.apiKeyRef,
++ default_model: s.model || (kind === 'anthropic' ? 'claude-sonnet-5' : 'gpt-4o-mini'),
++ capabilities: kind === 'anthropic'
++ ? ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision']
++ : ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json', 'vision', 'embed', 'transcribe'],
++ timeout_ms: s.timeoutMs, priority: 10, metadata: { configured_by: 'AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODEL', provider_name: s.provider || 'openai-compatible' },
++ };
++}
++
++function providerRecords(config, env) {
++ const out = [
++ { key: 'stub', display_name: 'Deterministic stub (synthetic output)', kind: 'stub', status: 'active', auth_mode: 'none', default_model: 'stub-1', capabilities: ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'embed', 'json', 'vision', 'transcribe'], timeout_ms: 10000, priority: 1000, metadata: { synthetic: true } },
++ sharedProviderRecord(config, env),
++ { key: 'whisper', display_name: 'whisper.cpp (local speech-to-text)', kind: 'whisper', status: 'active', auth_mode: 'none', default_model: 'whisper.cpp', capabilities: ['transcribe'], timeout_ms: 600000, priority: 20, metadata: { configured_by: 'WHISPER_*' } },
++ ];
++ if (config.fallback.baseUrl) {
++ const kind = config.fallback.kind === 'anthropic' ? 'anthropic' : 'openai';
++ out.push({ key: 'fallback', display_name: 'Fallback provider', kind, status: 'active', base_url: config.fallback.baseUrl, auth_mode: resolveSecret(config.fallback.apiKeyRef, env) ? (kind === 'anthropic' ? 'x-api-key' : 'bearer') : 'none', secret_ref: config.fallback.apiKeyRef, default_model: config.fallback.model || null, capabilities: [], timeout_ms: 30000, priority: 50, metadata: { configured_by: 'AI_FALLBACK_*' } });
++ }
++ if (config.httpSeamUrl) {
++ out.push({ key: 'http-seam', display_name: 'Local HTTP seam', kind: 'http', status: 'active', base_url: config.httpSeamUrl, auth_mode: 'none', default_model: null, capabilities: [], timeout_ms: 30000, priority: 60, metadata: { configured_by: 'AI_HTTP_SEAM_URL' } });
++ }
++ return out;
++}
++
++function seed({ registry, quotas, config, env = process.env, db }) {
++ // Providers
++ for (const p of providerRecords(config, env)) {
++ const prev = registry.getProvider(p.key);
++ if (!prev || prev.origin === 'seed') {
++ const same = prev && Object.keys(p).every(k => JSON.stringify(p[k] ?? null) === JSON.stringify(prev[k] ?? null));
++ if (!same) registry.upsertProvider({ base_url: null, secret_ref: null, ...p }, { actor: 'seed', origin: 'seed' });
++ }
++ }
++ const shared = registry.getProvider('shared');
++ const hasFallback = Boolean(registry.getProvider('fallback'));
++ const fallbacks = hasFallback ? [{ provider: 'fallback', model: null }] : [];
++
++ // Models
++ const models = new Set([shared.default_model, ...Object.values(config.shared.roleModels)]);
++ for (const m of models) if (m && !registry.getModel('shared', m)) registry.upsertModel({ provider_key: 'shared', model_key: m, type: 'chat', supports: { json: true, vision: true } }, { actor: 'seed' });
++ if (!registry.getModel('stub', 'stub-1')) registry.upsertModel({ provider_key: 'stub', model_key: 'stub-1', type: 'chat', supports: { json: true, vision: true }, metadata: { synthetic: true } }, { actor: 'seed' });
++ if (!registry.getModel('whisper', 'whisper.cpp')) registry.upsertModel({ provider_key: 'whisper', model_key: 'whisper.cpp', type: 'stt', supports: { json: false } }, { actor: 'seed' });
++
++ // Routes
++ for (const role of live.ROLES) {
++ registry.seedVersioned('route', `live.${role}`, {
++ primary: { provider: 'shared', model: config.shared.roleModels[role] || null }, fallbacks,
++ options: {}, max_output_tokens: null, response_format: 'text', timeout_ms: ROLE_TIMEOUT[role], alias_of: null,
++ });
++ }
++ registry.seedVersioned('route', 'live.stt', { primary: { provider: 'whisper', model: null }, fallbacks: [], options: {}, max_output_tokens: null, response_format: 'text', timeout_ms: 600000, alias_of: null });
++ registry.seedVersioned('route', 'default.chat', { primary: { provider: 'shared', model: null }, fallbacks, options: {}, max_output_tokens: 800, response_format: 'text', timeout_ms: 30000, alias_of: null });
++ registry.seedVersioned('route', 'default.json', { primary: { provider: 'shared', model: null }, fallbacks, options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json', timeout_ms: 60000, alias_of: null });
++ registry.seedVersioned('route', 'default.embedding', { primary: { provider: 'shared', model: env.AI_EMBEDDING_MODEL || 'text-embedding-3-small' }, fallbacks: [], options: {}, max_output_tokens: null, response_format: 'text', timeout_ms: 30000, alias_of: null });
++ for (const key of HISTORICAL_ROUTES) registry.seedVersioned('route', key, { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: null, response_format: 'json', timeout_ms: null, alias_of: 'default.json' });
++
++ // Templates, then workflows (workflows reference templates)
++ for (const t of [...live.templates, ...core.templates, ...products.templates]) {
++ const { key, ...def } = t;
++ registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
++ }
++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows]) {
++ const { key, ...def } = w;
++ registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
++ }
++
++ // Quotas
++ const seedQuota = (q) => {
++ const prev = db.prepare("SELECT origin FROM quotas WHERE scope_type = ? AND scope_id = ? AND window = ? AND COALESCE(workflow_prefix, '') = ''").get(q.scope_type, q.scope_type === 'global' ? '*' : q.scope_id, q.window);
++ if (!prev || prev.origin === 'seed') quotas.upsert(q, { actor: 'seed', origin: 'seed' });
++ };
++ if (config.quotas.globalCostPerDay > 0) seedQuota({ scope_type: 'global', window: 'day', max_cost_usd: config.quotas.globalCostPerDay });
++ if (config.quotas.serviceRequestsPerMinute > 0) seedQuota({ scope_type: 'service', scope_id: '*', window: 'minute', max_requests: config.quotas.serviceRequestsPerMinute });
++ if (config.quotas.serviceRequestsPerDay > 0) seedQuota({ scope_type: 'service', scope_id: '*', window: 'day', max_requests: config.quotas.serviceRequestsPerDay });
++}
++
++module.exports = { seed, providerRecords, HISTORICAL_ROUTES };
ddiff --git a/test/auth.test.js b/test/auth.test.js
nnew file mode 100644
iindex 0000000..fb341f8
---- /dev/null
++++ b/test/auth.test.js
@@@ -0,0 +1,108 @@
++'use strict';
++// Service-token auth and capability denial: no token, a bad signature, the wrong audience and an
++// expired token are 401; a token without the capability is 403 capability.denied; family grants
++// (ai.*) work; namespace-limited tokens cannot run other products' workflows; admin routes need the
++// manage capabilities; provider responses never contain a secret value; runs are private to the
++// requester.
++
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, request, token, suite, ALL } = require('./helpers');
++
++const t = suite('auth');
++let h;
++const SECRET = 'sk-test-THIS-MUST-NEVER-BE-RETURNED-0123456789';
++const body = { workflow: 'ai.generate', input: { prompt: 'hi' } };
++
++t.test('boot with a secret in the environment', async () => {
++ h = await boot({ env: { AI_API_KEY: SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'true' } });
++});
++
++t.test('missing, forged, wrong-audience and expired tokens are 401', async () => {
++ let r = await request(h.base, 'POST', '/api/v1/runs', { body });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.body.code, 'token.missing');
++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
++ const forgedKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey;
++ r = await request(h.base, 'POST', '/api/v1/runs', { tok: token('live', ALL, { key: forgedKey }), body });
++ assert.strictEqual(r.body.code, 'token.bad_signature');
++ r = await request(h.base, 'POST', '/api/v1/runs', { tok: token('live', ALL, { aud: 'openvibe.events' }), body });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.body.code, 'token.wrong_audience');
++ r = await request(h.base, 'POST', '/api/v1/runs', { tok: token('live', ALL, { exp: Math.floor(Date.now() / 1000) - 3600 }), body });
++ assert.strictEqual(r.body.code, 'token.expired');
++});
++
++t.test('a token without the capability is 403 capability.denied', async () => {
++ const readOnly = token('live', ['ai.run.read']);
++ let r = await request(h.base, 'POST', '/api/v1/runs', { tok: readOnly, body });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'capability.denied');
++ r = await request(h.base, 'POST', '/api/v1/summarize', { tok: token('live', ['events.event.publish']), body: { text: 'x' } });
++ assert.strictEqual(r.status, 403);
++ const runner = token('live', ['ai.run.create']);
++ for (const [m, p] of [['GET', '/api/v1/providers'], ['POST', '/api/v1/providers'], ['POST', '/api/v1/templates/ai.generate/versions'], ['GET', '/api/v1/usage'], ['GET', '/api/v1/audit'], ['POST', '/api/v1/quotas'], ['DELETE', '/api/v1/cache']]) {
++ const x = await request(h.base, m, p, { tok: runner, body: m === 'GET' || m === 'DELETE' ? undefined : {} });
++ assert.strictEqual(x.status, 403, `${m} ${p}`);
++ }
++ const wf = await request(h.base, 'GET', '/api/v1/workflows', { tok: runner });
++ assert.strictEqual(wf.status, 200, 'callers can discover workflow schemas');
++});
++
++t.test('family grants (ai.*) and exact grants both work', async () => {
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: token('live', ['ai.*']), body })).status, 201);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: token('live', ['ai.run.create']), body })).status, 201);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs', { tok: token('live', ['ai.run.*x']), body })).status, 403);
++});
++
++t.test('a namespace-limited token may only run workflows in its namespaces', async () => {
++ const liveOnly = token('live', ['ai.run.create'], { ns: ['live.*'] });
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: liveOnly, body: { workflow: 'live.translate', input: { text: 'bonjour tout le monde', from: 'fr', to: 'en' } } })).status, 201);
++ const r = await request(h.base, 'POST', '/api/v1/runs', { tok: liveOnly, body: { workflow: 'wiki.generate_page', input: { title: 'x', sources: [{ source_type: 'web.page' }] } } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'capability.namespace_denied');
++});
++
++t.test('provider responses never contain a secret value', async () => {
++ const admin = token('ops', ALL);
++ const list = await request(h.base, 'GET', '/api/v1/providers', { tok: admin });
++ assert.ok(!list.text.includes(SECRET), 'secret leaked in provider list');
++ const shared = list.body.providers.find(p => p.key === 'shared');
++ assert.strictEqual(shared.secret_ref, 'env:AI_API_KEY');
++ assert.strictEqual(shared.credentials, 'configured');
++ const one = await request(h.base, 'GET', '/api/v1/providers/shared', { tok: admin });
++ assert.ok(!one.text.includes(SECRET));
++ const bad = await request(h.base, 'POST', '/api/v1/providers', { tok: admin, body: { key: 'x', kind: 'openai', api_key: 'sk-raw' } });
++ assert.strictEqual(bad.status, 422, 'raw secrets are refused');
++ const bad2 = await request(h.base, 'POST', '/api/v1/providers', { tok: admin, body: { key: 'x', kind: 'openai', secret_ref: 'sk-raw-value' } });
++ assert.strictEqual(bad2.status, 422, 'secret_ref must be a reference');
++ const status = await request(h.base, 'GET', '/api/v1/status', { tok: admin });
++ assert.ok(!status.text.includes(SECRET));
++ const reqs = await request(h.base, 'GET', '/api/v1/requests?debug=1', { tok: admin });
++ assert.ok(!reqs.text.includes(SECRET));
++ const audit = await request(h.base, 'GET', '/api/v1/audit?limit=500', { tok: admin });
++ assert.ok(!audit.text.includes(SECRET));
++});
++
++t.test("one service cannot read, cancel or retry another service's run", async () => {
++ const mine = await request(h.base, 'POST', '/api/v1/runs?wait=3000', { tok: token('live', ALL), body });
++ const id = mine.body.run.id;
++ const other = token('games', ['ai.run.create', 'ai.run.read']);
++ assert.strictEqual((await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok: other })).status, 404);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${id}/retry`, { tok: other })).status, 404);
++ const list = await request(h.base, 'GET', '/api/v1/runs', { tok: other });
++ assert.ok(!list.body.runs.some(r => r.id === id));
++ assert.strictEqual((await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok: token('ops', ['ai.usage.read']) })).status, 200, 'usage readers can inspect');
++});
++
++t.test('health is public and says nothing secret', async () => {
++ const r = await request(h.base, 'GET', '/api/health');
++ assert.strictEqual(r.status, 200);
++ const ready = await request(h.base, 'GET', '/api/ready');
++ assert.strictEqual(ready.status, 200);
++ assert.ok(!ready.text.includes(SECRET));
++});
++
++t.test('shutdown', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/cache.test.js b/test/cache.test.js
nnew file mode 100644
iindex 0000000..cc63f05
---- /dev/null
++++ b/test/cache.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++// The cache never crosses actor or resource scope: a private entry written for actor A (or target
++// X) is never served to actor B (or target Y), nor to another service; a service-scoped workflow
++// shares within one service only; a new workflow/template version misses; synthetic stub output is
++// never cached; options.cache=false bypasses it.
++
++const assert = require('assert');
++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
++
++const t = suite('cache');
++const live = token('live', ALL);
++const other = token('community', ['ai.run.create', 'ai.run.read']);
++const A = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA' };
++const B = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB' };
++let h;
++let seam;
++
++const summarize = (tok, extra = {}) => request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'ai.summarize', input: { text: 'The same private text for everyone.' }, ...extra } });
++
++t.test('boot with a real (non-synthetic) seam provider', async () => {
++ seam = await seamServer((b) => ({ text: `summary #${seam.calls}`, usage: { input: 5, output: 2 } }));
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
++ await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['summarize', 'generate', 'chat', 'json'] } });
++ await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
++ await request(h.base, 'POST', '/api/v1/routes/live.chat/versions', { tok: live, body: { primary: { provider: 'seam' }, fallbacks: [] } });
++});
++
++t.test("actor A's repeat is served from A's cache without a provider call", async () => {
++ const first = await summarize(live, { on_behalf_of: A });
++ assert.strictEqual(first.body.run.status, 'succeeded');
++ const calls = seam.calls;
++ const again = await summarize(live, { on_behalf_of: A });
++ assert.strictEqual(again.body.run.status, 'cached');
++ assert.strictEqual(again.body.run.provenance.cached_from, first.body.run.id);
++ assert.deepStrictEqual(again.body.run.output, first.body.run.output);
++ assert.strictEqual(seam.calls, calls);
++});
++
++t.test("actor B never receives actor A's private cache entry", async () => {
++ const calls = seam.calls;
++ const b = await summarize(live, { on_behalf_of: B });
++ assert.strictEqual(b.body.run.status, 'succeeded', 'B is computed fresh, not served A\'s entry');
++ assert.strictEqual(seam.calls, calls + 1);
++ assert.notDeepStrictEqual(b.body.run.output, (await summarize(live, { on_behalf_of: A })).body.run.output);
++ const none = await summarize(live);
++ assert.strictEqual(none.body.run.status, 'succeeded', 'no actor is its own scope too');
++});
++
++t.test('resource scope: the same input for a different target misses', async () => {
++ const x = { service: 'live', type: 'vod', id: '1' };
++ const y = { service: 'live', type: 'vod', id: '2' };
++ await summarize(live, { target: x });
++ assert.strictEqual((await summarize(live, { target: x })).body.run.status, 'cached');
++ assert.strictEqual((await summarize(live, { target: y })).body.run.status, 'succeeded');
++});
++
++t.test('another service never reads this service\'s entries', async () => {
++ const r = await summarize(other);
++ assert.strictEqual(r.body.run.status, 'succeeded');
++});
++
++t.test('service-scoped workflows (translation) share within the service only', async () => {
++ const tr = (tok, obo) => request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'live.translate', input: { text: 'こんにちは、みなさん', from: 'ja', to: 'en' }, on_behalf_of: obo } });
++ assert.strictEqual((await tr(live, A)).body.run.status, 'succeeded');
++ assert.strictEqual((await tr(live, B)).body.run.status, 'cached', 'public text translation is shared inside Live');
++ assert.strictEqual((await tr(other, A)).body.run.status, 'succeeded', 'but not with another service');
++});
++
++t.test('a new template version misses the old entries', async () => {
++ await summarize(live, { on_behalf_of: A });
++ const v = await request(h.base, 'POST', '/api/v1/templates/ai.summarize/versions', { tok: live, body: { system_prompt: 'Summarize faithfully, in plain words.' } });
++ assert.strictEqual(v.status, 201);
++ assert.strictEqual((await summarize(live, { on_behalf_of: A })).body.run.status, 'succeeded');
++});
++
++t.test('options.cache=false bypasses the cache', async () => {
++ await summarize(live, { on_behalf_of: A });
++ const r = await summarize(live, { on_behalf_of: A, options: { cache: false } });
++ assert.strictEqual(r.body.run.status, 'succeeded');
++});
++
++t.test('synthetic stub output is never cached', async () => {
++ await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'stub' }, fallbacks: [] } });
++ const a = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'x' } } });
++ const b = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'x' } } });
++ assert.strictEqual(a.body.run.synthetic, true);
++ assert.strictEqual(b.body.run.status, 'succeeded');
++});
++
++t.test('cache stats and purge are admin operations', async () => {
++ const s = await request(h.base, 'GET', '/api/v1/cache', { tok: live });
++ assert.ok(s.body.cache.some(c => c.workflow_key === 'ai.summarize' && c.privacy === 'private'));
++ assert.strictEqual((await request(h.base, 'DELETE', '/api/v1/cache', { tok: other })).status, 403);
++ const p = await request(h.base, 'DELETE', '/api/v1/cache?workflow=ai.summarize', { tok: live });
++ assert.ok(p.body.removed > 0);
++});
++
++t.test('shutdown', async () => { await h.stop(); await seam.close(); });
++
++t.run();
ddiff --git a/test/fallback.test.js b/test/fallback.test.js
nnew file mode 100644
iindex 0000000..5075aee
---- /dev/null
++++ b/test/fallback.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++// Routing: a degraded primary falls back AND records that it did (run, request log, audit); the
++// circuit breaker opens after repeated failures and the open provider is skipped; disabled or
++// credential-less providers are skipped; timeouts are strict; when nothing can answer the run
++// fails with an explicit provider.unavailable instead of inventing output.
++
++const assert = require('assert');
++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
++
++const t = suite('fallback');
++const tok = token('live', ALL);
++let h;
++let ok;
++let slow;
++
++async function route(primary, fallbacks) {
++ const r = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok, body: { primary: { provider: primary }, fallbacks: fallbacks.map(p => ({ provider: p })), timeout_ms: 5000 } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.body.route;
++}
++async function provider(body) {
++ const r = await request(h.base, 'POST', '/api/v1/providers', { tok, body: { kind: 'http', auth_mode: 'none', capabilities: ['chat', 'generate', 'json'], timeout_ms: 5000, ...body } });
++ assert.strictEqual(r.status, 201, r.text);
++}
++const generate = (prompt) => request(h.base, 'POST', '/api/v1/generate', { tok, body: { prompt, options: { cache: false } } });
++
++t.test('boot with stub fallback off and two seam providers', async () => {
++ ok = await seamServer(() => ({ text: 'a real answer', model: 'seam-model', usage: { input: 12, output: 4 } }));
++ slow = await seamServer(() => new Promise(r => setTimeout(() => r({ text: 'too late' }), 2500)));
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_BREAKER_FAILURES: '2', AI_BREAKER_COOLDOWN_MS: '600000' } });
++ await provider({ key: 'dead', base_url: 'http://127.0.0.1:1/x' });
++ await provider({ key: 'ok', base_url: ok.url });
++ await provider({ key: 'slow', base_url: slow.url, timeout_ms: 1000 });
++ const v = await route('dead', ['ok']);
++ assert.ok(v.version >= 2, 'a route edit is a new version');
++});
++
++t.test('a failing primary falls back, and the run, request log and audit all say so', async () => {
++ const r = await generate('hello');
++ assert.strictEqual(r.status, 201, r.text);
++ const run = r.body.run;
++ assert.strictEqual(run.status, 'succeeded');
++ assert.strictEqual(run.output.text, 'a real answer');
++ assert.strictEqual(run.synthetic, false);
++ assert.strictEqual(run.provenance.provider, 'ok');
++ assert.strictEqual(run.provenance.fallback_used, true);
++ const d = await request(h.base, 'GET', `/api/v1/runs/${run.id}`, { tok });
++ const reqs = d.body.requests;
++ assert.ok(reqs.filter(x => x.provider_key === 'dead' && x.status === 'error').length === 2, 'primary tried and retried once');
++ const good = reqs.find(x => x.provider_key === 'ok');
++ assert.strictEqual(good.status, 'ok');
++ assert.strictEqual(good.fallback, 1);
++ assert.strictEqual(good.tokens_in, 12);
++ assert.ok(good.prompt_hash && good.input_hash && good.output_hash, 'hashes, not raw prompts');
++ assert.ok(!('debug_prompt' in good), 'raw prompt fields are not exposed');
++ const audit = await request(h.base, 'GET', `/api/v1/audit?action=run.fallback&target_id=${run.id}`, { tok });
++ assert.strictEqual(audit.body.audit.length, 1);
++ const fb = await request(h.base, 'GET', '/api/v1/requests?fallback=1', { tok });
++ assert.ok(fb.body.requests.some(x => x.run_id === run.id));
++});
++
++t.test('the circuit opens after repeated failures and the open provider is skipped', async () => {
++ await generate('second'); // second consecutive failure of `dead` opens it
++ assert.strictEqual(h.pool.health('dead').state, 'open');
++ const r = await generate('third');
++ const d = await request(h.base, 'GET', `/api/v1/runs/${r.body.run.id}`, { tok });
++ const skip = d.body.requests.find(x => x.provider_key === 'dead');
++ assert.strictEqual(skip.status, 'skipped');
++ assert.strictEqual(skip.skip_reason, 'circuit_open');
++ assert.strictEqual(r.body.run.provenance.fallback_used, true);
++ const audit = await request(h.base, 'GET', '/api/v1/audit?action=provider.circuit_opened', { tok });
++ assert.ok(audit.body.audit.some(a => a.target_id === 'dead'));
++ const reset = await request(h.base, 'POST', '/api/v1/providers/dead/reset', { tok });
++ assert.strictEqual(reset.body.health.state, 'closed');
++});
++
++t.test('a strict timeout counts as a failure and moves on', async () => {
++ await route('slow', ['ok']);
++ const started = Date.now();
++ const r = await generate('slowly');
++ assert.strictEqual(r.body.run.provenance.provider, 'ok');
++ const d = await request(h.base, 'GET', `/api/v1/runs/${r.body.run.id}`, { tok });
++ assert.ok(d.body.requests.some(x => x.provider_key === 'slow' && x.status === 'timeout'));
++ assert.ok(Date.now() - started < 5000, 'did not wait for the slow provider');
++});
++
++t.test('disabled and credential-less providers are skipped', async () => {
++ await request(h.base, 'POST', '/api/v1/providers/slow/disable', { tok });
++ await request(h.base, 'POST', '/api/v1/providers', { tok, body: { key: 'nokey', kind: 'openai', base_url: 'https://api.openai.com/v1', auth_mode: 'bearer', secret_ref: 'env:NOT_SET_ANYWHERE', capabilities: ['chat', 'generate'] } });
++ await route('slow', ['nokey', 'ok']);
++ const r = await generate('skip them');
++ const d = await request(h.base, 'GET', `/api/v1/runs/${r.body.run.id}`, { tok });
++ assert.deepStrictEqual(d.body.requests.filter(x => x.status === 'skipped').map(x => [x.provider_key, x.skip_reason]), [['slow', 'disabled'], ['nokey', 'no_credentials']]);
++ assert.strictEqual(r.body.run.provenance.provider, 'ok');
++});
++
++t.test('with nothing able to answer the run fails explicitly (no stub in production mode)', async () => {
++ await request(h.base, 'POST', '/api/v1/providers/ok/disable', { tok });
++ const r = await generate('nobody home');
++ assert.strictEqual(r.body.run.status, 'failed');
++ assert.strictEqual(r.body.run.error.code, 'provider.unavailable');
++ assert.strictEqual(r.body.run.output, null);
++});
++
++t.test('with stub fallback on (dev/test default) the stub answers and is marked synthetic', async () => {
++ await h.stop();
++ h = await boot({ env: { AI_BREAKER_FAILURES: '5' } });
++ const r = await request(h.base, 'POST', '/api/v1/generate', { tok, body: { prompt: 'anything' } });
++ assert.strictEqual(r.body.run.status, 'succeeded');
++ assert.strictEqual(r.body.run.provenance.provider, 'stub');
++ assert.strictEqual(r.body.run.synthetic, true);
++ assert.strictEqual(r.body.run.provenance.fallback_used, true, 'the shared provider (no key) was skipped');
++ assert.ok(r.body.run.output.text.startsWith('(synthetic)'));
++});
++
++t.test('shutdown', async () => { await h.stop(); await ok.close(); await slow.close(); });
++
++t.run();
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..47526f5
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * Shared test fixtures: a generated Network signing key and token minting, a booted AI service on
++ * a random port with a temp database, a tiny assertion runner, and a local HTTP-seam provider
++ * server whose behaviour each test scripts.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const nodeHttp = require('http');
++const { serviceAuth } = require('openvibe-contracts');
++const { load } = require('../server/config');
++const { start } = require('../server/index');
++
++const ISSUER = 'https://openvibe.network';
++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++});
++const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
++
++function token(slug, cap, { aud = 'openvibe.ai', ns = [], exp = Math.floor(Date.now() / 1000) + 300, key = privateKey, sub } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({
++ iss: ISSUER, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns, iat: now, exp,
++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ }, key);
++}
++
++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(), 'ov-ai-test-'));
++ made.push(d);
++ return d;
++}
++
++/** Boot the service. `env` goes through config.load() and is also what secrets resolve against. */
++async function boot({ env = {}, dir = tmpDir(), clock, fetchImpl } = {}) {
++ const fullEnv = {
++ NODE_ENV: 'test', PORT: '0', AI_DB_PATH: path.join(dir, 'ai.db'), OV_NETWORK_PUBLIC_KEY: publicKey,
++ AI_PROVIDER_RETRY_DELAY_MS: '5', AI_QUOTA_SERVICE_RPM: '0', AI_QUOTA_SERVICE_RPD: '0',
++ ...env,
++ };
++ const config = load(fullEnv);
++ const h = await start({ config, env: fullEnv, log: silent, clock, fetchImpl });
++ const base = `http://127.0.0.1:${h.server.address().port}`;
++ return { ...h, base, dir, env: fullEnv, stop: () => h.close() };
++}
++
++async function request(base, method, p, { tok, body, headers = {} } = {}) {
++ const h = { ...headers };
++ if (tok) h.Authorization = `Bearer ${tok}`;
++ if (body !== undefined) h['Content-Type'] = 'application/json';
++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
++ 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 };
++}
++
++/**
++ * A local HTTP-seam provider: handler(body) -> response object (or { status, body }, or a Promise).
++ * .calls counts requests, .last is the last request body.
++ */
++async function seamServer(handler) {
++ const s = { calls: 0, last: null, handler };
++ s.server = nodeHttp.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', async () => {
++ s.calls++;
++ let body = {};
++ try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
++ s.last = body;
++ try {
++ const out = await s.handler(body, req);
++ const status = out && out.status ? out.status : 200;
++ const payload = out && out.status ? out.body : out;
++ res.writeHead(status, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(payload || {}));
++ } catch (e) {
++ res.writeHead(500, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify({ error: e.message }));
++ }
++ });
++ });
++ await new Promise((r) => s.server.listen(0, '127.0.0.1', r));
++ s.url = `http://127.0.0.1:${s.server.address().port}`;
++ s.close = () => new Promise((r) => { s.server.closeAllConnections?.(); s.server.close(() => r()); });
++ return s;
++}
++
++/** Sequential async test runner with a summary line; exits non-zero on any failure. */
++function suite(name) {
++ const tests = [];
++ return {
++ test(title, fn) { tests.push({ title, fn }); },
++ async run() {
++ let failed = 0;
++ for (const t of tests) {
++ try {
++ await t.fn();
++ console.log(` ✓ ${t.title}`);
++ } catch (e) {
++ failed++;
++ console.log(` ✗ ${t.title}\n ${e && e.stack ? e.stack.split('\n').slice(0, 6).join('\n ') : e}`);
++ }
++ }
++ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);
++ // Exit explicitly: a failed test may leave a server or timer open behind it.
++ process.exit(failed ? 1 : 0);
++ },
++ };
++}
++
++const ALL = ['ai.run.create', 'ai.run.read', 'ai.workflow.manage', 'ai.provider.manage', 'ai.usage.read'];
++
++module.exports = { token, boot, request, seamServer, suite, tmpDir, silent, publicKey, privateKey, ISSUER, ALL };
ddiff --git a/test/providers.test.js b/test/providers.test.js
nnew file mode 100644
iindex 0000000..b260cbc
---- /dev/null
++++ b/test/providers.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++// Provider adapters (ported from Live) against local fake servers — OpenAI-compatible (with the
++// json_schema -> json_object step-down), Anthropic (forced tool JSON, cache_control, usage incl.
++// cached tokens) — plus the stub's determinism and schema shapes, the whisper noise filter,
++// template rendering and the loose JSON repair.
++
++const assert = require('assert');
++const { seamServer, suite } = require('./helpers');
++const { createOpenAiProvider, buildBody: openaiBody } = require('../server/providers/openai');
++const { createAnthropicProvider, buildBody: anthropicBody } = require('../server/providers/anthropic');
++const { createStubProvider } = require('../server/providers/stub');
++const { cleanSegments } = require('../server/providers/whisper');
++const { render } = require('../server/templates');
++const { parseJsonLoose } = require('../server/util');
++const schemas = require('../server/schemas');
++
++const t = suite('providers');
++const req = (extra = {}) => ({ system: [{ text: 'sys', cache: true }], messages: [{ role: 'user', content: 'hi' }], maxTokens: 50, temperature: 0.5, timeoutMs: 3000, model: 'gpt-4o-mini', ...extra });
++
++t.test('OpenAI body: roles, reasoning models, structured output, prompt cache key', () => {
++ const b = openaiBody({ ...req(), baseUrl: 'https://api.openai.com/v1', json: { name: 'r', schema: { type: 'object' } }, cacheKey: 'k' }, 'schema');
++ assert.deepStrictEqual(b.messages[0], { role: 'system', content: 'sys' });
++ assert.strictEqual(b.max_tokens, 50);
++ assert.strictEqual(b.response_format.type, 'json_schema');
++ assert.strictEqual(b.prompt_cache_key, 'k');
++ const r = openaiBody({ ...req({ model: 'gpt-5-nano' }), baseUrl: 'https://x.example/v1' }, null);
++ assert.ok(r.max_completion_tokens >= 256 + 512);
++ assert.strictEqual(r.reasoning_effort, 'minimal');
++ assert.strictEqual(r.temperature, undefined);
++ assert.strictEqual(r.prompt_cache_key, undefined);
++});
++
++t.test('Anthropic body: cached system blocks, image block, forced tool for JSON', () => {
++ const b = anthropicBody({ ...req(), image: { mediaType: 'image/jpeg', base64: 'AAAA' }, json: { name: 'r', schema: { type: 'object' } } });
++ assert.deepStrictEqual(b.system[0].cache_control, { type: 'ephemeral' });
++ assert.strictEqual(b.messages[0].content[1].type, 'image');
++ assert.strictEqual(b.tool_choice.name, 'r');
++});
++
++t.test('OpenAI adapter steps down json_schema -> json_object on a gateway that refuses it', async () => {
++ const seen = [];
++ const srv = await seamServer((body) => {
++ seen.push(body.response_format && body.response_format.type);
++ if (body.response_format && body.response_format.type === 'json_schema') return { status: 400, body: { error: { message: 'response_format json_schema is not supported' } } };
++ return { choices: [{ message: { content: '{"a":1}' } }], usage: { prompt_tokens: 7, completion_tokens: 3, prompt_tokens_details: { cached_tokens: 2 } }, model: 'm' };
++ });
++ const a = createOpenAiProvider({ key: 'o', base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
++ const r = await a.chat(req({ json: { name: 'r', schema: { type: 'object' } } }));
++ assert.deepStrictEqual(seen, ['json_schema', 'json_object']);
++ assert.strictEqual(r.text, '{"a":1}');
++ assert.deepStrictEqual(r.usage, { input: 7, output: 3, cached: 2 });
++ assert.ok(a.supports('embed') && a.supports('vision'));
++ await srv.close();
++});
++
++t.test('OpenAI adapter: auth failures and timeouts are typed errors', async () => {
++ const srv = await seamServer((b) => (b.messages[b.messages.length - 1].content === 'slow' ? new Promise(r => setTimeout(() => r({}), 1500)) : { status: 401, body: { error: { message: 'bad key' } } }));
++ const a = createOpenAiProvider({ key: 'o', base_url: srv.url, capabilities: [] }, { apiKey: 'k' });
++ await assert.rejects(a.chat(req({ messages: [{ role: 'user', content: 'x' }] })), e => e.code === 'provider.auth' && e.status === 401);
++ await assert.rejects(a.chat(req({ messages: [{ role: 'user', content: 'slow' }], timeoutMs: 300 })), e => e.code === 'provider.timeout');
++ const ctl = new AbortController();
++ const p = a.chat(req({ messages: [{ role: 'user', content: 'slow' }], signal: ctl.signal }));
++ ctl.abort();
++ await assert.rejects(p, e => e.code === 'provider.cancelled');
++ await srv.close();
++});
++
++t.test('Anthropic adapter returns tool JSON and counts cache reads', async () => {
++ const srv = await seamServer((b, r) => {
++ assert.strictEqual(r.headers['x-api-key'], 'ak');
++ return { content: [{ type: 'tool_use', name: 'r', input: { ok: true } }], usage: { input_tokens: 5, output_tokens: 2, cache_read_input_tokens: 10, cache_creation_input_tokens: 1 }, model: 'claude-x' };
++ });
++ const a = createAnthropicProvider({ key: 'a', base_url: srv.url, capabilities: [] }, { apiKey: 'ak' });
++ const r = await a.chat(req({ json: { name: 'r', schema: { type: 'object' } } }));
++ assert.deepStrictEqual(r.json, { ok: true });
++ assert.deepStrictEqual(r.usage, { input: 16, output: 2, cached: 10 });
++ assert.strictEqual(a.supports('embed'), false);
++ await srv.close();
++});
++
++t.test('stub: deterministic, schema-shaped, clearly synthetic', async () => {
++ const s = createStubProvider({ key: 'stub', metadata: {} });
++ const schema = { type: 'object', additionalProperties: false, required: ['a', 'b', 'c', 'd'], properties: { a: { enum: ['x', 'y'] }, b: { type: 'integer', minimum: 3, maximum: 5 }, c: { type: 'array', items: { type: 'string', maxLength: 30 }, minItems: 2, maxItems: 2 }, d: { type: ['string', 'null'] } } };
++ const one = await s.generate({ messages: [{ role: 'user', content: 'q' }], json: { name: 'r', schema } });
++ const two = await s.generate({ messages: [{ role: 'user', content: 'q' }], json: { name: 'r', schema } });
++ assert.deepStrictEqual(one.json, two.json);
++ assert.ok(schemas.validate(schema, one.json).valid, JSON.stringify(one.json));
++ assert.ok(one.json.d.startsWith('(synthetic)'));
++ assert.strictEqual(one.synthetic, true);
++ const diff = await s.generate({ messages: [{ role: 'user', content: 'other' }], json: { name: 'r', schema } });
++ assert.notDeepStrictEqual(diff.json, one.json);
++ const e = await s.embed({ input: ['a', 'b'] });
++ assert.strictEqual(e.vectors.length, 2);
++ assert.ok(Math.abs(e.vectors[0].reduce((n, x) => n + x * x, 0) - 1) < 1e-3, 'unit vectors');
++ const tx = await s.transcribe({});
++ assert.ok(tx.segments.length >= 2 && tx.segments.every(x => x.end > x.start));
++ const failing = createStubProvider({ key: 'f', metadata: { fail: 'error' } });
++ await assert.rejects(failing.chat({ messages: [] }), e2 => e2.code === 'provider.http');
++});
++
++t.test("whisper filter (Live's): hallucinations, subtitle boilerplate, loops; fillers only without VAD", () => {
++ const segs = [{ start: 0, end: 1, text: 'Thank you.' }, { start: 1, end: 2, text: 'ok so the build broke' }, { start: 2, end: 3, text: 'ok so the build broke' },
++ { start: 3, end: 4, text: '[MUSIC]' }, { start: 4, end: 5, text: 'For more information, visit www.fema.org.' }, { start: 5, end: 6, text: 'you' }];
++ assert.deepStrictEqual(cleanSegments(segs, false).map(s => s.text), ['ok so the build broke']);
++ assert.deepStrictEqual(cleanSegments(segs, true).map(s => s.text), ['ok so the build broke', 'you']);
++});
++
++t.test('templates render values, JSON and sections; loose JSON repair works', () => {
++ assert.strictEqual(render('a {{x}} {{json y}}{{#z}} z!{{/z}}{{^w}} no-w{{/w}}', { x: 1, y: { k: 'v' }, z: [1] }), 'a 1 {"k":"v"} z! no-w');
++ assert.strictEqual(render('{{#e}}hidden{{/e}}{{a.b}}', { e: [], a: { b: 'deep' } }), 'deep');
++ assert.deepStrictEqual(parseJsonLoose('Sure! {"tags":["a","b"}'), { tags: ['a', 'b'] });
++ assert.deepStrictEqual(parseJsonLoose('{“a”: 1,}'), { a: 1 });
++});
++
++t.run();
ddiff --git a/test/quota.test.js b/test/quota.test.js
nnew file mode 100644
iindex 0000000..dd236ac
---- /dev/null
++++ b/test/quota.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++// Quotas are enforced BEFORE any provider is called: a refused request is a 429 problem with
++// Retry-After, creates no run and never reaches a provider. Per-service '*' quotas count each
++// service separately; attribution quotas (Live's per-streamer budgets) and cost caps work too.
++
++const assert = require('assert');
++const { boot, request, token, suite, seamServer, ALL } = require('./helpers');
++
++const t = suite('quota');
++const live = token('live', ALL);
++const tools = token('tools', ['ai.run.create', 'ai.run.read']);
++let h;
++let seam;
++
++const runCount = () => h.db.prepare('SELECT COUNT(*) n FROM runs').get().n;
++const gen = (tok, prompt, extra = {}) => request(h.base, 'POST', '/api/v1/generate', { tok, body: { prompt, options: { cache: false }, ...extra } });
++
++t.test('boot with a counting seam provider as the only route target', async () => {
++ seam = await seamServer(() => ({ text: 'ok', usage: { input: 1000, output: 1000 } }));
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_QUOTA_SERVICE_RPM: '3' } });
++ await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'seam', kind: 'http', base_url: seam.url, auth_mode: 'none', capabilities: ['generate', 'chat'] } });
++ await request(h.base, 'POST', '/api/v1/models', { tok: live, body: { provider_key: 'seam', model_key: 'm1', cost: { in_per_mtok: 1000, out_per_mtok: 1000 } } });
++ const r = await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'seam', model: 'm1' }, fallbacks: [] } });
++ assert.strictEqual(r.status, 201, r.text);
++});
++
++t.test('the per-service rate refuses the 4th request with 429 before the provider is called', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await gen(live, `p${i}`)).status, 201);
++ const callsBefore = seam.calls;
++ const runsBefore = runCount();
++ const r = await gen(live, 'one too many');
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.body.code, 'quota.exceeded');
++ assert.ok(Number(r.headers.get('retry-after')) >= 1);
++ assert.ok(r.body.retry_after_seconds >= 1 && r.body.retry_after_seconds <= 60);
++ assert.strictEqual(r.body.quota.scope_type, 'service');
++ assert.strictEqual(r.body.quota.scope_id, 'live');
++ assert.strictEqual(seam.calls, callsBefore, 'the provider was never called');
++ assert.strictEqual(runCount(), runsBefore, 'no run was created');
++});
++
++t.test("'*' service quotas give each service its own window", async () => {
++ const r = await gen(tools, 'from tools');
++ assert.strictEqual(r.status, 201, 'another service is not limited by live\'s usage');
++});
++
++t.test('attribution quotas limit one attributed owner (Live per-streamer budget)', async () => {
++ await request(h.base, 'POST', '/api/v1/quotas', { tok: live, body: { scope_type: 'service', scope_id: '*', window: 'minute', max_requests: 1000 } });
++ const q = await request(h.base, 'POST', '/api/v1/quotas', { tok: live, body: { scope_type: 'attribution', scope_id: 'live:user:42', window: 'day', max_requests: 1 } });
++ assert.strictEqual(q.status, 201, q.text);
++ const who = { service: 'live', type: 'user', id: '42' };
++ assert.strictEqual((await gen(live, 'streamer 42 a', { attribution: who })).status, 201);
++ const calls = seam.calls;
++ const r = await gen(live, 'streamer 42 b', { attribution: who });
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.body.quota.scope_type, 'attribution');
++ assert.strictEqual(seam.calls, calls);
++ assert.strictEqual((await gen(live, 'streamer 7', { attribution: { service: 'live', type: 'user', id: '7' } })).status, 201, 'other owners unaffected');
++});
++
++t.test('a daily cost cap (AI_MAX_COST_USD_PER_DAY semantics) stops spend once reached', async () => {
++ await request(h.base, 'POST', '/api/v1/quotas', { tok: live, body: { scope_type: 'global', window: 'day', max_cost_usd: 0.001 } });
++ // Each call costs (1000 + 1000) tokens at $1000/Mtok = $2, so the cap is already spent.
++ const calls = seam.calls;
++ const r = await gen(tools, 'after the cap');
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.body.quota.scope_type, 'global');
++ assert.strictEqual(seam.calls, calls);
++ const usage = await request(h.base, 'GET', '/api/v1/usage', { tok: live });
++ assert.ok(usage.body.usage.some(u => u.requester === 'service:live' && u.cost_usd > 0), 'usage is recorded per requester');
++ const cur = await request(h.base, 'GET', '/api/v1/quotas', { tok: live });
++ assert.ok(cur.body.current.some(c => c.quota.scope_type === 'global' && c.used.cost_usd > 0.001));
++});
++
++t.test('quota changes are audited', async () => {
++ const a = await request(h.base, 'GET', '/api/v1/audit?target_type=quota', { tok: live });
++ assert.ok(a.body.audit.some(x => x.action === 'quota.create' && x.actor === 'svc:live'));
++});
++
++t.test('shutdown', async () => { await h.stop(); await seam.close(); });
++
++t.run();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..d3ca65e
---- /dev/null
++++ b/test/run.js
@@@ -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, a generated signing key, the deterministic
++ * stub provider and local HTTP servers on random ports; none of them needs the network, a paid
++ * key or a running OpenVibe.Network.
++ *
++ * npm test # everything
++ * npm test -- quota cache # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(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);
++})();
ddiff --git a/test/runs.test.js b/test/runs.test.js
nnew file mode 100644
iindex 0000000..9eac8e4
---- /dev/null
++++ b/test/runs.test.js
@@@ -0,0 +1,150 @@
++'use strict';
++// Run lifecycle: async creation and polling, idempotency (replay and conflict), cancel of a running
++// and of a queued run, retry of failed/cancelled runs (and refusal for succeeded ones), audit rows
++// for every create/cancel/retry and every template/workflow/provider change, and restart recovery.
++
++const assert = require('assert');
++const { boot, request, token, suite, tmpDir, ALL } = require('./helpers');
++
++const t = suite('runs');
++const live = token('live', ALL);
++const tools = token('tools', ['ai.run.create', 'ai.run.read']);
++let h;
++const dir = tmpDir();
++
++const sleep = (ms) => new Promise(r => setTimeout(r, ms));
++async function until(fn, ms = 5000) {
++ const end = Date.now() + ms;
++ for (;;) {
++ const v = await fn();
++ if (v) return v;
++ if (Date.now() > end) throw new Error('timed out waiting');
++ await sleep(25);
++ }
++}
++const getRun = async (id, tok = live) => (await request(h.base, 'GET', `/api/v1/runs/${id}`, { tok })).body;
++
++t.test('boot with a slow stub on default.chat and one run at a time', async () => {
++ h = await boot({ dir, env: { AI_MAX_CONCURRENT_RUNS: '1', AI_STUB_FALLBACK: 'false' } });
++ const p = await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'slowstub', kind: 'stub', auth_mode: 'none', capabilities: ['chat', 'generate', 'summarize', 'json'], metadata: { delay_ms: 400 } } });
++ assert.strictEqual(p.status, 201, p.text);
++ await request(h.base, 'POST', '/api/v1/routes/default.chat/versions', { tok: live, body: { primary: { provider: 'slowstub' }, fallbacks: [] } });
++});
++
++t.test('async create answers 202 with a Location, then the run completes', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'async please' } } });
++ assert.strictEqual(r.status, 202);
++ assert.ok(['queued', 'running'].includes(r.body.run.status));
++ assert.strictEqual(r.headers.get('location'), `/api/v1/runs/${r.body.run.id}`);
++ const done = await until(async () => { const d = await getRun(r.body.run.id); return d.run.status === 'succeeded' && d; });
++ assert.ok(done.run.output.text);
++ assert.ok(done.run.trace_id, 'trace id recorded');
++});
++
++t.test('idempotency: same key + input replays the run; a different input conflicts', async () => {
++ const body = { workflow: 'ai.generate', input: { prompt: 'once' }, idempotency_key: 'idem-key-0001' };
++ const a = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body });
++ assert.strictEqual(a.status, 201);
++ const b = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body });
++ assert.strictEqual(b.status, 200);
++ assert.strictEqual(b.body.run.id, a.body.run.id);
++ assert.strictEqual(b.body.replayed, true);
++ const hdr = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'once' } }, headers: { 'Idempotency-Key': 'idem-key-0001' } });
++ assert.strictEqual(hdr.body.run.id, a.body.run.id, 'the Idempotency-Key header works too');
++ const c = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { ...body, input: { prompt: 'twice' } } });
++ assert.strictEqual(c.status, 409);
++ assert.strictEqual(c.body.code, 'idempotency.conflict');
++ const other = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: tools, body });
++ assert.notStrictEqual(other.body.run.id, a.body.run.id, 'keys are per requester');
++});
++
++t.test('cancel a running run and a queued one', async () => {
++ const first = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'long 1' }, options: { cache: false } } });
++ const second = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'long 2' }, options: { cache: false } } });
++ await until(async () => (await getRun(first.body.run.id)).run.status === 'running');
++ assert.strictEqual((await getRun(second.body.run.id)).run.status, 'queued', 'concurrency 1 keeps the second queued');
++ const c2 = await request(h.base, 'POST', `/api/v1/runs/${second.body.run.id}/cancel`, { tok: live });
++ assert.strictEqual(c2.body.run.status, 'cancelled');
++ const c1 = await request(h.base, 'POST', `/api/v1/runs/${first.body.run.id}/cancel`, { tok: live });
++ assert.strictEqual(c1.body.run.status, 'cancelled');
++ await sleep(500);
++ const d = await getRun(first.body.run.id);
++ assert.strictEqual(d.run.status, 'cancelled', 'a late provider answer does not resurrect it');
++ assert.strictEqual(d.run.output, null);
++ assert.ok(d.requests.some(x => x.status === 'cancelled'), 'the in-flight call was aborted');
++ const again = await request(h.base, 'POST', `/api/v1/runs/${first.body.run.id}/cancel`, { tok: live });
++ assert.strictEqual(again.status, 409);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${first.body.run.id}/cancel`, { tok: tools })).status, 404, 'other callers cannot see it');
++});
++
++t.test('retry: a failed run is retried as a new run; succeeded runs cannot be retried', async () => {
++ await request(h.base, 'POST', '/api/v1/providers/slowstub/disable', { tok: live });
++ const failed = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'will fail first' } } });
++ assert.strictEqual(failed.body.run.status, 'failed');
++ assert.strictEqual(failed.body.run.error.code, 'provider.unavailable');
++ await request(h.base, 'POST', '/api/v1/providers/slowstub/enable', { tok: live });
++ const retried = await request(h.base, 'POST', `/api/v1/runs/${failed.body.run.id}/retry?wait=5000`, { tok: live });
++ assert.strictEqual(retried.status, 201);
++ assert.strictEqual(retried.body.run.status, 'succeeded');
++ assert.strictEqual(retried.body.run.retry_of, failed.body.run.id);
++ assert.notStrictEqual(retried.body.run.id, failed.body.run.id);
++ const no = await request(h.base, 'POST', `/api/v1/runs/${retried.body.run.id}/retry`, { tok: live });
++ assert.strictEqual(no.status, 409);
++ assert.strictEqual(no.body.code, 'run.not_retryable');
++});
++
++t.test('passthrough prompts and inline images are not kept on the run record', async () => {
++ await request(h.base, 'POST', '/api/v1/routes/live.chat/versions', { tok: live, body: { primary: { provider: 'slowstub' }, fallbacks: [] } });
++ await request(h.base, 'POST', '/api/v1/routes/live.vision/versions', { tok: live, body: { primary: { provider: 'slowstub' }, fallbacks: [] } });
++ await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'slowstub', kind: 'stub', capabilities: ['chat', 'generate', 'summarize', 'json', 'vision'], metadata: { delay_ms: 10 } } });
++ const p = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'live.chat.insight', input: { role: 'chat', kind: 'chat_user', user: 'PRIVATE CHAT LOG: alice said hi' } } });
++ assert.strictEqual(p.body.run.status, 'succeeded');
++ const row = h.db.prepare('SELECT input, input_hash FROM runs WHERE id = ?').get(p.body.run.id);
++ assert.ok(!row.input.includes('PRIVATE CHAT LOG'), 'raw prompt not stored');
++ assert.ok(row.input_hash);
++ const png = `data:image/png;base64,${Buffer.alloc(600, 7).toString('base64')}`;
++ const img = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'live.stream.describe_frame', input: { image: { data_url: png } } } });
++ const irow = h.db.prepare('SELECT input FROM runs WHERE id = ?').get(img.body.run.id);
++ assert.ok(!irow.input.includes(png.slice(40, 120)) && irow.input.includes('data_url_sha256'));
++ await request(h.base, 'POST', '/api/v1/providers/slowstub/disable', { tok: live });
++ const failed = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok: live, body: { workflow: 'live.chat.insight', input: { role: 'chat', user: 'again' } } });
++ assert.strictEqual(failed.body.run.status, 'failed');
++ const nr = await request(h.base, 'POST', `/api/v1/runs/${failed.body.run.id}/retry`, { tok: live });
++ assert.strictEqual(nr.status, 409);
++ assert.strictEqual(nr.body.code, 'run.input_not_retained');
++ await request(h.base, 'POST', '/api/v1/providers/slowstub/enable', { tok: live });
++});
++
++t.test('audit rows exist for runs and for registry changes', async () => {
++ const a = (await request(h.base, 'GET', '/api/v1/audit?limit=500', { tok: live })).body.audit;
++ const actions = new Set(a.map(x => x.action));
++ for (const x of ['run.create', 'run.cancel', 'run.retry', 'provider.create', 'provider.update', 'route.version', 'template.version', 'workflow.version']) assert.ok(actions.has(x), `no ${x} audit row`);
++ const create = a.find(x => x.action === 'run.create' && x.actor === 'svc:live');
++ assert.ok(create.trace_id, 'audit rows carry the trace id');
++ assert.ok(create.metadata.workflow);
++ const v = await request(h.base, 'POST', '/api/v1/workflows/ai.generate/versions', { tok: live, body: { description: 'edited' } });
++ assert.strictEqual(v.body.workflow.version, 2);
++ const wa = (await request(h.base, 'GET', '/api/v1/audit?action=workflow.version&target_id=ai.generate', { tok: live })).body.audit;
++ assert.strictEqual(wa[0].actor, 'svc:live');
++ assert.strictEqual(wa[0].metadata.version, 2);
++});
++
++t.test('runs still in flight at shutdown end as run.interrupted, retryable after restart', async () => {
++ await request(h.base, 'POST', '/api/v1/providers', { tok: live, body: { key: 'slowstub', kind: 'stub', metadata: { delay_ms: 3000 } } });
++ const r = await request(h.base, 'POST', '/api/v1/runs', { tok: live, body: { workflow: 'ai.generate', input: { prompt: 'cut short' }, options: { cache: false } } });
++ await until(async () => (await getRun(r.body.run.id)).run.status === 'running');
++ await h.stop();
++ h = await boot({ dir, env: { AI_MAX_CONCURRENT_RUNS: '1', AI_STUB_FALLBACK: 'false' } });
++ const d = await getRun(r.body.run.id);
++ assert.strictEqual(d.run.status, 'failed');
++ assert.strictEqual(d.run.error.code, 'run.interrupted');
++ h.db.prepare("UPDATE runs SET status = 'running', error_code = NULL, finished_at = NULL WHERE id = ?").run(r.body.run.id);
++ await h.stop();
++ h = await boot({ dir, env: { AI_MAX_CONCURRENT_RUNS: '1', AI_STUB_FALLBACK: 'false' } });
++ assert.strictEqual((await getRun(r.body.run.id)).run.error.code, 'run.interrupted', 'boot recovery marks orphans too');
++ assert.strictEqual(h.registry.getWorkflow('ai.generate').version, 2, 'a reboot does not clobber admin versions with the seed');
++});
++
++t.test('shutdown', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/ssrf.test.js b/test/ssrf.test.js
nnew file mode 100644
iindex 0000000..391a93e
---- /dev/null
++++ b/test/ssrf.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++// Media/image inputs are fetched only from allow-listed OpenVibe hosts over https, never from IP
++// literals, internal addresses (checked at connect time, so DNS answers pointing inside are refused),
++// or via redirects to anything else. The operator-configured Media internal origin is the only
++// exception. Refusals fail the run explicitly (fetch.refused) — nothing is fabricated in its place.
++
++const assert = require('assert');
++const nodeHttp = require('http');
++const { boot, request, token, suite, ALL } = require('./helpers');
++const { createFetcher, isPublicAddress } = require('../server/fetcher');
++const { load } = require('../server/config');
++
++const t = suite('ssrf');
++const tok = token('live', ALL);
++let h;
++let media;
++let mediaBase;
++
++const refused = async (fetcher, url) => {
++ try { await fetcher.fetchUrl(url); } catch (e) { return e.code; }
++ return 'fetched';
++};
++
++t.test('address policy: internal, loopback, link-local, mapped IPv6 are not public', () => {
++ for (const ip of ['127.0.0.1', '10.1.2.3', '192.168.1.1', '172.16.0.1', '169.254.169.254', '0.0.0.0', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', 'fe80::1', 'fc00::1', '100.64.0.1']) {
++ assert.strictEqual(isPublicAddress(ip), false, ip);
++ }
++ for (const ip of ['1.1.1.1', '93.184.216.34', '2606:4700:4700::1111']) assert.strictEqual(isPublicAddress(ip), true, ip);
++});
++
++t.test('URL rules: https + allow-listed host only', async () => {
++ const f = createFetcher(load({ NODE_ENV: 'test' }));
++ const bad = ['http://openvibe.media/v/1', 'https://evil.example/x', 'https://openvibe.media.evil.example/x', 'https://evilopenvibe.media/x', 'https://127.0.0.1/x',
++ 'https://[::1]/x', 'https://user:[email protected]/x', 'file:///etc/passwd', 'gopher://openvibe.media/x', 'not a url'];
++ for (const u of bad) {
++ try { f.judge(u); assert.fail(`judged ok: ${u}`); } catch (e) { assert.strictEqual(e.code, 'fetch.refused', u); }
++ }
++ assert.ok(f.judge('https://openvibe.media/v/1'));
++ assert.ok(f.judge('https://ai.openvibe.network/x'), '*.openvibe.network');
++ assert.strictEqual(f.mediaRefUrl({ media_id: 'legacy:live:vod:42' }), 'https://openvibe.media/v/42');
++ assert.throws(() => f.mediaRefUrl({ media_id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPA' }), e => e.code === 'media.unresolvable');
++});
++
++t.test('an allow-listed NAME that resolves to an internal address is refused at connect time', async () => {
++ const f = createFetcher(load({ NODE_ENV: 'test', AI_FETCH_ALLOW_HOSTS: 'localhost' }));
++ assert.ok(f.judge('https://localhost/x'), 'the name itself is on the list');
++ assert.strictEqual(await refused(f, 'https://localhost/x'), 'fetch.refused');
++});
++
++t.test('the internal Media origin is fetched; its redirect elsewhere is refused; size is capped', async () => {
++ media = nodeHttp.createServer((req, res) => {
++ if (req.url === '/redir') { res.writeHead(302, { Location: 'http://169.254.169.254/latest/meta-data' }); return res.end(); }
++ if (req.url === '/redir-ok') { res.writeHead(302, { Location: '/img' }); return res.end(); }
++ if (req.url === '/big') { res.writeHead(200, { 'Content-Type': 'image/png' }); return res.end(Buffer.alloc(4096)); }
++ res.writeHead(200, { 'Content-Type': 'image/png' });
++ res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64'));
++ });
++ await new Promise(r => media.listen(0, '127.0.0.1', r));
++ mediaBase = `http://127.0.0.1:${media.address().port}`;
++ const f = createFetcher(load({ NODE_ENV: 'test', OV_MEDIA_INTERNAL_URL: mediaBase, AI_FETCH_MAX_IMAGE_BYTES: '1000' }));
++ const ok = await f.fetchUrl(`${mediaBase}/img`);
++ assert.ok(ok.bytes > 0);
++ assert.ok((await f.fetchUrl(`${mediaBase}/redir-ok`)).bytes > 0, 'same-origin redirect ok');
++ assert.strictEqual(await refused(f, `${mediaBase}/redir`), 'fetch.refused');
++ try { await f.loadImage({ url: `${mediaBase}/big` }); assert.fail('big image fetched'); } catch (e) { assert.strictEqual(e.code, 'source.too_large'); }
++ const other = `http://127.0.0.1:${media.address().port + 1}/x`;
++ assert.strictEqual(await refused(f, other), 'fetch.refused', 'another port on loopback is not the internal origin');
++});
++
++t.test('a run whose image URL is not allowed fails with fetch.refused and no provider call', async () => {
++ h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase } });
++ const before = h.pool.stats.stub ? h.pool.stats.stub.calls : 0;
++ for (const url of ['http://169.254.169.254/latest/meta-data', 'https://internal.example/x', 'http://127.0.0.1:22/']) {
++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'live.stream.describe_frame', input: { image: { url } } } });
++ assert.strictEqual(r.body.run.status, 'failed', url);
++ assert.strictEqual(r.body.run.error.code, 'fetch.refused', url);
++ assert.strictEqual(r.body.run.output, null);
++ }
++ assert.strictEqual(h.pool.stats.stub ? h.pool.stats.stub.calls : 0, before);
++ const good = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'live.stream.describe_frame', input: { image: { url: `${mediaBase}/img` } } } });
++ assert.strictEqual(good.body.run.status, 'succeeded');
++ const tx = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'live.media.transcribe', input: { media_url: 'https://evil.example/a.mp4' } } });
++ assert.strictEqual(tx.body.run.error.code, 'fetch.refused');
++});
++
++t.test('shutdown', async () => { await h.stop(); media.close(); });
++
++t.run();
ddiff --git a/test/versioning.test.js b/test/versioning.test.js
nnew file mode 100644
iindex 0000000..4b88016
---- /dev/null
++++ b/test/versioning.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++// Templates, workflows and routes are versioned: an edit is a new version, old versions stay
++// readable, a run records the exact versions it used, lifecycle changes (deprecate/archive/disable)
++// only change status, the newest ACTIVE version is what new runs use, and seeding never overwrites
++// an admin's version.
++
++const assert = require('assert');
++const { boot, request, token, suite, ALL } = require('./helpers');
++
++const t = suite('versioning');
++const tok = token('live', ALL);
++let h;
++const run = (body) => request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body });
++
++t.test('boot', async () => { h = await boot(); });
++
++t.test('a template edit is a new version; v1 stays readable; runs record the version they used', async () => {
++ const r1 = await run({ workflow: 'ai.summarize', input: { text: 'abc def' } });
++ assert.strictEqual(r1.body.run.template.version, 1);
++ const v2 = await request(h.base, 'POST', '/api/v1/templates/ai.summarize/versions', { tok, body: { user_prompt: 'Summarize in {{max_words}} words:\n{{text}}' } });
++ assert.strictEqual(v2.status, 201);
++ assert.strictEqual(v2.body.template.version, 2);
++ const r2 = await run({ workflow: 'ai.summarize', input: { text: 'abc def' } });
++ assert.strictEqual(r2.body.run.template.version, 2);
++ const old = await request(h.base, 'GET', '/api/v1/templates/ai.summarize?version=1', { tok });
++ assert.strictEqual(old.body.template.version, 1);
++ assert.ok(old.body.template.user_prompt.includes('{{#style}}'), 'v1 content unchanged');
++ assert.deepStrictEqual(old.body.versions.map(v => v.version), [1, 2]);
++ assert.strictEqual((await request(h.base, 'GET', `/api/v1/runs/${r1.body.run.id}`, { tok })).body.run.template.version, 1, 'old run still says v1');
++});
++
++t.test('an invalid edit is refused and creates no version', async () => {
++ const bad = await request(h.base, 'POST', '/api/v1/workflows/ai.summarize/versions', { tok, body: { input_schema: { type: 'nonsense-type' } } });
++ assert.strictEqual(bad.status, 422);
++ const bad2 = await request(h.base, 'POST', '/api/v1/workflows/ai.summarize/versions', { tok, body: { steps: [{ kind: 'llm', template: 'no.such.template' }] } });
++ assert.strictEqual(bad2.status, 422);
++ assert.strictEqual(h.registry.getWorkflow('ai.summarize').version, 1);
++});
++
++t.test('deprecating the newest workflow version sends new runs to the newest active one', async () => {
++ const v2 = await request(h.base, 'POST', '/api/v1/workflows/ai.summarize/versions', { tok, body: { description: 'v2 description' } });
++ assert.strictEqual(v2.body.workflow.version, 2);
++ assert.strictEqual((await run({ workflow: 'ai.summarize', input: { text: 'x y' } })).body.run.workflow.version, 2);
++ const dep = await request(h.base, 'POST', '/api/v1/workflows/ai.summarize/versions/2/status', { tok, body: { status: 'deprecated' } });
++ assert.strictEqual(dep.body.workflow.status, 'deprecated');
++ assert.strictEqual((await run({ workflow: 'ai.summarize', input: { text: 'x y z' } })).body.run.workflow.version, 1);
++ assert.strictEqual((await run({ workflow: 'ai.summarize', version: 2, input: { text: 'pinned' } })).body.run.workflow.version, 2, 'a deprecated version can still be pinned');
++ await request(h.base, 'POST', '/api/v1/workflows/ai.summarize/versions/2/status', { tok, body: { status: 'archived' } });
++ const arch = await run({ workflow: 'ai.summarize', version: 2, input: { text: 'pinned' } });
++ assert.strictEqual(arch.status, 409);
++ assert.strictEqual(arch.body.code, 'workflow.inactive');
++});
++
++t.test('route versions and disabling a route', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/routes/default.embedding/versions', { tok, body: { primary: { provider: 'stub', model: 'stub-1' } } });
++ assert.strictEqual(r.body.route.version, 2);
++ const e = await request(h.base, 'POST', '/api/v1/embed', { tok, body: { input: 'x' } });
++ assert.deepStrictEqual(e.body.run.route, { key: 'default.embedding', version: 2 });
++ await request(h.base, 'POST', '/api/v1/routes/default.embedding/versions', { tok, body: { status: 'disabled' } });
++ const off = await request(h.base, 'POST', '/api/v1/embed', { tok, body: { input: 'y' } });
++ assert.strictEqual(off.body.run.status, 'failed');
++ assert.strictEqual(off.body.run.error.code, 'route.unavailable');
++ const hist = await request(h.base, 'GET', '/api/v1/routes?history=1', { tok });
++ assert.strictEqual(hist.body.routes.filter(x => x.key === 'default.embedding').length, 3);
++});
++
++t.test('historical route keys are explicit aliases of default.json', async () => {
++ const r = await request(h.base, 'GET', '/api/v1/routes/news.summarize', { tok });
++ assert.strictEqual(r.body.route.alias_of, 'default.json');
++ const run1 = await run({ workflow: 'news.summarize_story', input: { sources: [{ source_type: 'news.article', content: 'x' }] } });
++ assert.strictEqual(run1.body.run.route.key, 'default.json');
++});
++
++t.test('seed: a code change becomes a new version, unless an admin versioned the key', async () => {
++ const before = h.registry.getTemplate('ai.generate').version;
++ h.registry.seedVersioned('template', 'ai.generate', { name: 'Generate text', system_prompt: '{{system}}', user_prompt: 'CHANGED {{prompt}}', default_route: 'default.chat', input_schema: { type: 'object' }, output_schema: { type: 'object' } });
++ assert.strictEqual(h.registry.getTemplate('ai.generate').version, before + 1);
++ assert.strictEqual(h.registry.getTemplate('ai.generate').created_by, 'seed');
++ const again = h.registry.seedVersioned('template', 'ai.summarize', { name: 'x', user_prompt: 'seed wants this' });
++ assert.strictEqual(again.created_by, 'svc:live', 'admin version kept');
++});
++
++t.test('shutdown', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
nnew file mode 100644
iindex 0000000..38080c6
---- /dev/null
++++ b/test/workflows.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++// Every seeded workflow: schemas compile, a realistic input validates and runs end to end on the
++// stub provider, the output matches the versioned output schema, product workflows store their
++// sources as citations, and bad input is refused with the schema errors.
++
++const assert = require('assert');
++const nodeHttp = require('http');
++const { boot, request, token, suite, ALL } = require('./helpers');
++const schemas = require('../server/schemas');
++const { KIND_TO_WORKFLOW } = require('../server/workflows/live');
++
++const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
++const SRC = (n) => Array.from({ length: n }, (_, i) => ({ source_type: 'news.article', source_id: `a${i}`, title: `Source ${i}`, url: `https://example.org/${i}`, content: `Fact number ${i} happened on day ${i}.`, published_at: '2026-09-20' }));
++const PASS = { role: 'chat', kind: 'chat_global', system: [{ text: 'You summarize chat.', cache: true }], user: 'Summarize: hi all', max_tokens: 80 };
++
++const EXAMPLES = {
++ 'live.translate': { text: 'こんにちは、みなさん', from: 'ja', to: 'en', context: 'chat' },
++ 'live.paste.describe_image': { title: 'my screenshot', image: { data_url: PNG } },
++ 'live.paste.summarize_text': { title: 'notes', content: 'A list of things to buy: eggs, milk.' },
++ 'live.stream.describe_frame': { image: { data_url: PNG } },
++ 'live.stream.summarize': { observations: ['Streamer opens a code editor', 'Streamer fixes a bug'], speech: [{ start_sec: 12, text: 'ok so this function is broken' }], sounds: [{ start_sec: 30, label: 'Laughter', confidence: 0.8 }] },
++ 'live.streamer.overview': { streamer: { username: 'ann', display_name: 'Ann', bio: 'I code', category: 'desktop', category_inferred: true }, memories: ['coding in rust'], vods: [{ title: 'Rust night', category: 'desktop' }], pastes: [{ title: 'snippet', summary: 'a rust snippet' }] },
++ 'live.media.overview': { frames: ['a desk', 'a keyboard'], transcript: 'hello chat' },
++ 'live.stream.recap': { facts: { streamer: 'Ann', title: 'Rust night', duration: '2h 1m', viewers: { peak: 4, avg: 2 } } },
++ 'live.media.transcribe': { media_url: '__MEDIA__/v/1', language: 'en' },
++ 'network.site_copy': { sites: [{ id: 'live', name: 'OpenVibe.Live', what: 'live streaming' }], links: [{ id: 'tools', name: 'Tools', about: 'online tools' }] },
++ 'ai.chat': { messages: [{ role: 'user', content: 'hello' }] },
++ 'ai.generate': { prompt: 'Write a haiku about SQLite.' },
++ 'ai.summarize': { text: 'SQLite is an embedded database engine. It is small and fast.', max_words: 20 },
++ 'ai.classify': { text: 'I love this!', labels: ['positive', 'negative', 'neutral'] },
++ 'ai.extract': { text: 'Order 42 ships to Berlin.', schema: { type: 'object', additionalProperties: false, required: ['order', 'city'], properties: { order: { type: 'string' }, city: { type: 'string' } } } },
++ 'ai.enrich': { record: { name: 'Widget' }, instructions: 'normalise the name' },
++ 'ai.embed': { input: ['one', 'two'] },
++ 'wiki.generate_space': { topic: 'SQLite', sources: SRC(2) },
++ 'wiki.generate_page': { title: 'WAL mode', sources: SRC(3) },
++ 'blog.draft_post': { topic: 'Why we self-host', sources: SRC(1) },
++ 'news.summarize_story': { topic: 'Example', sources: SRC(3) },
++ 'news.compare_perspectives': { topic: 'Example', sources: SRC(2) },
++ 'reviews.summarize_entity': { entity: { name: 'Widget', type: 'product' }, sources: SRC(2) },
++ 'deals.enrich_deal': { offer: { title: 'Widget 50% off', merchant: 'Shop' }, sources: SRC(1) },
++ 'coupons.extract_coupon': { merchant: { name: 'Shop', domain: 'shop.example' }, sources: SRC(1) },
++ 'trade.summarize_market_context': { instrument: { symbol: 'XYZ', name: 'XYZ Corp' }, stale_after_hours: 24, sources: SRC(2) },
++ 'codes.generate_docs': { project: 'demo', sources: [{ source_type: 'code.file', title: 'index.js', content: 'module.exports = 1;' }] },
++ 'games.generate_lore': { world: 'Hobo Quest', prompt: 'The origin of the tin-can knights', sources: SRC(1) },
++ 'moderation.classify': { text: 'you absolute potato, gg', context: 'arena chat' },
++};
++for (const k of new Set(Object.values(KIND_TO_WORKFLOW).concat('live.complete'))) EXAMPLES[k] = PASS;
++
++const t = suite('workflows');
++let h;
++let media;
++const tok = token('live', ALL);
++
++t.test('boot seeds every required workflow key', async () => {
++ media = nodeHttp.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'video/mp4' }); res.end(Buffer.alloc(2048, 1)); });
++ await new Promise(r => media.listen(0, '127.0.0.1', r));
++ const mediaBase = `http://127.0.0.1:${media.address().port}`;
++ EXAMPLES['live.media.transcribe'].media_url = `${mediaBase}/v/1`;
++ h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase } });
++ const keys = h.registry.listWorkflows().map(w => w.key);
++ for (const k of ['wiki.generate_space', 'wiki.generate_page', 'blog.draft_post', 'news.summarize_story', 'news.compare_perspectives', 'reviews.summarize_entity', 'deals.enrich_deal',
++ 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify',
++ 'live.translate', 'live.stream.describe_frame', 'live.stream.summarize', 'live.viewers.reply', 'live.chat.insight', 'live.hero.slogans', 'live.stream.recap', 'network.site_copy']) {
++ assert.ok(keys.includes(k), `missing ${k}`);
++ }
++ for (const k of ['default.chat', 'default.json', 'default.embedding', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify']) {
++ assert.ok(h.registry.getRoute(k), `missing historical route ${k}`);
++ }
++});
++
++t.test('every workflow has compiling, versioned input and output schemas and an example', async () => {
++ for (const w of h.registry.listWorkflows()) {
++ assert.strictEqual(w.version, 1);
++ schemas.assertSchema(w.input_schema, `${w.key} input`);
++ schemas.assertSchema(w.output_schema, `${w.key} output`);
++ assert.ok(EXAMPLES[w.key], `no example input for ${w.key}`);
++ const v = schemas.validate(w.input_schema, EXAMPLES[w.key]);
++ assert.ok(v.valid, `${w.key} example invalid: ${JSON.stringify(v.errors)}`);
++ }
++});
++
++t.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {
++ for (const w of h.registry.listWorkflows()) {
++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });
++ assert.strictEqual(r.status, 201, `${w.key}: ${r.status} ${r.text.slice(0, 300)}`);
++ const run = r.body.run;
++ assert.strictEqual(run.status, 'succeeded', `${w.key}: ${JSON.stringify(run.error)}`);
++ assert.strictEqual(run.synthetic, true, `${w.key} should be marked synthetic`);
++ assert.strictEqual(run.provenance.origin, 'ai');
++ assert.strictEqual(run.provenance.workflow, w.key);
++ assert.ok(run.provenance.model, `${w.key} has a model`);
++ assert.ok(schemas.validate(w.output_schema, run.output).valid, `${w.key} output invalid`);
++ if (EXAMPLES[w.key].sources) {
++ assert.strictEqual(run.citations_count, EXAMPLES[w.key].sources.length, `${w.key} citations`);
++ const d = await request(h.base, 'GET', `/api/v1/runs/${run.id}`, { tok });
++ assert.strictEqual(d.body.citations.length, EXAMPLES[w.key].sources.length);
++ assert.ok(d.body.citations.every(c => c.source_type && c.content_hash), 'citations carry type and content hash');
++ }
++ }
++});
++
++t.test('citations index only the provided sources', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'news.summarize_story', input: { sources: SRC(2) } } });
++ const seen = JSON.stringify(r.body.run.output).match(/"citations":\[([0-9,]*)\]/g) || [];
++ for (const m of seen) for (const n of m.replace(/\D+/g, ' ').trim().split(' ').filter(Boolean)) assert.ok(Number(n) < 2, `citation ${n} out of range`);
++});
++
++t.test('invalid input is refused with the schema errors; unknown workflows are 404', async () => {
++ let r = await request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow: 'news.summarize_story', input: { sources: [] } } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.body.code, 'input.invalid');
++ assert.ok(Array.isArray(r.body.errors) && r.body.errors.length);
++ r = await request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow: 'nope.nothing', input: {} } });
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(r.body.code, 'workflow.not_found');
++});
++
++t.test('a workflow with nothing to work from fails explicitly (no fabricated output)', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'live.media.overview', input: { frames: [] } } });
++ assert.strictEqual(r.body.run.status, 'failed');
++ assert.strictEqual(r.body.run.error.code, 'input.insufficient');
++ assert.strictEqual(r.body.run.output, null);
++});
++
++t.test('translation of non-text short-circuits without a provider call', async () => {
++ const before = h.pool.stats.stub ? h.pool.stats.stub.calls : 0;
++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'live.translate', input: { text: ':kek: !sr', to: 'ja' } } });
++ assert.strictEqual(r.body.run.status, 'succeeded');
++ assert.strictEqual(r.body.run.output.reason, 'not_translatable');
++ assert.strictEqual(r.body.run.provenance.provider, null);
++ assert.strictEqual(h.pool.stats.stub.calls, before);
++});
++
++t.test('direct operations are runs of ai.<op>', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/classify', { tok, body: { text: 'great', labels: ['good', 'bad'] } });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.body.run.workflow.key, 'ai.classify');
++ assert.ok(['good', 'bad'].includes(r.body.run.output.label));
++ const e = await request(h.base, 'POST', '/api/v1/embed', { tok, body: { input: 'x' } });
++ assert.strictEqual(e.body.run.output.vectors.length, 1);
++ assert.strictEqual(e.body.run.output.dimensions, 64);
++ const again = await request(h.base, 'POST', '/api/v1/embed', { tok, body: { input: 'x' } });
++ assert.deepStrictEqual(again.body.run.output, e.body.run.output, 'stub is deterministic');
++});
++
++t.test('shutdown', async () => { await h.stop(); media.close(); });
++
++t.run();