openvibers/openvibe.codes Commits
Codes moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G2), async from end to end.
Changes
@@@ -5,6 +5,14 @@ PORT=4900HOST=127.0.0.1BASE_URL=https://openvibe.codesTRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh codes: the runtime role through PgBouncer,++# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=++# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.CODES_DB_PATH=/var/lib/openvibe-codes/codes.db# Per-actor limits (server/http/actor-limits.js): reads one caller (an app, a person, else an address)# may make to the API and the portal per minute and per hour; past them 429 rate_limited with@@@ -10,6 +10,9 @@ jobs:with:syntax-dirs: server public/js test scriptscontracts-service: codes++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).security:uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176@@@ -47,7 +47,8 @@ Everything is server-rendered and usable without JavaScript. The only scripts of- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).--- **openvibe-contracts** v0.53.0, **openvibe-sdk v0.13.0**, **openvibe-shared v1.25.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).++- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).++- **openvibe-contracts** v0.76.0, **openvibe-sdk v0.20.1**, **openvibe-shared v1.27.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).@@@ -110,7 +111,11 @@ as they raise the per-address limit.Production deploys with `sudo ovhost deploy codes` on the host (strategy `git-checkout`: fetch,fast-forward `/opt/openvibe.codes`, install on a lockfile change, restart, wait for `/api/ready`).--The unit is `openvibe-codes.service` on `127.0.0.1:4900`, the env file `/etc/openvibe/codes.env`. State lives in++The unit is `openvibe-codes.service` on `127.0.0.1:4900`, the env file `/etc/openvibe/codes.env`. The database is++`ov_codes` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh codes` writes its settings); the++release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk++`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old++`/var/lib/openvibe-codes/codes.db` stays read-only for 7 days as the rollback. State lives in`/var/lib/openvibe-codes`; nginx serves `openvibe.codes` from[deploy/nginx/openvibe.codes.conf](deploy/nginx/openvibe.codes.conf).Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the@@@ -4,11 +4,15 @@"code": true,"wave": "W20","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express 4, better-sqlite3 11",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4900,"domain": "openvibe.codes",-- "contracts": "openvibe-contracts v0.53.0",-- "packages": ["openvibe-contracts v0.53.0", "openvibe-sdk v0.13.0", "openvibe-shared v1.25.0"],++ "contracts": "openvibe-contracts v0.76.0",++ "packages": [++ "openvibe-contracts v0.76.0",++ "openvibe-sdk v0.20.1",++ "openvibe-shared v1.27.0"++ ],"summary": "Deployed and public since 2026-09-23: https://openvibe.codes is served by this portal (deployed with `sudo ovhost deploy codes`, unit openvibe-codes on 127.0.0.1:4900 behind nginx), and the domain left OpenVibe.Sites. Sign-in uses the registered Network OAuth client `codes` (PKCE S256); the principal `codes` holds events.event.publish and the Events relay is configured. On 2026-09-23 production held 0 manifests, 0 releases and 0 playground runs. The external-developer path (account, project, sandbox app, grants, Media upload, app events) was run once on production on 2026-09-23 with public endpoints and curl, not through these playgrounds; that check is not committed to a repository or CI.","works": ["Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",@@@ -3,8 +3,8 @@ Description=OpenVibe.Codes — the developer portal (projects over Network's API# Network (sign-in, JWKS, the projects API, registry, tokens) and Events (the outbox relay) should be# up first, but Codes degrades honestly without them: docs, tools and policy pages serve; project# pages show Network's failure; events wait in the outbox (see /api/ready).--After=network.target--Wants=openvibe-network.service++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service[Service]Type=simplennew file mode 100644@@@ -0,0 +1,107 @@++-- phase: expand++-- OpenVibe.Codes on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.++++CREATE TABLE manifests (++ id text COLLATE "C" PRIMARY KEY, -- mfs_<ULID>++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('app', 'mod')),++ app_id text COLLATE "C" NOT NULL, -- Network app (app_<ULID>) that publishes it++ project_id text COLLATE "C" NOT NULL, -- Network project (prj_<ULID>) at the time of creation++ subject_id text COLLATE "C" NOT NULL, -- the manifest's own id (app_… or mod_…)++ version text COLLATE "C" NOT NULL,++ body text COLLATE "C" NOT NULL, -- the manifest JSON exactly as validated++ contracts_version text COLLATE "C" NOT NULL, -- openvibe-contracts release it was validated with++ created_by text COLLATE "C" NOT NULL, -- user:usr_… or app:app_…++ created_at text COLLATE "C" NOT NULL++);++CREATE INDEX idx_manifests_app ON manifests(app_id);++++CREATE TABLE releases (++ id text COLLATE "C" PRIMARY KEY, -- rel_<ULID>++ app_id text COLLATE "C" NOT NULL,++ project_id text COLLATE "C" NOT NULL,++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('app', 'mod')),++ subject_id text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ version text COLLATE "C" NOT NULL,++ manifest_id text COLLATE "C" NOT NULL REFERENCES manifests(id),++ status text COLLATE "C" NOT NULL CHECK (status IN ('draft', 'published', 'deprecated', 'revoked')),++ compatibility text COLLATE "C" NOT NULL DEFAULT '{}',++ notes text COLLATE "C" NOT NULL DEFAULT '',++ created_by text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ published_by text COLLATE "C", published_at TEXT,++ deprecated_by text COLLATE "C", deprecated_at TEXT, deprecation_reason TEXT, replacement TEXT,++ revoked_by text COLLATE "C", revoked_at TEXT, revocation_reason TEXT,++ UNIQUE (app_id, kind, subject_id, version)++);++CREATE INDEX idx_releases_app ON releases(app_id, created_at);++++CREATE TABLE release_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ release_id text COLLATE "C" NOT NULL,++ action text COLLATE "C" NOT NULL,++ actor text COLLATE "C" NOT NULL,++ at text COLLATE "C" NOT NULL,++ detail text COLLATE "C" NOT NULL DEFAULT '{}'++);++CREATE INDEX idx_release_log_release ON release_log(release_id, id);++CREATE FUNCTION release_log_append_only_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'release_log is append-only'; END $$;++CREATE TRIGGER release_log_append_only_update BEFORE UPDATE ON release_log FOR EACH ROW EXECUTE FUNCTION release_log_append_only_update();++CREATE FUNCTION release_log_append_only_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'release_log is append-only'; END $$;++CREATE TRIGGER release_log_append_only_delete BEFORE DELETE ON release_log FOR EACH ROW EXECUTE FUNCTION release_log_append_only_delete();++++CREATE TABLE trust (++ app_id text COLLATE "C" PRIMARY KEY,++ tier text COLLATE "C" NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),++ note text COLLATE "C" NOT NULL DEFAULT '',++ set_by text COLLATE "C" NOT NULL,++ set_at text COLLATE "C" NOT NULL++);++CREATE TABLE trust_history (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ app_id text COLLATE "C" NOT NULL,++ from_tier text COLLATE "C" NOT NULL,++ to_tier text COLLATE "C" NOT NULL,++ note text COLLATE "C" NOT NULL DEFAULT '',++ set_by text COLLATE "C" NOT NULL,++ set_at text COLLATE "C" NOT NULL++);++++CREATE TABLE playground_runs (++ id text COLLATE "C" PRIMARY KEY, -- run_<ULID>++ at text COLLATE "C" NOT NULL,++ actor text COLLATE "C" NOT NULL, -- user:usr_…++ project_id text COLLATE "C" NOT NULL,++ app_id text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('events', 'media')),++ capability text COLLATE "C" NOT NULL,++ credential text COLLATE "C" NOT NULL CHECK (credential IN ('none', 'client_secret', 'access_token')),++ outcome text COLLATE "C" NOT NULL CHECK (outcome IN ('ok', 'refused', 'failed')),++ stage text COLLATE "C" NOT NULL, -- app | grant | token | call | done++ http_status bigint,++ code text COLLATE "C",++ detail text COLLATE "C" NOT NULL DEFAULT '',++ ref text COLLATE "C" -- event_id or media key on success++);++CREATE INDEX idx_runs_app ON playground_runs(app_id, at);++CREATE INDEX idx_runs_actor ON playground_runs(actor, at);++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;@@@ -16,14 +16,32 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"},"engines": {"node": ">=22.0.0"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -236,6 +254,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/content-disposition": {"version": "0.5.4","resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",@@@ -318,6 +345,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -754,6 +790,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/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/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -818,12 +897,24 @@"safe-buffer": "^5.0.1"}},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1023,9 +1114,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.53.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",++ "version": "0.76.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1039,18 +1130,38 @@}},"node_modules/openvibe-sdk": {-- "version": "0.13.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",-- "integrity": "sha512-RvTYWvkHjgOt7kOZwEDYjReb5MoJcpEenVAykM2S1/GeDqgKUMQrsSINvF4TM3HBwNHxlevO1kXzVMob2o2igg==",++ "version": "0.20.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",++ "integrity": "sha512-3Zwz8sJhWhnuw9GYr6ieIDp9CIl6ug0QqJE5ifDsk4ckrethBjcDzjadE1GSCGQDFXXLf/+bVWtI7X3VOVXmFQ==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {-- "version": "1.25.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",-- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",++ "version": "1.27.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "integrity": "sha512-/H7dyFw+cURcdjyfbpyY7mNc6F+Pcb9TwkVwZHR+9d3M62q70KDhZLo/gZi71eaYxUjD8lO8tmbXu70EAa9paQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1071,6 +1182,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1194,6 +1433,27 @@"node": ">= 6"}},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1409,6 +1669,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1544,6 +1819,15 @@"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz","integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==","license": "ISC"++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -7,7 +7,8 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/run.js"++ "test": "node test/run.js",++ "test:pg": "CODES_TEST_STORE=pg node test/run.js"},"keywords": ["openvibe",@@@ -27,11 +28,16 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "pg": "^8.23.0"},"engines": {"node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"}}nnew file mode 100644@@@ -0,0 +1,26 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Codes's SQLite database (CODES_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is++ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts++ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its++ * name and columns.++ */++require('dotenv').config();++const { runSqliteMigration } = require('openvibe-sdk/db');++const configLib = require('../server/config');++const { MIGRATIONS } = require('../server/db');++++const TABLES = {};++++if (require.main === module) {++ const config = configLib.load();++ runSqliteMigration({ service: 'codes', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })++ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });++}++++module.exports = { TABLES };@@@ -49,11 +49,12 @@ const VERSION = require('../package.json').version;* opts: config, store | dbPath, now (clock), fetchImpl, log, limitsNow (the per-actor limiter's clock,* tests), actorLimits (false: count nobody, tests only)*/--function createApp(opts = {}) {++async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;const fetchImpl = opts.fetchImpl;-- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });++ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.++ const store = opts.store || await openStore(config, { now: opts.now, log });const docs = generate({ now: store.now });const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });@@@ -78,7 +79,10 @@ function createApp(opts = {}) {app.locals.ctx = ctx;// Per-actor limits (http/actor-limits.js) at /api/v1, the portal, release actions and the tools'// forms, counted once the caller is known; the per-address limits below stay.-- ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.actorLimits !== false });++ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.actorLimits !== false, valkey });app.use(contracts.http.middleware());app.use(helmet({@@@ -112,7 +116,7 @@ function createApp(opts = {}) {app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.release.mount(app, { registry: metrics.registry });-- const readiness = createCodesReadiness({ store, keys, network, outbox, docs, config, release: release.release });++ const readiness = createCodesReadiness({ store, keys, network, outbox, docs, config, release: release.release, valkey: ctx.valkey });app.get('/api/ready', readiness.handler);// ── Who is asking (verified offline; refreshed when expired) ──@@@ -118,8 +118,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}/** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */-- function refresh(req, res) {-- if (!req.codesRefresh) req.codesRefresh = doRefresh(req, res);++ async function refresh(req, res) {++ if (!req.codesRefresh) req.codesRefresh = await doRefresh(req, res);return req.codesRefresh;}@@@ -93,8 +93,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {return value;}const registry = {-- services: () => cached('services', () => registryClient.services()),-- descriptor: () => cached('descriptor', () => client.json({ baseUrl: base, path: '/.well-known/openvibe', auth: false })),++ services: async () => await cached('services', () => registryClient.services()),++ descriptor: async () => await cached('descriptor', () => client.json({ baseUrl: base, path: '/.well-known/openvibe', auth: false })),};/**@@@ -40,6 +40,10 @@ function load(env = process.env) {hour: Math.max(1, int(env.CODES_LIMITS_HOUR, 3000)),},++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:codes:' },++ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.dbPath: env.CODES_DB_PATH || './data/codes.db',// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer@@@ -1,7 +1,7 @@'use strict';/**-- * OpenVibe.Codes' own SQLite database (WAL, created on boot, idempotent).++ * OpenVibe.Codes' own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot.** Codes owns ONLY what is Codes' by ADR-014: release metadata keyed to Network app ids, trust tiers* (metadata, never authority), validated manifests, and playground run logs. Projects, members,@@@ -12,146 +12,51 @@*/const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');++const { createDb } = require('openvibe-sdk/db');const { ids } = require('openvibe-contracts');const TABLES = ['manifests', 'releases', 'release_log', 'trust', 'trust_history', 'playground_runs'];--const SCHEMA = `--CREATE TABLE IF NOT EXISTS manifests (-- id TEXT PRIMARY KEY, -- mfs_<ULID>-- kind TEXT NOT NULL CHECK (kind IN ('app', 'mod')),-- app_id TEXT NOT NULL, -- Network app (app_<ULID>) that publishes it-- project_id TEXT NOT NULL, -- Network project (prj_<ULID>) at the time of creation-- subject_id TEXT NOT NULL, -- the manifest's own id (app_… or mod_…)-- version TEXT NOT NULL,-- body TEXT NOT NULL, -- the manifest JSON exactly as validated-- contracts_version TEXT NOT NULL, -- openvibe-contracts release it was validated with-- created_by TEXT NOT NULL, -- user:usr_… or app:app_…-- created_at TEXT NOT NULL--);--CREATE INDEX IF NOT EXISTS idx_manifests_app ON manifests(app_id);----CREATE TABLE IF NOT EXISTS releases (-- id TEXT PRIMARY KEY, -- rel_<ULID>-- app_id TEXT NOT NULL,-- project_id TEXT NOT NULL,-- environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),-- kind TEXT NOT NULL CHECK (kind IN ('app', 'mod')),-- subject_id TEXT NOT NULL,-- name TEXT NOT NULL,-- version TEXT NOT NULL,-- manifest_id TEXT NOT NULL REFERENCES manifests(id),-- status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'deprecated', 'revoked')),-- compatibility TEXT NOT NULL DEFAULT '{}',-- notes TEXT NOT NULL DEFAULT '',-- created_by TEXT NOT NULL,-- created_at TEXT NOT NULL,-- published_by TEXT, published_at TEXT,-- deprecated_by TEXT, deprecated_at TEXT, deprecation_reason TEXT, replacement TEXT,-- revoked_by TEXT, revoked_at TEXT, revocation_reason TEXT,-- UNIQUE (app_id, kind, subject_id, version)--);--CREATE INDEX IF NOT EXISTS idx_releases_app ON releases(app_id, created_at);----CREATE TABLE IF NOT EXISTS release_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- release_id TEXT NOT NULL,-- action TEXT NOT NULL,-- actor TEXT NOT NULL,-- at TEXT NOT NULL,-- detail TEXT NOT NULL DEFAULT '{}'--);--CREATE INDEX IF NOT EXISTS idx_release_log_release ON release_log(release_id, id);--CREATE TRIGGER IF NOT EXISTS release_log_append_only_update BEFORE UPDATE ON release_log-- BEGIN SELECT RAISE(ABORT, 'release_log is append-only'); END;--CREATE TRIGGER IF NOT EXISTS release_log_append_only_delete BEFORE DELETE ON release_log-- BEGIN SELECT RAISE(ABORT, 'release_log is append-only'); END;----CREATE TABLE IF NOT EXISTS trust (-- app_id TEXT PRIMARY KEY,-- tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),-- note TEXT NOT NULL DEFAULT '',-- set_by TEXT NOT NULL,-- set_at TEXT NOT NULL--);--CREATE TABLE IF NOT EXISTS trust_history (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- app_id TEXT NOT NULL,-- from_tier TEXT NOT NULL,-- to_tier TEXT NOT NULL,-- note TEXT NOT NULL DEFAULT '',-- set_by TEXT NOT NULL,-- set_at TEXT NOT NULL--);----CREATE TABLE IF NOT EXISTS playground_runs (-- id TEXT PRIMARY KEY, -- run_<ULID>-- at TEXT NOT NULL,-- actor TEXT NOT NULL, -- user:usr_…-- project_id TEXT NOT NULL,-- app_id TEXT NOT NULL,-- kind TEXT NOT NULL CHECK (kind IN ('events', 'media')),-- capability TEXT NOT NULL,-- credential TEXT NOT NULL CHECK (credential IN ('none', 'client_secret', 'access_token')),-- outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'refused', 'failed')),-- stage TEXT NOT NULL, -- app | grant | token | call | done-- http_status INTEGER,-- code TEXT,-- detail TEXT NOT NULL DEFAULT '',-- ref TEXT -- event_id or media key on success--);--CREATE INDEX IF NOT EXISTS idx_runs_app ON playground_runs(app_id, at);--CREATE INDEX IF NOT EXISTS idx_runs_actor ON playground_runs(actor, at);--`;++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');/**-- * ADR-013 is binding for trust tiers: unreviewed, reviewed, first-party. Databases created before-- * that used the roadmap's four names; map them (untrusted→unreviewed, verified|trusted→reviewed,-- * platform-maintained→first-party) and rebuild `trust` with the new CHECK. Idempotent: a database-- * already on ADR-013 names is left untouched. Tiers stay metadata; no grant depends on them.++ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database++ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.*/--const TIER_MAP = { untrusted: 'unreviewed', verified: 'reviewed', trusted: 'reviewed', 'platform-maintained': 'first-party' };--function migrateTrustTiers(db) {-- const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'trust'").get();-- const oldCheck = row && /'untrusted'/.test(row.sql);-- const stale = db.prepare(`SELECT COUNT(*) AS n FROM trust_history WHERE from_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')}) OR to_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')})`)-- .get(...Object.keys(TIER_MAP), ...Object.keys(TIER_MAP)).n;-- if (!oldCheck && !stale) return { migrated: false };-- const mapSql = (col) => `CASE ${col} ${Object.entries(TIER_MAP).map(([a, b]) => `WHEN '${a}' THEN '${b}'`).join(' ')} ELSE ${col} END`;-- db.transaction(() => {-- if (oldCheck) {-- db.exec(`CREATE TABLE trust_adr013 (-- app_id TEXT PRIMARY KEY,-- tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),-- note TEXT NOT NULL DEFAULT '',-- set_by TEXT NOT NULL,-- set_at TEXT NOT NULL-- );-- INSERT INTO trust_adr013 (app_id, tier, note, set_by, set_at) SELECT app_id, ${mapSql('tier')}, note, set_by, set_at FROM trust;-- DROP TABLE trust;-- ALTER TABLE trust_adr013 RENAME TO trust;`);-- }-- db.exec(`UPDATE trust_history SET from_tier = ${mapSql('from_tier')}, to_tier = ${mapSql('to_tier')}`);-- })();-- return { migrated: true };++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh codes)');++ log.warn(`[Codes] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ const db = createDb({ pglite: DEV_PGLITE, service: 'codes', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'codes-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'codes', registry, log });}--function openStore(dbPath, { now = () => Date.now() } = {}) {-- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });-- const db = new Database(dbPath);-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');-- db.exec(SCHEMA);-- migrateTrustTiers(db);++/**++ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms), so tests and replays are++ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).++ */++function createStore(db, { now = () => Date.now() } = {}) {return {db,now,iso: () => new Date(now()).toISOString(),newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx(() => fn()),close: () => db.close(),};}--module.exports = { openStore, TABLES, TIER_MAP, migrateTrustTiers };++/** openDb + createStore. */++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS, TABLES };@@@ -67,19 +67,19 @@ function grantAdvice(capability) {function createPlayground({ store, config, network, keys, fetchImpl, log = console }) {const { db } = store;-- function record(run) {++ async function record(run) {const id = store.newId('run');-- db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref)++ await db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, store.iso(), run.actor, run.project_id, run.app_id, run.kind, run.capability, run.credential, run.outcome, run.stage,run.http_status || null, run.code || null, run.detail || '', run.ref || null);return id;}-- const runsFor = (appId, limit = 20) => db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);-- const runsForProject = (projectId) => db.prepare('SELECT id, at, actor, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE project_id = ? ORDER BY at, id').all(String(projectId));-- const deleteRunsForProject = (projectId) => db.prepare('DELETE FROM playground_runs WHERE project_id = ?').run(String(projectId)).changes;-- const recentCount = (actor) => db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString()).n;++ const runsFor = async (appId, limit = 20) => await db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);++ const runsForProject = async (projectId) => await db.prepare('SELECT id, at, actor, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE project_id = ? ORDER BY at, id').all(String(projectId));++ const deleteRunsForProject = async (projectId) => (await db.prepare('DELETE FROM playground_runs WHERE project_id = ?').run(String(projectId))).changes;++ const recentCount = async (actor) => (await db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString())).n;/** Steps 1–2 only: may this app run this playground at all? */function precheck(app, kind) {@@@ -161,54 +161,54 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = consoconst k = KINDS[kind];const secrets = [credential && credential.value].filter(Boolean);const base = { actor, project_id: app.project_id, app_id: app.id, kind, capability: k ? k.needs : String(kind), credential: 'none' };-- const finish = (out) => {++ const finish = async (out) => {const safe = { ...out, detail: scrub(out.detail, secrets) };-- const runId = record({ ...base, ...safe });++ const runId = await record({ ...base, ...safe });return { ...safe, runId };};-- if (recentCount(actor) >= config.playground.runsPerHour) {++ if (await recentCount(actor) >= config.playground.runsPerHour) {return { outcome: 'refused', stage: 'rate', code: 'playground.rate_limited', detail: `at most ${config.playground.runsPerHour} playground runs an hour` };}const pre = precheck(app, kind);-- if (!pre.ok) return { ...finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };++ if (!pre.ok) return { ...await finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };let envelope = null;if (kind === 'events') {const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });-- if (e.error) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });++ if (e.error) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });envelope = e.envelope;} else {-- if (!file || !file.buffer || !file.buffer.length) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: 'choose a file' });-- if (file.buffer.length > config.playground.maxUploadBytes) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: `playground uploads are at most ${config.playground.maxUploadBytes} bytes` });++ if (!file || !file.buffer || !file.buffer.length) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: 'choose a file' });++ if (file.buffer.length > config.playground.maxUploadBytes) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: `playground uploads are at most ${config.playground.maxUploadBytes} bytes` });}if (!credential || !credential.value || !['client_secret', 'access_token'].includes(credential.type)) {-- return finish({ outcome: 'refused', stage: 'token', code: 'playground.no_credential', detail: 'enter the app\'s client secret (used once, never stored) or an access token you minted for it' });++ return await finish({ outcome: 'refused', stage: 'token', code: 'playground.no_credential', detail: 'enter the app\'s client secret (used once, never stored) or an access token you minted for it' });}base.credential = credential.type;const tok = await appToken(app, k, credential);-- if (!tok.ok) return finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });++ if (!tok.ok) return await finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });const client = sdkClient(tok.token);try {if (kind === 'events') {const events = createEventsClient(client, { source: envelope.source, baseUrl: config.playground.eventsUrl });const out = await events.publish(envelope);-- return { ...finish({ outcome: 'ok', stage: 'done', http_status: 200, detail: out.duplicate ? 'accepted (duplicate event_id)' : `accepted as seq ${out.seq}`, ref: out.event_id }), result: { event_id: out.event_id, seq: out.seq, duplicate: out.duplicate } };++ return { ...await finish({ outcome: 'ok', stage: 'done', http_status: 200, detail: out.duplicate ? 'accepted (duplicate event_id)' : `accepted as seq ${out.seq}`, ref: out.event_id }), result: { event_id: out.event_id, seq: out.seq, duplicate: out.duplicate } };}const media = createMediaClient(client, { app: app.project_id, baseUrl: config.playground.mediaUrl, publicOrigin: config.playground.mediaUrl });const out = await media.files.upload(file.buffer, { filename: file.filename, contentType: file.mimeType });-- return { ...finish({ outcome: 'ok', stage: 'done', http_status: 201, detail: `stored ${out.size} bytes`, ref: out.key }), result: { key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256 } };++ return { ...await finish({ outcome: 'ok', stage: 'done', http_status: 201, detail: `stored ${out.size} bytes`, ref: out.key }), result: { key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256 } };} catch (err) {if (isOpenVibeError(err)) {const service = kind === 'events' ? 'OpenVibe.Events' : 'OpenVibe.Media';const unreachable = !err.status;-- return finish({ outcome: 'failed', stage: 'call', http_status: err.status || null, code: err.code,++ return await finish({ outcome: 'failed', stage: 'call', http_status: err.status || null, code: err.code,detail: unreachable ? `${service} did not answer (${err.code})` : `${service} answered ${err.status}: ${err.detail || err.title || err.code}` });}log.error('[Codes] playground error:', scrub(err && err.message, secrets));-- return finish({ outcome: 'failed', stage: 'call', code: 'playground.error', detail: 'unexpected error calling the service' });++ return await finish({ outcome: 'failed', stage: 'call', code: 'playground.error', detail: 'unexpected error calling the service' });}}@@@ -57,9 +57,9 @@ async function buildExport({ network, token, project, includeAudit, releases, plquotas: quotas.quotas || [],audit: audit || { entries: null, note: 'the audit log needs the admin role in this project' },codes: {-- releases: releases.listForProject(project.id),-- trust: appIds.map((id) => ({ app_id: id, ...trust.get(id), history: trust.history(id) })),-- playground_runs: playground.runsForProject(project.id),++ releases: await releases.listForProject(project.id),++ trust: (await Promise.all(appIds.map(async (id) => ({ app_id: id, ...await trust.get(id), history: await trust.history(id) })))),++ playground_runs: await playground.runsForProject(project.id),},};}@@@ -29,7 +29,7 @@ const manageRole = (environment) => (environment === 'production' ? 'admin' : 'dfunction createReleases({ store, outbox, trust }) {const { db } = store;-- function view(r) {++ async function view(r) {if (!r) return null;return {id: r.id, app_id: r.app_id, project_id: r.project_id, environment: r.environment, kind: r.kind,@@@ -38,24 +38,24 @@ function createReleases({ store, outbox, trust }) {created_at: r.created_at, created_by: r.created_by,published_at: r.published_at || null, deprecated_at: r.deprecated_at || null, deprecation_reason: r.deprecation_reason || null,replacement: r.replacement || null, revoked_at: r.revoked_at || null, revocation_reason: r.revocation_reason || null,-- trust: trust.get(r.app_id),++ trust: await trust.get(r.app_id),};}-- const get = (id) => view(db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id)));-- const manifestOf = (id) => {-- const m = db.prepare('SELECT * FROM manifests WHERE id = ?').get(String(id));++ const get = async (id) => await view(await db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id)));++ const manifestOf = async (id) => {++ const m = await db.prepare('SELECT * FROM manifests WHERE id = ?').get(String(id));return m ? { id: m.id, kind: m.kind, body: JSON.parse(m.body), contracts_version: m.contracts_version, created_at: m.created_at } : null;};-- const listForApp = (appId, { includeDrafts = false } = {}) => db.prepare(++ const listForApp = async (appId, { includeDrafts = false } = {}) => (await Promise.all((await db.prepare(`SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,-- ).all(String(appId)).map(view);-- const recentPublic = (limit = 20) => db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit).map(view);-- const log = (releaseId) => db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId)++ ).all(String(appId))).map(view)));++ const recentPublic = async (limit = 20) => (await Promise.all((await db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit)).map(view)));++ const log = async (releaseId) => (await db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId)).map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));-- function writeLog(releaseId, action, actor, detail = {}) {-- db.prepare('INSERT INTO release_log (release_id, action, actor, at, detail) VALUES (?, ?, ?, ?, ?)')++ async function writeLog(releaseId, action, actor, detail = {}) {++ await db.prepare('INSERT INTO release_log (release_id, action, actor, at, detail) VALUES (?, ?, ?, ?, ?)').run(releaseId, action, actor, store.iso(), JSON.stringify(detail));}@@@ -73,16 +73,16 @@ function createReleases({ store, outbox, trust }) {if (!actor.role || RANK[actor.role] < RANK[need]) fail(403, 'release.forbidden', `managing ${app.environment} releases needs the ${need} role in the project (Network reports ${actor.role || 'no role'})`);}-- function event(type, rel, actor, extra = {}) {++ async function event(type, rel, actor, extra = {}) {const [atype, aid] = actor.label.split(':');-- return outbox.emit({++ return await outbox.emit({event_type: type,actor: { type: atype === 'app' ? 'app' : 'user', id: aid },subject: { type: 'app', id: rel.app_id },visibility: 'public',payload: {project_id: rel.project_id, release_id: rel.id, kind: rel.kind, subject_id: rel.subject_id, version: rel.version,-- environment: rel.environment, trust_tier: trust.get(rel.app_id).tier, ...extra,++ environment: rel.environment, trust_tier: (await trust.get(rel.app_id)).tier, ...extra,},}, { traceparent: actor.traceparent });}@@@ -91,54 +91,54 @@ function createReleases({ store, outbox, trust }) {* Create a draft from a manifest. app: { id, project_id, environment, grants, revoked_at } as* Network reported it (or, for an app token, as its claims say).*/-- function createDraft({ actor, app, kind, manifest, notes = '', eventTypes }) {++ async function createDraft({ actor, app, kind, manifest, notes = '', eventTypes }) {if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');assertCanManage(actor, app);const result = validateManifest(kind, manifest, { app, viewerSubject: actor.kind === 'user' ? actor.subject : null, eventTypes });if (!result.valid) fail(422, 'manifest.invalid', 'the manifest does not validate', { validation: result });const subjectId = manifest.id;-- const exists = db.prepare('SELECT id FROM releases WHERE app_id = ? AND kind = ? AND subject_id = ? AND version = ?').get(app.id, kind, subjectId, manifest.version);++ const exists = await db.prepare('SELECT id FROM releases WHERE app_id = ? AND kind = ? AND subject_id = ? AND version = ?').get(app.id, kind, subjectId, manifest.version);if (exists) fail(409, 'release.exists', `version ${manifest.version} of ${subjectId} already has a release (${exists.id}); versions are immutable`);const cleanNotes = String(notes || '').slice(0, 2000);const manifestId = store.newId('mfs');const releaseId = store.newId('rel');const t = store.iso();-- db.transaction(() => {-- db.prepare(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(manifestId, kind, app.id, app.project_id, subjectId, manifest.version, JSON.stringify(manifest), contractsVersion, actor.label, t);-- db.prepare(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at)++ await db.prepare(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)`).run(releaseId, app.id, app.project_id, app.environment, kind, subjectId, String(manifest.name).slice(0, 80), manifest.version, manifestId,JSON.stringify(manifest.compatibility || {}), cleanNotes, actor.label, t);-- writeLog(releaseId, 'created', actor.label, { version: manifest.version, kind, warnings: result.warnings.length });-- })();-- return { release: get(releaseId), validation: result };++ await writeLog(releaseId, 'created', actor.label, { version: manifest.version, kind, warnings: result.warnings.length });++ });++ return { release: await get(releaseId), validation: result };}-- function load(id) {-- const r = db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id));++ async function load(id) {++ const r = await db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id));if (!r) fail(404, 'release.not_found', 'no such release');return r;}-- function publish({ actor, releaseId, app }) {-- const r = load(releaseId);++ async function publish({ actor, releaseId, app }) {++ const r = await load(releaseId);if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');assertCanManage(actor, { ...app, environment: r.environment });if (r.status !== 'draft') fail(409, 'release.not_draft', `release is ${r.status}`);let env;-- db.transaction(() => {-- db.prepare("UPDATE releases SET status = 'published', published_by = ?, published_at = ? WHERE id = ? AND status = 'draft'").run(actor.label, store.iso(), r.id);-- writeLog(r.id, 'published', actor.label);-- env = event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });-- })();-- return { release: get(r.id), event_id: env.event_id };++ await db.tx(async () => {++ await db.prepare("UPDATE releases SET status = 'published', published_by = ?, published_at = ? WHERE id = ? AND status = 'draft'").run(actor.label, store.iso(), r.id);++ await writeLog(r.id, 'published', actor.label);++ env = await event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });++ });++ return { release: await get(r.id), event_id: env.event_id };}-- function deprecate({ actor, releaseId, app, reason, replacement }) {-- const r = load(releaseId);++ async function deprecate({ actor, releaseId, app, reason, replacement }) {++ const r = await load(releaseId);if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');assertCanManage(actor, { ...app, environment: r.environment });if (r.status !== 'published') fail(409, 'release.not_published', `release is ${r.status}`);@@@ -146,22 +146,22 @@ function createReleases({ store, outbox, trust }) {if (!why) fail(422, 'release.invalid', 'say why the release is deprecated');let repl = null;if (replacement) {-- const rr = db.prepare('SELECT id, app_id, status FROM releases WHERE id = ?').get(String(replacement));++ const rr = await db.prepare('SELECT id, app_id, status FROM releases WHERE id = ?').get(String(replacement));if (!rr || rr.app_id !== r.app_id || rr.status !== 'published') fail(422, 'release.invalid', 'the replacement must be a published release of the same app');repl = rr.id;}let env;-- db.transaction(() => {-- db.prepare("UPDATE releases SET status = 'deprecated', deprecated_by = ?, deprecated_at = ?, deprecation_reason = ?, replacement = ? WHERE id = ?")++ await db.tx(async () => {++ await db.prepare("UPDATE releases SET status = 'deprecated', deprecated_by = ?, deprecated_at = ?, deprecation_reason = ?, replacement = ? WHERE id = ?").run(actor.label, store.iso(), why, repl, r.id);-- writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });-- env = event('codes.app.deprecated', r, actor, { reason: why, replacement: repl });-- })();-- return { release: get(r.id), event_id: env.event_id };++ await writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });++ env = await event('codes.app.deprecated', r, actor, { reason: why, replacement: repl });++ });++ return { release: await get(r.id), event_id: env.event_id };}-- function revoke({ actor, releaseId, app, reason }) {-- const r = load(releaseId);++ async function revoke({ actor, releaseId, app, reason }) {++ const r = await load(releaseId);if (app && app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');assertCanManage(actor, { ...(app || { id: r.app_id }), environment: r.environment }, { revoke: true });if (r.status === 'revoked') fail(409, 'release.revoked', 'release is already revoked');@@@ -172,25 +172,25 @@ function createReleases({ store, outbox, trust }) {// (ADR-022): it goes to Network's audit log too. A member revoking their own release is not.const byStaff = actor.kind === 'user' && actor.staff && !(actor.role && RANK[actor.role] >= RANK[manageRole(r.environment)]);let env = null;-- db.transaction(() => {-- db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);-- writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });++ await db.tx(async () => {++ await db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);++ await writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });// A draft was never public: nobody downstream knows it, so there is nothing to announce.-- if (wasPublic) env = event('codes.app.revoked', r, actor, { reason: why });++ if (wasPublic) env = await event('codes.app.revoked', r, actor, { reason: why });if (byStaff) {const [ckind, cid] = String(r.created_by).split(':');-- outbox.moderationAction({++ await outbox.moderationAction({action: 'release.revoked', target: { type: 'release', id: r.id, owner_subject: ckind === 'user' ? cid : null },actorSubject: actor.subject, reason: why, details: { app_id: r.app_id, project_id: r.project_id, version: r.version, was: r.status },}, { traceparent: actor.traceparent });}-- })();-- return { release: get(r.id), event_id: env ? env.event_id : null };++ });++ return { release: await get(r.id), event_id: env ? env.event_id : null };}/** Every release of a project (drafts included), each with its manifest and log: for the project export. */-- const listForProject = (projectId) => db.prepare('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at, id').all(String(projectId))-- .map((r) => ({ ...view(r), manifest: manifestOf(r.manifest_id), log: log(r.id) }));++ const listForProject = async (projectId) => (await Promise.all((await db.prepare('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at, id').all(String(projectId)))++ .map(async (r) => ({ ...await view(r), manifest: await manifestOf(r.manifest_id), log: await log(r.id) }))));/*** Codes' part of deleting a project (Network archives the project itself). Drafts were never@@@ -199,22 +199,22 @@ function createReleases({ store, outbox, trust }) {* revocation (compatibility policy), so each emits codes.app.revoked as any revocation does.* The caller has checked that the actor owns the project (or is staff).*/-- function retireProject({ actor, projectId, reason = actor.staff && actor.role !== 'owner' ? 'project deleted by OpenVibe staff' : 'project deleted by its owner' }) {-- const rows = db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));++ async function retireProject({ actor, projectId, reason = actor.staff && actor.role !== 'owner' ? 'project deleted by OpenVibe staff' : 'project deleted by its owner' }) {++ const rows = await db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));const out = { revoked: [], deletedDrafts: [] };for (const r of rows.filter((x) => x.status !== 'draft')) {-- revoke({ actor, releaseId: r.id, app: null, reason });++ await revoke({ actor, releaseId: r.id, app: null, reason });out.revoked.push(r.id);}-- db.transaction(() => {++ await db.tx(async () => {for (const r of rows.filter((x) => x.status === 'draft')) {-- db.prepare("DELETE FROM releases WHERE id = ? AND status = 'draft'").run(r.id);-- const shared = db.prepare('SELECT COUNT(*) AS n FROM releases WHERE manifest_id = ?').get(r.manifest_id).n;-- if (!shared) db.prepare('DELETE FROM manifests WHERE id = ?').run(r.manifest_id);-- writeLog(r.id, 'deleted', actor.label, { reason, was: 'draft' });++ await db.prepare("DELETE FROM releases WHERE id = ? AND status = 'draft'").run(r.id);++ const shared = (await db.prepare('SELECT COUNT(*) AS n FROM releases WHERE manifest_id = ?').get(r.manifest_id)).n;++ if (!shared) await db.prepare('DELETE FROM manifests WHERE id = ?').run(r.manifest_id);++ await writeLog(r.id, 'deleted', actor.label, { reason, was: 'draft' });out.deletedDrafts.push(r.id);}-- })();++ });return out;}@@@ -15,38 +15,38 @@ const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;function createTrust({ store, outbox = null }) {const { db } = store;-- function get(appId) {-- const r = db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));++ async function get(appId) {++ const r = await db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'unreviewed', note: '', set_by: null, set_at: null };}-- function set({ appId, tier, note, actor }) {++ async function set({ appId, tier, note, actor }) {if (!actor || !actor.staff) throw Object.assign(new Error('only Codes staff set trust tiers'), { status: 403, code: 'trust.staff_only' });if (!APP_ID_RE.test(String(appId))) throw Object.assign(new Error('not an app id'), { status: 422, code: 'trust.invalid' });if (!TIERS.includes(tier)) throw Object.assign(new Error(`tier is one of ${TIERS.join(', ')}`), { status: 422, code: 'trust.invalid' });const clean = String(note || '').trim().slice(0, 500);if (!clean) throw Object.assign(new Error('say why (the note is public)'), { status: 422, code: 'trust.invalid' });-- const before = get(appId).tier;++ const before = (await get(appId)).tier;const t = store.iso();-- db.transaction(() => {-- db.prepare(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT(app_id) DO UPDATE SET tier = excluded.tier, note = excluded.note, set_by = excluded.set_by, set_at = excluded.set_at`).run(appId, tier, clean, actor.label, t);-- db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)').run(appId, before, tier, clean, actor.label, t);if (outbox) {const [kind, id] = String(actor.label).split(':');-- outbox.moderationAction({++ await outbox.moderationAction({action: 'app.trust_changed', target: { type: 'app', id: appId }, actorSubject: kind === 'user' ? id : null,reason: clean, details: { from: before, to: tier },}, { traceparent: actor.traceparent });}-- })();-- return get(appId);++ });++ return await get(appId);}-- const history = (appId) => db.prepare('SELECT from_tier, to_tier, note, set_by, set_at FROM trust_history WHERE app_id = ? ORDER BY id DESC').all(String(appId));-- const listSet = () => db.prepare('SELECT * FROM trust ORDER BY set_at DESC LIMIT 200').all();++ const history = async (appId) => await db.prepare('SELECT from_tier, to_tier, note, set_by, set_at FROM trust_history WHERE app_id = ? ORDER BY id DESC').all(String(appId));++ const listSet = async () => await db.prepare('SELECT * FROM trust ORDER BY set_at DESC LIMIT 200').all();return { get, set, history, listSet, TIERS };}@@@ -17,7 +17,7 @@*/const { createClient } = require('openvibe-sdk/core');const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked', 'codes.moderation.action'];@@@ -35,7 +35,9 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {}const events = createEventsClient(createClient(clientOpts), { source: 'codes' });let lastError = null;-- const outbox = createOutbox(db, {++ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient++ // transaction); several processes relay one table safely (leases).++ const outbox = createPgOutbox(db, {events,intervalMs: config.events.intervalMs,now,@@@ -45,21 +47,20 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {lastError = msg;},});-- outbox.ensureSchema();/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */-- function emit(envelope, { traceparent } = {}) {++ async function emit(envelope, { traceparent } = {}) {if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`Codes does not produce ${envelope.event_type}`);-- return outbox.enqueue(envelope, { traceparent });++ return await outbox.enqueue(db, envelope, { traceparent });}/*** codes.moderation.action (common.moderation-action@1), inside the caller's transaction. actor:* the staff member's subject; target: { type, id, owner_subject? }. Never the content.*/-- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {++ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };-- return emit({++ return await emit({event_type: 'codes.moderation.action',actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'codes' },subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },@@@ -75,7 +76,7 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {enabled,start() { if (enabled) outbox.start(); },stop: () => outbox.stop(),-- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),++ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),};}@@@ -22,7 +22,7 @@* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages and* docs (the per-address limit bounds them).*/--const { createActorLimiter } = require('openvibe-sdk/limits');++const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');function actor(req) {const p = req.principal;@@@ -66,7 +66,7 @@ const BUDGETS = {* limits.budget(name) (one of BUDGETS). enabled=false (tests only, as they raise the per-address* limit) counts nobody.*/--function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {++function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {const refused = registry? registry.counter({ name: 'codes_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] }): null;@@@ -74,6 +74,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lolimits: { minute: config.limits.minute, hour: config.limits.hour },actor: enabled ? actor : () => null,now,++ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),onLimited(e) {// The actor is an app principal, a subject id or an address, never a token.log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);@@@ -66,21 +66,21 @@ function createApi(ctx) {const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next): problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];-- r.get('/apps/:app/releases', ...readAccess, reads, (req, res) => {++ r.get('/apps/:app/releases', ...readAccess, reads, async (req, res) => {if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');res.set('Cache-Control', 'public, max-age=60');-- res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });++ res.json({ app_id: req.params.app, trust: await trust.get(req.params.app), releases: await releases.listForApp(req.params.app) });});-- r.get('/apps/:app/trust', ...readAccess, reads, (req, res) => {++ r.get('/apps/:app/trust', ...readAccess, reads, async (req, res) => {if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');res.set('Cache-Control', 'public, max-age=60');-- res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });++ res.json({ app_id: req.params.app, ...await trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });});-- r.get('/releases/:id', ...readAccess, reads, (req, res) => {-- const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;++ r.get('/releases/:id', ...readAccess, reads, async (req, res) => {++ const rel = REL_RE.test(req.params.id) ? await releases.get(req.params.id) : null;if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');res.set('Cache-Control', 'public, max-age=60');-- const m = releases.manifestOf(rel.manifest_id);++ const m = await releases.manifestOf(rel.manifest_id);res.json({ release: rel, manifest: m ? m.body : null });});@@@ -108,7 +108,7 @@ function createApi(ctx) {return problem(req, res, 500, 'internal.error', 'Internal error');};-- r.post('/apps/:app/releases', limiter, ...manageAccess, B('codes.release.create'), json, (req, res) => {++ r.post('/apps/:app/releases', limiter, ...manageAccess, B('codes.release.create'), json, async (req, res) => {if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');const p = appPrincipal(req, res, req.params.app);if (!p) return;@@@ -116,24 +116,24 @@ function createApi(ctx) {const b = req.body || {};try {const kind = b.kind === 'mod' ? 'mod' : 'app';-- const out = releases.createDraft({ actor: p.actor, app: p.app, kind, manifest: b.manifest, notes: b.notes, eventTypes: docs.eventTypes });++ const out = await releases.createDraft({ actor: p.actor, app: p.app, kind, manifest: b.manifest, notes: b.notes, eventTypes: docs.eventTypes });let release = out.release;-- if (b.publish === true) release = releases.publish({ actor: p.actor, releaseId: release.id, app: p.app }).release;++ if (b.publish === true) release = (await releases.publish({ actor: p.actor, releaseId: release.id, app: p.app })).release;res.status(201).json({ release, warnings: out.validation.warnings });} catch (err) { fail(req, res, err); }});for (const action of ['publish', 'deprecate', 'revoke']) {-- r.post(`/releases/:id/${action}`, limiter, ...manageAccess, B('codes.release.manage'), json, (req, res) => {-- const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;++ r.post(`/releases/:id/${action}`, limiter, ...manageAccess, B('codes.release.manage'), json, async (req, res) => {++ const rel = REL_RE.test(req.params.id) ? await releases.get(req.params.id) : null;if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');const p = appPrincipal(req, res, rel.app_id);if (!p) return;const b = req.body || {};try {-- const out = action === 'publish' ? releases.publish({ actor: p.actor, releaseId: rel.id, app: p.app })-- : action === 'deprecate' ? releases.deprecate({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason, replacement: b.replacement })-- : releases.revoke({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason });++ const out = action === 'publish' ? await releases.publish({ actor: p.actor, releaseId: rel.id, app: p.app })++ : action === 'deprecate' ? await releases.deprecate({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason, replacement: b.replacement })++ : await releases.revoke({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason });res.json(out);} catch (err) { fail(req, res, err); }});@@@ -56,8 +56,8 @@ function createPageRoutes(ctx) {const governance = loadGovernance();const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });-- r.get('/', (req, res) => {-- const recent = releases.recentPublic(10);++ r.get('/', async (req, res) => {++ const recent = await releases.recentPublic(10);page(req, res, {index: true, cache: PUBLIC_CACHE,body: html`<h1>Build on OpenVibe</h1>@@@ -178,11 +178,11 @@ ${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w// ── Public release pages ────────────────────────────────const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;-- r.get('/apps/:app', (req, res, next) => {++ r.get('/apps/:app', async (req, res, next) => {if (!APP_ID_RE.test(req.params.app)) return next();-- const list = releases.listForApp(req.params.app);-- const t = trust.get(req.params.app);-- const hist = trust.history(req.params.app);++ const list = await releases.listForApp(req.params.app);++ const t = await trust.get(req.params.app);++ const hist = await trust.history(req.params.app);page(req, res, {index: list.length > 0, cache: PUBLIC_CACHE, title: list[0] ? list[0].name : req.params.app,crumbs: [{ label: 'Apps' }, { label: req.params.app }],@@@ -199,7 +199,7 @@ ${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], h});r.get('/releases/:id', async (req, res, next) => {-- const rel = releases.get(req.params.id);++ const rel = await releases.get(req.params.id);if (!rel) return next();if (rel.status === 'draft') {// Drafts are visible to members of the app's project only — as Network reports it.@@@ -209,7 +209,7 @@ ${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], h}if (!member) return next();}-- const m = releases.manifestOf(rel.manifest_id);++ const m = await releases.manifestOf(rel.manifest_id);const signedIn = req.viewer.kind === 'user';page(req, res, {index: rel.status === 'published', cache: rel.status === 'draft' ? null : PUBLIC_CACHE, title: `${rel.name} ${rel.version}`,@@@ -225,7 +225,7 @@ ${rel.revoked_at ? html`<dt>Revoked</dt><dd>${time(rel.revoked_at)}: ${rel.revoc${rel.notes ? html`<h2>Notes</h2><p>${rel.notes}</p>` : ''}<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>${signedIn ? releaseActions(req, rel) : ''}--<h2>History</h2>${table(['When', 'Action', 'By'], releases.log(rel.id).map((l) => [time(l.at), l.action, l.actor]))}`,++<h2>History</h2>${table(['When', 'Action', 'By'], (await releases.log(rel.id)).map((l) => [time(l.at), l.action, l.actor]))}`,});});@@@ -240,7 +240,7 @@ ${signedIn ? releaseActions(req, rel) : ''}}// ── Staff: trust tiers (metadata) ───────────────────────-- r.get('/staff', (req, res) => {++ r.get('/staff', async (req, res) => {if (!req.viewer.staff) return page(req, res, { title: 'Staff', body: html`<h1>Staff only</h1><p>Trust tiers are set by Codes staff.</p>` }, req.viewer.kind === 'user' ? 403 : 401);page(req, res, {title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],@@@ -250,14 +250,14 @@ ${signedIn ? releaseActions(req, rel) : ''}<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label><label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label><label>Note (public) <input name="note" required maxlength="500"></label><button type="submit">Set tier</button></form>--<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], trust.listSet().map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,++<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], (await trust.listSet()).map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,});});// Per-actor limit (http/actor-limits.js) before the form is read.-- r.post('/staff/trust', ctx.actorLimits.budget('codes.trust.set'), form, (req, res) => {++ r.post('/staff/trust', ctx.actorLimits.budget('codes.trust.set'), form, async (req, res) => {if (!req.viewer.staff || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);try {-- trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });++ await trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });res.redirect(303, '/staff?done=1');} catch (err) {page(req, res, { title: 'Staff', body: problemBox({ status: err.status || 422, code: err.code || 'trust.invalid', detail: err.message }) }, err.status || 422);@@@ -114,10 +114,10 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p<p class="muted small">New projects are sandbox-only. OpenVibe staff enable production and set which capabilities a project's apps may hold (its allowance).</p>`,}, status);}-- r.get('/', (req, res) => projectsPage(req, res));++ r.get('/', async (req, res) => await projectsPage(req, res));r.post('/', B('codes.project.create'), form, guard, async (req, res) => {const got = await net(req, res, (t) => network.projects.create(t, { name: String(req.body.name || '') }));-- if (!got.ok) return projectsPage(req, res, { problem: got.problem, status: got.problem.status });++ if (!got.ok) return await projectsPage(req, res, { problem: got.problem, status: got.problem.status });res.redirect(303, `/projects/${got.data.id}`);});@@@ -301,8 +301,8 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/mif (!got.ok) return problemPage(req, res, got.problem, { title: 'Delete project', back: back(req) });}const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.role, staff: req.viewer.staff, traceparent: req.ov.traceparent };-- const retired = releases.retireProject({ actor, projectId: project.id });-- const runs = playground.deleteRunsForProject(project.id);++ const retired = await releases.retireProject({ actor, projectId: project.id });++ const runs = await playground.deleteRunsForProject(project.id);log.info(`[Codes] project ${project.id} deleted by ${actor.label}: archived in Network, ${retired.revoked.length} releases revoked, ${retired.deletedDrafts.length} drafts and ${runs} playground runs deleted`);res.redirect(303, `${back(req)}?done=${encodeURIComponent(`Deleted: ${retired.revoked.length} revoked, ${retired.deletedDrafts.length} drafts and ${runs} runs removed`)}`);});@@@ -427,8 +427,8 @@ const client = sdk.createClient({const grantRows = grants.ok ? (grants.data.grants || []) : [];const byCap = new Map(grantRows.map((g) => [g.capability, g]));const offer = catalog.ok ? offered(catalog.data, project) : [];-- const t = trust.get(app.id);-- const rels = releases.listForApp(app.id, { includeDrafts: true });++ const t = await trust.get(app.id);++ const rels = await releases.listForApp(app.id, { includeDrafts: true });page(req, res, {title: app.name, scripts: ['js/copy.js'],crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name }],@@@ -562,7 +562,7 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo<label><input type="radio" name="credential_type" value="access_token"> access token you minted for this app</label><input type="password" name="credential" value="" autocomplete="off" required aria-label="Credential"></fieldset>`;const pre = (p) => (p.ok ? '' : html`<div class="notice ${p.stage === 'grant' ? 'warn' : 'bad'}"><strong>Cannot run:</strong> ${p.detail}${p.missing && p.grantable ? html` <a href="${base}#request">Request it</a>.` : ''}</div>`);-- const runs = playground.runsFor(app.id);++ const runs = await playground.runsFor(app.id);page(req, res, {title: `Playground · ${app.name}`,crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'Playground' }],@@@ -596,7 +596,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));-- r.get('/:project/apps/:app/playground', idParams, (req, res) => playgroundPage(req, res));++ r.get('/:project/apps/:app/playground', idParams, async (req, res) => await playgroundPage(req, res));r.post('/:project/apps/:app/playground/events', idParams, B('codes.playground.run'), form, guard, async (req, res) => {const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));@@@ -608,7 +608,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`input: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },});// The credential is NOT passed back: the form comes back with it empty.-- return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });++ return await playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });});r.post('/:project/apps/:app/playground/media', idParams, B('codes.playground.run'), async (req, res) => {@@@ -625,7 +625,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`credential: { type: parsed.fields.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: String(parsed.fields.credential || '').trim() },file: parsed.file,});-- return playgroundPage(req, res, { result: out, status: statusFor(out) });++ return await playgroundPage(req, res, { result: out, status: statusFor(out) });});// ── Releases (Codes-owned) ──────────────────────────────@@@ -651,12 +651,12 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no}, status);}-- r.get('/:project/apps/:app/releases/new', idParams, (req, res) => editorPage(req, res, { kind: req.query.kind }));++ r.get('/:project/apps/:app/releases/new', idParams, async (req, res) => await editorPage(req, res, { kind: req.query.kind }));r.post('/:project/apps/:app/releases', idParams, B('codes.release.create'), form, guard, async (req, res) => {const b = req.body || {};const kind = b.kind === 'mod' ? 'mod' : 'app';const p = manifestsLib.parse(b.manifest);-- if (p.error) return editorPage(req, res, { status: 422, kind, text: b.manifest || '', notes: b.notes, parseError: p.error });++ if (p.error) return await editorPage(req, res, { status: 422, kind, text: b.manifest || '', notes: b.notes, parseError: p.error });const [project, app] = await Promise.all([net(req, res, (t) => network.projects.get(t, req.params.project)),net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),@@@ -664,17 +664,17 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? noif (!project.ok || !app.ok) return problemPage(req, res, (project.ok ? app : project).problem, { back: appBase(req) });if (b.intent !== 'create') {const v = manifestsLib.validate(kind, p.manifest, { app: app.data, viewerSubject: req.viewer.subject, eventTypes: docs.eventTypes });-- return editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });++ return await editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });}try {-- const out = releases.createDraft({++ const out = await releases.createDraft({actor: { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.data.role, staff: req.viewer.staff, traceparent: req.ov.traceparent },app: app.data, kind, manifest: p.manifest, notes: b.notes, eventTypes: docs.eventTypes,});res.redirect(303, `/releases/${out.release.id}`);} catch (err) {if (!(err instanceof ReleaseError)) return unexpected(req, res, err);-- return editorPage(req, res, { status: err.status, kind, text: b.manifest, notes: b.notes, validation: err.validation || null, problem: err.validation ? null : { status: err.status, code: err.code, detail: err.detail } });++ return await editorPage(req, res, { status: err.status, kind, text: b.manifest, notes: b.notes, validation: err.validation || null, problem: err.validation ? null : { status: err.status, code: err.code, detail: err.detail } });}});@@@ -698,7 +698,7 @@ function createReleaseActionRoutes(ctx) {if (req.viewer.kind !== 'user' || !req.viewer.subject) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>` }, 401);if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1>` }, 403);if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);-- const rel = releases.get(req.params.id);++ const rel = await releases.get(req.params.id);if (!rel) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);let project = null;let app = null;@@@ -717,11 +717,11 @@ function createReleaseActionRoutes(ctx) {}const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project ? project.role : null, staff: req.viewer.staff, traceparent: req.ov.traceparent };try {-- if (action === 'publish') releases.publish({ actor, releaseId: rel.id, app });-- else if (action === 'deprecate') releases.deprecate({ actor, releaseId: rel.id, app, reason: req.body.reason, replacement: req.body.replacement || null });++ if (action === 'publish') await releases.publish({ actor, releaseId: rel.id, app });++ else if (action === 'deprecate') await releases.deprecate({ actor, releaseId: rel.id, app, reason: req.body.reason, replacement: req.body.replacement || null });else {if (req.body.confirm !== '1') throw new ReleaseError(422, 'codes.confirm', 'tick the box to confirm');-- releases.revoke({ actor, releaseId: rel.id, app, reason: req.body.reason });++ await releases.revoke({ actor, releaseId: rel.id, app, reason: req.body.reason });}res.redirect(303, `/releases/${rel.id}?done=${action === 'publish' ? 'published' : (action === 'deprecate' ? 'deprecated' : 'revoked')}`);} catch (err) {@@@ -7,11 +7,12 @@*/const { createApp } = require('./app');--const { app, ctx } = createApp();++(async () => {++const { app, ctx } = await createApp();const { config } = ctx;const server = app.listen(config.port, config.host, () => {-- console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);++ console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);console.log(`[Codes] docs from openvibe-contracts v${ctx.docs.contractsVersion} and openvibe-sdk v${ctx.docs.sdkVersion}; events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}`);});server.keepAliveTimeout = 65_000;@@@ -22,10 +23,11 @@ function shutdown(signal) {console.log(`[Codes] ${signal}: closing`);server.close(async () => {try { await ctx.outbox.stop(); } catch { /* best effort */ }-- try { ctx.store.close(); } catch { /* already closed */ }++ try { await ctx.store.close(); } catch { /* already closed */ }process.exit(0);});setTimeout(() => process.exit(0), 5000).unref();}process.on('SIGTERM', () => shutdown('SIGTERM'));process.on('SIGINT', () => shutdown('SIGINT'));++})().catch((err) => { console.error('[Codes] failed to start:', err); process.exit(1); });@@@ -14,7 +14,7 @@const { createReadiness } = require('openvibe-shared/ready');const { TABLES } = require('./db');--function createCodesReadiness({ store, keys, network, outbox, docs, config, release = null }) {++function createCodesReadiness({ store, keys, network, outbox, docs, config, valkey = null, release = null }) {const { db } = store;return createReadiness({service: 'codes',@@@ -22,8 +22,8 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, relechecks: [{name: 'db', required: true,-- check: () => {-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));++ check: async () => {++ const names = new Set((await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));const missing = TABLES.filter((t) => !names.has(t));return missing.length ? `missing ${missing.join(', ')}` : true;},@@@ -34,6 +34,7 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele? { ok: true, detail: { contracts: docs.contractsVersion, sdk: docs.sdkVersion } }: 'reference docs were not generated'),},++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },{name: 'network_jwks', required: false,check: () => {@@@ -54,8 +55,8 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele},{name: 'events_relay', required: false,-- check: () => {-- const s = outbox.status();++ check: async () => {++ const s = await outbox.status();if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;return { ok: true, detail: { pending: s.pending } };@@@ -43,7 +43,7 @@ const { actor } = require('../server/http/actor-limits');assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);const theirs = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.read']);assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${theirs}` } })).status, 200, 'another app still passes');-- const from = (ip) => t.get(path, { headers: { 'X-Forwarded-For': ip } });++ const from = async (ip) => await t.get(path, { headers: { 'X-Forwarded-For': ip } });for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);assert.strictEqual((await from('203.0.113.7')).status, 429);assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');@@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];-- assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');++ assert.strictEqual(tag('openvibe-contracts'), 'v0.76.0');assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);-- assert.strictEqual(sdkPkg.version, '0.13.0');-- // The contracts tag v0.53.0 carries the released codes manifest and codes.app-manifest@1.++ assert.strictEqual(sdkPkg.version, '0.20.1');++ // The contracts tag v0.76.0 carries the released codes manifest and codes.app-manifest@1.assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');assert.ok(contracts.resolve('codes.app-manifest@1'));});@@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');const r = await t.get(p);assert.strictEqual(r.status, 200, p);assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);-- assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.53.0'), `${p} links the pinned tag`);-- if (contractsVersion !== '0.53.0') assert.ok(r.text.includes('(tag v0.53.0)'), `${p} says the tag differs from the package version`);++ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.76.0'), `${p} links the pinned tag`);++ if (contractsVersion !== '0.76.0') assert.ok(r.text.includes('(tag v0.76.0)'), `${p} says the tag differs from the package version`);assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);}const v = (await t.get('/api/v1/docs/versions')).json();assert.strictEqual(v.contracts, contractsVersion);-- assert.strictEqual(v.contracts_tag, 'v0.53.0');++ assert.strictEqual(v.contracts_tag, 'v0.76.0');assert.strictEqual(v.sdk, sdkPkg.version);});@@@ -47,7 +47,10 @@ async function boot(opts = {}) {const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };const config = configLib.load(env);-- const built = createApp({ config, log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });++ const { createStore } = require('../../server/db');++ // One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.++ const testdb = await require('./db').testDb();++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });await built.ctx.keys.ensure();if (opts.relay) built.ctx.outbox.start();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });@@@ -83,10 +86,10 @@ async function boot(opts = {}) {}/** Every value in every table of Codes' database, as one string. */-- function dbDump() {++ async function dbDump() {const db = built.ctx.store.db;-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);-- return tables.map((tname) => JSON.stringify(db.prepare(`SELECT * FROM "${tname}"`).all())).join('\n');++ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.prepare(`SELECT * FROM "${tname}"`).all())))).join('\n');}async function project(user, name = 'Test project') {@@@ -116,7 +119,7 @@ async function boot(opts = {}) {async close() {await new Promise((r) => server.close(r));await built.ctx.outbox.stop();-- built.ctx.store.close();++ await testdb.close();await network.close(); await events.close(); await media.close();fs.rmSync(dir, { recursive: true, force: true });},nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * CODES_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.CODES_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'codes', max });++++module.exports = { testDb, pgAvailable };@@@ -311,7 +311,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mif (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);-- return projectsApi(req, raw, json, u, parts, url.searchParams);++ return await projectsApi(req, raw, json, u, parts, url.searchParams);}return json(404, { error: 'not found' });});@@@ -94,7 +94,7 @@ const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';assert.strictEqual(api.status, 422);assert.strictEqual(api.json().valid, false);assert.strictEqual(api.json().contracts_version, require('openvibe-contracts/package.json').version);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM manifests').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM manifests').get()).n, 0);});await t.close();done();@@@ -19,8 +19,8 @@ const { boot, check, done } = require('./helpers/boot');const app = await t.app(owner, projectId, { name: 'Moderated' });const base = `/projects/${projectId}/apps/${app.id}`;const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Moderated' }), version });-- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()-- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'codes.moderation.action');++ const moderation = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'codes.moderation.action');const published = async (version) => {const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });const id = r.headers.get('location').split('/').pop();@@@ -40,14 +40,14 @@ const { boot, check, done } = require('./helpers/boot');const id = await published('1.0.0');const r = await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'Mine to pull', confirm: '1' } });assert.strictEqual(r.status, 303);-- assert.strictEqual(moderation().length, 0);++ assert.strictEqual((await moderation()).length, 0);});await check('staff revoking someone else\'s release: exactly one valid codes.moderation.action', async () => {const id = await published('1.1.0');const r = await t.get(`/releases/${id}/revoke`, { as: staff, form: { reason: 'Malware in the bundle', confirm: '1' } });assert.strictEqual(r.status, 303, r.text.slice(0, 300));-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, 1);valid(ev[0]);assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `release:${id}` });@@@ -60,10 +60,10 @@ const { boot, check, done } = require('./helpers/boot');await check('staff setting a trust tier: exactly one event; a refused change: none', async () => {assert.strictEqual((await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } })).status, 403);-- assert.strictEqual(moderation().length, 1);++ assert.strictEqual((await moderation()).length, 1);const r = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Read the source' } });assert.strictEqual(r.status, 303);-- const ev = moderation();++ const ev = await moderation();assert.strictEqual(ev.length, 2);const e = ev[1];valid(e);@@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/boot');const app = await t.app(owner, projectId);const base = `/projects/${projectId}/apps/${app.id}`;const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;-- const media = (fields, file = { name: 'a.txt', content: 'hello' }) => t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });++ const media = async (fields, file = { name: 'a.txt', content: 'hello' }) => await t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });const outside = () => ({token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,media: t.media.requests.length,@@@ -132,7 +132,7 @@ const { boot, check, done } = require('./helpers/boot');});await check('the run log records outcomes and codes, never the credential', async () => {-- const rows = t.ctx.store.db.prepare('SELECT * FROM playground_runs WHERE app_id = ? ORDER BY at').all(app.id);++ const rows = await t.ctx.store.db.prepare('SELECT * FROM playground_runs WHERE app_id = ? ORDER BY at').all(app.id);assert.ok(rows.length >= 6);assert.ok(rows.some((x) => x.outcome === 'ok' && x.ref));assert.ok(rows.some((x) => x.stage === 'grant' && x.code === 'playground.grant_missing'));@@@ -58,7 +58,7 @@ const { boot, check, done } = require('./helpers/boot');}const archivePath = `/projects/${projectId}/export/archive`;-- const exportAs = (who, form = {}) => t.get(archivePath, { as: who, form });++ const exportAs = async (who, form = {}) => await t.get(archivePath, { as: who, form });const minted = () => t.network.state.exportTokens;const lines = (buf) => buf.toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));let files = null;@@@ -156,7 +156,7 @@ const { boot, check, done } = require('./helpers/boot');for (const x of minted()) {const sig = x.token.split('.')[2];assert.ok(!everything.includes(sig), 'an export token in the archive');-- assert.ok(!t.dbDump().includes(sig), 'an export token in Codes\' database');++ assert.ok(!(await t.dbDump()).includes(sig), 'an export token in Codes\' database');assert.ok(!t.logs().includes(sig), 'an export token in the logs');}assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(everything), 'no JWT at all');@@@ -36,11 +36,11 @@ const { boot, check, done } = require('./helpers/boot');const published = await draft('1.0.0');assert.strictEqual((await t.get(`/releases/${published}/publish`, { as: owner, form: {} })).status, 303);const drafted = await draft('1.1.0');-- const draftManifest = db.prepare('SELECT manifest_id FROM releases WHERE id = ?').get(drafted).manifest_id;-- db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, detail)++ const draftManifest = (await db.prepare('SELECT manifest_id FROM releases WHERE id = ?').get(drafted)).manifest_id;++ await db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, detail)VALUES ('run_01JTEST00000000000000000000', ?, ?, ?, ?, 'media', 'media.object.upload', 'client_secret', 'ok', 'done', 'stored 5 bytes')`).run(new Date().toISOString(), `user:${owner.subject}`, projectId, app.id);-- const count = (sql, ...a) => db.prepare(sql).get(...a).n;++ const count = async (sql, ...a) => (await db.prepare(sql).get(...a)).n;const exportPath = `/projects/${projectId}/export`;await check('the owner exports everything Network and Codes hold for the project, as a JSON download', async () => {@@@ -109,11 +109,11 @@ const { boot, check, done } = require('./helpers/boot');} finally { t.ctx.network.projects.quotas = orig; }});-- const unchanged = () => {++ const unchanged = async () => {assert.strictEqual(t.network.state.projects.get(projectId).archived_at, null, 'not archived in Network');-- assert.strictEqual(count('SELECT COUNT(*) AS n FROM releases WHERE project_id = ?', projectId), 2);-- assert.strictEqual(count("SELECT COUNT(*) AS n FROM releases WHERE status = 'published' AND id = ?", published), 1);-- assert.strictEqual(count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 1);++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM releases WHERE project_id = ?', projectId), 2);++ assert.strictEqual(await count("SELECT COUNT(*) AS n FROM releases WHERE status = 'published' AND id = ?", published), 1);++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 1);};const del = `/projects/${projectId}/delete`;@@@ -121,7 +121,7 @@ const { boot, check, done } = require('./helpers/boot');const r = await t.get(del, { as: dev, form: { confirm_name: 'Exportable' } });assert.strictEqual(r.status, 403);assert.match(r.text, /only the project owner/);-- unchanged();++ await unchanged();});await check('delete: the wrong name, or no form token, changes nothing and calls no Network write', async () => {@@@ -132,7 +132,7 @@ const { boot, check, done } = require('./helpers/boot');const noCsrf = await t.get(del, { as: owner, form: { confirm_name: 'Exportable', csrf: 'forged' } });assert.strictEqual(noCsrf.status, 403);assert.strictEqual(t.network.requests.filter((q) => q.method === 'POST').length, before);-- unchanged();++ await unchanged();});await check('delete: when Network refuses to archive, Codes keeps everything', async () => {@@@ -142,7 +142,7 @@ const { boot, check, done } = require('./helpers/boot');const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });assert.notStrictEqual(r.status, 303);} finally { t.ctx.network.projects.archive = orig; }-- unchanged();++ await unchanged();});await check('delete by the owner: archived in Network, drafts and runs removed, public releases revoked with an event', async () => {@@@ -154,15 +154,15 @@ const { boot, check, done } = require('./helpers/boot');const p = t.network.state.projects.get(projectId);assert.ok(p.archived_at, 'archived in Network');assert.ok(t.network.state.apps.get(app.id).revoked_at, 'Network revoked the app');-- assert.strictEqual(count('SELECT COUNT(*) AS n FROM releases WHERE id = ?', drafted), 0, 'draft deleted');-- assert.strictEqual(count('SELECT COUNT(*) AS n FROM manifests WHERE id = ?', draftManifest), 0, 'its manifest deleted');-- assert.strictEqual(db.prepare('SELECT status, revocation_reason FROM releases WHERE id = ?').get(published).status, 'revoked');-- assert.strictEqual(count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 0);-- const ev = db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope));++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM releases WHERE id = ?', drafted), 0, 'draft deleted');++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM manifests WHERE id = ?', draftManifest), 0, 'its manifest deleted');++ assert.strictEqual((await db.prepare('SELECT status, revocation_reason FROM releases WHERE id = ?').get(published)).status, 'revoked');++ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 0);++ const ev = (await db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((x) => (typeof x.envelope === 'string' ? JSON.parse(x.envelope) : x.envelope));const revoked = ev.filter((e) => e.event_type === 'codes.app.revoked');assert.strictEqual(revoked.length, 1);assert.strictEqual(revoked[0].payload.release_id, published);-- assert.deepStrictEqual(db.prepare('SELECT action FROM release_log WHERE release_id = ? ORDER BY id').all(drafted).map((l) => l.action), ['created', 'deleted'], 'the append-only log records the deletion');++ assert.deepStrictEqual((await db.prepare('SELECT action FROM release_log WHERE release_id = ? ORDER BY id').all(drafted)).map((l) => l.action), ['created', 'deleted'], 'the append-only log records the deletion');// The public release page now says revoked.assert.match((await t.get(`/releases/${published}`)).text, /revoked/i);});@@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/boot');const app = await t.app(owner, projectId, { name: 'Releaser' });const base = `/projects/${projectId}/apps/${app.id}`;const manifest = (version, extra = {}) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Releaser' }), version, ...extra });-- const outbox = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));++ const outbox = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));let rel1;let rel2;@@@ -30,14 +30,14 @@ const { boot, check, done } = require('./helpers/boot');const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'validate' } });assert.strictEqual(r.status, 200);assert.match(r.text, /Valid\./);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get()).n, 0);});await check('an invalid manifest creates nothing and shows the errors', async () => {const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0', { id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ' }), intent: 'create' } });assert.strictEqual(r.status, 422);assert.match(r.text, /must be this app's id/);-- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get().n, 0);++ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get()).n, 0);});await check('a draft is created, visible to members only, and emits nothing', async () => {@@@ -48,7 +48,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual((await t.get(`/releases/${rel1}`)).status, 404, 'anonymous cannot see a draft');assert.strictEqual((await t.get(`/releases/${rel1}`, { as: owner })).status, 200);assert.strictEqual((await t.get(`/api/v1/releases/${rel1}`)).status, 404);-- assert.strictEqual(outbox().length, 0);++ assert.strictEqual((await outbox()).length, 0);});await check('versions are immutable: the same version again is 409', async () => {@@@ -66,7 +66,7 @@ const { boot, check, done } = require('./helpers/boot');await check('publishing emits codes.app.published (valid envelope, 3 segments, public)', async () => {const r = await t.get(`/releases/${rel1}/publish`, { as: owner, form: {} });assert.strictEqual(r.status, 303);-- const ev = outbox();++ const ev = await outbox();assert.strictEqual(ev.length, 1);const e = ev[0];assert.strictEqual(e.event_type, 'codes.app.published');@@@ -94,7 +94,7 @@ const { boot, check, done } = require('./helpers/boot');await t.get(`/releases/${rel2}/publish`, { as: owner, form: {} });const ok = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'Use 1.1.0', replacement: rel2 } });assert.strictEqual(ok.status, 303);-- const e = outbox().pop();++ const e = (await outbox()).pop();assert.strictEqual(e.event_type, 'codes.app.deprecated');assert.strictEqual(e.payload.replacement, rel2);});@@@ -104,7 +104,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(unconfirmed.status, 422);const ok = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'Security issue', confirm: '1' } });assert.strictEqual(ok.status, 303);-- assert.strictEqual(outbox().pop().event_type, 'codes.app.revoked');++ assert.strictEqual((await outbox()).pop().event_type, 'codes.app.revoked');const again = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'x', confirm: '1' } });assert.strictEqual(again.status, 409);});@@@ -112,13 +112,13 @@ const { boot, check, done } = require('./helpers/boot');await check('revoking a draft announces nothing (it was never public)', async () => {const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('2.0.0-rc.1'), intent: 'create' } });const id = r.headers.get('location').split('/').pop();-- const n = outbox().length;++ const n = (await outbox()).length;await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'abandoned', confirm: '1' } });-- assert.strictEqual(outbox().length, n);++ assert.strictEqual((await outbox()).length, n);});await check('only the three codes.app.* types are ever produced', async () => {-- const types = new Set(outbox().map((e) => e.event_type));++ const types = new Set((await outbox()).map((e) => e.event_type));for (const x of types) assert.ok(['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked'].includes(x), x);});@@@ -147,7 +147,7 @@ const { boot, check, done } = require('./helpers/boot');const mod = { ...manifests.template('mod', { appId: app.id }), id: contracts.ids.newId('mod'), version: '0.1.0' };const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'mod', manifest: JSON.stringify(mod), intent: 'create' } });assert.strictEqual(r.status, 303, r.text.slice(0, 300));-- const rel = t.ctx.releases.get(r.headers.get('location').split('/').pop());++ const rel = await t.ctx.releases.get(r.headers.get('location').split('/').pop());assert.strictEqual(rel.kind, 'mod');assert.strictEqual(rel.subject_id, mod.id);});@@@ -158,7 +158,7 @@ const { boot, check, done } = require('./helpers/boot');const r = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${token}` }, json: { kind: 'app', manifest: JSON.parse(manifest('4.0.0', { publisher: { type: 'app', id: app.id } })), publish: true } });assert.strictEqual(r.status, 201, r.text);assert.strictEqual(r.json().release.status, 'published');-- const e = outbox().pop();++ const e = (await outbox()).pop();assert.deepStrictEqual(e.actor, { type: 'app', id: app.id });});@@@ -100,7 +100,7 @@ const { boot, check, done } = require('./helpers/boot');});await check('no secret or token anywhere: database, logs, later responses', async () => {-- const dump = t.dbDump();++ const dump = await t.dbDump();const logs = t.logs();assert.ok(secrets.length >= 5);for (const s of secrets) {@@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');const annRelease = made.headers.get('location').split('/').pop();const networkState = () => JSON.stringify([[...t.network.state.projects.entries()], [...t.network.state.members.entries()], [...t.network.state.apps.entries()], [...t.network.state.creds.entries()]]);-- const snapshot = () => networkState() + t.dbDump();++ const snapshot = async () => networkState() + await t.dbDump();const HERS = ['Ann Hidden App', 'ann-draft-notes', ...(annApp.secret ? [annApp.secret] : [])];const refused = (r, what) => {assert.ok([401, 403, 404].includes(r.status) || (r.status === 303 && /\/login|\/auth/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);@@@ -51,7 +51,7 @@ const { boot, check, done } = require('./helpers/boot');});await check('forms: Bob cannot change Ann\'s project, apps, members, credentials, grants or releases', async () => {-- const before = snapshot();++ const before = await snapshot();const P = `/projects/${annProject}`;const A = `${P}/apps/${annApp.id}`;for (const [p, form] of [@@@ -71,7 +71,7 @@ const { boot, check, done } = require('./helpers/boot');]) {refused(await t.get(p, { as: bob, form }), `POST ${p}`);}-- assert.strictEqual(snapshot(), before, 'something of Ann\'s changed');++ assert.strictEqual(await snapshot(), before, 'something of Ann\'s changed');});await check('controls: Ann reads her project and publishes her release', async () => {@@@ -16,7 +16,7 @@ const manifests = require('../server/domain/manifests');const appModule = require('../server/app');let capturedApp = null;const realCreate = appModule.createApp;--appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };const { boot, check, done } = require('./helpers/boot');const { getPaths, crawl } = require('./security-crawl');@@@ -62,8 +62,8 @@ const { getPaths, crawl } = require('./security-crawl');assert.deepStrictEqual(r.found, []);});-- await check('the events outbox carries nothing of the draft (drafts emit nothing)', () => {-- const text = JSON.stringify(t.ctx.store.db.prepare('SELECT envelope FROM event_outbox').all());++ await check('the events outbox carries nothing of the draft (drafts emit nothing)', async () => {++ const text = JSON.stringify(await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox').all());assert.ok(!text.includes('ann-draft-notes') && !text.includes(release));});@@@ -15,7 +15,7 @@ const assert = require('assert');const appModule = require('../server/app');let capturedApp = null;const realCreate = appModule.createApp;--appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };const { boot, check, done } = require('./helpers/boot');const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');@@@ -56,8 +56,8 @@ const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-cliassert.deepStrictEqual(found, []);});-- await check('the outbox and the captured log lines carry no sentinel', () => {-- const text = t.dbDump() + t.logs();++ await check('the outbox and the captured log lines carry no sentinel', async () => {++ const text = await t.dbDump() + t.logs();for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);});@@@ -29,7 +29,7 @@ const { signJwt } = require('./helpers/mocks');const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');-- assert.strictEqual(s.ctx.trust.get(APP).tier, 'unreviewed');++ assert.strictEqual((await s.ctx.trust.get(APP)).tier, 'unreviewed');});await check('an app token is not a Codes session', async () => {@@@ -53,7 +53,7 @@ const { signJwt } = require('./helpers/mocks');assert.strictEqual(page.status, 200, page.text.slice(0, 200));const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });assert.strictEqual(r.status, 303);-- assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');++ assert.strictEqual((await s.ctx.trust.get(APP)).tier, 'reviewed');});await check('another site cannot sign a person out; this site can', async () => {ddeleted file mode 100644@@@ -1,68 +0,0 @@--'use strict';--/**-- * ADR-013 trust tiers (unreviewed, reviewed, first-party): databases written with the roadmap's-- * earlier names are migrated at boot — untrusted→unreviewed, verified|trusted→reviewed,-- * platform-maintained→first-party — including the history, and the CHECK constraint is rebuilt.-- * Running it again changes nothing. Tiers stay metadata.-- */--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const Database = require('better-sqlite3');--const { openStore } = require('../server/db');--const { createTrust } = require('../server/domain/trust');--const { check, done } = require('./helpers/boot');----const A = (n) => `app_01JABCDEFGHJKMNPQRSTVWXYZ${n}`;----(async () => {-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-trust-'));-- const file = path.join(dir, 'codes.db');-- // A database as the first Codes build created it.-- const old = new Database(file);-- old.exec(`CREATE TABLE trust (-- app_id TEXT PRIMARY KEY,-- tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),-- note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);-- CREATE TABLE trust_history (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, from_tier TEXT NOT NULL, to_tier TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);`);-- const ins = old.prepare('INSERT INTO trust VALUES (?, ?, ?, ?, ?)');-- ins.run(A(0), 'untrusted', 'n0', 'user:x', '2026-09-22T00:00:00Z');-- ins.run(A(1), 'verified', 'n1', 'user:x', '2026-09-22T00:00:00Z');-- ins.run(A(2), 'trusted', 'n2', 'user:x', '2026-09-22T00:00:00Z');-- ins.run(A(3), 'platform-maintained', 'n3', 'user:x', '2026-09-22T00:00:00Z');-- old.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)').run(A(3), 'trusted', 'platform-maintained', 'n', 'user:x', '2026-09-22T00:00:00Z');-- old.close();---- await check('old tier names are mapped and the CHECK only allows ADR-013 tiers', async () => {-- const store = openStore(file);-- const tiers = store.db.prepare('SELECT app_id, tier, note FROM trust ORDER BY app_id').all();-- assert.deepStrictEqual(tiers.map((r) => r.tier), ['unreviewed', 'reviewed', 'reviewed', 'first-party']);-- assert.deepStrictEqual(tiers.map((r) => r.note), ['n0', 'n1', 'n2', 'n3'], 'notes kept');-- assert.deepStrictEqual(store.db.prepare('SELECT from_tier, to_tier FROM trust_history').get(), { from_tier: 'reviewed', to_tier: 'first-party' });-- assert.throws(() => store.db.prepare("UPDATE trust SET tier = 'verified' WHERE app_id = ?").run(A(0)), /CHECK/);-- store.close();-- });---- await check('running it again changes nothing', async () => {-- const store = openStore(file);-- const sql = store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql;-- assert.ok(!/untrusted/.test(sql));-- assert.strictEqual(store.db.prepare('SELECT COUNT(*) AS n FROM trust').get().n, 4);-- const trust = createTrust({ store });-- assert.strictEqual(trust.get(A(0)).tier, 'unreviewed');-- assert.strictEqual(trust.get('app_01JZZZZZZZZZZZZZZZZZZZZZZZ').tier, 'unreviewed', 'the default is unreviewed');-- assert.deepStrictEqual(trust.TIERS, ['unreviewed', 'reviewed', 'first-party']);-- assert.throws(() => trust.set({ appId: A(0), tier: 'trusted', note: 'x', actor: { staff: true, label: 'user:x' } }), /tier is one of/);-- store.close();-- });---- await check('a fresh database is created on ADR-013 tiers', async () => {-- const store = openStore(path.join(dir, 'fresh.db'));-- assert.match(store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql, /'unreviewed', 'reviewed', 'first-party'/);-- store.close();-- });---- fs.rmSync(dir, { recursive: true, force: true });-- done();--})();
@@ -5,6 +5,14 @@ PORT=4900
@@ -5,6 +5,14 @@ PORT=4900
HOST=127.0.0.1
HOST=127.0.0.1
BASE_URL=https://openvibe.codes
BASE_URL=https://openvibe.codes
TRUST_PROXY=2
TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh codes: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations and the one-time import), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=
+# The SQLite file of releases before PostgreSQL: read once by scripts/migrate-to-postgres.js.
CODES_DB_PATH=/var/lib/openvibe-codes/codes.db
CODES_DB_PATH=/var/lib/openvibe-codes/codes.db
# Per-actor limits (server/http/actor-limits.js): reads one caller (an app, a person, else an address)
# Per-actor limits (server/http/actor-limits.js): reads one caller (an app, a person, else an address)
# may make to the API and the portal per minute and per hour; past them 429 rate_limited with
# may make to the API and the portal per minute and per hour; past them 429 rate_limited with
@@ -10,6 +10,9 @@ jobs:
@@ -10,6 +10,9 @@ jobs:
with:
with:
syntax-dirs: server public/js test scripts
syntax-dirs: server public/js test scripts
contracts-service: codes
contracts-service: codes
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
security:
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
@@ -47,7 +47,8 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
@@ -47,7 +47,8 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256), JWKS, `/api/v1/projects` (called server-side with the person's Network access token), the registry (`/api/v1/registry/services`, `/.well-known/openvibe`), client-credentials tokens (Codes' own for the events relay; the app's own in playgrounds).
- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
-- **openvibe-contracts** v0.53.0, **openvibe-sdk v0.13.0**, **openvibe-shared v1.25.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
+- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
+- **openvibe-contracts** v0.76.0, **openvibe-sdk v0.20.1**, **openvibe-shared v1.27.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@ -110,7 +111,11 @@ as they raise the per-address limit.
@@ -110,7 +111,11 @@ as they raise the per-address limit.
Production deploys with `sudo ovhost deploy codes` on the host (strategy `git-checkout`: fetch,
Production deploys with `sudo ovhost deploy codes` on the host (strategy `git-checkout`: fetch,
fast-forward `/opt/openvibe.codes`, install on a lockfile change, restart, wait for `/api/ready`).
fast-forward `/opt/openvibe.codes`, install on a lockfile change, restart, wait for `/api/ready`).
-The unit is `openvibe-codes.service` on `127.0.0.1:4900`, the env file `/etc/openvibe/codes.env`. State lives in
+The unit is `openvibe-codes.service` on `127.0.0.1:4900`, the env file `/etc/openvibe/codes.env`. The database is
+`ov_codes` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh codes` writes its settings); the
+release migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
+`runSqliteMigration`, with a `--pglite` rehearsal mode), run while the service is stopped; the old
+`/var/lib/openvibe-codes/codes.db` stays read-only for 7 days as the rollback. State lives in
`/var/lib/openvibe-codes`; nginx serves `openvibe.codes` from
`/var/lib/openvibe-codes`; nginx serves `openvibe.codes` from
[deploy/nginx/openvibe.codes.conf](deploy/nginx/openvibe.codes.conf).
[deploy/nginx/openvibe.codes.conf](deploy/nginx/openvibe.codes.conf).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
@@ -4,11 +4,15 @@
@@ -4,11 +4,15 @@
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express 4, better-sqlite3 11",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4900,
"port": 4900,
"domain": "openvibe.codes",
"domain": "openvibe.codes",
- "contracts": "openvibe-contracts v0.53.0",
- "packages": ["openvibe-contracts v0.53.0", "openvibe-sdk v0.13.0", "openvibe-shared v1.25.0"],
+ "contracts": "openvibe-contracts v0.76.0",
+ "packages": [
+ "openvibe-contracts v0.76.0",
+ "openvibe-sdk v0.20.1",
+ "openvibe-shared v1.27.0"
+ ],
"summary": "Deployed and public since 2026-09-23: https://openvibe.codes is served by this portal (deployed with `sudo ovhost deploy codes`, unit openvibe-codes on 127.0.0.1:4900 behind nginx), and the domain left OpenVibe.Sites. Sign-in uses the registered Network OAuth client `codes` (PKCE S256); the principal `codes` holds events.event.publish and the Events relay is configured. On 2026-09-23 production held 0 manifests, 0 releases and 0 playground runs. The external-developer path (account, project, sandbox app, grants, Media upload, app events) was run once on production on 2026-09-23 with public endpoints and curl, not through these playgrounds; that check is not committed to a repository or CI.",
"summary": "Deployed and public since 2026-09-23: https://openvibe.codes is served by this portal (deployed with `sudo ovhost deploy codes`, unit openvibe-codes on 127.0.0.1:4900 behind nginx), and the domain left OpenVibe.Sites. Sign-in uses the registered Network OAuth client `codes` (PKCE S256); the principal `codes` holds events.event.publish and the Events relay is configured. On 2026-09-23 production held 0 manifests, 0 releases and 0 playground runs. The external-developer path (account, project, sandbox app, grants, Media upload, app events) was run once on production on 2026-09-23 with public endpoints and curl, not through these playgrounds; that check is not committed to a repository or CI.",
"works": [
"works": [
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
"Sign-in with OpenVibe.Network (authorization code + PKCE S256), session tokens in httpOnly cookies",
@@ -3,8 +3,8 @@ Description=OpenVibe.Codes — the developer portal (projects over Network's API
@@ -3,8 +3,8 @@ Description=OpenVibe.Codes — the developer portal (projects over Network's API
# Network (sign-in, JWKS, the projects API, registry, tokens) and Events (the outbox relay) should be
# Network (sign-in, JWKS, the projects API, registry, tokens) and Events (the outbox relay) should be
# up first, but Codes degrades honestly without them: docs, tools and policy pages serve; project
# up first, but Codes degrades honestly without them: docs, tools and policy pages serve; project
# pages show Network's failure; events wait in the outbox (see /api/ready).
# pages show Network's failure; events wait in the outbox (see /api/ready).
-After=network.target
-Wants=openvibe-network.service
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+-- phase: expand
+-- OpenVibe.Codes on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
+-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
+-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-28; never edited after it runs.
+
+CREATE TABLE manifests (
+ id text COLLATE "C" PRIMARY KEY, -- mfs_<ULID>
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('app', 'mod')),
+ app_id text COLLATE "C" NOT NULL, -- Network app (app_<ULID>) that publishes it
+ project_id text COLLATE "C" NOT NULL, -- Network project (prj_<ULID>) at the time of creation
+ subject_id text COLLATE "C" NOT NULL, -- the manifest's own id (app_… or mod_…)
+ version text COLLATE "C" NOT NULL,
+ body text COLLATE "C" NOT NULL, -- the manifest JSON exactly as validated
+ contracts_version text COLLATE "C" NOT NULL, -- openvibe-contracts release it was validated with
+ created_by text COLLATE "C" NOT NULL, -- user:usr_… or app:app_…
+ created_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX idx_manifests_app ON manifests(app_id);
+
+CREATE TABLE releases (
+ id text COLLATE "C" PRIMARY KEY, -- rel_<ULID>
+ app_id text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C" NOT NULL,
+ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('app', 'mod')),
+ subject_id text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ version text COLLATE "C" NOT NULL,
+ manifest_id text COLLATE "C" NOT NULL REFERENCES manifests(id),
+ status text COLLATE "C" NOT NULL CHECK (status IN ('draft', 'published', 'deprecated', 'revoked')),
+ compatibility text COLLATE "C" NOT NULL DEFAULT '{}',
+ notes text COLLATE "C" NOT NULL DEFAULT '',
+ created_by text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ published_by text COLLATE "C", published_at TEXT,
+ deprecated_by text COLLATE "C", deprecated_at TEXT, deprecation_reason TEXT, replacement TEXT,
+ revoked_by text COLLATE "C", revoked_at TEXT, revocation_reason TEXT,
+ UNIQUE (app_id, kind, subject_id, version)
+);
+CREATE INDEX idx_releases_app ON releases(app_id, created_at);
+
+CREATE TABLE release_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ release_id text COLLATE "C" NOT NULL,
+ action text COLLATE "C" NOT NULL,
+ actor text COLLATE "C" NOT NULL,
+ at text COLLATE "C" NOT NULL,
+ detail text COLLATE "C" NOT NULL DEFAULT '{}'
+);
+CREATE INDEX idx_release_log_release ON release_log(release_id, id);
+CREATE FUNCTION release_log_append_only_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'release_log is append-only'; END $$;
+CREATE TRIGGER release_log_append_only_update BEFORE UPDATE ON release_log FOR EACH ROW EXECUTE FUNCTION release_log_append_only_update();
+CREATE FUNCTION release_log_append_only_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'release_log is append-only'; END $$;
+CREATE TRIGGER release_log_append_only_delete BEFORE DELETE ON release_log FOR EACH ROW EXECUTE FUNCTION release_log_append_only_delete();
+
+CREATE TABLE trust (
+ app_id text COLLATE "C" PRIMARY KEY,
+ tier text COLLATE "C" NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),
+ note text COLLATE "C" NOT NULL DEFAULT '',
+ set_by text COLLATE "C" NOT NULL,
+ set_at text COLLATE "C" NOT NULL
+);
+CREATE TABLE trust_history (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ app_id text COLLATE "C" NOT NULL,
+ from_tier text COLLATE "C" NOT NULL,
+ to_tier text COLLATE "C" NOT NULL,
+ note text COLLATE "C" NOT NULL DEFAULT '',
+ set_by text COLLATE "C" NOT NULL,
+ set_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE playground_runs (
+ id text COLLATE "C" PRIMARY KEY, -- run_<ULID>
+ at text COLLATE "C" NOT NULL,
+ actor text COLLATE "C" NOT NULL, -- user:usr_…
+ project_id text COLLATE "C" NOT NULL,
+ app_id text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('events', 'media')),
+ capability text COLLATE "C" NOT NULL,
+ credential text COLLATE "C" NOT NULL CHECK (credential IN ('none', 'client_secret', 'access_token')),
+ outcome text COLLATE "C" NOT NULL CHECK (outcome IN ('ok', 'refused', 'failed')),
+ stage text COLLATE "C" NOT NULL, -- app | grant | token | call | done
+ http_status bigint,
+ code text COLLATE "C",
+ detail text COLLATE "C" NOT NULL DEFAULT '',
+ ref text COLLATE "C" -- event_id or media key on success
+);
+CREATE INDEX idx_runs_app ON playground_runs(app_id, at);
+CREATE INDEX idx_runs_actor ON playground_runs(actor, at);
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
@@ -16,14 +16,32 @@
@@ -16,14 +16,32 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -236,6 +254,15 @@
@@ -236,6 +254,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"node_modules/content-disposition": {
"version": "0.5.4",
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -318,6 +345,15 @@
@@ -318,6 +345,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -754,6 +790,49 @@
@@ -754,6 +790,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/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/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -818,12 +897,24 @@
@@ -818,12 +897,24 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1023,9 +1114,9 @@
@@ -1023,9 +1114,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.53.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
+ "version": "0.76.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1039,18 +1130,38 @@
@@ -1039,18 +1130,38 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.13.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",
- "integrity": "sha512-RvTYWvkHjgOt7kOZwEDYjReb5MoJcpEenVAykM2S1/GeDqgKUMQrsSINvF4TM3HBwNHxlevO1kXzVMob2o2igg==",
+ "version": "0.20.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
+ "integrity": "sha512-3Zwz8sJhWhnuw9GYr6ieIDp9CIl6ug0QqJE5ifDsk4ckrethBjcDzjadE1GSCGQDFXXLf/+bVWtI7X3VOVXmFQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.25.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0",
- "integrity": "sha512-oqZ+PvljrWi8IEbtg6bDYai17ZV2sALFx/ezMptniELSr10TgFUiGUQt+b1IWwiHv0T2Y1U2iSrkirJxMNgjSw==",
+ "version": "1.27.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "integrity": "sha512-/H7dyFw+cURcdjyfbpyY7mNc6F+Pcb9TwkVwZHR+9d3M62q70KDhZLo/gZi71eaYxUjD8lO8tmbXu70EAa9paQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1071,6 +1182,134 @@
@@ -1071,6 +1182,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1194,6 +1433,27 @@
@@ -1194,6 +1433,27 @@
"node": ">= 6"
"node": ">= 6"
}
}
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1409,6 +1669,21 @@
@@ -1409,6 +1669,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1544,6 +1819,15 @@
@@ -1544,6 +1819,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/run.js"
+ "test": "node test/run.js",
+ "test:pg": "CODES_TEST_STORE=pg node test/run.js"
},
},
"keywords": [
"keywords": [
"openvibe",
"openvibe",
@@ -27,11 +28,16 @@
@@ -27,11 +28,16 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.13.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.25.0"
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "pg": "^8.23.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Codes's SQLite database (CODES_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
+ * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
+ * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
+ * name and columns.
+ */
+require('dotenv').config();
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const configLib = require('../server/config');
+const { MIGRATIONS } = require('../server/db');
+
+const TABLES = {};
+
+if (require.main === module) {
+ const config = configLib.load();
+ runSqliteMigration({ service: 'codes', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES })
+ .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
+}
+
+module.exports = { TABLES };
@@ -49,11 +49,12 @@ const VERSION = require('../package.json').version;
@@ -49,11 +49,12 @@ const VERSION = require('../package.json').version;
* opts: config, store | dbPath, now (clock), fetchImpl, log, limitsNow (the per-actor limiter's clock,
* opts: config, store | dbPath, now (clock), fetchImpl, log, limitsNow (the per-actor limiter's clock,
* tests), actorLimits (false: count nobody, tests only)
* tests), actorLimits (false: count nobody, tests only)
*/
*/
-function createApp(opts = {}) {
+async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
const fetchImpl = opts.fetchImpl;
const fetchImpl = opts.fetchImpl;
- const store = opts.store || openStore(opts.dbPath || config.dbPath, { now: opts.now });
+ // PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
+ const store = opts.store || await openStore(config, { now: opts.now, log });
const docs = generate({ now: store.now });
const docs = generate({ now: store.now });
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
@@ -78,7 +79,10 @@ function createApp(opts = {}) {
@@ -78,7 +79,10 @@ function createApp(opts = {}) {
app.locals.ctx = ctx;
app.locals.ctx = ctx;
// Per-actor limits (http/actor-limits.js) at /api/v1, the portal, release actions and the tools'
// Per-actor limits (http/actor-limits.js) at /api/v1, the portal, release actions and the tools'
// forms, counted once the caller is known; the per-address limits below stay.
// forms, counted once the caller is known; the per-address limits below stay.
- ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.actorLimits !== false });
+ // Valkey (ADR-035): shared, never-authoritative state (per-actor limit counters). Optional.
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+ ctx.actorLimits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.actorLimits !== false, valkey });
app.use(contracts.http.middleware());
app.use(contracts.http.middleware());
app.use(helmet({
app.use(helmet({
@@ -112,7 +116,7 @@ function createApp(opts = {}) {
@@ -112,7 +116,7 @@ function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- const readiness = createCodesReadiness({ store, keys, network, outbox, docs, config, release: release.release });
+ const readiness = createCodesReadiness({ store, keys, network, outbox, docs, config, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
@@ -118,8 +118,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -118,8 +118,8 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
/** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
/** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
- function refresh(req, res) {
- if (!req.codesRefresh) req.codesRefresh = doRefresh(req, res);
+ async function refresh(req, res) {
+ if (!req.codesRefresh) req.codesRefresh = await doRefresh(req, res);
return req.codesRefresh;
return req.codesRefresh;
}
}
@@ -93,8 +93,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
@@ -93,8 +93,8 @@ function createNetworkClient({ config, fetchImpl, log = console }) {
return value;
return value;
}
}
const registry = {
const registry = {
- services: () => cached('services', () => registryClient.services()),
- descriptor: () => cached('descriptor', () => client.json({ baseUrl: base, path: '/.well-known/openvibe', auth: false })),
+ services: async () => await cached('services', () => registryClient.services()),
+ descriptor: async () => await cached('descriptor', () => client.json({ baseUrl: base, path: '/.well-known/openvibe', auth: false })),
};
};
/**
/**
@@ -40,6 +40,10 @@ function load(env = process.env) {
@@ -40,6 +40,10 @@ function load(env = process.env) {
hour: Math.max(1, int(env.CODES_LIMITS_HOUR, 3000)),
hour: Math.max(1, int(env.CODES_LIMITS_HOUR, 3000)),
},
},
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:codes:' },
+ // The SQLite file of releases before the switch: read once by scripts/migrate-to-postgres.js.
dbPath: env.CODES_DB_PATH || './data/codes.db',
dbPath: env.CODES_DB_PATH || './data/codes.db',
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
// OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), JWKS, the developer
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Codes' own SQLite database (WAL, created on boot, idempotent).
+ * OpenVibe.Codes' own PostgreSQL database (ADR-035, roadmap WS-X2): the schema is migrations/NNNN_*.sql, applied at boot.
*
*
* Codes owns ONLY what is Codes' by ADR-014: release metadata keyed to Network app ids, trust tiers
* Codes owns ONLY what is Codes' by ADR-014: release metadata keyed to Network app ids, trust tiers
* (metadata, never authority), validated manifests, and playground run logs. Projects, members,
* (metadata, never authority), validated manifests, and playground run logs. Projects, members,
@@ -12,146 +12,51 @@
@@ -12,146 +12,51 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const TABLES = ['manifests', 'releases', 'release_log', 'trust', 'trust_history', 'playground_runs'];
const TABLES = ['manifests', 'releases', 'release_log', 'trust', 'trust_history', 'playground_runs'];
-const SCHEMA = `
-CREATE TABLE IF NOT EXISTS manifests (
- id TEXT PRIMARY KEY, -- mfs_<ULID>
- kind TEXT NOT NULL CHECK (kind IN ('app', 'mod')),
- app_id TEXT NOT NULL, -- Network app (app_<ULID>) that publishes it
- project_id TEXT NOT NULL, -- Network project (prj_<ULID>) at the time of creation
- subject_id TEXT NOT NULL, -- the manifest's own id (app_… or mod_…)
- version TEXT NOT NULL,
- body TEXT NOT NULL, -- the manifest JSON exactly as validated
- contracts_version TEXT NOT NULL, -- openvibe-contracts release it was validated with
- created_by TEXT NOT NULL, -- user:usr_… or app:app_…
- created_at TEXT NOT NULL
-);
-CREATE INDEX IF NOT EXISTS idx_manifests_app ON manifests(app_id);
-
-CREATE TABLE IF NOT EXISTS releases (
- id TEXT PRIMARY KEY, -- rel_<ULID>
- app_id TEXT NOT NULL,
- project_id TEXT NOT NULL,
- environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),
- kind TEXT NOT NULL CHECK (kind IN ('app', 'mod')),
- subject_id TEXT NOT NULL,
- name TEXT NOT NULL,
- version TEXT NOT NULL,
- manifest_id TEXT NOT NULL REFERENCES manifests(id),
- status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'deprecated', 'revoked')),
- compatibility TEXT NOT NULL DEFAULT '{}',
- notes TEXT NOT NULL DEFAULT '',
- created_by TEXT NOT NULL,
- created_at TEXT NOT NULL,
- published_by TEXT, published_at TEXT,
- deprecated_by TEXT, deprecated_at TEXT, deprecation_reason TEXT, replacement TEXT,
- revoked_by TEXT, revoked_at TEXT, revocation_reason TEXT,
- UNIQUE (app_id, kind, subject_id, version)
-);
-CREATE INDEX IF NOT EXISTS idx_releases_app ON releases(app_id, created_at);
-
-CREATE TABLE IF NOT EXISTS release_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- release_id TEXT NOT NULL,
- action TEXT NOT NULL,
- actor TEXT NOT NULL,
- at TEXT NOT NULL,
- detail TEXT NOT NULL DEFAULT '{}'
-);
-CREATE INDEX IF NOT EXISTS idx_release_log_release ON release_log(release_id, id);
-CREATE TRIGGER IF NOT EXISTS release_log_append_only_update BEFORE UPDATE ON release_log
- BEGIN SELECT RAISE(ABORT, 'release_log is append-only'); END;
-CREATE TRIGGER IF NOT EXISTS release_log_append_only_delete BEFORE DELETE ON release_log
- BEGIN SELECT RAISE(ABORT, 'release_log is append-only'); END;
-
-CREATE TABLE IF NOT EXISTS trust (
- app_id TEXT PRIMARY KEY,
- tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),
- note TEXT NOT NULL DEFAULT '',
- set_by TEXT NOT NULL,
- set_at TEXT NOT NULL
-);
-CREATE TABLE IF NOT EXISTS trust_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- app_id TEXT NOT NULL,
- from_tier TEXT NOT NULL,
- to_tier TEXT NOT NULL,
- note TEXT NOT NULL DEFAULT '',
- set_by TEXT NOT NULL,
- set_at TEXT NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS playground_runs (
- id TEXT PRIMARY KEY, -- run_<ULID>
- at TEXT NOT NULL,
- actor TEXT NOT NULL, -- user:usr_…
- project_id TEXT NOT NULL,
- app_id TEXT NOT NULL,
- kind TEXT NOT NULL CHECK (kind IN ('events', 'media')),
- capability TEXT NOT NULL,
- credential TEXT NOT NULL CHECK (credential IN ('none', 'client_secret', 'access_token')),
- outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'refused', 'failed')),
- stage TEXT NOT NULL, -- app | grant | token | call | done
- http_status INTEGER,
- code TEXT,
- detail TEXT NOT NULL DEFAULT '',
- ref TEXT -- event_id or media key on success
-);
-CREATE INDEX IF NOT EXISTS idx_runs_app ON playground_runs(app_id, at);
-CREATE INDEX IF NOT EXISTS idx_runs_actor ON playground_runs(actor, at);
-`;
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
/**
/**
- * ADR-013 is binding for trust tiers: unreviewed, reviewed, first-party. Databases created before
- * that used the roadmap's four names; map them (untrusted→unreviewed, verified|trusted→reviewed,
- * platform-maintained→first-party) and rebuild `trust` with the new CHECK. Idempotent: a database
- * already on ADR-013 names is left untouched. Tiers stay metadata; no grant depends on them.
+ * The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
+ * in data/pglite. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the embedded handle.
*/
*/
-const TIER_MAP = { untrusted: 'unreviewed', verified: 'reviewed', trusted: 'reviewed', 'platform-maintained': 'first-party' };
-function migrateTrustTiers(db) {
- const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'trust'").get();
- const oldCheck = row && /'untrusted'/.test(row.sql);
- const stale = db.prepare(`SELECT COUNT(*) AS n FROM trust_history WHERE from_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')}) OR to_tier IN (${Object.keys(TIER_MAP).map(() => '?').join(', ')})`)
- .get(...Object.keys(TIER_MAP), ...Object.keys(TIER_MAP)).n;
- if (!oldCheck && !stale) return { migrated: false };
- const mapSql = (col) => `CASE ${col} ${Object.entries(TIER_MAP).map(([a, b]) => `WHEN '${a}' THEN '${b}'`).join(' ')} ELSE ${col} END`;
- db.transaction(() => {
- if (oldCheck) {
- db.exec(`CREATE TABLE trust_adr013 (
- app_id TEXT PRIMARY KEY,
- tier TEXT NOT NULL CHECK (tier IN ('unreviewed', 'reviewed', 'first-party')),
- note TEXT NOT NULL DEFAULT '',
- set_by TEXT NOT NULL,
- set_at TEXT NOT NULL
- );
- INSERT INTO trust_adr013 (app_id, tier, note, set_by, set_at) SELECT app_id, ${mapSql('tier')}, note, set_by, set_at FROM trust;
- DROP TABLE trust;
- ALTER TABLE trust_adr013 RENAME TO trust;`);
- }
- db.exec(`UPDATE trust_history SET from_tier = ${mapSql('from_tier')}, to_tier = ${mapSql('to_tier')}`);
- })();
- return { migrated: true };
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh codes)');
+ log.warn(`[Codes] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ const db = createDb({ pglite: DEV_PGLITE, service: 'codes', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'codes-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'codes', registry, log });
}
}
-function openStore(dbPath, { now = () => Date.now() } = {}) {
- if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
- const db = new Database(dbPath);
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
- db.exec(SCHEMA);
- migrateTrustTiers(db);
+/**
+ * Every store on a migrated database handle. opts.now — injectable clock (epoch ms), so tests and replays are
+ * deterministic. store.tx(fn) is a transaction; inside it, plain db calls join it (ambient).
+ */
+function createStore(db, { now = () => Date.now() } = {}) {
return {
return {
db,
db,
now,
now,
iso: () => new Date(now()).toISOString(),
iso: () => new Date(now()).toISOString(),
newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx(() => fn()),
close: () => db.close(),
close: () => db.close(),
};
};
}
}
-module.exports = { openStore, TABLES, TIER_MAP, migrateTrustTiers };
+/** openDb + createStore. */
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS, TABLES };
@@ -67,19 +67,19 @@ function grantAdvice(capability) {
@@ -67,19 +67,19 @@ function grantAdvice(capability) {
function createPlayground({ store, config, network, keys, fetchImpl, log = console }) {
function createPlayground({ store, config, network, keys, fetchImpl, log = console }) {
const { db } = store;
const { db } = store;
- function record(run) {
+ async function record(run) {
const id = store.newId('run');
const id = store.newId('run');
- db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref)
+ await db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, store.iso(), run.actor, run.project_id, run.app_id, run.kind, run.capability, run.credential, run.outcome, run.stage,
.run(id, store.iso(), run.actor, run.project_id, run.app_id, run.kind, run.capability, run.credential, run.outcome, run.stage,
run.http_status || null, run.code || null, run.detail || '', run.ref || null);
run.http_status || null, run.code || null, run.detail || '', run.ref || null);
return id;
return id;
}
}
- const runsFor = (appId, limit = 20) => db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);
- const runsForProject = (projectId) => db.prepare('SELECT id, at, actor, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE project_id = ? ORDER BY at, id').all(String(projectId));
- const deleteRunsForProject = (projectId) => db.prepare('DELETE FROM playground_runs WHERE project_id = ?').run(String(projectId)).changes;
- const recentCount = (actor) => db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString()).n;
+ const runsFor = async (appId, limit = 20) => await db.prepare('SELECT id, at, actor, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE app_id = ? ORDER BY at DESC, id DESC LIMIT ?').all(String(appId), limit);
+ const runsForProject = async (projectId) => await db.prepare('SELECT id, at, actor, app_id, kind, capability, credential, outcome, stage, http_status, code, detail, ref FROM playground_runs WHERE project_id = ? ORDER BY at, id').all(String(projectId));
+ const deleteRunsForProject = async (projectId) => (await db.prepare('DELETE FROM playground_runs WHERE project_id = ?').run(String(projectId))).changes;
+ const recentCount = async (actor) => (await db.prepare('SELECT COUNT(*) AS n FROM playground_runs WHERE actor = ? AND at > ?').get(actor, new Date(store.now() - 3600_000).toISOString())).n;
/** Steps 1–2 only: may this app run this playground at all? */
/** Steps 1–2 only: may this app run this playground at all? */
function precheck(app, kind) {
function precheck(app, kind) {
@@ -161,54 +161,54 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
@@ -161,54 +161,54 @@ function createPlayground({ store, config, network, keys, fetchImpl, log = conso
const k = KINDS[kind];
const k = KINDS[kind];
const secrets = [credential && credential.value].filter(Boolean);
const secrets = [credential && credential.value].filter(Boolean);
const base = { actor, project_id: app.project_id, app_id: app.id, kind, capability: k ? k.needs : String(kind), credential: 'none' };
const base = { actor, project_id: app.project_id, app_id: app.id, kind, capability: k ? k.needs : String(kind), credential: 'none' };
- const finish = (out) => {
+ const finish = async (out) => {
const safe = { ...out, detail: scrub(out.detail, secrets) };
const safe = { ...out, detail: scrub(out.detail, secrets) };
- const runId = record({ ...base, ...safe });
+ const runId = await record({ ...base, ...safe });
return { ...safe, runId };
return { ...safe, runId };
};
};
- if (recentCount(actor) >= config.playground.runsPerHour) {
+ if (await recentCount(actor) >= config.playground.runsPerHour) {
return { outcome: 'refused', stage: 'rate', code: 'playground.rate_limited', detail: `at most ${config.playground.runsPerHour} playground runs an hour` };
return { outcome: 'refused', stage: 'rate', code: 'playground.rate_limited', detail: `at most ${config.playground.runsPerHour} playground runs an hour` };
}
}
const pre = precheck(app, kind);
const pre = precheck(app, kind);
- if (!pre.ok) return { ...finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };
+ if (!pre.ok) return { ...await finish({ outcome: 'refused', stage: pre.stage, code: pre.code, detail: pre.detail }), missing: pre.missing, grantable: pre.grantable };
let envelope = null;
let envelope = null;
if (kind === 'events') {
if (kind === 'events') {
const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });
const e = eventInput({ ...input, appId: app.id, projectId: app.project_id });
- if (e.error) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
+ if (e.error) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: e.error });
envelope = e.envelope;
envelope = e.envelope;
} else {
} else {
- if (!file || !file.buffer || !file.buffer.length) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: 'choose a file' });
- if (file.buffer.length > config.playground.maxUploadBytes) return finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: `playground uploads are at most ${config.playground.maxUploadBytes} bytes` });
+ if (!file || !file.buffer || !file.buffer.length) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: 'choose a file' });
+ if (file.buffer.length > config.playground.maxUploadBytes) return await finish({ outcome: 'refused', stage: 'input', code: 'playground.invalid_input', detail: `playground uploads are at most ${config.playground.maxUploadBytes} bytes` });
}
}
if (!credential || !credential.value || !['client_secret', 'access_token'].includes(credential.type)) {
if (!credential || !credential.value || !['client_secret', 'access_token'].includes(credential.type)) {
- return finish({ outcome: 'refused', stage: 'token', code: 'playground.no_credential', detail: 'enter the app\'s client secret (used once, never stored) or an access token you minted for it' });
+ return await finish({ outcome: 'refused', stage: 'token', code: 'playground.no_credential', detail: 'enter the app\'s client secret (used once, never stored) or an access token you minted for it' });
}
}
base.credential = credential.type;
base.credential = credential.type;
const tok = await appToken(app, k, credential);
const tok = await appToken(app, k, credential);
- if (!tok.ok) return finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });
+ if (!tok.ok) return await finish({ outcome: 'refused', stage: 'token', code: tok.code, detail: tok.detail, http_status: tok.http_status });
const client = sdkClient(tok.token);
const client = sdkClient(tok.token);
try {
try {
if (kind === 'events') {
if (kind === 'events') {
const events = createEventsClient(client, { source: envelope.source, baseUrl: config.playground.eventsUrl });
const events = createEventsClient(client, { source: envelope.source, baseUrl: config.playground.eventsUrl });
const out = await events.publish(envelope);
const out = await events.publish(envelope);
- return { ...finish({ outcome: 'ok', stage: 'done', http_status: 200, detail: out.duplicate ? 'accepted (duplicate event_id)' : `accepted as seq ${out.seq}`, ref: out.event_id }), result: { event_id: out.event_id, seq: out.seq, duplicate: out.duplicate } };
+ return { ...await finish({ outcome: 'ok', stage: 'done', http_status: 200, detail: out.duplicate ? 'accepted (duplicate event_id)' : `accepted as seq ${out.seq}`, ref: out.event_id }), result: { event_id: out.event_id, seq: out.seq, duplicate: out.duplicate } };
}
}
const media = createMediaClient(client, { app: app.project_id, baseUrl: config.playground.mediaUrl, publicOrigin: config.playground.mediaUrl });
const media = createMediaClient(client, { app: app.project_id, baseUrl: config.playground.mediaUrl, publicOrigin: config.playground.mediaUrl });
const out = await media.files.upload(file.buffer, { filename: file.filename, contentType: file.mimeType });
const out = await media.files.upload(file.buffer, { filename: file.filename, contentType: file.mimeType });
- return { ...finish({ outcome: 'ok', stage: 'done', http_status: 201, detail: `stored ${out.size} bytes`, ref: out.key }), result: { key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256 } };
+ return { ...await finish({ outcome: 'ok', stage: 'done', http_status: 201, detail: `stored ${out.size} bytes`, ref: out.key }), result: { key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256 } };
} catch (err) {
} catch (err) {
if (isOpenVibeError(err)) {
if (isOpenVibeError(err)) {
const service = kind === 'events' ? 'OpenVibe.Events' : 'OpenVibe.Media';
const service = kind === 'events' ? 'OpenVibe.Events' : 'OpenVibe.Media';
const unreachable = !err.status;
const unreachable = !err.status;
- return finish({ outcome: 'failed', stage: 'call', http_status: err.status || null, code: err.code,
+ return await finish({ outcome: 'failed', stage: 'call', http_status: err.status || null, code: err.code,
detail: unreachable ? `${service} did not answer (${err.code})` : `${service} answered ${err.status}: ${err.detail || err.title || err.code}` });
detail: unreachable ? `${service} did not answer (${err.code})` : `${service} answered ${err.status}: ${err.detail || err.title || err.code}` });
}
}
log.error('[Codes] playground error:', scrub(err && err.message, secrets));
log.error('[Codes] playground error:', scrub(err && err.message, secrets));
- return finish({ outcome: 'failed', stage: 'call', code: 'playground.error', detail: 'unexpected error calling the service' });
+ return await finish({ outcome: 'failed', stage: 'call', code: 'playground.error', detail: 'unexpected error calling the service' });
}
}
}
}
@@ -57,9 +57,9 @@ async function buildExport({ network, token, project, includeAudit, releases, pl
@@ -57,9 +57,9 @@ async function buildExport({ network, token, project, includeAudit, releases, pl
quotas: quotas.quotas || [],
quotas: quotas.quotas || [],
audit: audit || { entries: null, note: 'the audit log needs the admin role in this project' },
audit: audit || { entries: null, note: 'the audit log needs the admin role in this project' },
codes: {
codes: {
- releases: releases.listForProject(project.id),
- trust: appIds.map((id) => ({ app_id: id, ...trust.get(id), history: trust.history(id) })),
- playground_runs: playground.runsForProject(project.id),
+ releases: await releases.listForProject(project.id),
+ trust: (await Promise.all(appIds.map(async (id) => ({ app_id: id, ...await trust.get(id), history: await trust.history(id) })))),
+ playground_runs: await playground.runsForProject(project.id),
},
},
};
};
}
}
@@ -29,7 +29,7 @@ const manageRole = (environment) => (environment === 'production' ? 'admin' : 'd
@@ -29,7 +29,7 @@ const manageRole = (environment) => (environment === 'production' ? 'admin' : 'd
function createReleases({ store, outbox, trust }) {
function createReleases({ store, outbox, trust }) {
const { db } = store;
const { db } = store;
- function view(r) {
+ async function view(r) {
if (!r) return null;
if (!r) return null;
return {
return {
id: r.id, app_id: r.app_id, project_id: r.project_id, environment: r.environment, kind: r.kind,
id: r.id, app_id: r.app_id, project_id: r.project_id, environment: r.environment, kind: r.kind,
@@ -38,24 +38,24 @@ function createReleases({ store, outbox, trust }) {
@@ -38,24 +38,24 @@ function createReleases({ store, outbox, trust }) {
created_at: r.created_at, created_by: r.created_by,
created_at: r.created_at, created_by: r.created_by,
published_at: r.published_at || null, deprecated_at: r.deprecated_at || null, deprecation_reason: r.deprecation_reason || null,
published_at: r.published_at || null, deprecated_at: r.deprecated_at || null, deprecation_reason: r.deprecation_reason || null,
replacement: r.replacement || null, revoked_at: r.revoked_at || null, revocation_reason: r.revocation_reason || null,
replacement: r.replacement || null, revoked_at: r.revoked_at || null, revocation_reason: r.revocation_reason || null,
- trust: trust.get(r.app_id),
+ trust: await trust.get(r.app_id),
};
};
}
}
- const get = (id) => view(db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id)));
- const manifestOf = (id) => {
- const m = db.prepare('SELECT * FROM manifests WHERE id = ?').get(String(id));
+ const get = async (id) => await view(await db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id)));
+ const manifestOf = async (id) => {
+ const m = await db.prepare('SELECT * FROM manifests WHERE id = ?').get(String(id));
return m ? { id: m.id, kind: m.kind, body: JSON.parse(m.body), contracts_version: m.contracts_version, created_at: m.created_at } : null;
return m ? { id: m.id, kind: m.kind, body: JSON.parse(m.body), contracts_version: m.contracts_version, created_at: m.created_at } : null;
};
};
- const listForApp = (appId, { includeDrafts = false } = {}) => db.prepare(
+ const listForApp = async (appId, { includeDrafts = false } = {}) => (await Promise.all((await db.prepare(
`SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,
`SELECT * FROM releases WHERE app_id = ? ${includeDrafts ? '' : "AND status != 'draft'"} ORDER BY created_at DESC, id DESC`,
- ).all(String(appId)).map(view);
- const recentPublic = (limit = 20) => db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit).map(view);
- const log = (releaseId) => db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId)
+ ).all(String(appId))).map(view)));
+ const recentPublic = async (limit = 20) => (await Promise.all((await db.prepare("SELECT * FROM releases WHERE status IN ('published', 'deprecated') ORDER BY published_at DESC LIMIT ?").all(limit)).map(view)));
+ const log = async (releaseId) => (await db.prepare('SELECT action, actor, at, detail FROM release_log WHERE release_id = ? ORDER BY id').all(releaseId))
.map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));
.map((l) => ({ ...l, detail: JSON.parse(l.detail || '{}') }));
- function writeLog(releaseId, action, actor, detail = {}) {
- db.prepare('INSERT INTO release_log (release_id, action, actor, at, detail) VALUES (?, ?, ?, ?, ?)')
+ async function writeLog(releaseId, action, actor, detail = {}) {
+ await db.prepare('INSERT INTO release_log (release_id, action, actor, at, detail) VALUES (?, ?, ?, ?, ?)')
.run(releaseId, action, actor, store.iso(), JSON.stringify(detail));
.run(releaseId, action, actor, store.iso(), JSON.stringify(detail));
}
}
@@ -73,16 +73,16 @@ function createReleases({ store, outbox, trust }) {
@@ -73,16 +73,16 @@ function createReleases({ store, outbox, trust }) {
if (!actor.role || RANK[actor.role] < RANK[need]) fail(403, 'release.forbidden', `managing ${app.environment} releases needs the ${need} role in the project (Network reports ${actor.role || 'no role'})`);
if (!actor.role || RANK[actor.role] < RANK[need]) fail(403, 'release.forbidden', `managing ${app.environment} releases needs the ${need} role in the project (Network reports ${actor.role || 'no role'})`);
}
}
- function event(type, rel, actor, extra = {}) {
+ async function event(type, rel, actor, extra = {}) {
const [atype, aid] = actor.label.split(':');
const [atype, aid] = actor.label.split(':');
- return outbox.emit({
+ return await outbox.emit({
event_type: type,
event_type: type,
actor: { type: atype === 'app' ? 'app' : 'user', id: aid },
actor: { type: atype === 'app' ? 'app' : 'user', id: aid },
subject: { type: 'app', id: rel.app_id },
subject: { type: 'app', id: rel.app_id },
visibility: 'public',
visibility: 'public',
payload: {
payload: {
project_id: rel.project_id, release_id: rel.id, kind: rel.kind, subject_id: rel.subject_id, version: rel.version,
project_id: rel.project_id, release_id: rel.id, kind: rel.kind, subject_id: rel.subject_id, version: rel.version,
- environment: rel.environment, trust_tier: trust.get(rel.app_id).tier, ...extra,
+ environment: rel.environment, trust_tier: (await trust.get(rel.app_id)).tier, ...extra,
},
},
}, { traceparent: actor.traceparent });
}, { traceparent: actor.traceparent });
}
}
@@ -91,54 +91,54 @@ function createReleases({ store, outbox, trust }) {
@@ -91,54 +91,54 @@ function createReleases({ store, outbox, trust }) {
* Create a draft from a manifest. app: { id, project_id, environment, grants, revoked_at } as
* Create a draft from a manifest. app: { id, project_id, environment, grants, revoked_at } as
* Network reported it (or, for an app token, as its claims say).
* Network reported it (or, for an app token, as its claims say).
*/
*/
- function createDraft({ actor, app, kind, manifest, notes = '', eventTypes }) {
+ async function createDraft({ actor, app, kind, manifest, notes = '', eventTypes }) {
if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
assertCanManage(actor, app);
assertCanManage(actor, app);
const result = validateManifest(kind, manifest, { app, viewerSubject: actor.kind === 'user' ? actor.subject : null, eventTypes });
const result = validateManifest(kind, manifest, { app, viewerSubject: actor.kind === 'user' ? actor.subject : null, eventTypes });
if (!result.valid) fail(422, 'manifest.invalid', 'the manifest does not validate', { validation: result });
if (!result.valid) fail(422, 'manifest.invalid', 'the manifest does not validate', { validation: result });
const subjectId = manifest.id;
const subjectId = manifest.id;
- const exists = db.prepare('SELECT id FROM releases WHERE app_id = ? AND kind = ? AND subject_id = ? AND version = ?').get(app.id, kind, subjectId, manifest.version);
+ const exists = await db.prepare('SELECT id FROM releases WHERE app_id = ? AND kind = ? AND subject_id = ? AND version = ?').get(app.id, kind, subjectId, manifest.version);
if (exists) fail(409, 'release.exists', `version ${manifest.version} of ${subjectId} already has a release (${exists.id}); versions are immutable`);
if (exists) fail(409, 'release.exists', `version ${manifest.version} of ${subjectId} already has a release (${exists.id}); versions are immutable`);
const cleanNotes = String(notes || '').slice(0, 2000);
const cleanNotes = String(notes || '').slice(0, 2000);
const manifestId = store.newId('mfs');
const manifestId = store.newId('mfs');
const releaseId = store.newId('rel');
const releaseId = store.newId('rel');
const t = store.iso();
const t = store.iso();
- db.transaction(() => {
- db.prepare(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(manifestId, kind, app.id, app.project_id, subjectId, manifest.version, JSON.stringify(manifest), contractsVersion, actor.label, t);
.run(manifestId, kind, app.id, app.project_id, subjectId, manifest.version, JSON.stringify(manifest), contractsVersion, actor.label, t);
- db.prepare(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at)
+ await db.prepare(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)`)
.run(releaseId, app.id, app.project_id, app.environment, kind, subjectId, String(manifest.name).slice(0, 80), manifest.version, manifestId,
.run(releaseId, app.id, app.project_id, app.environment, kind, subjectId, String(manifest.name).slice(0, 80), manifest.version, manifestId,
JSON.stringify(manifest.compatibility || {}), cleanNotes, actor.label, t);
JSON.stringify(manifest.compatibility || {}), cleanNotes, actor.label, t);
- writeLog(releaseId, 'created', actor.label, { version: manifest.version, kind, warnings: result.warnings.length });
- })();
- return { release: get(releaseId), validation: result };
+ await writeLog(releaseId, 'created', actor.label, { version: manifest.version, kind, warnings: result.warnings.length });
+ });
+ return { release: await get(releaseId), validation: result };
}
}
- function load(id) {
- const r = db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id));
+ async function load(id) {
+ const r = await db.prepare('SELECT * FROM releases WHERE id = ?').get(String(id));
if (!r) fail(404, 'release.not_found', 'no such release');
if (!r) fail(404, 'release.not_found', 'no such release');
return r;
return r;
}
}
- function publish({ actor, releaseId, app }) {
- const r = load(releaseId);
+ async function publish({ actor, releaseId, app }) {
+ const r = await load(releaseId);
if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
if (app.revoked_at) fail(409, 'release.app_revoked', 'the app is revoked in Network');
assertCanManage(actor, { ...app, environment: r.environment });
assertCanManage(actor, { ...app, environment: r.environment });
if (r.status !== 'draft') fail(409, 'release.not_draft', `release is ${r.status}`);
if (r.status !== 'draft') fail(409, 'release.not_draft', `release is ${r.status}`);
let env;
let env;
- db.transaction(() => {
- db.prepare("UPDATE releases SET status = 'published', published_by = ?, published_at = ? WHERE id = ? AND status = 'draft'").run(actor.label, store.iso(), r.id);
- writeLog(r.id, 'published', actor.label);
- env = event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });
- })();
- return { release: get(r.id), event_id: env.event_id };
+ await db.tx(async () => {
+ await db.prepare("UPDATE releases SET status = 'published', published_by = ?, published_at = ? WHERE id = ? AND status = 'draft'").run(actor.label, store.iso(), r.id);
+ await writeLog(r.id, 'published', actor.label);
+ env = await event('codes.app.published', r, actor, { compatibility: JSON.parse(r.compatibility || '{}') });
+ });
+ return { release: await get(r.id), event_id: env.event_id };
}
}
- function deprecate({ actor, releaseId, app, reason, replacement }) {
- const r = load(releaseId);
+ async function deprecate({ actor, releaseId, app, reason, replacement }) {
+ const r = await load(releaseId);
if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
if (app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
assertCanManage(actor, { ...app, environment: r.environment });
assertCanManage(actor, { ...app, environment: r.environment });
if (r.status !== 'published') fail(409, 'release.not_published', `release is ${r.status}`);
if (r.status !== 'published') fail(409, 'release.not_published', `release is ${r.status}`);
@@ -146,22 +146,22 @@ function createReleases({ store, outbox, trust }) {
@@ -146,22 +146,22 @@ function createReleases({ store, outbox, trust }) {
if (!why) fail(422, 'release.invalid', 'say why the release is deprecated');
if (!why) fail(422, 'release.invalid', 'say why the release is deprecated');
let repl = null;
let repl = null;
if (replacement) {
if (replacement) {
- const rr = db.prepare('SELECT id, app_id, status FROM releases WHERE id = ?').get(String(replacement));
+ const rr = await db.prepare('SELECT id, app_id, status FROM releases WHERE id = ?').get(String(replacement));
if (!rr || rr.app_id !== r.app_id || rr.status !== 'published') fail(422, 'release.invalid', 'the replacement must be a published release of the same app');
if (!rr || rr.app_id !== r.app_id || rr.status !== 'published') fail(422, 'release.invalid', 'the replacement must be a published release of the same app');
repl = rr.id;
repl = rr.id;
}
}
let env;
let env;
- db.transaction(() => {
- db.prepare("UPDATE releases SET status = 'deprecated', deprecated_by = ?, deprecated_at = ?, deprecation_reason = ?, replacement = ? WHERE id = ?")
+ await db.tx(async () => {
+ await db.prepare("UPDATE releases SET status = 'deprecated', deprecated_by = ?, deprecated_at = ?, deprecation_reason = ?, replacement = ? WHERE id = ?")
.run(actor.label, store.iso(), why, repl, r.id);
.run(actor.label, store.iso(), why, repl, r.id);
- writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
- env = event('codes.app.deprecated', r, actor, { reason: why, replacement: repl });
- })();
- return { release: get(r.id), event_id: env.event_id };
+ await writeLog(r.id, 'deprecated', actor.label, { reason: why, replacement: repl });
+ env = await event('codes.app.deprecated', r, actor, { reason: why, replacement: repl });
+ });
+ return { release: await get(r.id), event_id: env.event_id };
}
}
- function revoke({ actor, releaseId, app, reason }) {
- const r = load(releaseId);
+ async function revoke({ actor, releaseId, app, reason }) {
+ const r = await load(releaseId);
if (app && app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
if (app && app.id !== r.app_id) fail(404, 'release.not_found', 'no such release');
assertCanManage(actor, { ...(app || { id: r.app_id }), environment: r.environment }, { revoke: true });
assertCanManage(actor, { ...(app || { id: r.app_id }), environment: r.environment }, { revoke: true });
if (r.status === 'revoked') fail(409, 'release.revoked', 'release is already revoked');
if (r.status === 'revoked') fail(409, 'release.revoked', 'release is already revoked');
@@ -172,25 +172,25 @@ function createReleases({ store, outbox, trust }) {
@@ -172,25 +172,25 @@ function createReleases({ store, outbox, trust }) {
// (ADR-022): it goes to Network's audit log too. A member revoking their own release is not.
// (ADR-022): it goes to Network's audit log too. A member revoking their own release is not.
const byStaff = actor.kind === 'user' && actor.staff && !(actor.role && RANK[actor.role] >= RANK[manageRole(r.environment)]);
const byStaff = actor.kind === 'user' && actor.staff && !(actor.role && RANK[actor.role] >= RANK[manageRole(r.environment)]);
let env = null;
let env = null;
- db.transaction(() => {
- db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);
- writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });
+ await db.tx(async () => {
+ await db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);
+ await writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });
// A draft was never public: nobody downstream knows it, so there is nothing to announce.
// A draft was never public: nobody downstream knows it, so there is nothing to announce.
- if (wasPublic) env = event('codes.app.revoked', r, actor, { reason: why });
+ if (wasPublic) env = await event('codes.app.revoked', r, actor, { reason: why });
if (byStaff) {
if (byStaff) {
const [ckind, cid] = String(r.created_by).split(':');
const [ckind, cid] = String(r.created_by).split(':');
- outbox.moderationAction({
+ await outbox.moderationAction({
action: 'release.revoked', target: { type: 'release', id: r.id, owner_subject: ckind === 'user' ? cid : null },
action: 'release.revoked', target: { type: 'release', id: r.id, owner_subject: ckind === 'user' ? cid : null },
actorSubject: actor.subject, reason: why, details: { app_id: r.app_id, project_id: r.project_id, version: r.version, was: r.status },
actorSubject: actor.subject, reason: why, details: { app_id: r.app_id, project_id: r.project_id, version: r.version, was: r.status },
}, { traceparent: actor.traceparent });
}, { traceparent: actor.traceparent });
}
}
- })();
- return { release: get(r.id), event_id: env ? env.event_id : null };
+ });
+ return { release: await get(r.id), event_id: env ? env.event_id : null };
}
}
/** Every release of a project (drafts included), each with its manifest and log: for the project export. */
/** Every release of a project (drafts included), each with its manifest and log: for the project export. */
- const listForProject = (projectId) => db.prepare('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at, id').all(String(projectId))
- .map((r) => ({ ...view(r), manifest: manifestOf(r.manifest_id), log: log(r.id) }));
+ const listForProject = async (projectId) => (await Promise.all((await db.prepare('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at, id').all(String(projectId)))
+ .map(async (r) => ({ ...await view(r), manifest: await manifestOf(r.manifest_id), log: await log(r.id) }))));
/**
/**
* Codes' part of deleting a project (Network archives the project itself). Drafts were never
* Codes' part of deleting a project (Network archives the project itself). Drafts were never
@@ -199,22 +199,22 @@ function createReleases({ store, outbox, trust }) {
@@ -199,22 +199,22 @@ function createReleases({ store, outbox, trust }) {
* revocation (compatibility policy), so each emits codes.app.revoked as any revocation does.
* revocation (compatibility policy), so each emits codes.app.revoked as any revocation does.
* The caller has checked that the actor owns the project (or is staff).
* The caller has checked that the actor owns the project (or is staff).
*/
*/
- function retireProject({ actor, projectId, reason = actor.staff && actor.role !== 'owner' ? 'project deleted by OpenVibe staff' : 'project deleted by its owner' }) {
- const rows = db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));
+ async function retireProject({ actor, projectId, reason = actor.staff && actor.role !== 'owner' ? 'project deleted by OpenVibe staff' : 'project deleted by its owner' }) {
+ const rows = await db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));
const out = { revoked: [], deletedDrafts: [] };
const out = { revoked: [], deletedDrafts: [] };
for (const r of rows.filter((x) => x.status !== 'draft')) {
for (const r of rows.filter((x) => x.status !== 'draft')) {
- revoke({ actor, releaseId: r.id, app: null, reason });
+ await revoke({ actor, releaseId: r.id, app: null, reason });
out.revoked.push(r.id);
out.revoked.push(r.id);
}
}
- db.transaction(() => {
+ await db.tx(async () => {
for (const r of rows.filter((x) => x.status === 'draft')) {
for (const r of rows.filter((x) => x.status === 'draft')) {
- db.prepare("DELETE FROM releases WHERE id = ? AND status = 'draft'").run(r.id);
- const shared = db.prepare('SELECT COUNT(*) AS n FROM releases WHERE manifest_id = ?').get(r.manifest_id).n;
- if (!shared) db.prepare('DELETE FROM manifests WHERE id = ?').run(r.manifest_id);
- writeLog(r.id, 'deleted', actor.label, { reason, was: 'draft' });
+ await db.prepare("DELETE FROM releases WHERE id = ? AND status = 'draft'").run(r.id);
+ const shared = (await db.prepare('SELECT COUNT(*) AS n FROM releases WHERE manifest_id = ?').get(r.manifest_id)).n;
+ if (!shared) await db.prepare('DELETE FROM manifests WHERE id = ?').run(r.manifest_id);
+ await writeLog(r.id, 'deleted', actor.label, { reason, was: 'draft' });
out.deletedDrafts.push(r.id);
out.deletedDrafts.push(r.id);
}
}
- })();
+ });
return out;
return out;
}
}
@@ -15,38 +15,38 @@ const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -15,38 +15,38 @@ const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
function createTrust({ store, outbox = null }) {
function createTrust({ store, outbox = null }) {
const { db } = store;
const { db } = store;
- function get(appId) {
- const r = db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));
+ async function get(appId) {
+ const r = await db.prepare('SELECT * FROM trust WHERE app_id = ?').get(String(appId));
return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'unreviewed', note: '', set_by: null, set_at: null };
return r ? { tier: r.tier, note: r.note, set_by: r.set_by, set_at: r.set_at } : { tier: 'unreviewed', note: '', set_by: null, set_at: null };
}
}
- function set({ appId, tier, note, actor }) {
+ async function set({ appId, tier, note, actor }) {
if (!actor || !actor.staff) throw Object.assign(new Error('only Codes staff set trust tiers'), { status: 403, code: 'trust.staff_only' });
if (!actor || !actor.staff) throw Object.assign(new Error('only Codes staff set trust tiers'), { status: 403, code: 'trust.staff_only' });
if (!APP_ID_RE.test(String(appId))) throw Object.assign(new Error('not an app id'), { status: 422, code: 'trust.invalid' });
if (!APP_ID_RE.test(String(appId))) throw Object.assign(new Error('not an app id'), { status: 422, code: 'trust.invalid' });
if (!TIERS.includes(tier)) throw Object.assign(new Error(`tier is one of ${TIERS.join(', ')}`), { status: 422, code: 'trust.invalid' });
if (!TIERS.includes(tier)) throw Object.assign(new Error(`tier is one of ${TIERS.join(', ')}`), { status: 422, code: 'trust.invalid' });
const clean = String(note || '').trim().slice(0, 500);
const clean = String(note || '').trim().slice(0, 500);
if (!clean) throw Object.assign(new Error('say why (the note is public)'), { status: 422, code: 'trust.invalid' });
if (!clean) throw Object.assign(new Error('say why (the note is public)'), { status: 422, code: 'trust.invalid' });
- const before = get(appId).tier;
+ const before = (await get(appId)).tier;
const t = store.iso();
const t = store.iso();
- db.transaction(() => {
- db.prepare(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(app_id) DO UPDATE SET tier = excluded.tier, note = excluded.note, set_by = excluded.set_by, set_at = excluded.set_at`)
ON CONFLICT(app_id) DO UPDATE SET tier = excluded.tier, note = excluded.note, set_by = excluded.set_by, set_at = excluded.set_at`)
.run(appId, tier, clean, actor.label, t);
.run(appId, tier, clean, actor.label, t);
- db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(appId, before, tier, clean, actor.label, t);
.run(appId, before, tier, clean, actor.label, t);
if (outbox) {
if (outbox) {
const [kind, id] = String(actor.label).split(':');
const [kind, id] = String(actor.label).split(':');
- outbox.moderationAction({
+ await outbox.moderationAction({
action: 'app.trust_changed', target: { type: 'app', id: appId }, actorSubject: kind === 'user' ? id : null,
action: 'app.trust_changed', target: { type: 'app', id: appId }, actorSubject: kind === 'user' ? id : null,
reason: clean, details: { from: before, to: tier },
reason: clean, details: { from: before, to: tier },
}, { traceparent: actor.traceparent });
}, { traceparent: actor.traceparent });
}
}
- })();
- return get(appId);
+ });
+ return await get(appId);
}
}
- const history = (appId) => db.prepare('SELECT from_tier, to_tier, note, set_by, set_at FROM trust_history WHERE app_id = ? ORDER BY id DESC').all(String(appId));
- const listSet = () => db.prepare('SELECT * FROM trust ORDER BY set_at DESC LIMIT 200').all();
+ const history = async (appId) => await db.prepare('SELECT from_tier, to_tier, note, set_by, set_at FROM trust_history WHERE app_id = ? ORDER BY id DESC').all(String(appId));
+ const listSet = async () => await db.prepare('SELECT * FROM trust ORDER BY set_at DESC LIMIT 200').all();
return { get, set, history, listSet, TIERS };
return { get, set, history, listSet, TIERS };
}
}
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
*/
*/
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked', 'codes.moderation.action'];
const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked', 'codes.moderation.action'];
@@ -35,7 +35,9 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -35,7 +35,9 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
}
}
const events = createEventsClient(createClient(clientOpts), { source: 'codes' });
const events = createEventsClient(createClient(clientOpts), { source: 'codes' });
let lastError = null;
let lastError = null;
- const outbox = createOutbox(db, {
+ // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient
+ // transaction); several processes relay one table safely (leases).
+ const outbox = createPgOutbox(db, {
events,
events,
intervalMs: config.events.intervalMs,
intervalMs: config.events.intervalMs,
now,
now,
@@ -45,21 +47,20 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -45,21 +47,20 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
lastError = msg;
lastError = msg;
},
},
});
});
- outbox.ensureSchema();
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
/** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
- function emit(envelope, { traceparent } = {}) {
+ async function emit(envelope, { traceparent } = {}) {
if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`Codes does not produce ${envelope.event_type}`);
if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`Codes does not produce ${envelope.event_type}`);
- return outbox.enqueue(envelope, { traceparent });
+ return await outbox.enqueue(db, envelope, { traceparent });
}
}
/**
/**
* codes.moderation.action (common.moderation-action@1), inside the caller's transaction. actor:
* codes.moderation.action (common.moderation-action@1), inside the caller's transaction. actor:
* the staff member's subject; target: { type, id, owner_subject? }. Never the content.
* the staff member's subject; target: { type, id, owner_subject? }. Never the content.
*/
*/
- function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
+ async function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
- return emit({
+ return await emit({
event_type: 'codes.moderation.action',
event_type: 'codes.moderation.action',
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'codes' },
actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'codes' },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
@@ -75,7 +76,7 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -75,7 +76,7 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
stop: () => outbox.stop(),
stop: () => outbox.stop(),
- status: () => ({ enabled, pending: outbox.pending(), rejected: outbox.rejected(), last_error: lastError }),
+ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
};
};
}
}
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages and
* Never limited: /api/health, /api/ready, /release.json, /metrics, sign-in, and the public pages and
* docs (the per-address limit bounds them).
* docs (the per-address limit bounds them).
*/
*/
-const { createActorLimiter } = require('openvibe-sdk/limits');
+const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
function actor(req) {
function actor(req) {
const p = req.principal;
const p = req.principal;
@@ -66,7 +66,7 @@ const BUDGETS = {
@@ -66,7 +66,7 @@ const BUDGETS = {
* limits.budget(name) (one of BUDGETS). enabled=false (tests only, as they raise the per-address
* limits.budget(name) (one of BUDGETS). enabled=false (tests only, as they raise the per-address
* limit) counts nobody.
* limit) counts nobody.
*/
*/
-function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true }) {
+function createActorLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
const refused = registry
const refused = registry
? registry.counter({ name: 'codes_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
? registry.counter({ name: 'codes_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
: null;
: null;
@@ -74,6 +74,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
@@ -74,6 +74,8 @@ function createActorLimits({ config, now = () => Date.now(), registry = null, lo
limits: { minute: config.limits.minute, hour: config.limits.hour },
limits: { minute: config.limits.minute, hour: config.limits.hour },
actor: enabled ? actor : () => null,
actor: enabled ? actor : () => null,
now,
now,
+ // Shared across processes on Valkey (ADR-035) when VALKEY_URL is set; in-process otherwise.
+ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
onLimited(e) {
onLimited(e) {
// The actor is an app principal, a subject id or an address, never a token.
// The actor is an app principal, a subject id or an address, never a token.
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
@@ -66,21 +66,21 @@ function createApi(ctx) {
@@ -66,21 +66,21 @@ function createApi(ctx) {
const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)
const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)
: problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];
: problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];
- r.get('/apps/:app/releases', ...readAccess, reads, (req, res) => {
+ r.get('/apps/:app/releases', ...readAccess, reads, async (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
- res.json({ app_id: req.params.app, trust: trust.get(req.params.app), releases: releases.listForApp(req.params.app) });
+ res.json({ app_id: req.params.app, trust: await trust.get(req.params.app), releases: await releases.listForApp(req.params.app) });
});
});
- r.get('/apps/:app/trust', ...readAccess, reads, (req, res) => {
+ r.get('/apps/:app/trust', ...readAccess, reads, async (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
- res.json({ app_id: req.params.app, ...trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
+ res.json({ app_id: req.params.app, ...await trust.get(req.params.app), note_on_authority: 'metadata only; grants in OpenVibe.Network are the authority' });
});
});
- r.get('/releases/:id', ...readAccess, reads, (req, res) => {
- const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
+ r.get('/releases/:id', ...readAccess, reads, async (req, res) => {
+ const rel = REL_RE.test(req.params.id) ? await releases.get(req.params.id) : null;
if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
if (!rel || rel.status === 'draft') return problem(req, res, 404, 'release.not_found', 'no such release');
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
- const m = releases.manifestOf(rel.manifest_id);
+ const m = await releases.manifestOf(rel.manifest_id);
res.json({ release: rel, manifest: m ? m.body : null });
res.json({ release: rel, manifest: m ? m.body : null });
});
});
@@ -108,7 +108,7 @@ function createApi(ctx) {
@@ -108,7 +108,7 @@ function createApi(ctx) {
return problem(req, res, 500, 'internal.error', 'Internal error');
return problem(req, res, 500, 'internal.error', 'Internal error');
};
};
- r.post('/apps/:app/releases', limiter, ...manageAccess, B('codes.release.create'), json, (req, res) => {
+ r.post('/apps/:app/releases', limiter, ...manageAccess, B('codes.release.create'), json, async (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
const p = appPrincipal(req, res, req.params.app);
const p = appPrincipal(req, res, req.params.app);
if (!p) return;
if (!p) return;
@@ -116,24 +116,24 @@ function createApi(ctx) {
@@ -116,24 +116,24 @@ function createApi(ctx) {
const b = req.body || {};
const b = req.body || {};
try {
try {
const kind = b.kind === 'mod' ? 'mod' : 'app';
const kind = b.kind === 'mod' ? 'mod' : 'app';
- const out = releases.createDraft({ actor: p.actor, app: p.app, kind, manifest: b.manifest, notes: b.notes, eventTypes: docs.eventTypes });
+ const out = await releases.createDraft({ actor: p.actor, app: p.app, kind, manifest: b.manifest, notes: b.notes, eventTypes: docs.eventTypes });
let release = out.release;
let release = out.release;
- if (b.publish === true) release = releases.publish({ actor: p.actor, releaseId: release.id, app: p.app }).release;
+ if (b.publish === true) release = (await releases.publish({ actor: p.actor, releaseId: release.id, app: p.app })).release;
res.status(201).json({ release, warnings: out.validation.warnings });
res.status(201).json({ release, warnings: out.validation.warnings });
} catch (err) { fail(req, res, err); }
} catch (err) { fail(req, res, err); }
});
});
for (const action of ['publish', 'deprecate', 'revoke']) {
for (const action of ['publish', 'deprecate', 'revoke']) {
- r.post(`/releases/:id/${action}`, limiter, ...manageAccess, B('codes.release.manage'), json, (req, res) => {
- const rel = REL_RE.test(req.params.id) ? releases.get(req.params.id) : null;
+ r.post(`/releases/:id/${action}`, limiter, ...manageAccess, B('codes.release.manage'), json, async (req, res) => {
+ const rel = REL_RE.test(req.params.id) ? await releases.get(req.params.id) : null;
if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
if (!rel) return problem(req, res, 404, 'release.not_found', 'no such release');
const p = appPrincipal(req, res, rel.app_id);
const p = appPrincipal(req, res, rel.app_id);
if (!p) return;
if (!p) return;
const b = req.body || {};
const b = req.body || {};
try {
try {
- const out = action === 'publish' ? releases.publish({ actor: p.actor, releaseId: rel.id, app: p.app })
- : action === 'deprecate' ? releases.deprecate({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason, replacement: b.replacement })
- : releases.revoke({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason });
+ const out = action === 'publish' ? await releases.publish({ actor: p.actor, releaseId: rel.id, app: p.app })
+ : action === 'deprecate' ? await releases.deprecate({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason, replacement: b.replacement })
+ : await releases.revoke({ actor: p.actor, releaseId: rel.id, app: p.app, reason: b.reason });
res.json(out);
res.json(out);
} catch (err) { fail(req, res, err); }
} catch (err) { fail(req, res, err); }
});
});
@@ -56,8 +56,8 @@ function createPageRoutes(ctx) {
@@ -56,8 +56,8 @@ function createPageRoutes(ctx) {
const governance = loadGovernance();
const governance = loadGovernance();
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
- r.get('/', (req, res) => {
- const recent = releases.recentPublic(10);
+ r.get('/', async (req, res) => {
+ const recent = await releases.recentPublic(10);
page(req, res, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
body: html`<h1>Build on OpenVibe</h1>
body: html`<h1>Build on OpenVibe</h1>
@@ -178,11 +178,11 @@ ${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w
@@ -178,11 +178,11 @@ ${Array.isArray(status.notYet) ? html`<h3>Not yet</h3><ul>${status.notYet.map((w
// ── Public release pages ────────────────────────────────
// ── Public release pages ────────────────────────────────
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
- r.get('/apps/:app', (req, res, next) => {
+ r.get('/apps/:app', async (req, res, next) => {
if (!APP_ID_RE.test(req.params.app)) return next();
if (!APP_ID_RE.test(req.params.app)) return next();
- const list = releases.listForApp(req.params.app);
- const t = trust.get(req.params.app);
- const hist = trust.history(req.params.app);
+ const list = await releases.listForApp(req.params.app);
+ const t = await trust.get(req.params.app);
+ const hist = await trust.history(req.params.app);
page(req, res, {
page(req, res, {
index: list.length > 0, cache: PUBLIC_CACHE, title: list[0] ? list[0].name : req.params.app,
index: list.length > 0, cache: PUBLIC_CACHE, title: list[0] ? list[0].name : req.params.app,
crumbs: [{ label: 'Apps' }, { label: req.params.app }],
crumbs: [{ label: 'Apps' }, { label: req.params.app }],
@@ -199,7 +199,7 @@ ${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], h
@@ -199,7 +199,7 @@ ${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], h
});
});
r.get('/releases/:id', async (req, res, next) => {
r.get('/releases/:id', async (req, res, next) => {
- const rel = releases.get(req.params.id);
+ const rel = await releases.get(req.params.id);
if (!rel) return next();
if (!rel) return next();
if (rel.status === 'draft') {
if (rel.status === 'draft') {
// Drafts are visible to members of the app's project only — as Network reports it.
// Drafts are visible to members of the app's project only — as Network reports it.
@@ -209,7 +209,7 @@ ${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], h
@@ -209,7 +209,7 @@ ${hist.length ? html`<h2>Trust history</h2>${table(['When', 'Change', 'Note'], h
}
}
if (!member) return next();
if (!member) return next();
}
}
- const m = releases.manifestOf(rel.manifest_id);
+ const m = await releases.manifestOf(rel.manifest_id);
const signedIn = req.viewer.kind === 'user';
const signedIn = req.viewer.kind === 'user';
page(req, res, {
page(req, res, {
index: rel.status === 'published', cache: rel.status === 'draft' ? null : PUBLIC_CACHE, title: `${rel.name} ${rel.version}`,
index: rel.status === 'published', cache: rel.status === 'draft' ? null : PUBLIC_CACHE, title: `${rel.name} ${rel.version}`,
@@ -225,7 +225,7 @@ ${rel.revoked_at ? html`<dt>Revoked</dt><dd>${time(rel.revoked_at)}: ${rel.revoc
@@ -225,7 +225,7 @@ ${rel.revoked_at ? html`<dt>Revoked</dt><dd>${time(rel.revoked_at)}: ${rel.revoc
${rel.notes ? html`<h2>Notes</h2><p>${rel.notes}</p>` : ''}
${rel.notes ? html`<h2>Notes</h2><p>${rel.notes}</p>` : ''}
<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>
<h2>Manifest</h2><p class="muted small">Validated with openvibe-contracts ${m ? m.contracts_version : '?'}.</p><pre><code>${m ? JSON.stringify(m.body, null, 2) : ''}</code></pre>
${signedIn ? releaseActions(req, rel) : ''}
${signedIn ? releaseActions(req, rel) : ''}
-<h2>History</h2>${table(['When', 'Action', 'By'], releases.log(rel.id).map((l) => [time(l.at), l.action, l.actor]))}`,
+<h2>History</h2>${table(['When', 'Action', 'By'], (await releases.log(rel.id)).map((l) => [time(l.at), l.action, l.actor]))}`,
});
});
});
});
@@ -240,7 +240,7 @@ ${signedIn ? releaseActions(req, rel) : ''}
@@ -240,7 +240,7 @@ ${signedIn ? releaseActions(req, rel) : ''}
}
}
// ── Staff: trust tiers (metadata) ───────────────────────
// ── Staff: trust tiers (metadata) ───────────────────────
- r.get('/staff', (req, res) => {
+ r.get('/staff', async (req, res) => {
if (!req.viewer.staff) return page(req, res, { title: 'Staff', body: html`<h1>Staff only</h1><p>Trust tiers are set by Codes staff.</p>` }, req.viewer.kind === 'user' ? 403 : 401);
if (!req.viewer.staff) return page(req, res, { title: 'Staff', body: html`<h1>Staff only</h1><p>Trust tiers are set by Codes staff.</p>` }, req.viewer.kind === 'user' ? 403 : 401);
page(req, res, {
page(req, res, {
title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],
title: 'Staff: trust tiers', crumbs: [{ label: 'Staff' }],
@@ -250,14 +250,14 @@ ${signedIn ? releaseActions(req, rel) : ''}
@@ -250,14 +250,14 @@ ${signedIn ? releaseActions(req, rel) : ''}
<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label>
<label>App id <input name="app_id" required pattern="app_[0-9A-HJKMNP-TV-Z]{26}"></label>
<label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>
<label>Tier <select name="tier">${trust.TIERS.map((t) => html`<option>${t}</option>`)}</select></label>
<label>Note (public) <input name="note" required maxlength="500"></label><button type="submit">Set tier</button></form>
<label>Note (public) <input name="note" required maxlength="500"></label><button type="submit">Set tier</button></form>
-<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], trust.listSet().map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,
+<h2>Set tiers</h2>${table(['App', 'Tier', 'Note', 'By', 'When'], (await trust.listSet()).map((t) => [html`<a href="/apps/${t.app_id}">${t.app_id}</a>`, t.tier, t.note, t.set_by, time(t.set_at)]))}`,
});
});
});
});
// Per-actor limit (http/actor-limits.js) before the form is read.
// Per-actor limit (http/actor-limits.js) before the form is read.
- r.post('/staff/trust', ctx.actorLimits.budget('codes.trust.set'), form, (req, res) => {
+ r.post('/staff/trust', ctx.actorLimits.budget('codes.trust.set'), form, async (req, res) => {
if (!req.viewer.staff || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
if (!req.viewer.staff || !sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Forbidden', body: html`<h1>Forbidden</h1>` }, 403);
try {
try {
- trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
+ await trust.set({ appId: req.body.app_id, tier: req.body.tier, note: req.body.note, actor: { staff: true, label: `user:${req.viewer.subject}` } });
res.redirect(303, '/staff?done=1');
res.redirect(303, '/staff?done=1');
} catch (err) {
} catch (err) {
page(req, res, { title: 'Staff', body: problemBox({ status: err.status || 422, code: err.code || 'trust.invalid', detail: err.message }) }, err.status || 422);
page(req, res, { title: 'Staff', body: problemBox({ status: err.status || 422, code: err.code || 'trust.invalid', detail: err.message }) }, err.status || 422);
@@ -114,10 +114,10 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
@@ -114,10 +114,10 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
<p class="muted small">New projects are sandbox-only. OpenVibe staff enable production and set which capabilities a project's apps may hold (its allowance).</p>`,
<p class="muted small">New projects are sandbox-only. OpenVibe staff enable production and set which capabilities a project's apps may hold (its allowance).</p>`,
}, status);
}, status);
}
}
- r.get('/', (req, res) => projectsPage(req, res));
+ r.get('/', async (req, res) => await projectsPage(req, res));
r.post('/', B('codes.project.create'), form, guard, async (req, res) => {
r.post('/', B('codes.project.create'), form, guard, async (req, res) => {
const got = await net(req, res, (t) => network.projects.create(t, { name: String(req.body.name || '') }));
const got = await net(req, res, (t) => network.projects.create(t, { name: String(req.body.name || '') }));
- if (!got.ok) return projectsPage(req, res, { problem: got.problem, status: got.problem.status });
+ if (!got.ok) return await projectsPage(req, res, { problem: got.problem, status: got.problem.status });
res.redirect(303, `/projects/${got.data.id}`);
res.redirect(303, `/projects/${got.data.id}`);
});
});
@@ -301,8 +301,8 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
@@ -301,8 +301,8 @@ ${canChange || self ? html`<form method="post" action="/projects/${project.id}/m
if (!got.ok) return problemPage(req, res, got.problem, { title: 'Delete project', back: back(req) });
if (!got.ok) return problemPage(req, res, got.problem, { title: 'Delete project', back: back(req) });
}
}
const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.role, staff: req.viewer.staff, traceparent: req.ov.traceparent };
const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.role, staff: req.viewer.staff, traceparent: req.ov.traceparent };
- const retired = releases.retireProject({ actor, projectId: project.id });
- const runs = playground.deleteRunsForProject(project.id);
+ const retired = await releases.retireProject({ actor, projectId: project.id });
+ const runs = await playground.deleteRunsForProject(project.id);
log.info(`[Codes] project ${project.id} deleted by ${actor.label}: archived in Network, ${retired.revoked.length} releases revoked, ${retired.deletedDrafts.length} drafts and ${runs} playground runs deleted`);
log.info(`[Codes] project ${project.id} deleted by ${actor.label}: archived in Network, ${retired.revoked.length} releases revoked, ${retired.deletedDrafts.length} drafts and ${runs} playground runs deleted`);
res.redirect(303, `${back(req)}?done=${encodeURIComponent(`Deleted: ${retired.revoked.length} revoked, ${retired.deletedDrafts.length} drafts and ${runs} runs removed`)}`);
res.redirect(303, `${back(req)}?done=${encodeURIComponent(`Deleted: ${retired.revoked.length} revoked, ${retired.deletedDrafts.length} drafts and ${runs} runs removed`)}`);
});
});
@@ -427,8 +427,8 @@ const client = sdk.createClient({
@@ -427,8 +427,8 @@ const client = sdk.createClient({
const grantRows = grants.ok ? (grants.data.grants || []) : [];
const grantRows = grants.ok ? (grants.data.grants || []) : [];
const byCap = new Map(grantRows.map((g) => [g.capability, g]));
const byCap = new Map(grantRows.map((g) => [g.capability, g]));
const offer = catalog.ok ? offered(catalog.data, project) : [];
const offer = catalog.ok ? offered(catalog.data, project) : [];
- const t = trust.get(app.id);
- const rels = releases.listForApp(app.id, { includeDrafts: true });
+ const t = await trust.get(app.id);
+ const rels = await releases.listForApp(app.id, { includeDrafts: true });
page(req, res, {
page(req, res, {
title: app.name, scripts: ['js/copy.js'],
title: app.name, scripts: ['js/copy.js'],
crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name }],
crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name }],
@@ -562,7 +562,7 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
@@ -562,7 +562,7 @@ ${manage ? html`<h2>Revoke this app</h2><form method="post" action="${base}/revo
<label><input type="radio" name="credential_type" value="access_token"> access token you minted for this app</label>
<label><input type="radio" name="credential_type" value="access_token"> access token you minted for this app</label>
<input type="password" name="credential" value="" autocomplete="off" required aria-label="Credential"></fieldset>`;
<input type="password" name="credential" value="" autocomplete="off" required aria-label="Credential"></fieldset>`;
const pre = (p) => (p.ok ? '' : html`<div class="notice ${p.stage === 'grant' ? 'warn' : 'bad'}"><strong>Cannot run:</strong> ${p.detail}${p.missing && p.grantable ? html` <a href="${base}#request">Request it</a>.` : ''}</div>`);
const pre = (p) => (p.ok ? '' : html`<div class="notice ${p.stage === 'grant' ? 'warn' : 'bad'}"><strong>Cannot run:</strong> ${p.detail}${p.missing && p.grantable ? html` <a href="${base}#request">Request it</a>.` : ''}</div>`);
- const runs = playground.runsFor(app.id);
+ const runs = await playground.runsFor(app.id);
page(req, res, {
page(req, res, {
title: `Playground · ${app.name}`,
title: `Playground · ${app.name}`,
crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'Playground' }],
crumbs: [{ label: 'Projects', href: '/projects' }, { label: project.name, href: `/projects/${project.id}` }, { label: app.name, href: base }, { label: 'Playground' }],
@@ -596,7 +596,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
@@ -596,7 +596,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));
const statusFor = (out) => (out.outcome === 'ok' ? 200 : (out.stage === 'grant' || out.stage === 'app' ? 403 : (out.stage === 'input' ? 422 : (out.stage === 'rate' ? 429 : (out.stage === 'token' ? (out.http_status && out.http_status >= 400 ? out.http_status : 401) : 502)))));
- r.get('/:project/apps/:app/playground', idParams, (req, res) => playgroundPage(req, res));
+ r.get('/:project/apps/:app/playground', idParams, async (req, res) => await playgroundPage(req, res));
r.post('/:project/apps/:app/playground/events', idParams, B('codes.playground.run'), form, guard, async (req, res) => {
r.post('/:project/apps/:app/playground/events', idParams, B('codes.playground.run'), form, guard, async (req, res) => {
const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
const got = await net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app));
@@ -608,7 +608,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
@@ -608,7 +608,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
input: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },
input: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload },
});
});
// The credential is NOT passed back: the form comes back with it empty.
// The credential is NOT passed back: the form comes back with it empty.
- return playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
+ return await playgroundPage(req, res, { result: out, values: { event_type: b.event_type, subject_type: b.subject_type, subject_id: b.subject_id, payload: b.payload }, status: statusFor(out) });
});
});
r.post('/:project/apps/:app/playground/media', idParams, B('codes.playground.run'), async (req, res) => {
r.post('/:project/apps/:app/playground/media', idParams, B('codes.playground.run'), async (req, res) => {
@@ -625,7 +625,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
@@ -625,7 +625,7 @@ ${r.result ? html`<pre><code>${JSON.stringify(r.result, null, 2)}</code></pre>`
credential: { type: parsed.fields.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: String(parsed.fields.credential || '').trim() },
credential: { type: parsed.fields.credential_type === 'access_token' ? 'access_token' : 'client_secret', value: String(parsed.fields.credential || '').trim() },
file: parsed.file,
file: parsed.file,
});
});
- return playgroundPage(req, res, { result: out, status: statusFor(out) });
+ return await playgroundPage(req, res, { result: out, status: statusFor(out) });
});
});
// ── Releases (Codes-owned) ──────────────────────────────
// ── Releases (Codes-owned) ──────────────────────────────
@@ -651,12 +651,12 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
@@ -651,12 +651,12 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
}, status);
}, status);
}
}
- r.get('/:project/apps/:app/releases/new', idParams, (req, res) => editorPage(req, res, { kind: req.query.kind }));
+ r.get('/:project/apps/:app/releases/new', idParams, async (req, res) => await editorPage(req, res, { kind: req.query.kind }));
r.post('/:project/apps/:app/releases', idParams, B('codes.release.create'), form, guard, async (req, res) => {
r.post('/:project/apps/:app/releases', idParams, B('codes.release.create'), form, guard, async (req, res) => {
const b = req.body || {};
const b = req.body || {};
const kind = b.kind === 'mod' ? 'mod' : 'app';
const kind = b.kind === 'mod' ? 'mod' : 'app';
const p = manifestsLib.parse(b.manifest);
const p = manifestsLib.parse(b.manifest);
- if (p.error) return editorPage(req, res, { status: 422, kind, text: b.manifest || '', notes: b.notes, parseError: p.error });
+ if (p.error) return await editorPage(req, res, { status: 422, kind, text: b.manifest || '', notes: b.notes, parseError: p.error });
const [project, app] = await Promise.all([
const [project, app] = await Promise.all([
net(req, res, (t) => network.projects.get(t, req.params.project)),
net(req, res, (t) => network.projects.get(t, req.params.project)),
net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),
net(req, res, (t) => network.projects.app(t, req.params.project, req.params.app)),
@@ -664,17 +664,17 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
@@ -664,17 +664,17 @@ ${problem ? problemBox(problem, { title: 'Not created' }) : ''}${parseError ? no
if (!project.ok || !app.ok) return problemPage(req, res, (project.ok ? app : project).problem, { back: appBase(req) });
if (!project.ok || !app.ok) return problemPage(req, res, (project.ok ? app : project).problem, { back: appBase(req) });
if (b.intent !== 'create') {
if (b.intent !== 'create') {
const v = manifestsLib.validate(kind, p.manifest, { app: app.data, viewerSubject: req.viewer.subject, eventTypes: docs.eventTypes });
const v = manifestsLib.validate(kind, p.manifest, { app: app.data, viewerSubject: req.viewer.subject, eventTypes: docs.eventTypes });
- return editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });
+ return await editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });
}
}
try {
try {
- const out = releases.createDraft({
+ const out = await releases.createDraft({
actor: { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.data.role, staff: req.viewer.staff, traceparent: req.ov.traceparent },
actor: { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project.data.role, staff: req.viewer.staff, traceparent: req.ov.traceparent },
app: app.data, kind, manifest: p.manifest, notes: b.notes, eventTypes: docs.eventTypes,
app: app.data, kind, manifest: p.manifest, notes: b.notes, eventTypes: docs.eventTypes,
});
});
res.redirect(303, `/releases/${out.release.id}`);
res.redirect(303, `/releases/${out.release.id}`);
} catch (err) {
} catch (err) {
if (!(err instanceof ReleaseError)) return unexpected(req, res, err);
if (!(err instanceof ReleaseError)) return unexpected(req, res, err);
- return editorPage(req, res, { status: err.status, kind, text: b.manifest, notes: b.notes, validation: err.validation || null, problem: err.validation ? null : { status: err.status, code: err.code, detail: err.detail } });
+ return await editorPage(req, res, { status: err.status, kind, text: b.manifest, notes: b.notes, validation: err.validation || null, problem: err.validation ? null : { status: err.status, code: err.code, detail: err.detail } });
}
}
});
});
@@ -698,7 +698,7 @@ function createReleaseActionRoutes(ctx) {
@@ -698,7 +698,7 @@ function createReleaseActionRoutes(ctx) {
if (req.viewer.kind !== 'user' || !req.viewer.subject) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>` }, 401);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>` }, 401);
if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1>` }, 403);
if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return page(req, res, { title: 'Form expired', body: html`<h1>This form has expired</h1>` }, 403);
if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
if (!REL_RE.test(req.params.id)) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
- const rel = releases.get(req.params.id);
+ const rel = await releases.get(req.params.id);
if (!rel) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
if (!rel) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1>` }, 404);
let project = null;
let project = null;
let app = null;
let app = null;
@@ -717,11 +717,11 @@ function createReleaseActionRoutes(ctx) {
@@ -717,11 +717,11 @@ function createReleaseActionRoutes(ctx) {
}
}
const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project ? project.role : null, staff: req.viewer.staff, traceparent: req.ov.traceparent };
const actor = { kind: 'user', label: `user:${req.viewer.subject}`, subject: req.viewer.subject, role: project ? project.role : null, staff: req.viewer.staff, traceparent: req.ov.traceparent };
try {
try {
- if (action === 'publish') releases.publish({ actor, releaseId: rel.id, app });
- else if (action === 'deprecate') releases.deprecate({ actor, releaseId: rel.id, app, reason: req.body.reason, replacement: req.body.replacement || null });
+ if (action === 'publish') await releases.publish({ actor, releaseId: rel.id, app });
+ else if (action === 'deprecate') await releases.deprecate({ actor, releaseId: rel.id, app, reason: req.body.reason, replacement: req.body.replacement || null });
else {
else {
if (req.body.confirm !== '1') throw new ReleaseError(422, 'codes.confirm', 'tick the box to confirm');
if (req.body.confirm !== '1') throw new ReleaseError(422, 'codes.confirm', 'tick the box to confirm');
- releases.revoke({ actor, releaseId: rel.id, app, reason: req.body.reason });
+ await releases.revoke({ actor, releaseId: rel.id, app, reason: req.body.reason });
}
}
res.redirect(303, `/releases/${rel.id}?done=${action === 'publish' ? 'published' : (action === 'deprecate' ? 'deprecated' : 'revoked')}`);
res.redirect(303, `/releases/${rel.id}?done=${action === 'publish' ? 'published' : (action === 'deprecate' ? 'deprecated' : 'revoked')}`);
} catch (err) {
} catch (err) {
@@ -7,11 +7,12 @@
@@ -7,11 +7,12 @@
*/
*/
const { createApp } = require('./app');
const { createApp } = require('./app');
-const { app, ctx } = createApp();
+(async () => {
+const { app, ctx } = await createApp();
const { config } = ctx;
const { config } = ctx;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
- console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
+ console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Codes] docs from openvibe-contracts v${ctx.docs.contractsVersion} and openvibe-sdk v${ctx.docs.sdkVersion}; events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}`);
console.log(`[Codes] docs from openvibe-contracts v${ctx.docs.contractsVersion} and openvibe-sdk v${ctx.docs.sdkVersion}; events relay ${ctx.outbox.enabled ? `on → ${config.events.url}` : 'off (events wait in event_outbox)'}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
@@ -22,10 +23,11 @@ function shutdown(signal) {
@@ -22,10 +23,11 @@ function shutdown(signal) {
console.log(`[Codes] ${signal}: closing`);
console.log(`[Codes] ${signal}: closing`);
server.close(async () => {
server.close(async () => {
try { await ctx.outbox.stop(); } catch { /* best effort */ }
try { await ctx.outbox.stop(); } catch { /* best effort */ }
- try { ctx.store.close(); } catch { /* already closed */ }
+ try { await ctx.store.close(); } catch { /* already closed */ }
process.exit(0);
process.exit(0);
});
});
setTimeout(() => process.exit(0), 5000).unref();
setTimeout(() => process.exit(0), 5000).unref();
}
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGINT', () => shutdown('SIGINT'));
+})().catch((err) => { console.error('[Codes] failed to start:', err); process.exit(1); });
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
const { TABLES } = require('./db');
const { TABLES } = require('./db');
-function createCodesReadiness({ store, keys, network, outbox, docs, config, release = null }) {
+function createCodesReadiness({ store, keys, network, outbox, docs, config, valkey = null, release = null }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'codes',
service: 'codes',
@@ -22,8 +22,8 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele
@@ -22,8 +22,8 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele
checks: [
checks: [
{
{
name: 'db', required: true,
name: 'db', required: true,
- check: () => {
- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
+ check: async () => {
+ const names = new Set((await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name));
const missing = TABLES.filter((t) => !names.has(t));
const missing = TABLES.filter((t) => !names.has(t));
return missing.length ? `missing ${missing.join(', ')}` : true;
return missing.length ? `missing ${missing.join(', ')}` : true;
},
},
@@ -34,6 +34,7 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele
@@ -34,6 +34,7 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele
? { ok: true, detail: { contracts: docs.contractsVersion, sdk: docs.sdkVersion } }
? { ok: true, detail: { contracts: docs.contractsVersion, sdk: docs.sdkVersion } }
: 'reference docs were not generated'),
: 'reference docs were not generated'),
},
},
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
@@ -54,8 +55,8 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele
@@ -54,8 +55,8 @@ function createCodesReadiness({ store, keys, network, outbox, docs, config, rele
},
},
{
{
name: 'events_relay', required: false,
name: 'events_relay', required: false,
- check: () => {
- const s = outbox.status();
+ check: async () => {
+ const s = await outbox.status();
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (!s.enabled) return `relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset); ${s.pending} events waiting`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
if (s.rejected) return `${s.rejected} events rejected by OpenVibe.Events`;
return { ok: true, detail: { pending: s.pending } };
return { ok: true, detail: { pending: s.pending } };
@@ -43,7 +43,7 @@ const { actor } = require('../server/http/actor-limits');
@@ -43,7 +43,7 @@ const { actor } = require('../server/http/actor-limits');
assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
assert.deepStrictEqual([r.status, r.json().code], [429, 'rate_limited']);
const theirs = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.read']);
const theirs = t.network.mintApp(other.id, 'openvibe.codes', ['codes.release.read']);
assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${theirs}` } })).status, 200, 'another app still passes');
assert.strictEqual((await t.get(path, { headers: { authorization: `Bearer ${theirs}` } })).status, 200, 'another app still passes');
- const from = (ip) => t.get(path, { headers: { 'X-Forwarded-For': ip } });
+ const from = async (ip) => await t.get(path, { headers: { 'X-Forwarded-For': ip } });
for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);
for (let i = 0; i < 3; i++) assert.strictEqual((await from('203.0.113.7')).status, 200);
assert.strictEqual((await from('203.0.113.7')).status, 429);
assert.strictEqual((await from('203.0.113.7')).status, 429);
assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');
assert.strictEqual((await from('203.0.113.8')).status, 200, 'another address still passes');
@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
- assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
+ assert.strictEqual(tag('openvibe-contracts'), 'v0.76.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
- assert.strictEqual(sdkPkg.version, '0.13.0');
- // The contracts tag v0.53.0 carries the released codes manifest and codes.app-manifest@1.
+ assert.strictEqual(sdkPkg.version, '0.20.1');
+ // The contracts tag v0.76.0 carries the released codes manifest and codes.app-manifest@1.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
assert.ok(contracts.resolve('codes.app-manifest@1'));
});
});
@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.strictEqual(r.status, 200, p);
assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
- assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.53.0'), `${p} links the pinned tag`);
- if (contractsVersion !== '0.53.0') assert.ok(r.text.includes('(tag v0.53.0)'), `${p} says the tag differs from the package version`);
+ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.76.0'), `${p} links the pinned tag`);
+ if (contractsVersion !== '0.76.0') assert.ok(r.text.includes('(tag v0.76.0)'), `${p} says the tag differs from the package version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
}
}
const v = (await t.get('/api/v1/docs/versions')).json();
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
assert.strictEqual(v.contracts, contractsVersion);
- assert.strictEqual(v.contracts_tag, 'v0.53.0');
+ assert.strictEqual(v.contracts_tag, 'v0.76.0');
assert.strictEqual(v.sdk, sdkPkg.version);
assert.strictEqual(v.sdk, sdkPkg.version);
});
});
@@ -47,7 +47,10 @@ async function boot(opts = {}) {
@@ -47,7 +47,10 @@ async function boot(opts = {}) {
const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
const config = configLib.load(env);
const config = configLib.load(env);
- const built = createApp({ config, log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });
+ const { createStore } = require('../../server/db');
+ // One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
+ const testdb = await require('./db').testDb();
+ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
@@ -83,10 +86,10 @@ async function boot(opts = {}) {
@@ -83,10 +86,10 @@ async function boot(opts = {}) {
}
}
/** Every value in every table of Codes' database, as one string. */
/** Every value in every table of Codes' database, as one string. */
- function dbDump() {
+ async function dbDump() {
const db = built.ctx.store.db;
const db = built.ctx.store.db;
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name);
- return tables.map((tname) => JSON.stringify(db.prepare(`SELECT * FROM "${tname}"`).all())).join('\n');
+ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.prepare(`SELECT * FROM "${tname}"`).all())))).join('\n');
}
}
async function project(user, name = 'Test project') {
async function project(user, name = 'Test project') {
@@ -116,7 +119,7 @@ async function boot(opts = {}) {
@@ -116,7 +119,7 @@ async function boot(opts = {}) {
async close() {
async close() {
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
await built.ctx.outbox.stop();
await built.ctx.outbox.stop();
- built.ctx.store.close();
+ await testdb.close();
await network.close(); await events.close(); await media.close();
await network.close(); await events.close(); await media.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * CODES_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.CODES_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'codes', max });
+
+module.exports = { testDb, pgAvailable };
@@ -311,7 +311,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -311,7 +311,7 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');
if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
- return projectsApi(req, raw, json, u, parts, url.searchParams);
+ return await projectsApi(req, raw, json, u, parts, url.searchParams);
}
}
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
@@ -94,7 +94,7 @@ const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -94,7 +94,7 @@ const PRJ = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
assert.strictEqual(api.status, 422);
assert.strictEqual(api.status, 422);
assert.strictEqual(api.json().valid, false);
assert.strictEqual(api.json().valid, false);
assert.strictEqual(api.json().contracts_version, require('openvibe-contracts/package.json').version);
assert.strictEqual(api.json().contracts_version, require('openvibe-contracts/package.json').version);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM manifests').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM manifests').get()).n, 0);
});
});
await t.close();
await t.close();
done();
done();
@@ -19,8 +19,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -19,8 +19,8 @@ const { boot, check, done } = require('./helpers/boot');
const app = await t.app(owner, projectId, { name: 'Moderated' });
const app = await t.app(owner, projectId, { name: 'Moderated' });
const base = `/projects/${projectId}/apps/${app.id}`;
const base = `/projects/${projectId}/apps/${app.id}`;
const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Moderated' }), version });
const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Moderated' }), version });
- const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
- .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'codes.moderation.action');
+ const moderation = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all())
+ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((e) => e.event_type === 'codes.moderation.action');
const published = async (version) => {
const published = async (version) => {
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });
const id = r.headers.get('location').split('/').pop();
const id = r.headers.get('location').split('/').pop();
@@ -40,14 +40,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -40,14 +40,14 @@ const { boot, check, done } = require('./helpers/boot');
const id = await published('1.0.0');
const id = await published('1.0.0');
const r = await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'Mine to pull', confirm: '1' } });
const r = await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'Mine to pull', confirm: '1' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- assert.strictEqual(moderation().length, 0);
+ assert.strictEqual((await moderation()).length, 0);
});
});
await check('staff revoking someone else\'s release: exactly one valid codes.moderation.action', async () => {
await check('staff revoking someone else\'s release: exactly one valid codes.moderation.action', async () => {
const id = await published('1.1.0');
const id = await published('1.1.0');
const r = await t.get(`/releases/${id}/revoke`, { as: staff, form: { reason: 'Malware in the bundle', confirm: '1' } });
const r = await t.get(`/releases/${id}/revoke`, { as: staff, form: { reason: 'Malware in the bundle', confirm: '1' } });
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
valid(ev[0]);
valid(ev[0]);
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `release:${id}` });
assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `release:${id}` });
@@ -60,10 +60,10 @@ const { boot, check, done } = require('./helpers/boot');
@@ -60,10 +60,10 @@ const { boot, check, done } = require('./helpers/boot');
await check('staff setting a trust tier: exactly one event; a refused change: none', async () => {
await check('staff setting a trust tier: exactly one event; a refused change: none', async () => {
assert.strictEqual((await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } })).status, 403);
assert.strictEqual((await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } })).status, 403);
- assert.strictEqual(moderation().length, 1);
+ assert.strictEqual((await moderation()).length, 1);
const r = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Read the source' } });
const r = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Read the source' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- const ev = moderation();
+ const ev = await moderation();
assert.strictEqual(ev.length, 2);
assert.strictEqual(ev.length, 2);
const e = ev[1];
const e = ev[1];
valid(e);
valid(e);
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -19,7 +19,7 @@ const { boot, check, done } = require('./helpers/boot');
const app = await t.app(owner, projectId);
const app = await t.app(owner, projectId);
const base = `/projects/${projectId}/apps/${app.id}`;
const base = `/projects/${projectId}/apps/${app.id}`;
const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;
const key = `p${projectId.replace(/^prj_/, '').toLowerCase()}`;
- const media = (fields, file = { name: 'a.txt', content: 'hello' }) => t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });
+ const media = async (fields, file = { name: 'a.txt', content: 'hello' }) => await t.get(`${base}/playground/media`, { as: owner, multipart: { fields, file } });
const outside = () => ({
const outside = () => ({
token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,
token: t.network.tokenRequests.filter((x) => x.client_id === app.id).length,
media: t.media.requests.length,
media: t.media.requests.length,
@@ -132,7 +132,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -132,7 +132,7 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('the run log records outcomes and codes, never the credential', async () => {
await check('the run log records outcomes and codes, never the credential', async () => {
- const rows = t.ctx.store.db.prepare('SELECT * FROM playground_runs WHERE app_id = ? ORDER BY at').all(app.id);
+ const rows = await t.ctx.store.db.prepare('SELECT * FROM playground_runs WHERE app_id = ? ORDER BY at').all(app.id);
assert.ok(rows.length >= 6);
assert.ok(rows.length >= 6);
assert.ok(rows.some((x) => x.outcome === 'ok' && x.ref));
assert.ok(rows.some((x) => x.outcome === 'ok' && x.ref));
assert.ok(rows.some((x) => x.stage === 'grant' && x.code === 'playground.grant_missing'));
assert.ok(rows.some((x) => x.stage === 'grant' && x.code === 'playground.grant_missing'));
@@ -58,7 +58,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -58,7 +58,7 @@ const { boot, check, done } = require('./helpers/boot');
}
}
const archivePath = `/projects/${projectId}/export/archive`;
const archivePath = `/projects/${projectId}/export/archive`;
- const exportAs = (who, form = {}) => t.get(archivePath, { as: who, form });
+ const exportAs = async (who, form = {}) => await t.get(archivePath, { as: who, form });
const minted = () => t.network.state.exportTokens;
const minted = () => t.network.state.exportTokens;
const lines = (buf) => buf.toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
const lines = (buf) => buf.toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
let files = null;
let files = null;
@@ -156,7 +156,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -156,7 +156,7 @@ const { boot, check, done } = require('./helpers/boot');
for (const x of minted()) {
for (const x of minted()) {
const sig = x.token.split('.')[2];
const sig = x.token.split('.')[2];
assert.ok(!everything.includes(sig), 'an export token in the archive');
assert.ok(!everything.includes(sig), 'an export token in the archive');
- assert.ok(!t.dbDump().includes(sig), 'an export token in Codes\' database');
+ assert.ok(!(await t.dbDump()).includes(sig), 'an export token in Codes\' database');
assert.ok(!t.logs().includes(sig), 'an export token in the logs');
assert.ok(!t.logs().includes(sig), 'an export token in the logs');
}
}
assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(everything), 'no JWT at all');
assert.ok(!/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(everything), 'no JWT at all');
@@ -36,11 +36,11 @@ const { boot, check, done } = require('./helpers/boot');
@@ -36,11 +36,11 @@ const { boot, check, done } = require('./helpers/boot');
const published = await draft('1.0.0');
const published = await draft('1.0.0');
assert.strictEqual((await t.get(`/releases/${published}/publish`, { as: owner, form: {} })).status, 303);
assert.strictEqual((await t.get(`/releases/${published}/publish`, { as: owner, form: {} })).status, 303);
const drafted = await draft('1.1.0');
const drafted = await draft('1.1.0');
- const draftManifest = db.prepare('SELECT manifest_id FROM releases WHERE id = ?').get(drafted).manifest_id;
- db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, detail)
+ const draftManifest = (await db.prepare('SELECT manifest_id FROM releases WHERE id = ?').get(drafted)).manifest_id;
+ await db.prepare(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage, detail)
VALUES ('run_01JTEST00000000000000000000', ?, ?, ?, ?, 'media', 'media.object.upload', 'client_secret', 'ok', 'done', 'stored 5 bytes')`)
VALUES ('run_01JTEST00000000000000000000', ?, ?, ?, ?, 'media', 'media.object.upload', 'client_secret', 'ok', 'done', 'stored 5 bytes')`)
.run(new Date().toISOString(), `user:${owner.subject}`, projectId, app.id);
.run(new Date().toISOString(), `user:${owner.subject}`, projectId, app.id);
- const count = (sql, ...a) => db.prepare(sql).get(...a).n;
+ const count = async (sql, ...a) => (await db.prepare(sql).get(...a)).n;
const exportPath = `/projects/${projectId}/export`;
const exportPath = `/projects/${projectId}/export`;
await check('the owner exports everything Network and Codes hold for the project, as a JSON download', async () => {
await check('the owner exports everything Network and Codes hold for the project, as a JSON download', async () => {
@@ -109,11 +109,11 @@ const { boot, check, done } = require('./helpers/boot');
@@ -109,11 +109,11 @@ const { boot, check, done } = require('./helpers/boot');
} finally { t.ctx.network.projects.quotas = orig; }
} finally { t.ctx.network.projects.quotas = orig; }
});
});
- const unchanged = () => {
+ const unchanged = async () => {
assert.strictEqual(t.network.state.projects.get(projectId).archived_at, null, 'not archived in Network');
assert.strictEqual(t.network.state.projects.get(projectId).archived_at, null, 'not archived in Network');
- assert.strictEqual(count('SELECT COUNT(*) AS n FROM releases WHERE project_id = ?', projectId), 2);
- assert.strictEqual(count("SELECT COUNT(*) AS n FROM releases WHERE status = 'published' AND id = ?", published), 1);
- assert.strictEqual(count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 1);
+ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM releases WHERE project_id = ?', projectId), 2);
+ assert.strictEqual(await count("SELECT COUNT(*) AS n FROM releases WHERE status = 'published' AND id = ?", published), 1);
+ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 1);
};
};
const del = `/projects/${projectId}/delete`;
const del = `/projects/${projectId}/delete`;
@@ -121,7 +121,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -121,7 +121,7 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.get(del, { as: dev, form: { confirm_name: 'Exportable' } });
const r = await t.get(del, { as: dev, form: { confirm_name: 'Exportable' } });
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
assert.match(r.text, /only the project owner/);
assert.match(r.text, /only the project owner/);
- unchanged();
+ await unchanged();
});
});
await check('delete: the wrong name, or no form token, changes nothing and calls no Network write', async () => {
await check('delete: the wrong name, or no form token, changes nothing and calls no Network write', async () => {
@@ -132,7 +132,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -132,7 +132,7 @@ const { boot, check, done } = require('./helpers/boot');
const noCsrf = await t.get(del, { as: owner, form: { confirm_name: 'Exportable', csrf: 'forged' } });
const noCsrf = await t.get(del, { as: owner, form: { confirm_name: 'Exportable', csrf: 'forged' } });
assert.strictEqual(noCsrf.status, 403);
assert.strictEqual(noCsrf.status, 403);
assert.strictEqual(t.network.requests.filter((q) => q.method === 'POST').length, before);
assert.strictEqual(t.network.requests.filter((q) => q.method === 'POST').length, before);
- unchanged();
+ await unchanged();
});
});
await check('delete: when Network refuses to archive, Codes keeps everything', async () => {
await check('delete: when Network refuses to archive, Codes keeps everything', async () => {
@@ -142,7 +142,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -142,7 +142,7 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
const r = await t.get(del, { as: owner, form: { confirm_name: 'Exportable' } });
assert.notStrictEqual(r.status, 303);
assert.notStrictEqual(r.status, 303);
} finally { t.ctx.network.projects.archive = orig; }
} finally { t.ctx.network.projects.archive = orig; }
- unchanged();
+ await unchanged();
});
});
await check('delete by the owner: archived in Network, drafts and runs removed, public releases revoked with an event', async () => {
await check('delete by the owner: archived in Network, drafts and runs removed, public releases revoked with an event', async () => {
@@ -154,15 +154,15 @@ const { boot, check, done } = require('./helpers/boot');
@@ -154,15 +154,15 @@ const { boot, check, done } = require('./helpers/boot');
const p = t.network.state.projects.get(projectId);
const p = t.network.state.projects.get(projectId);
assert.ok(p.archived_at, 'archived in Network');
assert.ok(p.archived_at, 'archived in Network');
assert.ok(t.network.state.apps.get(app.id).revoked_at, 'Network revoked the app');
assert.ok(t.network.state.apps.get(app.id).revoked_at, 'Network revoked the app');
- assert.strictEqual(count('SELECT COUNT(*) AS n FROM releases WHERE id = ?', drafted), 0, 'draft deleted');
- assert.strictEqual(count('SELECT COUNT(*) AS n FROM manifests WHERE id = ?', draftManifest), 0, 'its manifest deleted');
- assert.strictEqual(db.prepare('SELECT status, revocation_reason FROM releases WHERE id = ?').get(published).status, 'revoked');
- assert.strictEqual(count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 0);
- const ev = db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope));
+ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM releases WHERE id = ?', drafted), 0, 'draft deleted');
+ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM manifests WHERE id = ?', draftManifest), 0, 'its manifest deleted');
+ assert.strictEqual((await db.prepare('SELECT status, revocation_reason FROM releases WHERE id = ?').get(published)).status, 'revoked');
+ assert.strictEqual(await count('SELECT COUNT(*) AS n FROM playground_runs WHERE project_id = ?', projectId), 0);
+ const ev = (await db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((x) => (typeof x.envelope === 'string' ? JSON.parse(x.envelope) : x.envelope));
const revoked = ev.filter((e) => e.event_type === 'codes.app.revoked');
const revoked = ev.filter((e) => e.event_type === 'codes.app.revoked');
assert.strictEqual(revoked.length, 1);
assert.strictEqual(revoked.length, 1);
assert.strictEqual(revoked[0].payload.release_id, published);
assert.strictEqual(revoked[0].payload.release_id, published);
- assert.deepStrictEqual(db.prepare('SELECT action FROM release_log WHERE release_id = ? ORDER BY id').all(drafted).map((l) => l.action), ['created', 'deleted'], 'the append-only log records the deletion');
+ assert.deepStrictEqual((await db.prepare('SELECT action FROM release_log WHERE release_id = ? ORDER BY id').all(drafted)).map((l) => l.action), ['created', 'deleted'], 'the append-only log records the deletion');
// The public release page now says revoked.
// The public release page now says revoked.
assert.match((await t.get(`/releases/${published}`)).text, /revoked/i);
assert.match((await t.get(`/releases/${published}`)).text, /revoked/i);
});
});
@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/boot');
const app = await t.app(owner, projectId, { name: 'Releaser' });
const app = await t.app(owner, projectId, { name: 'Releaser' });
const base = `/projects/${projectId}/apps/${app.id}`;
const base = `/projects/${projectId}/apps/${app.id}`;
const manifest = (version, extra = {}) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Releaser' }), version, ...extra });
const manifest = (version, extra = {}) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Releaser' }), version, ...extra });
- const outbox = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
+ const outbox = async () => (await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope));
let rel1;
let rel1;
let rel2;
let rel2;
@@ -30,14 +30,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -30,14 +30,14 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'validate' } });
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0'), intent: 'validate' } });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /Valid\./);
assert.match(r.text, /Valid\./);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get()).n, 0);
});
});
await check('an invalid manifest creates nothing and shows the errors', async () => {
await check('an invalid manifest creates nothing and shows the errors', async () => {
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0', { id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ' }), intent: 'create' } });
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('1.0.0', { id: 'app_01JZZZZZZZZZZZZZZZZZZZZZZZ' }), intent: 'create' } });
assert.strictEqual(r.status, 422);
assert.strictEqual(r.status, 422);
assert.match(r.text, /must be this app's id/);
assert.match(r.text, /must be this app's id/);
- assert.strictEqual(t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get().n, 0);
+ assert.strictEqual((await t.ctx.store.db.prepare('SELECT COUNT(*) AS n FROM releases').get()).n, 0);
});
});
await check('a draft is created, visible to members only, and emits nothing', async () => {
await check('a draft is created, visible to members only, and emits nothing', async () => {
@@ -48,7 +48,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -48,7 +48,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual((await t.get(`/releases/${rel1}`)).status, 404, 'anonymous cannot see a draft');
assert.strictEqual((await t.get(`/releases/${rel1}`)).status, 404, 'anonymous cannot see a draft');
assert.strictEqual((await t.get(`/releases/${rel1}`, { as: owner })).status, 200);
assert.strictEqual((await t.get(`/releases/${rel1}`, { as: owner })).status, 200);
assert.strictEqual((await t.get(`/api/v1/releases/${rel1}`)).status, 404);
assert.strictEqual((await t.get(`/api/v1/releases/${rel1}`)).status, 404);
- assert.strictEqual(outbox().length, 0);
+ assert.strictEqual((await outbox()).length, 0);
});
});
await check('versions are immutable: the same version again is 409', async () => {
await check('versions are immutable: the same version again is 409', async () => {
@@ -66,7 +66,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -66,7 +66,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('publishing emits codes.app.published (valid envelope, 3 segments, public)', async () => {
await check('publishing emits codes.app.published (valid envelope, 3 segments, public)', async () => {
const r = await t.get(`/releases/${rel1}/publish`, { as: owner, form: {} });
const r = await t.get(`/releases/${rel1}/publish`, { as: owner, form: {} });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- const ev = outbox();
+ const ev = await outbox();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
const e = ev[0];
const e = ev[0];
assert.strictEqual(e.event_type, 'codes.app.published');
assert.strictEqual(e.event_type, 'codes.app.published');
@@ -94,7 +94,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -94,7 +94,7 @@ const { boot, check, done } = require('./helpers/boot');
await t.get(`/releases/${rel2}/publish`, { as: owner, form: {} });
await t.get(`/releases/${rel2}/publish`, { as: owner, form: {} });
const ok = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'Use 1.1.0', replacement: rel2 } });
const ok = await t.get(`/releases/${rel1}/deprecate`, { as: owner, form: { reason: 'Use 1.1.0', replacement: rel2 } });
assert.strictEqual(ok.status, 303);
assert.strictEqual(ok.status, 303);
- const e = outbox().pop();
+ const e = (await outbox()).pop();
assert.strictEqual(e.event_type, 'codes.app.deprecated');
assert.strictEqual(e.event_type, 'codes.app.deprecated');
assert.strictEqual(e.payload.replacement, rel2);
assert.strictEqual(e.payload.replacement, rel2);
});
});
@@ -104,7 +104,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -104,7 +104,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(unconfirmed.status, 422);
assert.strictEqual(unconfirmed.status, 422);
const ok = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'Security issue', confirm: '1' } });
const ok = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'Security issue', confirm: '1' } });
assert.strictEqual(ok.status, 303);
assert.strictEqual(ok.status, 303);
- assert.strictEqual(outbox().pop().event_type, 'codes.app.revoked');
+ assert.strictEqual((await outbox()).pop().event_type, 'codes.app.revoked');
const again = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'x', confirm: '1' } });
const again = await t.get(`/releases/${rel1}/revoke`, { as: owner, form: { reason: 'x', confirm: '1' } });
assert.strictEqual(again.status, 409);
assert.strictEqual(again.status, 409);
});
});
@@ -112,13 +112,13 @@ const { boot, check, done } = require('./helpers/boot');
@@ -112,13 +112,13 @@ const { boot, check, done } = require('./helpers/boot');
await check('revoking a draft announces nothing (it was never public)', async () => {
await check('revoking a draft announces nothing (it was never public)', async () => {
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('2.0.0-rc.1'), intent: 'create' } });
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest('2.0.0-rc.1'), intent: 'create' } });
const id = r.headers.get('location').split('/').pop();
const id = r.headers.get('location').split('/').pop();
- const n = outbox().length;
+ const n = (await outbox()).length;
await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'abandoned', confirm: '1' } });
await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'abandoned', confirm: '1' } });
- assert.strictEqual(outbox().length, n);
+ assert.strictEqual((await outbox()).length, n);
});
});
await check('only the three codes.app.* types are ever produced', async () => {
await check('only the three codes.app.* types are ever produced', async () => {
- const types = new Set(outbox().map((e) => e.event_type));
+ const types = new Set((await outbox()).map((e) => e.event_type));
for (const x of types) assert.ok(['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked'].includes(x), x);
for (const x of types) assert.ok(['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked'].includes(x), x);
});
});
@@ -147,7 +147,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -147,7 +147,7 @@ const { boot, check, done } = require('./helpers/boot');
const mod = { ...manifests.template('mod', { appId: app.id }), id: contracts.ids.newId('mod'), version: '0.1.0' };
const mod = { ...manifests.template('mod', { appId: app.id }), id: contracts.ids.newId('mod'), version: '0.1.0' };
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'mod', manifest: JSON.stringify(mod), intent: 'create' } });
const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'mod', manifest: JSON.stringify(mod), intent: 'create' } });
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
assert.strictEqual(r.status, 303, r.text.slice(0, 300));
- const rel = t.ctx.releases.get(r.headers.get('location').split('/').pop());
+ const rel = await t.ctx.releases.get(r.headers.get('location').split('/').pop());
assert.strictEqual(rel.kind, 'mod');
assert.strictEqual(rel.kind, 'mod');
assert.strictEqual(rel.subject_id, mod.id);
assert.strictEqual(rel.subject_id, mod.id);
});
});
@@ -158,7 +158,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -158,7 +158,7 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${token}` }, json: { kind: 'app', manifest: JSON.parse(manifest('4.0.0', { publisher: { type: 'app', id: app.id } })), publish: true } });
const r = await t.get(`/api/v1/apps/${app.id}/releases`, { headers: { authorization: `Bearer ${token}` }, json: { kind: 'app', manifest: JSON.parse(manifest('4.0.0', { publisher: { type: 'app', id: app.id } })), publish: true } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.json().release.status, 'published');
assert.strictEqual(r.json().release.status, 'published');
- const e = outbox().pop();
+ const e = (await outbox()).pop();
assert.deepStrictEqual(e.actor, { type: 'app', id: app.id });
assert.deepStrictEqual(e.actor, { type: 'app', id: app.id });
});
});
@@ -100,7 +100,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -100,7 +100,7 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('no secret or token anywhere: database, logs, later responses', async () => {
await check('no secret or token anywhere: database, logs, later responses', async () => {
- const dump = t.dbDump();
+ const dump = await t.dbDump();
const logs = t.logs();
const logs = t.logs();
assert.ok(secrets.length >= 5);
assert.ok(secrets.length >= 5);
for (const s of secrets) {
for (const s of secrets) {
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
const annRelease = made.headers.get('location').split('/').pop();
const annRelease = made.headers.get('location').split('/').pop();
const networkState = () => JSON.stringify([[...t.network.state.projects.entries()], [...t.network.state.members.entries()], [...t.network.state.apps.entries()], [...t.network.state.creds.entries()]]);
const networkState = () => JSON.stringify([[...t.network.state.projects.entries()], [...t.network.state.members.entries()], [...t.network.state.apps.entries()], [...t.network.state.creds.entries()]]);
- const snapshot = () => networkState() + t.dbDump();
+ const snapshot = async () => networkState() + await t.dbDump();
const HERS = ['Ann Hidden App', 'ann-draft-notes', ...(annApp.secret ? [annApp.secret] : [])];
const HERS = ['Ann Hidden App', 'ann-draft-notes', ...(annApp.secret ? [annApp.secret] : [])];
const refused = (r, what) => {
const refused = (r, what) => {
assert.ok([401, 403, 404].includes(r.status) || (r.status === 303 && /\/login|\/auth/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
assert.ok([401, 403, 404].includes(r.status) || (r.status === 303 && /\/login|\/auth/.test(r.headers.get('location') || '')), `${what}: ${r.status} ${r.text.slice(0, 160)}`);
@@ -51,7 +51,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -51,7 +51,7 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('forms: Bob cannot change Ann\'s project, apps, members, credentials, grants or releases', async () => {
await check('forms: Bob cannot change Ann\'s project, apps, members, credentials, grants or releases', async () => {
- const before = snapshot();
+ const before = await snapshot();
const P = `/projects/${annProject}`;
const P = `/projects/${annProject}`;
const A = `${P}/apps/${annApp.id}`;
const A = `${P}/apps/${annApp.id}`;
for (const [p, form] of [
for (const [p, form] of [
@@ -71,7 +71,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -71,7 +71,7 @@ const { boot, check, done } = require('./helpers/boot');
]) {
]) {
refused(await t.get(p, { as: bob, form }), `POST ${p}`);
refused(await t.get(p, { as: bob, form }), `POST ${p}`);
}
}
- assert.strictEqual(snapshot(), before, 'something of Ann\'s changed');
+ assert.strictEqual(await snapshot(), before, 'something of Ann\'s changed');
});
});
await check('controls: Ann reads her project and publishes her release', async () => {
await check('controls: Ann reads her project and publishes her release', async () => {
@@ -16,7 +16,7 @@ const manifests = require('../server/domain/manifests');
@@ -16,7 +16,7 @@ const manifests = require('../server/domain/manifests');
const appModule = require('../server/app');
const appModule = require('../server/app');
let capturedApp = null;
let capturedApp = null;
const realCreate = appModule.createApp;
const realCreate = appModule.createApp;
-appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };
+appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { getPaths, crawl } = require('./security-crawl');
const { getPaths, crawl } = require('./security-crawl');
@@ -62,8 +62,8 @@ const { getPaths, crawl } = require('./security-crawl');
@@ -62,8 +62,8 @@ const { getPaths, crawl } = require('./security-crawl');
assert.deepStrictEqual(r.found, []);
assert.deepStrictEqual(r.found, []);
});
});
- await check('the events outbox carries nothing of the draft (drafts emit nothing)', () => {
- const text = JSON.stringify(t.ctx.store.db.prepare('SELECT envelope FROM event_outbox').all());
+ await check('the events outbox carries nothing of the draft (drafts emit nothing)', async () => {
+ const text = JSON.stringify(await t.ctx.store.db.prepare('SELECT envelope FROM event_outbox').all());
assert.ok(!text.includes('ann-draft-notes') && !text.includes(release));
assert.ok(!text.includes('ann-draft-notes') && !text.includes(release));
});
});
@@ -15,7 +15,7 @@ const assert = require('assert');
@@ -15,7 +15,7 @@ const assert = require('assert');
const appModule = require('../server/app');
const appModule = require('../server/app');
let capturedApp = null;
let capturedApp = null;
const realCreate = appModule.createApp;
const realCreate = appModule.createApp;
-appModule.createApp = (...a) => { const built = realCreate(...a); capturedApp = built.app; return built; };
+appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
@@ -56,8 +56,8 @@ const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-cli
@@ -56,8 +56,8 @@ const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-cli
assert.deepStrictEqual(found, []);
assert.deepStrictEqual(found, []);
});
});
- await check('the outbox and the captured log lines carry no sentinel', () => {
- const text = t.dbDump() + t.logs();
+ await check('the outbox and the captured log lines carry no sentinel', async () => {
+ const text = await t.dbDump() + t.logs();
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
});
});
@@ -29,7 +29,7 @@ const { signJwt } = require('./helpers/mocks');
@@ -29,7 +29,7 @@ const { signJwt } = require('./helpers/mocks');
const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();
const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();
const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');
assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');
- assert.strictEqual(s.ctx.trust.get(APP).tier, 'unreviewed');
+ assert.strictEqual((await s.ctx.trust.get(APP)).tier, 'unreviewed');
});
});
await check('an app token is not a Codes session', async () => {
await check('an app token is not a Codes session', async () => {
@@ -53,7 +53,7 @@ const { signJwt } = require('./helpers/mocks');
@@ -53,7 +53,7 @@ const { signJwt } = require('./helpers/mocks');
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });
const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);
- assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');
+ assert.strictEqual((await s.ctx.trust.get(APP)).tier, 'reviewed');
});
});
await check('another site cannot sign a person out; this site can', async () => {
await check('another site cannot sign a person out; this site can', async () => {
deleted file mode 100644
deleted file mode 100644
@@ -1,68 +0,0 @@
@@ -1,68 +0,0 @@
-'use strict';
-/**
- * ADR-013 trust tiers (unreviewed, reviewed, first-party): databases written with the roadmap's
- * earlier names are migrated at boot — untrusted→unreviewed, verified|trusted→reviewed,
- * platform-maintained→first-party — including the history, and the CHECK constraint is rebuilt.
- * Running it again changes nothing. Tiers stay metadata.
- */
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const Database = require('better-sqlite3');
-const { openStore } = require('../server/db');
-const { createTrust } = require('../server/domain/trust');
-const { check, done } = require('./helpers/boot');
-
-const A = (n) => `app_01JABCDEFGHJKMNPQRSTVWXYZ${n}`;
-
-(async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-codes-trust-'));
- const file = path.join(dir, 'codes.db');
- // A database as the first Codes build created it.
- const old = new Database(file);
- old.exec(`CREATE TABLE trust (
- app_id TEXT PRIMARY KEY,
- tier TEXT NOT NULL CHECK (tier IN ('untrusted', 'verified', 'trusted', 'platform-maintained')),
- note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);
- CREATE TABLE trust_history (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, from_tier TEXT NOT NULL, to_tier TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', set_by TEXT NOT NULL, set_at TEXT NOT NULL);`);
- const ins = old.prepare('INSERT INTO trust VALUES (?, ?, ?, ?, ?)');
- ins.run(A(0), 'untrusted', 'n0', 'user:x', '2026-09-22T00:00:00Z');
- ins.run(A(1), 'verified', 'n1', 'user:x', '2026-09-22T00:00:00Z');
- ins.run(A(2), 'trusted', 'n2', 'user:x', '2026-09-22T00:00:00Z');
- ins.run(A(3), 'platform-maintained', 'n3', 'user:x', '2026-09-22T00:00:00Z');
- old.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)').run(A(3), 'trusted', 'platform-maintained', 'n', 'user:x', '2026-09-22T00:00:00Z');
- old.close();
-
- await check('old tier names are mapped and the CHECK only allows ADR-013 tiers', async () => {
- const store = openStore(file);
- const tiers = store.db.prepare('SELECT app_id, tier, note FROM trust ORDER BY app_id').all();
- assert.deepStrictEqual(tiers.map((r) => r.tier), ['unreviewed', 'reviewed', 'reviewed', 'first-party']);
- assert.deepStrictEqual(tiers.map((r) => r.note), ['n0', 'n1', 'n2', 'n3'], 'notes kept');
- assert.deepStrictEqual(store.db.prepare('SELECT from_tier, to_tier FROM trust_history').get(), { from_tier: 'reviewed', to_tier: 'first-party' });
- assert.throws(() => store.db.prepare("UPDATE trust SET tier = 'verified' WHERE app_id = ?").run(A(0)), /CHECK/);
- store.close();
- });
-
- await check('running it again changes nothing', async () => {
- const store = openStore(file);
- const sql = store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql;
- assert.ok(!/untrusted/.test(sql));
- assert.strictEqual(store.db.prepare('SELECT COUNT(*) AS n FROM trust').get().n, 4);
- const trust = createTrust({ store });
- assert.strictEqual(trust.get(A(0)).tier, 'unreviewed');
- assert.strictEqual(trust.get('app_01JZZZZZZZZZZZZZZZZZZZZZZZ').tier, 'unreviewed', 'the default is unreviewed');
- assert.deepStrictEqual(trust.TIERS, ['unreviewed', 'reviewed', 'first-party']);
- assert.throws(() => trust.set({ appId: A(0), tier: 'trusted', note: 'x', actor: { staff: true, label: 'user:x' } }), /tier is one of/);
- store.close();
- });
-
- await check('a fresh database is created on ADR-013 tiers', async () => {
- const store = openStore(path.join(dir, 'fresh.db'));
- assert.match(store.db.prepare("SELECT sql FROM sqlite_master WHERE name = 'trust'").get().sql, /'unreviewed', 'reviewed', 'first-party'/);
- store.close();
- });
-
- fs.rmSync(dir, { recursive: true, force: true });
- done();
-})();