openvibers/openvibe.codes Commits

Codes moves to PostgreSQL and Valkey (ADR-035, roadmap WS-X2, repository-pass group G2), async from end to end.

OP OpenVibers committed 28 Sept 2026 · 6329b53221 · 42 files, +757 −449

- Schema: migrations/0001_initial.sql (gen-migration: the SQLite schema and the SDK outbox). The SQLite-era trust
  tier rebuild (ADR-013 names) is gone with SQLite: production had no old-name rows left (checked), and its test
  goes with it.
- server/db.js: openDb (DATABASE_URL through PgBouncer, migrations as the owner, PGlite in development) and
  createStore; store.tx is an ambient transaction.
- Every read and write is awaited (sqlfix, then asyncify: release and trust changes run in awaited transactions).
  Per-actor limits in Valkey; /api/ready reports the store that answered and Valkey.
- scripts/migrate-to-postgres.js (runSqliteMigration); rehearsed on a copy of production: 7 releases, 7 manifests,
  28 log entries, 21 outbox events verified.
- Tests on createTestDb (PGlite; npm run test:pg through PgBouncer); the pin test and the docs version test follow
  the new tags. Pins: openvibe-contracts v0.76.0 (from v0.53.0), openvibe-sdk v0.20.1 (from v0.13.0),
  openvibe-shared v1.27.0 (from v1.25.0). README and STATUS.json follow.

npm test 28/28 on PGlite; npm run test:pg 28/28 through PgBouncer.

Changes

ddiff --git a/.env.example b/.env.example
iindex e204f2c..df0b351 100644
---- a/.env.example
++++ b/.env.example
@@@ -5,6 +5,14 @@ PORT=4900
HOST=127.0.0.1
BASE_URL=https://openvibe.codes
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
# 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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex c222e96..b6fd4d3 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -10,6 +10,9 @@ jobs:
with:
syntax-dirs: server public/js test scripts
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).
security:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/README.md b/README.md
iindex dd2c1d6..e8fef77 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/STATUS.json b/STATUS.json
iindex 286e18d..4ca3d7a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/deploy/systemd/openvibe-codes.service b/deploy/systemd/openvibe-codes.service
iindex a478f6e..29f15e1 100644
---- a/deploy/systemd/openvibe-codes.service
++++ b/deploy/systemd/openvibe-codes.service
@@@ -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=simple
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..3f216a5
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9cb3877..0fc0038 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 5c48700..e38dfdc 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
nnew file mode 100644
iindex 0000000..22acce3
---- /dev/null
++++ b/scripts/migrate-to-postgres.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 304e3a9..962aaed 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) ──
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 76690ba..e223537 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -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;
}
ddiff --git a/server/clients/network.js b/server/clients/network.js
iindex 981e9be..3ef37e5 100644
---- a/server/clients/network.js
++++ b/server/clients/network.js
@@@ -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 })),
};
/**
ddiff --git a/server/config.js b/server/config.js
iindex d6ef131..bc8402b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/db.js b/server/db.js
iindex 67267ad..804e26c 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/domain/playground.js b/server/domain/playground.js
iindex f5c579f..04afa82 100644
---- a/server/domain/playground.js
++++ b/server/domain/playground.js
@@@ -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 = conso
const k = KINDS[kind];
const secrets = [credential && credential.value].filter(Boolean);
const base = { actor, project_id: app.project_id, app_id: app.id, kind, capability: k ? k.needs : String(kind), credential: 'none' };
-- const finish = (out) => {
++ const 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' });
}
}
ddiff --git a/server/domain/project-export.js b/server/domain/project-export.js
iindex 3ac7912..a6d1238 100644
---- a/server/domain/project-export.js
++++ b/server/domain/project-export.js
@@@ -57,9 +57,9 @@ async function buildExport({ network, token, project, includeAudit, releases, pl
quotas: 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),
},
};
}
ddiff --git a/server/domain/releases.js b/server/domain/releases.js
iindex 7d8cd9e..20c3b96 100644
---- a/server/domain/releases.js
++++ b/server/domain/releases.js
@@@ -29,7 +29,7 @@ const manageRole = (environment) => (environment === 'production' ? 'admin' : 'd
function 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;
}
ddiff --git a/server/domain/trust.js b/server/domain/trust.js
iindex 1ac6aca..5e8433f 100644
---- a/server/domain/trust.js
++++ b/server/domain/trust.js
@@@ -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 };
}
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 30f233c..420fed3 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -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 }),
};
}
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 9ee2626..0d4ec2a 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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, lo
limits: { 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}`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 7e9f9f4..a5ce1b1 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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); }
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2e33909..fbd540c 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex 741c2a4..f441488 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -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}/m
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 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 ? no
if (!project.ok || !app.ok) return problemPage(req, res, (project.ok ? app : project).problem, { back: appBase(req) });
if (b.intent !== 'create') {
const v = manifestsLib.validate(kind, p.manifest, { app: app.data, viewerSubject: req.viewer.subject, eventTypes: docs.eventTypes });
-- return editorPage(req, res, { status: v.valid ? 200 : 422, kind, text: b.manifest, notes: b.notes, validation: v });
++ 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) {
ddiff --git a/server/index.js b/server/index.js
iindex f9d100d..598592f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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); });
ddiff --git a/server/observability.js b/server/observability.js
iindex 3f048e2..4bd6972 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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, rele
checks: [
{
name: 'db', required: true,
-- check: () => {
-- const names = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
++ 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 } };
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 52435db..130fff6 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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');
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 52a7e2b..8c2064a 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -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);
});
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 3b5cb94..f8fcac0 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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 });
},
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..10d86d6
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 86b062d..c137142 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -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 (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' });
});
ddiff --git a/test/manifests.test.js b/test/manifests.test.js
iindex 629ff48..4065d5f 100644
---- a/test/manifests.test.js
++++ b/test/manifests.test.js
@@@ -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();
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
iindex 3e6c0ef..4254add 100644
---- a/test/moderation-events.test.js
++++ b/test/moderation-events.test.js
@@@ -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);
ddiff --git a/test/playground.test.js b/test/playground.test.js
iindex de897e1..c0a8d2d 100644
---- a/test/playground.test.js
++++ b/test/playground.test.js
@@@ -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'));
ddiff --git a/test/project-archive.test.js b/test/project-archive.test.js
iindex 1c12371..dafb116 100644
---- a/test/project-archive.test.js
++++ b/test/project-archive.test.js
@@@ -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');
ddiff --git a/test/project-export-delete.test.js b/test/project-export-delete.test.js
iindex 90af88d..19e2fc6 100644
---- a/test/project-export-delete.test.js
++++ b/test/project-export-delete.test.js
@@@ -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);
});
ddiff --git a/test/releases.test.js b/test/releases.test.js
iindex 61826d4..95cdcf3 100644
---- a/test/releases.test.js
++++ b/test/releases.test.js
@@@ -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&#39;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 });
});
ddiff --git a/test/secrets.test.js b/test/secrets.test.js
iindex 4acce3c..a16165c 100644
---- a/test/secrets.test.js
++++ b/test/secrets.test.js
@@@ -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) {
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 4fde6b1..fc28781 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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 () => {
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 2c34a09..d2c959a 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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));
});
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 44d296d..a106b21 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -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-cli
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`);
});
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex f3b6fe3..af2ea34 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -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 () => {
ddiff --git a/test/trust-migration.test.js b/test/trust-migration.test.js
ddeleted file mode 100644
iindex 37f7ddb..0000000
---- a/test/trust-migration.test.js
++++ /dev/null
@@@ -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();
--})();