openvibers/openvibe.network Commits
T2 Network on PostgreSQL, work in progress: what eight DeepSeek attempts left in the working tree (asyncified call sites, dialect fixes, migrations 0003-0006, test fixes); unfinished, continued on this branch by a cloud session.
Changes
@@@ -49,8 +49,17 @@ ADMIN_PASSWORD=change-me# Owner account (gates owner-only admin areas: Deploy, SSH, secrets)OWNER_USERNAME=++# PostgreSQL (ADR-035, plan T2), written by OpenVibe.Host roles/data/add-service.sh network: the runtime++# role through PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite (PGLITE_DIR overrides it);++# production refuses to boot. DB_PATH is read only by the one-time import scripts, never by serving code.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++#VALKEY_PREFIX=ov:network:++# Paths (relative to project root)--DB_PATH=./data/network.db++DATA_DIR=dataAVATAR_PATH=data/avatarsJWT_PRIVATE_KEY=data/keys/private.pemJWT_PUBLIC_KEY=data/keys/public.pem@@@ -45,8 +45,8 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA## Depends on--- `openvibe-contracts` v0.75.0 (registry manifests, capability checks), `openvibe-sdk` v0.12.0 (events,-- per-actor limits), `openvibe-shared` v1.22.0, pinned by release tarball++- `openvibe-contracts` v0.80.0 (registry manifests, capability checks), `openvibe-sdk` v0.25.1 (PostgreSQL++ and Valkey adapters, events, per-actor limits), `openvibe-shared` v1.29.2, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`workflow), OpenVibe.Blog (the changelog feed it proxies)- email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets))@@@ -86,7 +86,7 @@ openvibe.network (port 4000)│ ├── internal/│ │ └── routes.js # Server-to-server API (verify-token, user sync, notif push)│ └── db/--│ └── database.js # SQLite schema, migrations, seeding++│ └── database.js # PostgreSQL handle (openvibe-sdk/db), migrations/, boot seeding├── public/│ ├── index.html # Landing page (hero + service cards)│ ├── login.html # Animated login/register page@@@ -113,7 +113,10 @@ openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem# 3. Configure environmentcp .env.example .env--# Edit .env — set ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE++# Edit .env — set ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE.++# In production also set DATABASE_URL (the runtime role through PgBouncer), DATABASE_DIRECT_URL (the owner++# role on a direct connection, for migrations) and VALKEY_URL (shared limit counters). Without++# DATABASE_URL, development uses an embedded PGlite database in data/pglite; production refuses to boot.# 4. Runnpm start@@@ -121,7 +124,7 @@ npm startAfter the first run the service will seed the URL registry with safe defaults and create an admin account from environment values. Visit `/api/setup/status` for setup health and use `/api/setup/bootstrap` to apply registry profiles manually.```--The server auto-creates the SQLite database and seeds OAuth2 clients on first run. Client secrets are logged to console once on creation — copy them to the consuming services' env files (`/etc/openvibe/<svc>.env`).++The server applies `migrations/NNNN_*.sql` at boot (as the owner on `DATABASE_DIRECT_URL`) and seeds OAuth2 clients and site settings on first run into PostgreSQL. Client secrets are logged to console once on creation — copy them to the consuming services' env files (`/etc/openvibe/<svc>.env`). The tests run on PGlite (`npm test`) or the PostgreSQL + PgBouncer containers (`npm run test:pg`, after `eval "$(openvibe-sdk scripts/test-services.sh up)"`).---@@@ -224,7 +227,7 @@ sudo apt install certbot python3-certbot-dns-cloudflare### How it works1. **Any service** pushes notifications to openvibe.network via `POST /internal/notifications/push`--2. **openvibe.network** stores them in SQLite with priority, category, and optional rich content++2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below)4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).@@@ -583,12 +586,12 @@ totals from the event's `stats`: counts only, never who watched or chatted, andand services with `network.analytics.creator.read` (Live's dashboards), also get average viewers, chatters,messages and watch minutes.--Network records one row per finished request in the `analytics_*` tables of `network.db` and rolls++Network records one row per finished request in the `analytics_*` tables of its PostgreSQL database and rollsthem up hourly and daily for the admin analytics pages (`/api/admin/analytics`, which also gathersLive, Tools, Games and Media). What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts--`docs/adr/ADR-021-analytics.md`). The module is `openvibe-shared/analytics` (openvibe-shared v1.4.0;--the same one Live and Tools use). `server/analytics/network.js` holds only Network's wiring: its own--connection, its path options (`paramPrefixes`, `pathRules`) and the prune job.++`docs/adr/ADR-021-analytics.md`). The module is `openvibe-shared/analytics` (the same one Live and Tools++use), on the service database through its PostgreSQL tracker (`AnalyticsTrackerPg`). `server/analytics/network.js`++holds only Network's wiring: its path options (`paramPrefixes`, `pathRules`) and the prune job.- **Stored:** event type, service, **route template** (the matched Express route, else the pathwithout its query and with ids, usernames and tokens replaced by `:id` / `:param`), method, status,@@@ -601,17 +604,14 @@ connection, its path options (`paramPrefixes`, `pathRules`) and the prune job.- **Opt-out:** a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all (no raw row, visitorhash, session id or rate counter), so it is also missing from the rollups.- **Admin panel:** the bot tables list user-agent classes and high-volume session ids, not IPs.--- **Connection:** the tracker opens its own connection to `network.db`, so its settings-- (`busy_timeout` 250, `secure_delete`) never apply to the identity connection.++- **Connection:** the tracker buffers and flushes into the service's own PostgreSQL handle (the same++ database the identity tables live in), so there is no second connection or file of its own.- **Retention:** raw events older than 30 days are deleted every night in batches of 5000 (the`analytics-prune` job: first run 5 minutes after boot, then every 24 h). Rollups are kept.--- **Operator CLI:** `scripts/analytics-prune.js` (a wrapper over `openvibe-shared/analytics/prune-cli`-- with Network's database and path rules) runs a dry run by default and changes nothing.-- `--apply` needs `--backup <new file>` (a verified, owner-only copy of all of `network.db`) or an-- explicit `--no-backup`. `--scrub` also rewrites the rows the old tracker wrote and the rollups'-- top-path and referer lists. Rollup totals are compared before and after the run. VACUUM rewrites-- all of `network.db`, so run it with the service stopped, or pass `--no-vacuum` while it is up. Run-- the CLI as the service user.++- **Operator CLI:** `scripts/analytics-prune.js` (over `openvibe-shared/analytics/pg`'s++ `pruneRawEventsPg`) runs a dry run by default and changes nothing; `--apply` deletes raw events older++ than `--days` (1..30, default 30). Run it where Network runs, with `DATABASE_URL` set. The SQLite++ prune CLI (online backup, VACUUM, one-time scrub) is gone with the file.## Shared Client Libraries@@@ -3,10 +3,10 @@"stage": "stable","code": true,"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",-- "runtime": "node 22, express, better-sqlite3",++ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.75.0",++ "contracts": "openvibe-contracts v0.80.0","capabilities": ["identity.subject.resolve","mods.grant.manage",@@@ -44,5 +44,5 @@"serves /shared/* from its pinned openvibe-shared","per-actor limits (openvibe-sdk/limits)"],-- "updated": "2026-09-28"++ "updated": "2026-09-29"}@@@ -1,6 +1,7 @@[Unit]Description=OpenVibe.Network — Identity, SSO & OpenCoins (port 4000)--After=network.target++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=postgresql.service pgbouncer.service valkey-server.service[Service]Type=simple@@@ -14,7 +14,7 @@ What exists today: the data model, the `/api/v1/projects` API, app tokens from `| Credential | `crd_<ULID>` | A client secret (`ovsec_…`, 256 random bits). Only its SHA-256 hash and last four characters are stored. || Grant | (app, capability) | `requested`, `approved`, `denied` or `revoked`. The audience is `openvibe.<capability owner>`. || Quota | (project, capability) | A limit, a window (`minute`, `hour`, `day`, `month`, `total`) and a unit (`requests`, `bytes`, `tokens`, …). |--| Audit | integer, append-only | SQLite triggers refuse UPDATE and DELETE. |++| Audit | integer, append-only | PostgreSQL triggers refuse UPDATE and DELETE. |### Rolesnnew file mode 100644@@@ -0,0 +1,48 @@++-- phase: expand++-- plan T2. Two tables the code created lazily (never at boot, so 0001_initial.sql missed them),++-- and the insertion-order `seq` identity for the tables whose code ordered by SQLite's implicit rowid.++-- PostgreSQL has no rowid; `seq` is a per-row identity, monotonic in insertion order, unique.++++CREATE TABLE admin_rate_limits (++ user_id bigint NOT NULL,++ action text NOT NULL,++ last_at bigint NOT NULL,++ PRIMARY KEY (user_id, action)++);++++CREATE TABLE moderation_audit (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ service text NOT NULL,++ action text NOT NULL,++ actor_subject text,++ target_type text,++ target_id text,++ target_subject text,++ scope text,++ reason text,++ details text NOT NULL DEFAULT '{}',++ occurred_at text NOT NULL,++ recorded_at text NOT NULL DEFAULT ov_now_iso(),++ seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE++);++CREATE INDEX idx_modaudit_when ON moderation_audit(occurred_at);++CREATE INDEX idx_modaudit_service ON moderation_audit(service, id);++CREATE INDEX idx_modaudit_actor ON moderation_audit(actor_subject, id);++++ALTER TABLE linked_accounts ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE user_sessions ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE oauth_tokens ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE oauth_codes ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE notifications ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE push_subscriptions ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE notification_preferences ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE user_preferences ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE user_effects ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE username_history ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE coin_transactions ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE user_follows ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE user_blocks ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE dev_projects ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE dev_project_members ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;++ALTER TABLE status_incident_updates ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- plan T2. Identity columns are GENERATED BY DEFAULT, not ALWAYS: an INSERT may name an id, the way the++-- SQLite PRIMARY KEYs it replaces allowed (the tests seed explicit ids; the one-time import writes the++-- source ids). Production never names one, so the sequence still owns new rows. Applied to every identity++-- column the earlier migrations created, whatever their table.++++DO $$++DECLARE r record;++BEGIN++ FOR r IN++ SELECT c.table_name, c.column_name++ FROM information_schema.columns c++ WHERE c.table_schema = current_schema()++ AND c.is_identity = 'YES'++ AND c.identity_generation = 'ALWAYS'++ LOOP++ EXECUTE format('ALTER TABLE %I ALTER COLUMN %I SET GENERATED BY DEFAULT', r.table_name, r.column_name);++ END LOOP;++END $$;nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- plan T2. The community-theme review columns (roadmap WS-E task 2): server/themes/routes.js added them with a++-- runtime ALTER TABLE on SQLite, so the booted schema 0001_initial.sql never carried them. The review gate (a++-- theme is visible to everyone only when review_status = 'approved') is unchanged; every existing row is++-- 'approved', the SQLite column default.++++ALTER TABLE themes ADD COLUMN review_status text NOT NULL DEFAULT 'approved';++ALTER TABLE themes ADD COLUMN reviewed_by bigint;++ALTER TABLE themes ADD COLUMN reviewed_at text;++ALTER TABLE themes ADD COLUMN review_note text;nnew file mode 100644@@@ -0,0 +1,32 @@++-- phase: expand++-- plan T2. The revisioned-configuration journal (openvibe-shared/config on PostgreSQL): server/admin/site-config.js++-- keeps site_settings as what everything reads and records changes to them as revisions of the namespace++-- network.site_settings. config_snapshots and config_keys are openvibe-shared/config's configSchema(), which inlines++-- from that module's config-pg.js (a service's migrations own its tables; the runtime role creates none).++++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL,++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/nodes.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/home-empty-catalog.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",++ "test": "node test/run.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js","test:pg": "NETWORK_TEST_STORE=pg node test/run.js",@@@ -1,54 +1,67 @@#!/usr/bin/env node/**-- * Raw analytics retention and one-time scrub for Network's analytics tables (ADR-021). They live in-- * network.db, Network's identity database, next to users and sessions. The command itself is-- * openvibe-shared/analytics/prune-cli; this wrapper passes Network's better-sqlite3, database and path-- * rules (/avatar/<name>, /users/by-username/<name>, /api/auth/anon/<token>; server/analytics/network.js).++ * ADR-021 raw analytics retention and the one-time scrub of pre-ADR rows, on Network's own PostgreSQL++ * (plan T2, ADR-035). The analytics tables live in the service database next to users and sessions++ * (migrations/0002_analytics.sql); the tracker itself schedules the prune at boot, this is the on-demand++ * operator form.*-- * node scripts/analytics-prune.js # dry run: counts only, changes nothing-- * node scripts/analytics-prune.js --scrub # dry run including what the scrub would rewrite-- * node scripts/analytics-prune.js --apply --backup <file> # online backup to <file>, then prune-- * node scripts/analytics-prune.js --apply --scrub --backup <file>-- * node scripts/analytics-prune.js --apply --no-backup # prune without a backup (explicit)-- * node scripts/analytics-prune.js --help # every option++ * node scripts/analytics-prune.js # dry run: how many raw events would go, nothing changes++ * node scripts/analytics-prune.js --apply # delete raw events older than --days*-- * Default database: $DB_PATH, else data/network.db. Relative --db and --backup paths are from the repo-- * root. The backup is a full copy of network.db (accounts, sessions): mode 0600.++ * --days <n> keep raw events newer than n days, 1..30 (default 30)++ * --apply actually prune (default: dry run)*-- * VACUUM (after an --apply, unless --no-vacuum) rewrites all of network.db and holds its write lock-- * meanwhile: Network's own writes (sign-ins, sessions) wait 5 s and then fail. Vacuum with the service-- * stopped, or pass --no-vacuum while it is up. Run as the service user so no file changes owner.++ * On SQLite this was openvibe-shared/analytics/prune-cli: it made an online copy of network.db, VACUUMed++ * it and scrubbed legacy rows. None of that survives the port — there is no database file to copy or++ * VACUUM, and openvibe-shared's PostgreSQL path (pruneRawEventsPg) covers retention. Run it where Network++ * runs, or with DATABASE_URL/DATABASE_DIRECT_URL pointing at a copy; it needs no elevated privileges.*/'use strict';--const path = require('path');--const Database = require('better-sqlite3');--const cli = require('openvibe-shared/analytics/prune-cli');--const networkAnalytics = require('../server/analytics/network');----const ROOT = path.join(__dirname, '..');----const VACUUM_WARNING = `--On Network, VACUUM rewrites all of network.db and holds its write lock meanwhile: Network's own writes--(sign-ins, sessions) wait 5 s and then fail. Vacuum with the service stopped, or pass --no-vacuum while--it is up. Default database: $DB_PATH, else data/network.db; relative paths are from the repo root. The--backup is a full copy of network.db (mode 0600). Run as the service user so no file changes owner.`;----/** The database the server uses: --db, else $DB_PATH, else data/network.db (relative to the repo root). */--function defaultDbPath(args, env = process.env) {-- return path.resolve(ROOT, args.db || env.DB_PATH || path.join('data', 'network.db'));++const { parseArgs } = require('./lib/db-ops');++const { pruneRawEventsPg } = require('openvibe-shared/analytics/pg');++++const USAGE = `Raw analytics retention (ADR-021, PostgreSQL).++++ node scripts/analytics-prune.js dry run: counts only, changes nothing++ node scripts/analytics-prune.js --apply delete raw events older than --days++++Options:++ --days <n> keep raw events newer than n days, 1..30 (default 30)++ --apply actually prune`;++++/** UTC 'YYYY-MM-DD HH:MM:SS', the tracker's own time shape (openvibe-shared/analytics/tracker.sqlTime). */++function sqlTime(ms) {++ const d = new Date(ms);++ const pad = (n) => String(n).padStart(2, '0');++ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;}--function main(argv, log) {-- return cli.main(argv, {-- Database,-- log,-- root: ROOT,-- defaultDb: defaultDbPath,-- ...networkAnalytics.PATH_OPTS,-- usage: cli.USAGE + '\n' + VACUUM_WARNING,-- });++/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it. */++async function openDb(injected) {++ if (injected) return injected;++ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the analytics tables live in the service database');++ const { createDb } = require('openvibe-sdk/db');++ return createDb({ url: process.env.DATABASE_URL, service: 'network-analytics-prune', max: 1 });++}++++async function main(argv = process.argv.slice(2), { db: injected, log = console } = {}) {++ const args = parseArgs(argv, { flags: ['apply', 'help'], values: ['days'] });++ if (args.help) { log.log(USAGE); return 0; }++ const days = Number(args.days || 30);++ if (!Number.isInteger(days) || days < 1 || days > 30) { log.error('--days must be an integer 1..30'); return 2; }++++ const db = await openDb(injected);++ if (!args.apply) {++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM analytics_events WHERE created_at < ?').get(sqlTime(Date.now() - days * 86400000))).n;++ log.log(`dry run: ${n} raw event(s) older than ${days} day(s) would go; rollups stay. Re-run with --apply to prune.`);++ return 0;++ }++++ const { removed, cutoff } = await pruneRawEventsPg(db, { days });++ log.log(`pruned ${removed} raw event(s) older than ${cutoff} (${days} day(s)); rollups stay.`);++ return 0;}--if (require.main === module) cli.run(main);++if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });--module.exports = { main, defaultDbPath };++module.exports = { main, sqlTime, USAGE };@@@ -7,26 +7,42 @@** node scripts/import-blocks.js --file <json> # dry run: what would be imported, nothing changes* node scripts/import-blocks.js --file <json> --apply # insert the blocks and queue their events-- * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)*-- * A pair is imported only when Network has never seen it: an existing row (blocked, or unblocked since)-- * is the person's later decision and is left alone. Pairs naming an unknown account, a guest or the same-- * person twice are skipped and counted. Each import is a normal block (server/identity/blocks.js setBlock):-- * revision 1 and network.block.changed in network_event_outbox in the same transaction; the running-- * server relays those rows within its next poll. Safe to run twice (the second run imports nothing).-- * Safe while Network runs (busy_timeout 5 s). Run as the database's owner; run as root it becomes-- * that owner before opening the database. Undo a pair with DELETE /api/v1/me/blocks/:subject as the person.++ * The database is the service's own PostgreSQL (DATABASE_URL, plan T2); run this where Network runs, or++ * with DATABASE_DIRECT_URL/DATABASE_URL pointing at a copy. A pair is imported only when Network has never++ * seen it: an existing row (blocked, or unblocked since) is the person's later decision and is left alone.++ * Pairs naming an unknown account, a guest or the same person twice are skipped and counted. Each import is++ * a normal block (server/identity/blocks.js setBlock): revision 1 and network.block.changed in++ * network_event_outbox in the same transaction; the running server relays those rows within its next poll.++ * Safe to run twice (the second run imports nothing). Undo a pair with DELETE /api/v1/me/blocks/:subject++ * as the person.*/'use strict';const fs = require('fs');const path = require('path');--const Database = require('better-sqlite3');--const ops = require('./lib/db-ops');++const { initDb } = require('../server/db/database');const blocks = require('../server/identity/blocks');--const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply] [--db <path>]';++const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply]';const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++/** --flag / --key value parsing (scripts/lib/db-ops.js keeps the shared one; this stays standalone). */++function parseArgs(argv, { flags = [], values = [] } = {}) {++ const out = { _: [] };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ const name = a.replace(/^--/, '');++ if (flags.includes(name)) out[name] = true;++ else if (values.includes(name)) {++ const v = argv[++i];++ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);++ out[name] = v;++ } else if (a === '--help' || a === '-h') out.help = true;++ else throw new Error(`unknown argument ${a}`);++ }++ return out;++}++function readPairs(file) {const data = JSON.parse(fs.readFileSync(file, 'utf8'));const list = Array.isArray(data) ? data : data && Array.isArray(data.pairs) ? data.pairs : null;@@@ -35,7 +51,7 @@ function readPairs(file) {}/** Sort each pair into import / skip. → { todo: [{ blocker, blocked }], skipped: { reason: n } } */--function plan(db, pairs) {++async function plan(db, pairs) {const person = db.prepare('SELECT is_anon FROM users WHERE subject_id = ?');const seen = db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?');const todo = [];@@@ -50,11 +66,11 @@ function plan(db, pairs) {const key = `${blocker}>${blocked}`;if (dup.has(key)) { skip('duplicate-in-file'); continue; }dup.add(key);-- const a = person.get(blocker);-- const b = person.get(blocked);++ const a = await person.get(blocker);++ const b = await person.get(blocked);if (!a || !b) { skip('unknown-account'); continue; }if (a.is_anon || b.is_anon) { skip('guest'); continue; }-- if (seen.get(blocker, blocked)) { skip('already-on-network'); continue; }++ if (await seen.get(blocker, blocked)) { skip('already-on-network'); continue; }todo.push({ blocker, blocked });}return { todo, skipped };@@@ -62,34 +78,28 @@ function plan(db, pairs) {async function main(argv, log = console.log) {let args;-- try { args = ops.parseArgs(argv, { flags: ['apply'], values: ['db', 'file'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }++ try { args = parseArgs(argv, { flags: ['apply'], values: ['file'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }if (args.help) { log(USAGE); return 0; }if (!args.file) { log(USAGE); return 2; }-- const file = ops.dbPath(args);-- if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }let pairs;try { pairs = readPairs(path.resolve(process.cwd(), args.file)); } catch (e) { log(`error: ${e.message}`); return 2; }-- ops.dropToOwnerOf(file, log);-- const db = new Database(file);-- db.pragma('busy_timeout = 5000');-- try {-- blocks.ensureSchema(db);-- const { todo, skipped } = plan(db, pairs);-- const why = Object.entries(skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';-- log(`database ${file}\npairs ${pairs.length}; to import ${todo.length}; skipped: ${why}`);-- if (!args.apply) { log(todo.length ? 'dry run: nothing changed. Re-run with --apply to import.' : 'nothing to import.'); return 0; }-- let imported = 0;-- let refused = 0;-- for (const p of todo) {-- try { if (blocks.setBlock(db, p.blocker, p.blocked, true).changed) imported++; } catch (e) {-- if (!(e instanceof blocks.BlockError)) throw e;-- refused++;-- log(`refused ${p.blocker} -> ${p.blocked}: ${e.message}`);-- }++ const db = await initDb();++ await blocks.ensureSchema(db);++ const { todo, skipped } = await plan(db, pairs);++ const why = Object.entries(skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';++ log(`database ${db.store}\npairs ${pairs.length}; to import ${todo.length}; skipped: ${why}`);++ if (!args.apply) { log(todo.length ? 'dry run: nothing changed. Re-run with --apply to import.' : 'nothing to import.'); return 0; }++ let imported = 0;++ let refused = 0;++ for (const p of todo) {++ try { if ((await blocks.setBlock(db, p.blocker, p.blocked, true)).changed) imported++; } catch (e) {++ if (!(e instanceof blocks.BlockError)) throw e;++ refused++;++ log(`refused ${p.blocker} -> ${p.blocked}: ${e.message}`);}-- log(`imported ${imported} block(s); ${imported} network.block.changed event(s) queued in network_event_outbox${refused ? `; refused ${refused}` : ''}`);-- return refused ? 1 : 0;-- } finally { db.close(); }++ }++ log(`imported ${imported} block(s); ${imported} network.block.changed event(s) queued in network_event_outbox${refused ? `; refused ${refused}` : ''}`);++ return refused ? 1 : 0;}if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });@@@ -1,51 +1,14 @@'use strict';/**-- * Shared by the operator scripts that change network.db (scripts/secrets-out-of-db.js,-- * scripts/import-blocks.js):-- *-- * dbPath(args) --db, else $DB_PATH, else data/network.db (relative to the repo root)-- * dropToOwnerOf(file) when run as root: become the file's owner (uid/gid) so nothing the script-- * writes (the -wal/-shm files, a backup) ends up owned by root; a no-op otherwise-- * backupTo(db, target) sqlite online backup to a NEW file, owner-only (0600), verified with-- * quick_check; refuses an existing target++ * Shared by the operator scripts: `parseArgs(argv, { flags, values })`, the `--flag` / `--key value`++ * parsing they all use. On PostgreSQL (plan T2, ADR-035) the scripts open the service database through++ * DATABASE_URL themselves (scripts/secrets-out-of-db.js, scripts/import-blocks.js); the old++ * better-sqlite3 helpers (dbPath, dropToOwnerOf, the sqlite online backup) are gone with the file.*/--const fs = require('fs');const path = require('path');const ROOT = path.join(__dirname, '..', '..');--function dbPath(args = {}, env = process.env) {-- return path.resolve(ROOT, args.db || env.DB_PATH || path.join('data', 'network.db'));--}----function dropToOwnerOf(file, log = console.log) {-- if (typeof process.getuid !== 'function' || process.getuid() !== 0) return false;-- const st = fs.statSync(file);-- if (st.uid === 0) return false;-- process.setgid(st.gid);-- process.setuid(st.uid);-- log(`running as uid ${st.uid} (owner of ${path.basename(file)})`);-- return true;--}----async function backupTo(db, target, { Database = require('better-sqlite3'), expect = null } = {}) {-- const file = path.resolve(ROOT, target);-- if (fs.existsSync(file)) throw new Error(`backup target ${file} already exists; choose a new path`);-- const umask = process.umask(0o077);-- try { await db.backup(file); } finally { process.umask(umask); }-- fs.chmodSync(file, 0o600);-- const check = new Database(file, { readonly: true });-- try {-- const ok = check.pragma('quick_check', { simple: true });-- if (ok !== 'ok') throw new Error(`backup check failed: quick_check=${ok}`);-- if (expect) {-- const n = check.prepare(`SELECT COUNT(*) AS n FROM ${expect.table}`).get().n;-- if (n < expect.rows) throw new Error(`backup check failed: ${expect.table} has ${n} rows, expected ${expect.rows}`);-- }-- } finally { check.close(); }-- return file;--}--/** --flag / --key value parsing shared by the scripts. */function parseArgs(argv, { flags = [], values = [] } = {}) {const out = { _: [] };@@@ -63,4 +26,4 @@ function parseArgs(argv, { flags = [], values = [] } = {}) {return out;}--module.exports = { ROOT, dbPath, dropToOwnerOf, backupTo, parseArgs };++module.exports = { ROOT, parseArgs };@@@ -10,7 +10,7 @@* --pglite a dry run: an in-memory PostgreSQL, migrations applied, every table imported with truncate* and verified, and the per-table count + checksum report printed. Nothing to set up, nothing* left behind. Use it to prove the schema is importable before touching a real target.-- * --sqlite the source file (default config.db.path). It is opened read-only; nothing in it changes.++ * --sqlite the source file (default $DB_PATH or data/network.db). It is opened read-only; nothing in it changes.* --json the report as JSON.** Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes@@@ -34,9 +34,12 @@ const TABLES = {};// (unsent events keep their place in the PostgreSQL outbox).const SKIP_SOURCE = [];++// The retired SQLite file: --sqlite, else DB_PATH, else data/network.db (relative to the repo root).++const DEFAULT_SQLITE = path.resolve(__dirname, '..', process.env.DB_PATH || path.join('data', 'network.db'));++if (require.main === module) {-- runSqliteMigration({ service: 'network', sqlite: config.db.path, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES, skipSource: SKIP_SOURCE })++ runSqliteMigration({ service: 'network', sqlite: DEFAULT_SQLITE, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES, skipSource: SKIP_SOURCE }).then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });}--module.exports = { TABLES, SKIP_SOURCE, MIGRATIONS };++module.exports = { TABLES, SKIP_SOURCE, MIGRATIONS, DEFAULT_SQLITE };@@@ -15,11 +15,12 @@*/require('dotenv').config();const fs = require('fs');--const config = require('../server/config');++const path = require('path');const args = process.argv.slice(2);const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };--const file = opt('--sqlite', config.db.path);++// The retired SQLite file: --sqlite, else DB_PATH, else data/network.db (relative to the repo root).++const file = opt('--sqlite', path.resolve(__dirname, '..', process.env.DB_PATH || path.join('data', 'network.db')));const asJson = args.includes('--json');/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */@@@ -1,46 +1,47 @@#!/usr/bin/env node/**-- * Provider secrets out of network.db (roadmap §18.2(12); server/secrets.js). Network reads each secret++ * Provider secrets out of site_settings (roadmap §18.2(12); server/secrets.js). Network reads each secret* from its environment variable first and from site_settings only as a fallback; this script is how the* operator empties the database copies once the environment has them. It prints setting and variable* NAMES, never a value (values are compared by SHA-256 only).*-- * sudo node scripts/secrets-out-of-db.js-- * dry run: every secret setting by name, whether network.db holds a value, its variable, whether++ * node scripts/secrets-out-of-db.js++ * dry run: every secret setting by name, whether the database holds a value, its variable, whether* the env file sets it (and to the same value), and whether the running service has it-- * sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]++ * node scripts/secrets-out-of-db.js --copy-to-env [--apply]* for each secret the database holds and the env file does not set, append VAR=value to the env* file (after copying it to <env file>.bak-<time>, same mode), so the values move without anyone-- * seeing them: the database is read by a child process running as its owner and the values go to-- * the file through a pipe, never to the terminal. Dry run without --apply. Restart the service after.-- * sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]-- * takes an online backup (0600, verified), then blanks the database copy of++ * seeing them: the values go to the file through this process, never to the terminal. Dry run++ * without --apply. Restart the service after.++ * node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]++ * writes the secret settings to a new owner-only (0600) JSON backup, then blanks the database copy of* - each secret whose variable the env file sets to the same value (a different value only with* --allow-different) AND the running service already has (restart it after editing the file)* - each secret setting Network never reads (Net tools tokens, old SES keys)* and leaves every other one as it is, saying why-- * sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]++ * node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]* rollback: puts the backed-up value back into each secret setting that is blank now*++ * On PostgreSQL (plan T2, ADR-035) the settings live in the service database: the script reads and writes++ * them through DATABASE_URL, exactly as the service does. There is no separate owner process and no++ * sqlite online backup; --backup writes a JSON file (0600) holding the values being blanked.++ ** --env-file <path> default /etc/openvibe/network.env-- * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)* --unit <name> systemd unit whose running process must already have the variables* (default openvibe-network); --no-service-check skips that check*-- * Run with sudo: the env file and the service's environment are root-only. The script reads them, then-- * becomes the database's owner before opening network.db, so no file changes owner.++ * Run with sudo: the env file and the service's environment are root-only.*/'use strict';const fs = require('fs');const path = require('path');const crypto = require('crypto');const { execFileSync } = require('child_process');--const Database = require('better-sqlite3');--const ops = require('./lib/db-ops');++const { parseArgs } = require('./lib/db-ops');const secrets = require('../server/secrets');const { isSensitiveSettingKey } = require('../server/auth/owner-guard');--const USAGE = `usage: sudo node scripts/secrets-out-of-db.js [--env-file <path>] [--db <path>] [--unit <name> | --no-service-check]++const USAGE = `usage: sudo node scripts/secrets-out-of-db.js [--env-file <path>] [--unit <name> | --no-service-check]sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;@@@ -50,6 +51,14 @@ const NOT_SECRET = new Set(['discord_oauth_client_id', 'vapid_public_key', 'vapiconst digest = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');++/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it. */++async function openDb(injected) {++ if (injected) return injected;++ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the settings live in the service database');++ const { createDb } = require('openvibe-sdk/db');++ return createDb({ url: process.env.DATABASE_URL, service: 'network-secrets', max: 1 });++}++function parseEnvText(text) {const util = require('util');if (typeof util.parseEnv === 'function') return util.parseEnv(text);@@@ -81,8 +90,8 @@ function serviceEnv(unit) {}/** Every secret-looking setting, classified. */--function classify(db) {-- const rows = db.prepare('SELECT key, value, type FROM site_settings').all();++async function classify(db) {++ const rows = await db.prepare('SELECT key, value, type FROM site_settings').all();const byKey = new Map(rows.map(r => [r.key, r]));const out = [];for (const s of secrets.SECRETS) out.push({ key: s.key, kind: 'provider', env: s.env, value: (byKey.get(s.key) || {}).value || '' });@@@ -97,31 +106,17 @@ function classify(db) {// A value systemd's EnvironmentFile and util.parseEnv read back unchanged without quoting.const PLAIN_VALUE = /^[A-Za-z0-9._\-+/=:~]+$/;--/** Child side of --copy-to-env: the provider values as JSON on stdout (a pipe to the parent only). */--function emitValues(file) {-- const db = new Database(file, { readonly: true, fileMustExist: true });-- try {-- const get = db.prepare('SELECT value FROM site_settings WHERE key = ?');-- const out = {};-- for (const s of secrets.SECRETS) { const r = get.get(s.key); if (r && r.value) out[s.env] = String(r.value); }-- process.stdout.write(JSON.stringify(out));-- } finally { db.close(); }--}----/** The database's provider values, read by a child process running as the database owner. */--function readValuesAsOwner(file) {-- const st = fs.statSync(file);-- const asOwner = typeof process.getuid === 'function' && process.getuid() === 0 && st.uid !== 0 ? { uid: st.uid, gid: st.gid } : {};-- const res = require('child_process').spawnSync(process.execPath, [__filename, '--db', file], {-- ...asOwner, env: { PATH: process.env.PATH, OV_SECRETS_EMIT: '1' }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000, maxBuffer: 1 << 20,-- });-- if (res.status !== 0) throw new Error(`reading the database failed: ${String(res.stderr || '').trim().split('\n').pop()}`);-- return JSON.parse(String(res.stdout));++/** The database's provider values: { ENV_NAME: value }. */++async function readValues(db) {++ const get = db.prepare('SELECT value FROM site_settings WHERE key = ?');++ const out = {};++ for (const s of secrets.SECRETS) { const r = await get.get(s.key); if (r && r.value) out[s.env] = String(r.value); }++ return out;}--function copyToEnv(file, envFile, envf, apply, log, now = new Date()) {++async function copyToEnv(db, envFile, envf, apply, log, now = new Date()) {if (envf.error) { log(`error: the env file ${envFile} is unreadable (${envf.error}); run with sudo`); return 2; }-- const values = readValuesAsOwner(file);++ const values = await readValues(db);const add = [];for (const s of secrets.SECRETS) {const v = values[s.env];@@@ -139,52 +134,60 @@ function copyToEnv(file, envFile, envf, apply, log, now = new Date()) {fs.copyFileSync(envFile, bak, fs.constants.COPYFILE_EXCL);fs.chmodSync(bak, fs.statSync(envFile).mode & 0o777);const text = fs.readFileSync(envFile, 'utf8');-- fs.appendFileSync(envFile, `${text.endsWith('\n') || !text ? '' : '\n'}# Provider secrets moved from network.db site_settings (scripts/secrets-out-of-db.js, ${now.toISOString()})\n${add.map(([k, v]) => `${k}=${v}`).join('\n')}\n`);++ fs.appendFileSync(envFile, `${text.endsWith('\n') || !text ? '' : '\n'}# Provider secrets moved from site_settings (scripts/secrets-out-of-db.js, ${now.toISOString()})\n${add.map(([k, v]) => `${k}=${v}`).join('\n')}\n`);log(`env file ${envFile}: appended ${add.map(([k]) => k).join(', ')} (previous version: ${bak}). Restart openvibe-network, then run --apply --backup <new file>.`);return 0;}--async function main(argv, log = console.log, { readService = serviceEnv } = {}) {++/** Write the site_settings rows to a NEW owner-only JSON backup; refuses an existing target. */++function writeBackup(target, rows, log) {++ const file = path.resolve(target);++ if (fs.existsSync(file)) throw new Error(`backup target ${file} already exists; choose a new path`);++ const umask = process.umask(0o077);++ try { fs.writeFileSync(file, JSON.stringify({ taken_at: new Date().toISOString(), settings: rows }, null, 2)); } finally { process.umask(umask); }++ fs.chmodSync(file, 0o600);++ log(`backup ${file} (0600). It holds the site_settings rows (owner-only): delete it once the rollback window is over.`);++ return file;++}++++async function main(argv, log = console.log, { readService = serviceEnv, db: injected } = {}) {let args;-- try { args = ops.parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check', 'copy-to-env'], values: ['db', 'backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }++ try { args = parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check', 'copy-to-env'], values: ['backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }if (args.help) { log(USAGE); return 0; }-- const file = ops.dbPath(args);-- if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }const envFile = args['env-file'] || '/etc/openvibe/network.env';-- if (args['copy-to-env']) {-- if (args.backup || args['restore-from']) { log('--copy-to-env takes only --apply, --db and --env-file'); return 2; }-- log(`database ${file}\nenv file ${envFile}`);-- return copyToEnv(file, envFile, readEnvFile(envFile), !!args.apply, log);-- }---- // Root-only reads first, then drop to the database owner.-- const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);-- const svc = args['restore-from'] || args['no-service-check'] ? null : readService(args.unit || 'openvibe-network');-- ops.dropToOwnerOf(file, log);-- const db = new Database(file);-- db.pragma('busy_timeout = 5000');++ let db;++ try { db = await openDb(injected); } catch (e) { log(`error: ${e.message}`); return 2; }try {-- log(`database ${file}`);++ if (args['copy-to-env']) {++ if (args.backup || args['restore-from']) { log('--copy-to-env takes only --apply and --env-file'); return 2; }++ log(`env file ${envFile}`);++ return await copyToEnv(db, envFile, readEnvFile(envFile), !!args.apply, log);++ }++++ const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);++ const svc = args['restore-from'] || args['no-service-check'] ? null : readService(args.unit || 'openvibe-network');++++ log('database DATABASE_URL');if (args['restore-from']) {-- const backup = new Database(path.resolve(ops.ROOT, args['restore-from']), { readonly: true, fileMustExist: true });-- const saved = new Map(backup.prepare('SELECT key, value FROM site_settings').all().map(r => [r.key, r.value]));-- backup.close();-- const todo = classify(db).filter(c => c.kind !== 'unclassified' && !c.value && saved.get(c.key));++ const backup = JSON.parse(fs.readFileSync(path.resolve(args['restore-from']), 'utf8'));++ const saved = new Map((backup.settings || []).map(r => [r.key, r.value]));++ const todo = (await classify(db)).filter(c => c.kind !== 'unclassified' && !c.value && saved.get(c.key));for (const c of todo) log(` restore ${c.key}`);log(`${todo.length} setting(s) to restore from ${args['restore-from']}`);if (!args.apply) { log('dry run: nothing changed. Re-run with --apply.'); return 0; }-- const up = db.prepare("UPDATE site_settings SET value = ? WHERE key = ? AND (value IS NULL OR value = '')");-- let n = 0;-- db.transaction(() => { for (const c of todo) n += up.run(saved.get(c.key), c.key).changes; })();-- log(`restored ${n} setting(s). Restart openvibe-network if it should read them again (the environment still wins where set).`);++ await db.tx(async (t) => {++ let n = 0;++ for (const c of todo) { n += (await t.prepare("UPDATE site_settings SET value = ? WHERE key = ? AND (value IS NULL OR value = '')").run(saved.get(c.key), c.key)).changes; }++ log(`restored ${n} setting(s). Restart openvibe-network if it should read them again (the environment still wins where set).`);++ });return 0;}log(`env file ${envFile}${envf.error ? ` (unreadable: ${envf.error}; run with sudo)` : ''}`);if (svc) log(`service ${svc.error ? `unknown (${svc.error})` : `pid ${svc.pid}`}`);const plan = [];-- for (const c of classify(db)) {++ for (const c of await classify(db)) {const row = { key: c.key, kind: c.kind, db: c.value ? 'value' : 'empty' };if (c.kind === 'provider') {row.env = c.env;@@@ -213,25 +216,22 @@ async function main(argv, log = console.log, { readService = serviceEnv } = {})const blank = plan.filter(p => p.action.startsWith('BLANK'));log(`${blank.length} setting(s) would be blanked; ${plan.filter(p => p.action.startsWith('keep')).length} kept.`);if (!args.apply) { log('dry run: nothing changed. Re-run with --apply --backup <new file>.'); return 0; }-- if (!args.backup) { log('refusing --apply without --backup <new file> (sqlite online backup, 0600)'); return 2; }++ if (!args.backup) { log('refusing --apply without --backup <new file> (owner-only JSON, 0600)'); return 2; }if (envf.error) { log(`refusing --apply: the env file ${envFile} is unreadable`); return 2; }if (!blank.length) { log('nothing to blank; no backup taken.'); return 0; }-- const total = db.prepare('SELECT COUNT(*) AS n FROM site_settings').get().n;-- const target = await ops.backupTo(db, args.backup, { expect: { table: 'site_settings', rows: total } });-- log(`backup ${target} (0600, quick_check ok). It holds the secrets (owner-only, like network.db): delete it once the rollback window is over.`);-- const up = db.prepare("UPDATE site_settings SET value = '' WHERE key = ?");-- let n = 0;-- db.transaction(() => { for (const p of blank) n += up.run(p.key).changes; })();-- log(`blanked ${n} setting(s): ${blank.map(p => p.key).join(', ')}`);++ const rows = await db.prepare('SELECT key, value FROM site_settings').all();++ writeBackup(args.backup, rows, log);++ await db.tx(async (t) => {++ const up = t.prepare("UPDATE site_settings SET value = '' WHERE key = ?");++ let n = 0;++ for (const p of blank) n += (await up.run(p.key)).changes;++ log(`blanked ${n} setting(s): ${blank.map(p => p.key).join(', ')}`);++ });return 0;-- } finally { db.close(); }++ } finally { if (!injected) await db.close().catch(() => {}); }}if (require.main === module) {-- // The --copy-to-env child: values go to the parent through a pipe, never to a terminal.-- if (process.env.OV_SECRETS_EMIT === '1' && !process.stdout.isTTY) {-- try { emitValues(ops.dbPath(ops.parseArgs(process.argv.slice(2), { values: ['db'] }))); process.exit(0); } catch (err) { console.error(err.message); process.exit(1); }-- }main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });}@@@ -37,10 +37,10 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,// Games and Media keep no analytics of their own. The shared navbar's anonymous page-view count// (frame_hits: day + host, nothing else) is the honest number for them; the summary says so.const BEACON_HOSTS = { games: 'openvibe.games', media: 'openvibe.media', tools: 'openvibe.tools' };-- function beaconFallback(svc, days) {++ async function beaconFallback(svc, days) {try {const host = BEACON_HOSTS[svc.name]; if (!host) return null;-- const row = analytics.db.prepare("SELECT COALESCE(SUM(hits), 0) AS n FROM frame_hits WHERE day >= date('now', ?) AND (host = ? OR host LIKE ?)").get(`-${Math.min(days || 30, 365)} days`, host, '%.' + host);++ const row = await analytics.db.prepare("SELECT COALESCE(SUM(hits), 0) AS n FROM frame_hits WHERE day >= substring(datetime('now', ?), 1, 10) AND (host = ? OR host LIKE ?)").get(`-${Math.min(days || 30, 365)} days`, host, '%.' + host);return { ok: true, analytics: { summary: { total_pageviews: row.n, source: 'navbar page-view count (no service analytics)' }, realtime: {} } };} catch { return null; }}@@@ -52,7 +52,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,const url = `${svc.url}${svc.path}${subPath}?${qs}`;const headers = { 'Content-Type': 'application/json' };-- if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);++ if (svc.auth === 'beacon') return subPath ? null : await beaconFallback(svc, days);if (svc.auth === 'internal') {const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);if (!t) return null;@@@ -62,11 +62,11 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,}const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });-- if (!res.ok) return subPath ? null : beaconFallback(svc, days);++ if (!res.ok) return subPath ? null : await beaconFallback(svc, days);return await res.json();} catch (err) {console.warn(`[Analytics] Failed to fetch from ${svc.name}:`, err.message);-- return subPath ? null : beaconFallback(svc, days);++ return subPath ? null : await beaconFallback(svc, days);}}@@@ -81,7 +81,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,// Fetch from all services in parallelconst promises = [-- Promise.resolve({ ok: true, analytics: analytics.getStats({ days, hours }), name: 'openvibe-network', label: 'OpenVibe.Network' }),++ { ok: true, analytics: await analytics.getStats({ days, hours }), name: 'openvibe-network', label: 'OpenVibe.Network' },...REMOTE_SERVICES.map(svc =>fetchRemoteAnalytics(svc, '', req.token, days, hours).then(result => ({...result, name: svc.name, label: svc.label,@@@ -231,7 +231,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,let data = null;if (name === 'openvibe-network') {-- data = analytics.getStats({ days, hours });++ data = await analytics.getStats({ days, hours });} else {const svc = REMOTE_SERVICES.find(s => s.name === name);if (svc) {@@@ -259,7 +259,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,const days = Math.min(parseInt(req.query.days) || 30, 365);const promises = [-- Promise.resolve({ ok: true, bots: analytics.getBotAnalysis(days), name: 'openvibe-network' }),++ { ok: true, bots: await analytics.getBotAnalysis(days), name: 'openvibe-network' },...REMOTE_SERVICES.map(svc =>fetchRemoteAnalytics(svc, '/bots', req.token, days).then(result => ({...result, name: svc.name,@@@ -284,13 +284,13 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,// Real-time snapshot// ═══════════════════════════════════════════════════════════-- router.get('/realtime', (req, res) => {++ router.get('/realtime', async (req, res) => {try {const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');const db = analytics.db;// ADR-021: raw events carry no IP; visitors are distinct rotating session ids.-- const realtime = db.prepare(`++ const realtime = await db.prepare(`SELECTCOUNT(*) as requests,COUNT(DISTINCT session_id) as visitors,@@@ -301,7 +301,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,WHERE created_at >= ?`).get(fiveMinAgo);-- const byPath = db.prepare(`++ const byPath = await db.prepare(`SELECT path, COUNT(*) as hitsFROM analytics_eventsWHERE created_at >= ? AND is_bot = 0@@@ -71,7 +71,7 @@ function createEventsOps({ db, eventsUrl, privateKey, issuer, fetchImpl = globaltry {const out = await call('POST', '/api/v1/deliveries/replay', ids ? { subscription_id: b.subscription_id, event_ids: ids } : { subscription_id: b.subscription_id, from_seq: fromSeq });if (out.status !== 200) return res.status(out.status === 404 ? 404 : 502).json({ ok: false, error: `Events answered ${out.status}`, detail: out.data && (out.data.detail || out.data.title) });-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'events_replay',++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'events_replay',JSON.stringify({ subscription_id: b.subscription_id, event_ids: ids ? ids.slice(0, 50) : undefined, event_count: ids ? ids.length : undefined, from_seq: fromSeq ?? undefined, queued: out.data.queued }));res.json({ ok: true, queued: out.data.queued });} catch (err) {@@@ -31,28 +31,7 @@ const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});const json = (v) => { const s = JSON.stringify(obj(v)); return s.length > 4000 ? JSON.stringify({ truncated: true }) : s; };--function ensure(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS moderation_audit (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- event_id TEXT NOT NULL UNIQUE,-- service TEXT NOT NULL,-- action TEXT NOT NULL,-- actor_subject TEXT,-- target_type TEXT,-- target_id TEXT,-- target_subject TEXT,-- scope TEXT,-- reason TEXT,-- details TEXT NOT NULL DEFAULT '{}',-- occurred_at TEXT NOT NULL,-- recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))-- );-- CREATE INDEX IF NOT EXISTS idx_modaudit_when ON moderation_audit(occurred_at);-- CREATE INDEX IF NOT EXISTS idx_modaudit_service ON moderation_audit(service, id);-- CREATE INDEX IF NOT EXISTS idx_modaudit_actor ON moderation_audit(actor_subject, id);-- `);--}++function ensure(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** One envelope → one audit row (or null when it is not an audit event). No I/O. */function rowOf(event) {@@@ -105,17 +84,17 @@ function rowOf(event) {function createModerationAudit(db) {ensure(db);-- const ins = db.prepare(`INSERT OR IGNORE INTO moderation_audit (event_id, service, action, actor_subject, target_type, target_id, target_subject, scope, reason, details, occurred_at)-- VALUES (@event_id, @service, @action, @actor_subject, @target_type, @target_id, @target_subject, @scope, @reason, @details, @occurred_at)`);++ const ins = db.prepare(`INSERT INTO moderation_audit (event_id, service, action, actor_subject, target_type, target_id, target_subject, scope, reason, details, occurred_at)++ VALUES (@event_id, @service, @action, @actor_subject, @target_type, @target_id, @target_subject, @scope, @reason, @details, @occurred_at) ON CONFLICT DO NOTHING`);/** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */-- function record(event) {++ async function record(event) {const row = rowOf(event);if (!row) return 'ignored:type';-- return ins.run(row).changes ? 'recorded' : 'ignored:duplicate';++ return (await ins.run(row)).changes ? 'recorded' : 'ignored:duplicate';}-- function list({ service = null, action = null, actor = null, target = null, since = null, until = null, before = null, limit = 50, max = 200 } = {}) {++ async function list({ service = null, action = null, actor = null, target = null, since = null, until = null, before = null, limit = 50, max = 200 } = {}) {const where = [];const args = [];const day = (v) => { const d = new Date(String(v || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };@@@ -128,7 +107,7 @@ function createModerationAudit(db) {const b = parseInt(before, 10);if (Number.isFinite(b) && b > 0) { where.push('id < ?'); args.push(b); }const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), max);-- const rows = db.prepare(`SELECT * FROM moderation_audit ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, n + 1);++ const rows = await db.prepare(`SELECT * FROM moderation_audit ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, n + 1);const more = rows.length > n;const items = rows.slice(0, n).map((r) => ({ ...r, details: (() => { try { return JSON.parse(r.details); } catch { return {}; } })() }));return { items, next: more && items.length ? items[items.length - 1].id : null };@@@ -137,16 +116,16 @@ function createModerationAudit(db) {/** Express router for GET /api/v1/staff/moderation-audit (mount behind requireAuth). */function router() {const r = express.Router();-- r.get('/', (req, res) => {++ r.get('/', async (req, res) => {if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });res.set('Cache-Control', 'private, no-store');const filters = { service: req.query.service, action: req.query.action, actor: req.query.actor, target: req.query.target, since: req.query.since, until: req.query.until };if (req.query.format === 'csv') {-- const { items } = list({ ...filters, limit: CSV_MAX, max: CSV_MAX });++ const { items } = await list({ ...filters, limit: CSV_MAX, max: CSV_MAX });res.set('Content-Disposition', `attachment; filename="moderation-audit-${new Date().toISOString().slice(0, 10)}.csv"`);return res.type('text/csv').send(toCsv(items));}-- res.json(list({ ...filters, before: req.query.before, limit: req.query.limit }));++ res.json(await list({ ...filters, before: req.query.before, limit: req.query.limit }));});return r;}@@@ -81,7 +81,7 @@ async function refreshService(req, serviceName) {try {const db = req.app.locals.db;const config = req.app.locals.config;-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);req.app.locals.urlRegistry = resolved;applyResolvedRegistryToConfig(config, resolved);return {@@@ -166,27 +166,27 @@ async function refreshServiceByKey(req, key) {if (!serviceName) {return { ok: false, service: null, mode: 'unknown', error: `No service defined for key ${key}` };}-- return refreshService(req, serviceName);++ return await refreshService(req, serviceName);}function createAdminRoutes(db, notificationService, emailService, requireAuth) {const siteConfig = require('./site-config');-- function getEmailMetrics() {++ async function getEmailMetrics() {const summary = {-- total: db.prepare('SELECT COUNT(*) AS count FROM email_delivery_log').get().count,-- sent: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent'").get().count,-- failed: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed'").get().count,-- sent_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent' AND created_at >= datetime('now', '-1 day')").get().count,-- failed_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed' AND created_at >= datetime('now', '-1 day')").get().count,-- password_resets_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'password_reset' AND created_at >= datetime('now', '-1 day')").get().count,-- notifications_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type LIKE 'notification:%' AND created_at >= datetime('now', '-1 day')").get().count,-- tests_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'test' AND created_at >= datetime('now', '-1 day')").get().count,-- last_sent_at: db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'sent' ORDER BY created_at DESC LIMIT 1").get()?.created_at || null,-- last_failed_at: db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'failed' ORDER BY created_at DESC LIMIT 1").get()?.created_at || null,++ total: (await db.prepare('SELECT COUNT(*) AS count FROM email_delivery_log').get()).count,++ sent: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent'").get()).count,++ failed: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed'").get()).count,++ sent_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent' AND created_at >= datetime('now', '-1 day')").get()).count,++ failed_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed' AND created_at >= datetime('now', '-1 day')").get()).count,++ password_resets_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'password_reset' AND created_at >= datetime('now', '-1 day')").get()).count,++ notifications_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type LIKE 'notification:%' AND created_at >= datetime('now', '-1 day')").get()).count,++ tests_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'test' AND created_at >= datetime('now', '-1 day')").get()).count,++ last_sent_at: (await db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'sent' ORDER BY created_at DESC LIMIT 1").get())?.created_at || null,++ last_failed_at: (await db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'failed' ORDER BY created_at DESC LIMIT 1").get())?.created_at || null,};-- const byType = db.prepare(`++ const byType = await db.prepare(`SELECT email_type, status, COUNT(*) AS countFROM email_delivery_logWHERE created_at >= datetime('now', '-30 day')@@@ -195,7 +195,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {LIMIT 20`).all();-- const recent = db.prepare(`++ const recent = await db.prepare(`SELECT id, email_type, recipient, subject, status, error_message, created_atFROM email_delivery_logORDER BY created_at DESC@@@ -214,7 +214,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}async function notifyServiceRefresh(req, key) {-- return refreshServiceByKey(req, key);++ return await refreshServiceByKey(req, key);}// A role change reaches Live (and every other consumer) as network.user.updated: the users trigger records@@@ -228,10 +228,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// ═══════════════════════════════════════════════════════// GET /api/admin/email — get current email config (owner-only: exposes key metadata)-- router.get('/email', requireOwner, (req, res) => {++ router.get('/email', requireOwner, async (req, res) => {try {const status = emailService.getStatus();-- res.json({ ok: true, email: status, metrics: getEmailMetrics() });++ res.json({ ok: true, email: status, metrics: await getEmailMetrics() });} catch (err) {res.status(500).json({ ok: false, error: err.message });}@@@ -247,12 +247,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {const setSetting = { run: (k, v, type) => { changes.set[k] = v; changes.types[k] = type; } };const skipped = [];-- const collect = () => {++ const collect = async () => {if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');// Only update API key if it's not a masked placeholder, and never while RESEND_API_KEY// provides it (server/secrets.js): the environment is the source then.if (api_key && !/\u2022/.test(api_key)) {-- if (secrets.source(db, 'resend_api_key') === 'env') skipped.push('resend_api_key');++ if (await secrets.source(db, 'resend_api_key') === 'env') skipped.push('resend_api_key');else setSetting.run('resend_api_key', api_key, 'string');}if (from_email) setSetting.run('email_from_address', from_email, 'string');@@@ -262,13 +262,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {if (from_email_openvibegames !== undefined) setSetting.run('email_from_openvibegames', from_email_openvibegames, 'string');if (from_email_openvibenetwork !== undefined) setSetting.run('email_from_openvibenetwork', from_email_openvibenetwork, 'string');};-- collect();-- if (Object.keys(changes.set).length) await siteConfig.forDb(db).change(changes, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || 'email settings').slice(0, 300) });++ await collect();++ if (Object.keys(changes.set).length) await (await siteConfig.forDb(db)).change(changes, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || 'email settings').slice(0, 300) });emailService.reload();// Audit-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'email_config_update', JSON.stringify({ from_email }));@@@ -297,15 +297,15 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// ═══════════════════════════════════════════════════════// Where each provider secret comes from (server/secrets.js): names and sources, never values.-- router.get('/secrets', requireOwner, (req, res) => {-- res.json({ ok: true, secrets: secrets.report(db) });++ router.get('/secrets', requireOwner, async (req, res) => {++ res.json({ ok: true, secrets: await secrets.report(db) });});// Core site settings are owner-only (off-limits to admins).-- router.get('/settings', requireOwner, (req, res) => {++ router.get('/settings', requireOwner, async (req, res) => {res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear heretry {-- const rows = db.prepare('SELECT * FROM site_settings').all();++ const rows = await db.prepare('SELECT * FROM site_settings').all();const settings = {};// Keys managed exclusively by the Email tab — hide from generic settingsconst EMAIL_MANAGED_KEYS = new Set([@@@ -321,7 +321,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Provider secrets set in the environment (server/secrets.js): the database copy is not// used, and the value is never shown; the UI says which variable provides it.if (secrets.isManaged(r.key)) {-- const src = secrets.source(db, r.key);++ const src = await secrets.source(db, r.key);if (src === 'env') { settings[r.key] = { value: '', type: r.type, source: 'env', env: secrets.envName(r.key), redacted: true }; continue; }if (!owner) { settings[r.key] = { value: maskSecret(r.value), type: r.type, source: src, env: secrets.envName(r.key), redacted: true }; continue; }settings[r.key] = { value: r.value, type: r.type, source: src, env: secrets.envName(r.key), secret: secrets.isSecret(r.key) };@@@ -353,16 +353,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {return res.json({ ok: true, skipped: true });}// A provider secret the environment provides is never saved into the database.-- if (secrets.isManaged(key) && secrets.source(db, key) === 'env') {++ if (secrets.isManaged(key) && await secrets.source(db, key) === 'env') {return res.json({ ok: true, skipped: true, source: 'env', env: secrets.envName(key), note: `set in the environment (${secrets.envName(key)}); not saved to the database` });}let revision = null;if (siteConfig.isConfigKey(key)) {-- revision = (await siteConfig.forDb(db).change({ set: { [key]: String(value) }, types: { [key]: type || 'string' } }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `setting ${key}`).slice(0, 300) })).revision;++ revision = (await (await siteConfig.forDb(db)).change({ set: { [key]: String(value) }, types: { [key]: type || 'string' } }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `setting ${key}`).slice(0, 300) })).revision;} else {-- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), type || 'string');++ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(key, String(value), type || 'string');}-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'setting_update', JSON.stringify({ key }));res.json({ ok: true, ...(revision ? { revision } : {}) });@@@ -376,18 +376,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// GET /config, /config/:namespace, /config/:namespace/history; POST /config/:namespace/rollback { to?, reason }// (the rollback first records rows changed outside the journal, so it only undoes configuration changes).let configRoutes = null;-- const configHandlers = () => configRoutes || (configRoutes = require('openvibe-shared/config').adminRoutes([siteConfig.forDb(db).store], {++ const configHandlers = async () => configRoutes || (configRoutes = require('openvibe-shared/config').adminRoutes([(await siteConfig.forDb(db)).store], {requireAdmin: (_q, _s, next) => next(), basePath: '/config', actor: (q) => siteConfig.actorOf(q.user),}));-- router.get('/config', requireOwner, (req, res, next) => configHandlers().list(req, res, next));-- router.get('/config/:namespace', requireOwner, (req, res, next) => configHandlers().get(req, res, next));-- router.get('/config/:namespace/history', requireOwner, (req, res, next) => configHandlers().history(req, res, next));++ router.get('/config', requireOwner, async (req, res, next) => (await configHandlers()).list(req, res, next));++ router.get('/config/:namespace', requireOwner, async (req, res, next) => (await configHandlers()).get(req, res, next));++ router.get('/config/:namespace/history', requireOwner, async (req, res, next) => (await configHandlers()).history(req, res, next));router.post('/config/:namespace/rollback', requireOwner, async (req, res) => {try {if (req.params.namespace !== 'network.site_settings') return res.status(404).json({ ok: false, error: 'No such configuration namespace' });const to = req.body && req.body.to != null ? Number(req.body.to) : undefined;-- const snap = await siteConfig.forDb(db).rollback({ actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || 'rollback').slice(0, 300), to });-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'setting_rollback', JSON.stringify({ revision: snap.revision }));++ const snap = await (await siteConfig.forDb(db)).rollback({ actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || 'rollback').slice(0, 300), to });++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'setting_rollback', JSON.stringify({ revision: snap.revision }));res.json(snap);} catch (err) {if (err && err.status && err.code) return res.status(err.status).json({ ok: false, error: err.message, code: err.code });@@@ -398,10 +398,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// ═══════════════════════════════════════════════════════════════════════// URL Registry-- router.get('/url-registry', (req, res) => {++ router.get('/url-registry', async (req, res) => {res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear heretry {-- let entries = urlRegistry.getAllRegistryEntries(db);++ let entries = await urlRegistry.getAllRegistryEntries(db);// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.if (!isOwner(req.user) && Array.isArray(entries)) {entries = entries.map((e) => {@@@ -432,13 +432,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {if (!key) return res.status(400).json({ ok: false, error: 'Key required' });if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });if (!guardRegistryKey(req, res, key)) return;-- const entry = urlRegistry.setRegistryEntry(db, key, value, req.user.id);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ const entry = await urlRegistry.setRegistryEntry(db, key, value, req.user.id);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_update', JSON.stringify({ key }));const refreshResult = await notifyServiceRefresh(req, key);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult));res.json({ ok: true, entry, refresh: refreshResult });@@@ -454,13 +454,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {if (!key) return res.status(400).json({ ok: false, error: 'Key required' });if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });if (!guardRegistryKey(req, res, key)) return;-- const entry = urlRegistry.setRegistryEntry(db, key, value, req.user.id);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ const entry = await urlRegistry.setRegistryEntry(db, key, value, req.user.id);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_update', JSON.stringify({ key }));const refreshResult = await notifyServiceRefresh(req, key);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult));res.json({ ok: true, entry, refresh: refreshResult });@@@ -469,13 +469,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.delete('/url-registry/:key', (req, res) => {++ router.delete('/url-registry/:key', async (req, res) => {try {const { key } = req.params;if (!key) return res.status(400).json({ ok: false, error: 'Key required' });if (!guardRegistryKey(req, res, key)) return;-- urlRegistry.resetRegistryEntry(db, key);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await urlRegistry.resetRegistryEntry(db, key);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_reset', JSON.stringify({ key }));res.json({ ok: true, key });@@@ -495,7 +495,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {const result = await refreshService(req, serviceName);refreshResults.push({ service: serviceName, results: [result] });}-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_refresh_all', JSON.stringify(refreshResults));res.json({ ok: true, refreshResults });@@@ -504,13 +504,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.post('/url-registry/:key/reset', (req, res) => {++ router.post('/url-registry/:key/reset', async (req, res) => {try {const { key } = req.params;if (!key) return res.status(400).json({ ok: false, error: 'Key required' });if (!guardRegistryKey(req, res, key)) return;-- urlRegistry.resetRegistryEntry(db, key);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await urlRegistry.resetRegistryEntry(db, key);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'url_registry_reset', JSON.stringify({ key }));res.json({ ok: true, key });@@@ -519,7 +519,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.post('/reset-db', requireOwner, (req, res) => {++ router.post('/reset-db', requireOwner, async (req, res) => {try {const serviceRoot = path.resolve(__dirname, '..');const scriptPath = path.join(serviceRoot, 'server', 'reset-db.js');@@@ -536,7 +536,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {if (result.status !== 0) {return res.status(500).json({ ok: false, error: result.stderr || result.stdout || `Exit code ${result.status}` });}-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'reset_db', JSON.stringify({ output: result.stdout.trim() }));res.json({ ok: true, message: result.stdout.trim() });} catch (err) {@@@ -546,7 +546,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Only the owner may grant admin — otherwise an admin could self-escalate here,// bypassing the owner gate on PUT /users/:id/role.-- router.post('/users/grant-admin', requireOwner, (req, res) => {++ router.post('/users/grant-admin', requireOwner, async (req, res) => {try {const { id, username, email } = req.body;if (!id && !username && !email) {@@@ -555,18 +555,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {let user;if (id) {-- user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(id);++ user = await db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(id);} else if (username) {-- user = db.prepare('SELECT id, username, email FROM users WHERE LOWER(username) = LOWER(?)').get(username);++ user = await db.prepare('SELECT id, username, email FROM users WHERE LOWER(username) = LOWER(?)').get(username);} else {-- user = db.prepare('SELECT id, username, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);++ user = await db.prepare('SELECT id, username, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);}if (!user) {return res.status(404).json({ ok: false, error: 'User not found' });}-- db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id,'grant_admin',JSON.stringify({ targetId: user.id, targetUsername: user.username, targetEmail: user.email }),@@@ -581,7 +581,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// User Management// ═══════════════════════════════════════════════════════-- router.get('/users', (req, res) => {++ router.get('/users', async (req, res) => {try {const limit = Math.min(parseInt(req.query.limit) || 50, 200);const offset = parseInt(req.query.offset) || 0;@@@ -589,19 +589,19 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {let users;if (search) {-- users = db.prepare(`++ users = await db.prepare(`SELECT id, username, display_name, email, role, is_banned, created_at, last_seenFROM users WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(`%${search}%`, `%${search}%`, `%${search}%`, limit, offset);} else {-- users = db.prepare(`++ users = await db.prepare(`SELECT id, username, display_name, email, role, is_banned, created_at, last_seenFROM users ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(limit, offset);}-- const total = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;++ const total = (await db.prepare('SELECT COUNT(*) as cnt FROM users').get()).cnt;res.json({ ok: true, users, total });} catch (err) {res.status(500).json({ ok: false, error: err.message });@@@ -613,7 +613,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// whoever controls the address controls the account. So both are requireOwner, not// requireAdmin, matching how the admin role itself is gated below.-- router.put('/users/:id/email', requireOwner, (req, res) => {++ router.put('/users/:id/email', requireOwner, async (req, res) => {try {const userId = parseInt(req.params.id, 10);if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });@@@ -628,20 +628,20 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {return res.status(400).json({ ok: false, error: 'Email address is too long' });}-- const target = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);++ const target = await db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);if (!target) return res.status(404).json({ ok: false, error: 'User not found' });// users.email is UNIQUE, so a collision would throw a raw SQLite error —// check first and return something the admin can actually act on.if (email) {-- const clash = db.prepare('SELECT id, username FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(email, userId);++ const clash = await db.prepare('SELECT id, username FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(email, userId);if (clash) {return res.status(409).json({ ok: false, error: `That email is already used by @${clash.username}` });}}-- db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(email, userId);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('UPDATE users SET email = ?, updated_at = ov_now() WHERE id = ?').run(email, userId);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_email_change',// Record both sides: this is the audit trail for a takeover-capable action.JSON.stringify({ targetId: userId, targetUsername: target.username, from: target.email || null, to: email })@@@ -657,7 +657,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {const userId = parseInt(req.params.id, 10);if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });-- const target = db.prepare('SELECT id, username, display_name, email FROM users WHERE id = ?').get(userId);++ const target = await db.prepare('SELECT id, username, display_name, email FROM users WHERE id = ?').get(userId);if (!target) return res.status(404).json({ ok: false, error: 'User not found' });if (!target.email) return res.status(400).json({ ok: false, error: 'That account has no email address set' });@@@ -668,7 +668,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Same minting path as the self-service flow, so a link issued here behaves// identically — hashed at rest, one live token per user, same TTL.-- const { resetUrl, expiresMinutes } = issueResetToken(db, req, userId);++ const { resetUrl, expiresMinutes } = await issueResetToken(db, req, userId);const sent = await emailService.sendPasswordResetEmail({to: target.email,username: target.display_name || target.username,@@@ -686,7 +686,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// generic message gave no way to know that without reading server logs.let reason = null;try {-- const row = db.prepare(`++ const row = await db.prepare(`SELECT error_message FROM email_delivery_logWHERE recipient = ? AND status = 'failed'ORDER BY id DESC LIMIT 1@@@ -699,7 +699,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {});}-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_password_reset_sent',JSON.stringify({ targetId: userId, targetUsername: target.username, to: target.email }));@@@ -711,7 +711,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.put('/users/:id/role', (req, res) => {++ router.put('/users/:id/role', async (req, res) => {try {const { role } = req.body;const validRoles = ['user', 'streamer', 'global_mod', 'admin'];@@@ -720,12 +720,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// still manage streamers / global mods / users, but not other admins.const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();const isOwner = req.user && String(req.user.username || '').toLowerCase() === OWNER;-- const target = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id);++ const target = await db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id);if (!isOwner && (role === 'admin' || (target && target.role === 'admin'))) {return res.status(403).json({ ok: false, error: 'Only the owner can grant or change the admin role' });}-- db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_role_change', JSON.stringify({ targetId: req.params.id, role }));res.json({ ok: true });@@@ -734,19 +734,19 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.put('/users/:id/ban', (req, res) => {++ router.put('/users/:id/ban', async (req, res) => {try {const { banned, reason } = req.body;-- db.prepare('UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?').run(banned ? 1 : 0, reason || null, req.params.id);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?').run(banned ? 1 : 0, reason || null, req.params.id);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason }));// A ban ends the person's tokens on every site at once (network.user.token_valid_after).-- if (banned) { revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }++ if (banned) { await revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }// Notify the userif (banned) {-- notificationService.create({++ await notificationService.create({user_id: parseInt(req.params.id),type: 'BAN',title: 'Account Suspended',@@@ -763,13 +763,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {});// Rename someone (their old name redirects on Live and stays reserved; server/identity/usernames.js).-- router.put('/users/:id/username', (req, res) => {++ router.put('/users/:id/username', async (req, res) => {try {-- const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);++ const target = await db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);if (!target) return res.status(404).json({ ok: false, error: 'No such user' });if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner renames the owner" });-- const out = require('../identity/usernames').rename(db, target.id, String((req.body && req.body.username) || ''), { actorId: req.user.id });-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_rename', JSON.stringify({ targetId: target.id, ...out }));++ const out = await require('../identity/usernames').rename(db, target.id, String((req.body && req.body.username) || ''), { actorId: req.user.id });++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_rename', JSON.stringify({ targetId: target.id, ...out }));res.json({ ok: true, ...out });} catch (err) {res.status(err.status || 500).json({ ok: false, error: err.message });@@@ -777,14 +777,14 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {});// End someone's sessions on every device and site (a stolen token, a shared computer).-- router.post('/users/:id/sign-out', (req, res) => {++ router.post('/users/:id/sign-out', async (req, res) => {try {-- const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);++ const target = await db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);if (!target) return res.status(404).json({ ok: false, error: 'No such user' });if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner ends the owner's sessions" });-- const { validAfter } = revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });++ const { validAfter } = await revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });revocation.kick(db);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));res.json({ ok: true, valid_after: validAfter });} catch (err) {res.status(500).json({ ok: false, error: err.message });@@@ -795,13 +795,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Broadcast Notifications// ═══════════════════════════════════════════════════════-- router.post('/broadcast', (req, res) => {++ router.post('/broadcast', async (req, res) => {try {const { title, message, icon, url, priority, category } = req.body;if (!title) return res.status(400).json({ ok: false, error: 'Title required' });// Abusable at volume — admins get one global notification per 6h; owners are exempt.-- const limit = checkAdminLimit(db, req.user, 'broadcast');++ const limit = await checkAdminLimit(db, req.user, 'broadcast');if (!limit.ok) {return res.status(429).json({ok: false,@@@ -811,8 +811,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}// Get all non-banned user IDs-- const userIds = db.prepare('SELECT id FROM users WHERE is_banned = 0').all().map(u => u.id);-- const results = notificationService.createBulk(userIds, {++ const userIds = (await db.prepare('SELECT id FROM users WHERE is_banned = 0').all()).map(u => u.id);++ const results = await notificationService.createBulk(userIds, {type: 'ADMIN_BROADCAST',title,message,@@@ -825,8 +825,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {service: 'network',});-- recordAdminAction(db, req.user, 'broadcast');-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await recordAdminAction(db, req.user, 'broadcast');++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'broadcast_notification', JSON.stringify({ title, recipients: results.length }));@@@ -840,13 +840,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// System Health// ═══════════════════════════════════════════════════════-- router.get('/health', (req, res) => {++ router.get('/health', async (req, res) => {try {-- const userCount = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;-- const notifCount = db.prepare('SELECT COUNT(*) as cnt FROM notifications').get().cnt;-- const unreadCount = db.prepare('SELECT COUNT(*) as cnt FROM notifications WHERE is_read = 0').get().cnt;-- const anonCount = db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get().cnt;-- const sessionCount = db.prepare('SELECT COUNT(*) as cnt FROM user_sessions WHERE is_active = 1').get().cnt;++ const userCount = (await db.prepare('SELECT COUNT(*) as cnt FROM users').get()).cnt;++ const notifCount = (await db.prepare('SELECT COUNT(*) as cnt FROM notifications').get()).cnt;++ const unreadCount = (await db.prepare('SELECT COUNT(*) as cnt FROM notifications WHERE is_read = 0').get()).cnt;++ const anonCount = (await db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get()).cnt;++ const sessionCount = (await db.prepare('SELECT COUNT(*) as cnt FROM user_sessions WHERE is_active = 1').get()).cnt;const emailStatus = emailService.getStatus();res.json({@@@ -871,11 +871,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Audit Log// ═══════════════════════════════════════════════════════-- router.get('/audit', (req, res) => {++ router.get('/audit', async (req, res) => {try {const limit = Math.min(parseInt(req.query.limit) || 50, 500);const offset = parseInt(req.query.offset) || 0;-- const rows = db.prepare(`++ const rows = await db.prepare(`SELECT a.*, u.username FROM audit_log aLEFT JOIN users u ON u.id = a.user_idORDER BY a.created_at DESC LIMIT ? OFFSET ?@@@ -898,16 +898,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {// Verification Keys (reserved username claims)// ═══════════════════════════════════════════════════════-- router.get('/verification-keys', (req, res) => {++ router.get('/verification-keys', async (req, res) => {try {-- const keys = db.getAllVerificationKeys();++ const keys = await db.getAllVerificationKeys();res.json({ ok: true, keys });} catch (err) {res.status(500).json({ ok: false, error: err.message });}});-- router.post('/verification-keys', (req, res) => {++ router.post('/verification-keys', async (req, res) => {try {const { target_username, note } = req.body;if (!target_username) {@@@ -918,13 +918,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}// Check if username already taken-- const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);++ const existingUser = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);if (existingUser) {return res.status(409).json({ ok: false, error: `Username "${target_username}" is already registered` });}// Check for duplicate active key-- const existingKey = db.getVerificationKeyByUsername(target_username);++ const existingKey = await db.getVerificationKeyByUsername(target_username);if (existingKey) {return res.status(409).json({ ok: false, error: `Active key already exists for "${target_username}"` });}@@@ -935,16 +935,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {crypto.randomBytes(2).toString('hex').toUpperCase()).join('-');-- db.createVerificationKey({++ await db.createVerificationKey({key,target_username,note: note || '',created_by: req.user.id,});-- const created = db.getVerificationKeyByKey(key);++ const created = await db.getVerificationKeyByKey(key);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'verification_key_create', JSON.stringify({ key, target_username }));@@@ -955,14 +955,14 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.delete('/verification-keys/:id', (req, res) => {++ router.delete('/verification-keys/:id', async (req, res) => {try {-- const result = db.revokeVerificationKey(req.params.id);++ const result = await db.revokeVerificationKey(req.params.id);if (result.changes === 0) {return res.status(404).json({ ok: false, error: 'Key not found or already used/revoked' });}-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'verification_key_revoke', JSON.stringify({ keyId: req.params.id }));@@@ -981,11 +981,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {'net.globalping_token', // Globalping API token (optional, 100 credits/hr free)];-- router.get('/net-config', requireOwner, (req, res) => {++ router.get('/net-config', requireOwner, async (req, res) => {try {const config = {};for (const key of NET_SETTING_KEYS) {-- const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key);++ const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key);config[key] = row ? row.value : '';}// Mask tokens for display@@@ -1000,7 +1000,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}});-- router.put('/net-config', requireOwner, (req, res) => {++ router.put('/net-config', requireOwner, async (req, res) => {try {const { key, value } = req.body;if (!key || !NET_SETTING_KEYS.includes(key)) {@@@ -1008,8 +1008,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {}// Skip if maskedif (value?.startsWith('••••')) return res.json({ ok: true, skipped: true });-- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), 'secret');-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(key, String(value), 'secret');++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'net_config_update', JSON.stringify({ key }));res.json({ ok: true });@@@ -28,14 +28,14 @@ const byDb = new WeakMap();const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);/** The journal for one database handle. */--function forDb(db) {++async function forDb(db) {if (byDb.has(db)) return byDb.get(db);const types = new Map(); // key → the row type last seen (a rollback that re-creates a row reuses it)const secretRows = new Set(); // keys stored with type 'secret'-- function rowsNow() {++ async function rowsNow() {const out = {};-- for (const r of db.prepare('SELECT key, value, type FROM site_settings').all()) {++ for (const r of await db.prepare('SELECT key, value, type FROM site_settings').all()) {if (!isConfigKey(r.key)) continue;out[r.key] = r.value == null ? '' : String(r.value);types.set(r.key, r.type || 'string');@@@ -46,34 +46,36 @@ function forDb(db) {const classify = (key) => (secretRows.has(key) || SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');let pendingTypes = {}; // the types the change being applied names for new rows-- function writeRows(target) {-- const now = rowsNow();++ async function writeRows(target) {++ const now = await rowsNow();const upd = db.prepare('UPDATE site_settings SET value = ? WHERE key = ?');const ins = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?)');const del = db.prepare('DELETE FROM site_settings WHERE key = ?');-- db.transaction(() => {++ await db.tx(async () => {for (const [k, v] of Object.entries(target)) {if (!isConfigKey(k)) continue;-- if (!(k in now)) ins.run(k, String(v), pendingTypes[k] || types.get(k) || 'string');-- else if (now[k] !== String(v)) upd.run(String(v), k);++ if (!(k in now)) await ins.run(k, String(v), pendingTypes[k] || types.get(k) || 'string');++ else if (now[k] !== String(v)) await upd.run(String(v), k);}-- for (const k of Object.keys(now)) if (!(k in target)) del.run(k);-- })();++ for (const k of Object.keys(now)) if (!(k in target)) await del.run(k);++ });}-- rowsNow(); // learn the types and secret rows before the store classifies anything-- const store = config.createConfigStore({++ const initial = await rowsNow(); // learn the types and secret rows before the store classifies anything++ const store = await config.createConfigStore({db, service: 'network', namespace: 'network.site_settings',classify,-- legacy: () => rowsNow(),-- onActivate: async (values) => writeRows(values),++ // The legacy source is snapshotted now (an object, not a callback): the revision it seeds is the rows++ // as they are at boot.++ legacy: initial,++ onActivate: async (values) => await writeRows(values),log: { info: (m) => console.log(`[Config] ${m}`), warn: (m) => console.warn(`[Config] ${m}`), error: (m) => console.error(`[Config] ${m}`) },});const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));/** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */async function sync() {-- const now = rowsNow();++ const now = await rowsNow();if (canonical({ ...store.get() }) === canonical(now)) return null;return store.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });}@@@ -18,10 +18,10 @@ const { isOwner } = require('../auth/owner-guard');// Which staff capability moderates a service; anything else is general content moderation.const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };--function staffList(db, { service = null } = {}) {++async function staffList(db, { service = null } = {}) {const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;-- return db.prepare("SELECT id, username, display_name, role, subject_id FROM users WHERE role IN ('global_mod', 'admin') AND COALESCE(is_banned, 0) = 0 ORDER BY role DESC, username")-- .all()++ return (await db.prepare("SELECT id, username, display_name, role, subject_id FROM users WHERE role IN ('global_mod', 'admin') AND COALESCE(is_banned, 0) = 0 ORDER BY role DESC, username")++ .all()).map((u) => {const owner = u.role === 'admin' && isOwner(u);return { subject: u.subject_id || null, username: u.username, display_name: u.display_name || u.username, role: owner ? 'owner' : u.role, is_owner: owner, capabilities: staff.capabilitiesOf({ role: u.role, is_owner: owner }) };@@@ -37,20 +37,20 @@ function createStaffApi({ db, requireAuth, guard }) {res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });});const serviceGuard = guard('network.staff.read');-- const list = (req, res) => {++ const list = async (req, res) => {const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;res.set('Cache-Control', 'private, no-store');-- res.json({ service, staff: staffList(db, { service }) });++ res.json({ service, staff: await staffList(db, { service }) });};r.get('/moderators', (req, res, next) => {// A service token (sub svc:*) goes through the capability guard; a person must be staff.const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');let sub = '';try { sub = JSON.parse(Buffer.from(bearer.split('.')[1] || '', 'base64url').toString('utf8')).sub || ''; } catch { /* not a JWT */ }-- if (String(sub).startsWith('svc:')) return serviceGuard(req, res, () => list(req, res));-- return requireAuth(req, res, () => {++ if (String(sub).startsWith('svc:')) return serviceGuard(req, res, async () => await list(req, res));++ return requireAuth(req, res, async () => {if (!staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });-- return list(req, res);++ return await list(req, res);});});return r;@@@ -23,29 +23,12 @@ const KEEP_DAYS = 400;const COLUMNS = ['stream_id', 'creator_subject', 'title', 'category', 'started_at', 'ended_at', 'duration_seconds','peak_viewers', 'avg_viewers', 'unique_chatters', 'messages', 'watch_minutes', 'received_at'];--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS creator_streams (-- stream_id INTEGER PRIMARY KEY,-- creator_subject TEXT NOT NULL,-- title TEXT,-- category TEXT,-- started_at TEXT NOT NULL,-- ended_at TEXT NOT NULL,-- duration_seconds INTEGER NOT NULL DEFAULT 0,-- peak_viewers INTEGER,-- avg_viewers REAL,-- unique_chatters INTEGER,-- messages INTEGER,-- watch_minutes INTEGER,-- received_at TEXT NOT NULL-- );-- CREATE INDEX IF NOT EXISTS idx_creator_streams_creator ON creator_streams(creator_subject, started_at);`);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }const int = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null);/** One live.stream.ended envelope → 'analytics:recorded' | 'ignored:<why>'. Inside the consumer's inbox claim. */--function record(db, event, { now = new Date().toISOString() } = {}) {++async function record(db, event, { now = new Date().toISOString() } = {}) {ensureSchema(db);if (event.source !== 'live') return 'ignored:source';const p = event.payload && typeof event.payload === 'object' ? event.payload : {};@@@ -55,7 +38,7 @@ function record(db, event, { now = new Date().toISOString() } = {}) {const started = Date.parse(p.started_at); const ended = Date.parse(p.ended_at);if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return 'ignored:times';const s = p.stats && typeof p.stats === 'object' ? p.stats : {};-- db.prepare(`INSERT INTO creator_streams (stream_id, creator_subject, title, category, started_at, ended_at, duration_seconds, peak_viewers, avg_viewers, unique_chatters, messages, watch_minutes, received_at)++ await db.prepare(`INSERT INTO creator_streams (stream_id, creator_subject, title, category, started_at, ended_at, duration_seconds, peak_viewers, avg_viewers, unique_chatters, messages, watch_minutes, received_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)ON CONFLICT(stream_id) DO UPDATE SET creator_subject = excluded.creator_subject, title = excluded.title, category = excluded.category,started_at = excluded.started_at, ended_at = excluded.ended_at, duration_seconds = excluded.duration_seconds,@@@ -65,16 +48,16 @@ function record(db, event, { now = new Date().toISOString() } = {}) {new Date(started).toISOString(), new Date(ended).toISOString(), int(p.duration_seconds) ?? Math.round((ended - started) / 1000),int(s.peak_viewers), Number.isFinite(Number(s.avg_viewers)) && Number(s.avg_viewers) >= 0 ? Math.round(Number(s.avg_viewers) * 10) / 10 : null,int(s.unique_chatters), int(s.messages), int(s.watch_minutes), now);-- db.prepare('DELETE FROM creator_streams WHERE ended_at < ?').run(new Date(Date.parse(now) - KEEP_DAYS * 86400000).toISOString());++ await db.prepare('DELETE FROM creator_streams WHERE ended_at < ?').run(new Date(Date.parse(now) - KEEP_DAYS * 86400000).toISOString());return 'analytics:recorded';}/** network.creator-analytics-result@1 for a creator over `days`; `full` adds the audience figures. */--function query(db, creator, { days = 30, full = false, now = Date.now() } = {}) {++async function query(db, creator, { days = 30, full = false, now = Date.now() } = {}) {ensureSchema(db);const d = Math.min(Math.max(parseInt(days, 10) || 30, 1), 365);const since = new Date(now - d * 86400000).toISOString();-- const rows = db.prepare('SELECT * FROM creator_streams WHERE creator_subject = ? AND started_at >= ? ORDER BY started_at DESC').all(creator, since);++ const rows = await db.prepare('SELECT * FROM creator_streams WHERE creator_subject = ? AND started_at >= ? ORDER BY started_at DESC').all(creator, since);const pick = (r) => {const o = { stream_id: r.stream_id, title: r.title, category: r.category, started_at: r.started_at, ended_at: r.ended_at, duration_seconds: r.duration_seconds };if (r.peak_viewers != null) o.peak_viewers = r.peak_viewers;@@@ -104,12 +87,12 @@ function query(db, creator, { days = 30, full = false, now = Date.now() } = {})return { creator, days: d, full: !!full, totals, daily, streams: rows.slice(0, 100).map(pick) };}--function viewerOf(req) {++async function viewerOf(req) {const h = req.headers.authorization || '';const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);if (!token) return null;try {-- const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });++ const out = await require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });return out && out.user ? out.user : null;} catch { return null; }}@@@ -118,18 +101,18 @@ function viewerOf(req) {function router({ fullGuard }) {const r = express.Router();r.use(http.middleware());-- r.get('/:creator/analytics', (req, res) => {++ r.get('/:creator/analytics', async (req, res) => {const db = req.app.locals.db;const key = String(req.params.creator || '');-- const u = SUBJECT_RE.test(key) ? db.prepare('SELECT subject_id, is_anon FROM users WHERE subject_id = ?').get(key)-- : NAME_RE.test(key) ? db.prepare('SELECT subject_id, is_anon FROM users WHERE username = ? COLLATE NOCASE').get(key) : null;++ const u = SUBJECT_RE.test(key) ? await db.prepare('SELECT subject_id, is_anon FROM users WHERE subject_id = ?').get(key)++ : NAME_RE.test(key) ? await db.prepare('SELECT subject_id, is_anon FROM users WHERE lower(username) = lower(?)').get(key) : null;if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) return http.sendProblem(res, 404, 'analytics.unknown_creator', { detail: 'no such creator', ctx: req.ov });-- const answer = (full) => res.set('Cache-Control', full ? 'private, no-store' : 'public, max-age=60').json(query(db, u.subject_id, { days: req.query.days, full }));-- const viewer = viewerOf(req);-- if (viewer) return answer(viewer.subject_id === u.subject_id);++ const answer = async (full) => res.set('Cache-Control', full ? 'private, no-store' : 'public, max-age=60').json(await query(db, u.subject_id, { days: req.query.days, full }));++ const viewer = await viewerOf(req);++ if (viewer) return await answer(viewer.subject_id === u.subject_id);const h = String(req.headers.authorization || '');-- if (h.startsWith('Bearer ')) return fullGuard(req, res, () => answer(true)); // a service token: full, or refused-- return answer(false);++ if (h.startsWith('Bearer ')) return fullGuard(req, res, async () => await answer(true)); // a service token: full, or refused++ return await answer(false);});return r;}@@@ -3,23 +3,19 @@* OpenVibe.Network's wiring of the ADR-021 analytics module, openvibe-shared/analytics (the one module* Live, Tools and Network use). Only what is Network-specific lives here:*-- * openAnalytics(dbPath) the tracker for service 'openvibe-network'. Network's analytics tables-- * (analytics_events / _hourly / _daily …) live in network.db, as they always-- * have; the tracker gets a better-sqlite3 connection of its own so its-- * per-connection settings (busy_timeout 250, secure_delete) never apply to-- * the identity connection (busy_timeout 5000).-- * schedulePrune(tracker) the nightly `analytics-prune` job (retention.schedulePrune): raw events-- * older than 30 days are deleted in bounded batches; rollups are kept.-- * First run 5 minutes after boot, then every 24 hours; timers are unref'd.-- * PATH_OPTS { paramPrefixes, pathRules }: the path options the tracker and-- * scripts/analytics-prune.js (prune-cli, for the one-time scrub) both use.++ * openAnalytics(db) the tracker for service 'openvibe-network' on the service's own PostgreSQL++ * handle (analyticsSchema() is migrations/0002_analytics.sql). The request++ * path never waits for the database: the tracker buffers and flushes every++ * 5 s (and at 100 rows); reads and rollups are async.++ * schedulePrune(tracker) ADR-021's raw-event retention (pruneRawEventsPg): raw events older than 30++ * days go, in batches; rollups stay. The tracker schedules this itself at boot,++ * so this is the on-demand form for operator scripts.++ * PATH_OPTS { paramPrefixes, pathRules }: the path options the tracker uses.** A request with `Sec-GPC: 1` or `DNT: 1` is not recorded (openvibe-shared v1.4.0).*/--const path = require('path');--const Database = require('better-sqlite3');--const { AnalyticsTracker, retention } = require('openvibe-shared/analytics');++const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');const SERVICE = 'openvibe-network';@@@ -39,14 +35,13 @@ const PATH_RULES = [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']];const PATH_OPTS = { paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES };--function openAnalytics(dbPath, opts = {}) {-- const db = new Database(path.resolve(dbPath));-- db.pragma('journal_mode = WAL');-- return new AnalyticsTracker(db, SERVICE, { retention: false, paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES, ...opts });++function openAnalytics(db, opts = {}) {++ return new AnalyticsTrackerPg(db, SERVICE, { paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES, ...opts });}++/** ADR-021 raw-event retention on demand (the tracker also schedules it at boot, after 5 minutes). */function schedulePrune(tracker, opts = {}) {-- return retention.schedulePrune(tracker.db, { label: 'Analytics', ...opts });++ return pruneRawEventsPg(tracker.db, opts);}--module.exports = { SERVICE, PARAM_PREFIXES, PATH_RULES, PATH_OPTS, openAnalytics, schedulePrune };++module.exports = { SERVICE, PARAM_PREFIXES, PATH_RULES, PATH_OPTS, analyticsSchema, openAnalytics, schedulePrune };@@@ -9,7 +9,9 @@* Every write takes NETWORK_LIMITS_MINUTE / NETWORK_LIMITS_HOUR (120 and 3000) as `network.api.write`; the table* below gives sensitive writes their own, tighter numbers on top. Past a limit the request answers 429 problem+json* `rate_limited` with Retry-After before any route runs; the refusal is logged by subject (never a token) and counted-- * in network_rate_limited_total{limit,window}. Counters live in this process.++ * in network_rate_limited_total{limit,window}. Counters live in this process unless a shared Valkey store is passed++ * (server/index.js builds it from VALKEY_URL; ADR-035) — and if Valkey is unreachable the SDK degrades to the++ * in-process counters, so a store outage slows nothing to a 500.** Never counted: /api/auth/* (sign-in has its own limiter), /api/webhooks (providers), /api/v1/realtime tickets.* /internal, /oauth, /api/health, /api/ready, /metrics and the JWKS are outside what this sees or are reads.@@@ -39,7 +41,7 @@ const ROUTES = [['network.block', /^(PUT|POST|DELETE)$/, /^\/v1\/me\/blocks(\/|$)/, { minute: 30, hour: 300 }],];--function createNetworkActorLimits({ env = process.env, publicKey, issuer, registry = null, now } = {}) {++function createNetworkActorLimits({ env = process.env, publicKey, issuer, registry = null, now, store = null } = {}) {let refused = null;if (registry && typeof registry.counter === 'function') {refused = registry.counter({ name: 'network_rate_limited_total', help: 'API writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });@@@ -60,6 +62,7 @@ function createNetworkActorLimits({ env = process.env, publicKey, issuer, registlimits: { minute: num(env.NETWORK_LIMITS_MINUTE, 120), hour: num(env.NETWORK_LIMITS_HOUR, 3000) },actor,...(now ? { now } : {}),++ ...(store ? { store } : {}),onLimited(e) {console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);if (refused) refused.inc({ limit: e.name, window: e.window });@@@ -19,16 +19,7 @@ const ADMIN_LIMITS = {};let _ensured = false;--function ensureTable(db) {-- if (_ensured) return;-- db.exec(`CREATE TABLE IF NOT EXISTS admin_rate_limits (-- user_id INTEGER NOT NULL,-- action TEXT NOT NULL,-- last_at INTEGER NOT NULL,-- PRIMARY KEY (user_id, action)-- )`);-- _ensured = true;--}++function ensureTable(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }function humanizeMs(ms) {const mins = Math.ceil(ms / 60000);@@@ -40,12 +31,12 @@ function humanizeMs(ms) {// Check (without consuming) whether `user` may perform `action` now.// Returns { ok:true } or { ok:false, retryMs, retryHuman }. Owners always ok.--function checkAdminLimit(db, user, action) {++async function checkAdminLimit(db, user, action) {if (isOwner(user)) return { ok: true };const windowMs = ADMIN_LIMITS[action];if (!windowMs) return { ok: true };ensureTable(db);-- const row = db.prepare('SELECT last_at FROM admin_rate_limits WHERE user_id = ? AND action = ?').get(user.id, action);++ const row = await db.prepare('SELECT last_at FROM admin_rate_limits WHERE user_id = ? AND action = ?').get(user.id, action);if (row && row.last_at) {const elapsed = Date.now() - row.last_at;if (elapsed < windowMs) {@@@ -57,11 +48,11 @@ function checkAdminLimit(db, user, action) {}// Start the cooldown after a successful action. No-op for owners / unlimited actions.--function recordAdminAction(db, user, action) {++async function recordAdminAction(db, user, action) {if (isOwner(user)) return;if (!ADMIN_LIMITS[action]) return;ensureTable(db);-- db.prepare(`INSERT INTO admin_rate_limits (user_id, action, last_at) VALUES (?, ?, ?)++ await db.prepare(`INSERT INTO admin_rate_limits (user_id, action, last_at) VALUES (?, ?, ?)ON CONFLICT(user_id, action) DO UPDATE SET last_at = excluded.last_at`).run(user.id, action, Date.now());}@@@ -28,9 +28,9 @@ setInterval(() => {* GET /api/auth/discord/link — Start Discord OAuth2 flow* Requires authenticated user. Redirects to Discord authorization.*/--router.get('/link', (req, res) => {++router.get('/link', async (req, res) => {const db = req.app.locals.db;-- const clientId = db.getSetting('discord_oauth_client_id');++ const clientId = await db.getSetting('discord_oauth_client_id');if (!clientId) {return res.status(503).json({ error: 'Discord linking is not configured' });}@@@ -77,8 +77,8 @@ router.get('/callback', async (req, res) => {}const db = req.app.locals.db;-- const clientId = db.getSetting('discord_oauth_client_id');-- const clientSecret = db.getSetting('discord_oauth_client_secret');++ const clientId = await db.getSetting('discord_oauth_client_id');++ const clientSecret = await db.getSetting('discord_oauth_client_secret');if (!clientId || !clientSecret) {return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=not_configured`);}@@@ -115,7 +115,7 @@ router.get('/callback', async (req, res) => {const discordUser = await userRes.json();// Check if this Discord account is already linked to a different user-- const existing = db.prepare(++ const existing = await db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'discord' AND service_user_id = ?").get(discordUser.id);@@@ -124,13 +124,13 @@ router.get('/callback', async (req, res) => {}// Link the account-- db.prepare(`++ await db.prepare(`INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)-- VALUES (?, 'discord', ?, ?, CURRENT_TIMESTAMP)++ VALUES (?, 'discord', ?, ?, ov_now())ON CONFLICT(user_id, service) DO UPDATE SETservice_user_id = ?,service_username = ?,-- linked_at = CURRENT_TIMESTAMP++ linked_at = ov_now()`).run(stateData.userId,discordUser.id,@@@ -140,7 +140,7 @@ router.get('/callback', async (req, res) => {);// Audit log-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(stateData.userId, 'discord_linked', `Discord: ${discordUser.username} (${discordUser.id})`);@@@ -155,16 +155,16 @@ router.get('/callback', async (req, res) => {* DELETE /api/auth/discord/link — Unlink Discord account* Requires authenticated user.*/--router.delete('/link', (req, res) => {++router.delete('/link', async (req, res) => {if (!req.user) return res.status(401).json({ error: 'Authentication required' });const db = req.app.locals.db;-- const result = db.prepare(++ const result = await db.prepare("DELETE FROM linked_accounts WHERE user_id = ? AND service = 'discord'").run(req.user.id);if (result.changes > 0) {-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'discord_unlinked', 'Discord account unlinked');}@@@ -175,15 +175,15 @@ router.delete('/link', (req, res) => {/*** GET /api/auth/discord/status — Check if Discord is linked*/--router.get('/status', (req, res) => {++router.get('/status', async (req, res) => {if (!req.user) return res.status(401).json({ error: 'Authentication required' });const db = req.app.locals.db;-- const linked = db.prepare(++ const linked = await db.prepare("SELECT service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ? AND service = 'discord'").get(req.user.id);-- const configured = !!(db.getSetting('discord_oauth_client_id') && db.getSetting('discord_oauth_client_secret'));++ const configured = !!(await db.getSetting('discord_oauth_client_id') && await db.getSetting('discord_oauth_client_secret'));res.json({ ok: true, configured, linked: linked || null });});@@@ -29,19 +29,19 @@ async function sendVerification(req, user, { force = false } = {}) {if (!emailService?.isEnabled) return { ok: false, reason: 'email-disabled' };// Per-user: 1 per 3 minutes, N per day (site_settings email_verify_user_daily_cap, default 6).-- const recent = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(user.id)?.c || 0;++ const recent = (await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(user.id))?.c || 0;if (recent > 0) return { ok: false, reason: 'rate-limited' };-- const dayCap = parseInt(db.getSetting('email_verify_user_daily_cap'), 10) || 6;-- const today = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(user.id)?.c || 0;++ const dayCap = parseInt(await db.getSetting('email_verify_user_daily_cap'), 10) || 6;++ const today = (await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(user.id))?.c || 0;if (today >= dayCap) return { ok: false, reason: 'cap' };// Same address hammered from many accounts (abuse) — 5/day per address.-- const perAddr = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(user.email)?.c || 0;++ const perAddr = (await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(user.email))?.c || 0;if (perAddr >= 5) return { ok: false, reason: 'cap' };// A bouncing address gets no more mail until the user changes it.if (user.email_bounced_at) return { ok: false, reason: 'bounced' };const raw = crypto.randomBytes(32).toString('base64url');-- db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+${TOKEN_TTL_MIN} minutes'))`)++ await db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+${TOKEN_TTL_MIN} minutes'))`).run(hashToken(raw), user.id, user.email);const verifyUrl = `${baseUrl(req)}/verify-email?token=${encodeURIComponent(raw)}`;const sent = await emailService.sendVerificationEmail({ to: user.email, username: user.display_name || user.username, verifyUrl, expiresMinutes: TOKEN_TTL_MIN });@@@ -49,30 +49,34 @@ async function sendVerification(req, user, { force = false } = {}) {}/** Consume a token. Returns { ok, error? , user? }. */--function consumeToken(db, raw) {++async function consumeToken(db, raw) {if (!raw) return { ok: false, error: 'Missing token' };-- const row = db.prepare("SELECT * FROM email_verification_tokens WHERE token_hash = ?").get(hashToken(raw));++ const row = await db.prepare("SELECT * FROM email_verification_tokens WHERE token_hash = ?").get(hashToken(raw));if (!row) return { ok: false, error: 'This verification link is invalid.' };if (row.used_at) return { ok: false, error: 'This verification link was already used.' };if (new Date(String(row.expires_at).replace(' ', 'T') + 'Z').getTime() < Date.now()) return { ok: false, error: 'This verification link has expired — request a new one from your account page.' };-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);if (!user) return { ok: false, error: 'Account not found.' };if (!user.email || user.email.toLowerCase() !== String(row.email).toLowerCase()) {return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };}-- const tx = db.transaction(() => {-- db.prepare("UPDATE email_verification_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?").run(row.token_hash);-- db.prepare("UPDATE users SET email_verified = 1, email_verified_at = CURRENT_TIMESTAMP, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);++ // Claim the token and verify the account in one transaction: the conditional UPDATE means of two++ // concurrent uses of one link exactly one succeeds (decision 1, plan T2).++ const claimed = await db.tx(async (t) => {++ const n = (await t.prepare("UPDATE email_verification_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL").run(row.token_hash)).changes;++ if (!n) return false;++ await t.prepare("UPDATE users SET email_verified = 1, email_verified_at = ov_now(), email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);++ return true;});-- tx();++ if (!claimed) return { ok: false, error: 'This verification link was already used.' };return { ok: true, user };}/** Called when a user sets/changes their address: unverify + (best-effort) send a new link. */--function onEmailChanged(req, userId) {++async function onEmailChanged(req, userId) {const db = req.app.locals.db;-- db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);++ await db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);if (user?.email) sendVerification(req, user).catch(() => {});}@@@ -80,10 +84,10 @@ function routes(requireAuth) {const router = express.Router();// Status for the account page: { email, verified, bounced, can_resend }-- router.get('/email/status', requireAuth, (req, res) => {++ router.get('/email/status', requireAuth, async (req, res) => {const db = req.app.locals.db;-- const u = db.prepare('SELECT email, email_verified, email_verified_at, email_bounced_at, email_bounce_reason FROM users WHERE id = ?').get(req.user.id) || {};-- const recent = db.prepare("SELECT MAX(created_at) AS t FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(req.user.id)?.t || null;++ const u = await db.prepare('SELECT email, email_verified, email_verified_at, email_bounced_at, email_bounce_reason FROM users WHERE id = ?').get(req.user.id) || {};++ const recent = (await db.prepare("SELECT MAX(created_at) AS t FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(req.user.id))?.t || null;res.json({ok: true,email: u.email || null,@@@ -99,7 +103,7 @@ function routes(requireAuth) {router.post('/email/send-verification', requireAuth, async (req, res) => {try {const db = req.app.locals.db;-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);const r = await sendVerification(req, user);if (r.ok) return res.json({ ok: true, message: `Verification email sent to ${user.email}. Check your inbox (and spam).` });const msg = {@@@ -119,9 +123,9 @@ function routes(requireAuth) {});// JSON verify (used by the /verify-email page)-- router.post('/email/verify', (req, res) => {++ router.post('/email/verify', async (req, res) => {const db = req.app.locals.db;-- const r = consumeToken(db, String(req.body?.token || ''));++ const r = await consumeToken(db, String(req.body?.token || ''));if (!r.ok) return res.status(400).json({ ok: false, error: r.error });try {req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.user.email} is confirmed. Go-live alerts from streamers you follow will now reach your inbox.`, service: 'network', url: 'https://openvibe.network/notifications' });@@@ -88,9 +88,9 @@ function createFedcmRoutes(getCtx) {// Browser-initiated fetches from other origins: helmet's same-origin CORP must not apply.router.use((req, res, next) => { res.set('Cross-Origin-Resource-Policy', 'cross-origin'); next(); });const base = () => (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');-- const sessionUser = (req) => {++ const sessionUser = async (req) => {const { verifySession, requestToken } = require('./session');-- const out = verifySession(requestToken(req), getCtx(req));++ const out = await verifySession(requestToken(req), getCtx(req));return out.error || !out.user || out.user.is_anon || out.user.is_banned ? null : out.user;};@@@ -108,10 +108,10 @@ function createFedcmRoutes(getCtx) {});});-- router.get('/accounts', (req, res) => {++ router.get('/accounts', async (req, res) => {res.set('Cache-Control', 'no-store');if (!isWebIdentity(req)) return res.status(400).json({ error: 'not a FedCM request' });-- const user = sessionUser(req);++ const user = await sessionUser(req);if (!user) { res.set('Set-Login', 'logged-out'); return res.status(401).json({ accounts: [] }); }res.set('Set-Login', 'logged-in');res.json({ accounts: [accountOf(user, base())] });@@@ -122,7 +122,7 @@ function createFedcmRoutes(getCtx) {res.json({ privacy_policy_url: 'https://openvibe.live/privacy', terms_of_service_url: 'https://openvibe.live/tos' });});-- router.post('/assertion', express.urlencoded({ extended: false }), (req, res) => {++ router.post('/assertion', express.urlencoded({ extended: false }), async (req, res) => {res.set('Cache-Control', 'no-store');const origin = rpOrigin(req.headers.origin);if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: { code: 'invalid_request' } });@@@ -131,7 +131,7 @@ function createFedcmRoutes(getCtx) {res.set('Vary', 'Origin');const clientId = String(req.body?.client_id || '').replace(/\/$/, '');if (clientId !== origin) return res.status(403).json({ error: { code: 'unauthorized_client' } });-- const user = sessionUser(req);++ const user = await sessionUser(req);if (!user) return res.status(401).json({ error: { code: 'access_denied' } });if (String(req.body?.account_id || '') !== String(user.id)) return res.status(403).json({ error: { code: 'access_denied' } });let nonce = req.body?.nonce || null;@@@ -139,12 +139,12 @@ function createFedcmRoutes(getCtx) {res.json({ token: signAssertion(user, origin, nonce, getCtx(req)) });});-- router.post('/disconnect', express.urlencoded({ extended: false }), (req, res) => {++ router.post('/disconnect', express.urlencoded({ extended: false }), async (req, res) => {const origin = rpOrigin(req.headers.origin);if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: 'invalid_request' });res.set('Access-Control-Allow-Origin', origin);res.set('Access-Control-Allow-Credentials', 'true');-- const user = sessionUser(req);++ const user = await sessionUser(req);// Nothing is stored per RP today (approval lives in the browser); answer with the account id.res.json({ account_id: user ? String(user.id) : String(req.body?.account_hint || '') });});@@@ -39,10 +39,10 @@ function sameSecret(a, b) {const y = Buffer.from(String(b || ''));return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);}--function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {++async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {const code = crypto.randomBytes(32).toString('hex');const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();-- db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)++ await db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method, nonce || null);return code;}@@@ -53,8 +53,8 @@ function getConfig(req) { return req.app.locals.config; }// OAuth2 authorization endpoint. Always shows the account chooser// so the user can pick which account to continue with, add a new// one, or create an account.--router.get('/authorize', (req, res) => {-- const db = getDb(req);++router.get('/authorize', async (req, res) => {++ const db = await getDb(req);const { client_id, redirect_uri, response_type, scope, state } = req.query;if (response_type !== 'code') {@@@ -67,7 +67,7 @@ router.get('/authorize', (req, res) => {// Developer apps (app_<ULID>, server/developer): exact redirect URI, PKCE S256 always, no silent// prompt=none (a third-party app never gets a code without the person choosing to continue).if (devTokens.isAppClient(client_id)) {-- const found = devTokens.checkAuthorizeRequest(db, req.query);++ const found = await devTokens.checkAuthorizeRequest(db, req.query);if (found.error) return res.status(400).json({ error: found.pkce ? 'invalid_request' : found.error, error_description: found.error });if (String(req.query.prompt || '') === 'none') {return res.redirect(`${redirect_uri}${redirect_uri.includes('?') ? '&' : '?'}error=interaction_required&state=${encodeURIComponent(state || '')}`);@@@ -78,7 +78,7 @@ router.get('/authorize', (req, res) => {}// Validate client-- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);if (!client) return res.status(400).json({ error: 'Unknown client_id' });const allowedUris = JSON.parse(client.redirect_uris || '[]');@@@ -99,11 +99,11 @@ router.get('/authorize', (req, res) => {try {const { verifySession, setSessionCookies } = require('./session');const have = req.cookies?.ov_token || req.cookies?.ov_sso;-- const out = verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });++ const out = await verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });if (!out.error) {const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req), { renew: out.decoded }) : have;setSessionCookies(res, token);-- const code = issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });++ const code = await issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });return res.redirect(withCode(redirect_uri, code, state));}} catch { /* fall through */ }@@@ -127,21 +127,21 @@ router.get('/authorize', (req, res) => {// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted// link could claim to be any app. redirect_host is shown beside it: where the code will go.--router.get('/client-info', (req, res) => {-- const db = getDb(req);++router.get('/client-info', async (req, res) => {++ const db = await getDb(req);res.set('Cache-Control', 'no-store');const { client_id, redirect_uri } = req.query;if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });let host = '';try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }if (devTokens.isAppClient(client_id)) {-- const found = devTokens.checkAuthorizeRequest(db, req.query);++ const found = await devTokens.checkAuthorizeRequest(db, req.query);if (found.error && !found.pkce) return res.status(404).json({ error: found.error });const app = found.app || null;if (!app) return res.status(404).json({ error: 'Unknown client_id' });return res.json({ name: app.name, third_party: true, redirect_host: host });}-- const client = db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));++ const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));if (!client) return res.status(404).json({ error: 'Unknown client_id' });const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });@@@ -152,8 +152,8 @@ router.get('/client-info', (req, res) => {// Called from the account chooser UI. Accepts an openvibe.network JWT// token and OAuth params, validates everything, issues an// authorization code, and returns the redirect URL.--router.post('/confirm', (req, res) => {-- const db = getDb(req);++router.post('/confirm', async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);const { token, client_id, redirect_uri, scope, state } = req.body;@@@ -161,12 +161,12 @@ router.post('/confirm', (req, res) => {return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });}-- const devApp = devTokens.isAppClient(client_id) ? devTokens.checkAuthorizeRequest(db, req.body) : null;++ const devApp = devTokens.isAppClient(client_id) ? await devTokens.checkAuthorizeRequest(db, req.body) : null;if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });let pkce = null;if (!devApp) {// Validate client-- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);if (!client) return res.status(400).json({ error: 'Unknown client_id' });const allowedUris = JSON.parse(client.redirect_uris || '[]');@@@ -182,7 +182,7 @@ router.post('/confirm', (req, res) => {// Verify the token as a live, unexpired session: not revoked by a password change// (token_valid_after), not a FedCM assertion or a service/app token.-- const out = require('./session').verifySession(String(token), { db, publicKey: req.app.locals.publicKey, config });++ const out = await require('./session').verifySession(String(token), { db, publicKey: req.app.locals.publicKey, config });if (out.error) {if (out.status === 403 || out.error === 'User not found') return res.status(403).json({ error: 'User not found or banned' });return res.status(401).json({ error: 'Invalid or expired token' });@@@ -193,14 +193,14 @@ router.post('/confirm', (req, res) => {const user = out.user;if (devApp) {-- const out = devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });++ const out = await devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });if (out.error) return res.status(out.status).json({ error: out.error });require('./session').setSessionCookies(res, token);return res.json({ redirect: withCode(redirect_uri, out.code, state) });}// Issue authorization code (bound to the PKCE challenge when the client sent one)-- const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce, nonce });++ const code = await issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce, nonce });// Also set cookie to this account so openvibe.network itself knows the active session.// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.@@@ -212,15 +212,15 @@ router.post('/confirm', (req, res) => {// ── POST /token ──────────────────────────────────────────────// OAuth2 token endpoint. Exchanges authorization codes or// refresh tokens for access tokens.--router.post('/token', (req, res) => {-- const db = getDb(req);++router.post('/token', async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.if (devTokens.isAppClient(client_id)) {-- const out = devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });++ const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });res.set('Cache-Control', 'no-store');return res.status(out.status).json(out.body);}@@@ -228,7 +228,7 @@ router.post('/token', (req, res) => {// Service principals (Wave 1): a first-party service trades its client credentials for a// short-lived, capability-scoped token. Checked before the user-grant path below.if (grant_type === 'client_credentials') {-- const out = principals.issueToken(db, {++ const out = await principals.issueToken(db, {clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,});@@@ -237,17 +237,17 @@ router.post('/token', (req, res) => {}// Validate client credentials-- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);if (!client || !sameSecret(client.client_secret, client_secret)) {return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });}if (grant_type === 'authorization_code') {-- return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri, req.body.code_verifier);++ return await handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri, req.body.code_verifier);} else if (grant_type === 'refresh_token') {-- return handleRefreshGrant(db, config, req, res, client, refresh_token);++ return await handleRefreshGrant(db, config, req, res, client, refresh_token);} else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {-- return handleFedcmAssertionGrant(db, config, req, res, client, req.body.assertion);++ return await handleFedcmAssertionGrant(db, config, req, res, client, req.body.assertion);} else {return res.status(400).json({ error: 'unsupported_grant_type' });}@@@ -260,23 +260,23 @@ router.post('/token', (req, res) => {* right after signing in there. A site that maps the account to its own user id (Live) still* owns service_user_id; the exchange never overwrites it, only bumps last_used_at.*/--function recordLinkedService(db, user, client) {++async function recordLinkedService(db, user, client) {if (!client?.client_id || !user?.id || user.is_anon) return;try {-- db.prepare(`++ await db.prepare(`INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at, last_used_at)-- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)++ VALUES (?, ?, ?, ?, ov_now(), ov_now())ON CONFLICT(user_id, service) DO UPDATE SET-- service_username = COALESCE(service_username, excluded.service_username),-- last_used_at = CURRENT_TIMESTAMP++ service_username = COALESCE(linked_accounts.service_username, excluded.service_username),++ last_used_at = ov_now()`).run(user.id, client.client_id, `network:${user.id}`, user.username || null);} catch (err) { console.warn('[OAuth] linked service record failed:', err.message); }}--function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, codeVerifier) {++async function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, codeVerifier) {if (!code) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing code' });-- const authCode = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(code);++ const authCode = await db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(code);if (!authCode) return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' });if (authCode.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });if (authCode.client_id !== client.client_id) return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });@@@ -288,30 +288,30 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co// A code bound to a PKCE challenge is only redeemable with the matching verifier.if (authCode.code_challenge && !verifierMatches(codeVerifier, authCode.code_challenge)) {-- db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);++ await db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });}// Mark code as used (atomically: two concurrent exchanges of one code cannot both succeed)-- if (db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ? AND used = 0').run(code).changes !== 1) {++ if ((await db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ? AND used = 0').run(code)).changes !== 1) {return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });}// Get user-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });// Issue tokens-- const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);-- recordLinkedService(db, user, client);++ const { accessToken, refreshToken } = await issueTokenPair(db, config, req, user, client);++ await recordLinkedService(db, user, client);// Get preferences for theme sync-- const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);++ const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);const { password_hash, token_valid_after, ...safeUser } = user;// OpenID Connect (server/auth/oidc.js): scope openid adds an id_token for this client.const idToken = oidc.wantsOpenid(authCode.scope)-- ? oidc.idToken({ user: { ...user, subject_id: subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer })++ ? oidc.idToken({ user: { ...user, subject_id: await subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer }): undefined;res.json({access_token: accessToken,@@@ -331,7 +331,7 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co* code grant issues, so its session logic does not change. The assertion must be ours, unused,* unexpired, and minted for an origin this client owns.*/--function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {++async function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {if (!assertion) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing assertion' });let decoded;try {@@@ -341,20 +341,20 @@ function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {} catch (err) {return res.status(400).json({ error: 'invalid_grant', error_description: err.message });}-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });-- const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);-- recordLinkedService(db, user, client);-- const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);++ const { accessToken, refreshToken } = await issueTokenPair(db, config, req, user, client);++ await recordLinkedService(db, user, client);++ const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);const { password_hash, token_valid_after, ...safeUser } = user;res.json({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: 86400, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });}--function handleRefreshGrant(db, config, req, res, client, refreshToken) {++async function handleRefreshGrant(db, config, req, res, client, refreshToken) {if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });// Hashed lookup, rotation, and family revocation on reuse (server/auth/refresh-tokens.js).-- const r = refreshTokens.rotate(db, refreshToken, client.client_id);++ const r = await refreshTokens.rotate(db, refreshToken, client.client_id);if (!r.ok) {if (r.reuse) console.warn(`[OAuth] refresh token reuse (${client.client_id}): family revoked`);return res.status(400).json({ error: r.error, error_description: r.description });@@@ -362,7 +362,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {const stored = r.row;// Get user-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });// A password change/reset (token_valid_after) ends every refresh token issued before it.if (user.token_valid_after && stored.created_at) {@@@ -373,8 +373,8 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {}// Issue new token pair: the next generation of the same family-- const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation });-- recordLinkedService(db, user, client);++ const { accessToken, refreshToken: newRefresh } = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation });++ await recordLinkedService(db, user, client);res.json({access_token: accessToken,@@@ -385,7 +385,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {});}--function issueTokenPair(db, config, req, user, client, family = {}) {++async function issueTokenPair(db, config, req, user, client, family = {}) {const privateKey = req.app.locals.privateKey;const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';@@@ -393,7 +393,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {{sub: user.id,id: user.id,-- subject_id: subjects.ensureUserSubject(db, user) || undefined,++ subject_id: await subjects.ensureUserSubject(db, user) || undefined,username: user.username,display_name: user.display_name || user.username,role: user.role,@@@ -411,7 +411,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {);// 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it.-- const refreshTokenValue = refreshTokens.issue(db, { clientId: client.client_id, userId: user.id, familyId: family.familyId, generation: family.generation || 0 });++ const refreshTokenValue = await refreshTokens.issue(db, { clientId: client.client_id, userId: user.id, familyId: family.familyId, generation: family.generation || 0 });return { accessToken, refreshToken: refreshTokenValue };}@@@ -89,19 +89,19 @@ function idToken({ user, clientId, nonce, privateKey, issuer }) {}/** GET|POST /oauth/userinfo with `Authorization: Bearer <Network access token>`. */--function userinfo(req, res) {++async function userinfo(req, res) {res.set('Cache-Control', 'no-store');const ah = String(req.headers.authorization || '');const token = ah.startsWith('Bearer ') ? ah.slice(7).trim() : '';const fail = (status, error) => res.status(status).set('WWW-Authenticate', `Bearer error="${error}"`).json({ error });if (!token) return fail(401, 'invalid_token');-- const out = require('./session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });++ const out = await require('./session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });if (out.error) return fail(out.status === 403 ? 403 : 401, out.status === 403 ? 'insufficient_scope' : 'invalid_token');// verifySession renews a recently expired session; an expired bearer token is not accepted here.if (typeof out.decoded.exp !== 'number' || out.decoded.exp * 1000 < Date.now()) return fail(401, 'invalid_token');const user = out.user;let subjectId = user.subject_id || null;-- try { subjectId = require('../identity/subjects').ensureUserSubject(req.app.locals.db, user) || subjectId; } catch { /* the claim is optional */ }++ try { subjectId = await require('../identity/subjects').ensureUserSubject(req.app.locals.db, user) || subjectId; } catch { /* the claim is optional */ }res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });}@@@ -60,13 +60,13 @@ function mintTicket({ subject, privateKey, issuer, now = Date.now(), ttlS = TTL_function router({ db, requireAuth, privateKey, issuer, streamUrl, isEnabled = () => process.env.REALTIME_TICKETS !== 'off', now = () => Date.now() }) {const r = express.Router();const url = streamUrlFrom(streamUrl);-- r.post('/ticket', requireAuth, (req, res) => {++ r.post('/ticket', requireAuth, async (req, res) => {res.set('Cache-Control', 'no-store');if (!isEnabled()) return res.status(503).json({ code: 'realtime.disabled', error: 'browser realtime is off; poll instead' });const user = req.user;if (!user || user.is_anon) return res.status(403).json({ code: 'realtime.guest', error: 'sign in with an account to get a realtime ticket' });let subject = user.subject_id;-- try { subject = require('../identity/subjects').ensureUserSubject(db, user) || subject; } catch { /* keep what the row has */ }++ try { subject = await require('../identity/subjects').ensureUserSubject(db, user) || subject; } catch { /* keep what the row has */ }if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return res.status(503).json({ code: 'realtime.no_subject', error: 'this account has no subject id yet' });if (!String(privateKey || '').includes('BEGIN')) return res.status(503).json({ code: 'realtime.disabled', error: 'no RS256 signing key' });const { ticket, claims } = mintTicket({ subject, privateKey, issuer, now: now() });@@@ -31,46 +31,39 @@ const newFamily = () => `fam_${crypto.randomBytes(12).toString('hex')}`;const at = (v) => (v ? new Date(String(v).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).getTime() : NaN);/** Columns and index (boot, idempotent). No data is changed here. */--function ensureSchema(db) {-- const cols = new Set(db.prepare('PRAGMA table_info(oauth_tokens)').all().map(c => c.name));-- if (!cols.has('family_id')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN family_id TEXT');-- if (!cols.has('generation')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN generation INTEGER NOT NULL DEFAULT 0');-- if (!cols.has('revoked_reason')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN revoked_reason TEXT');-- if (!cols.has('revoked_at')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN revoked_at DATETIME');-- db.exec('CREATE INDEX IF NOT EXISTS idx_oauth_tokens_family ON oauth_tokens(family_id)');--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** Store a new refresh token; returns the token (the only time it exists in clear). */--function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {++async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {const token = crypto.randomBytes(48).toString('hex');-- db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)++ await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), familyId || newFamily(), generation);return token;}/** The stored row for a presented token, or null. Never matches a stored hash presented as a token. */--function find(db, presented) {++async function find(db, presented) {const raw = String(presented || '');if (!RAW_RE.test(raw)) return null;-- const row = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(hash(raw));++ const row = await db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(hash(raw));if (row) return row;// A row still holding the raw token (written before this change, not hashed yet): hash it now,// then use it.-- const legacy = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(raw);++ const legacy = await db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(raw);if (!legacy) return null;-- db.prepare('UPDATE oauth_tokens SET token = ?, family_id = COALESCE(family_id, ?) WHERE id = ? AND token = ?').run(hash(raw), `fam_legacy_${legacy.id}`, legacy.id, raw);-- return db.prepare('SELECT * FROM oauth_tokens WHERE id = ?').get(legacy.id);++ await db.prepare('UPDATE oauth_tokens SET token = ?, family_id = COALESCE(family_id, ?) WHERE id = ? AND token = ?').run(hash(raw), `fam_legacy_${legacy.id}`, legacy.id, raw);++ return await db.prepare('SELECT * FROM oauth_tokens WHERE id = ?').get(legacy.id);}/** Revoke every live token of a family; returns how many. */--function revokeFamily(db, familyId, reason = 'reuse') {++async function revokeFamily(db, familyId, reason = 'reuse') {if (!familyId) return 0;-- return db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = ?, revoked_at = CURRENT_TIMESTAMP WHERE family_id = ? AND revoked = 0").run(reason, familyId).changes;++ return (await db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = ?, revoked_at = ov_now() WHERE family_id = ? AND revoked = 0").run(reason, familyId)).changes;}--function audit(db, row, details) {++async function audit(db, row, details) {try {-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(row.user_id, 'oauth_refresh_reuse', JSON.stringify({ client_id: row.client_id, family_id: row.family_id, ...details }));++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(row.user_id, 'oauth_refresh_reuse', JSON.stringify({ client_id: row.client_id, family_id: row.family_id, ...details }));} catch { /* audit is best effort */ }}@@@ -80,20 +73,20 @@ function audit(db, row, details) {* { ok: false, error: 'invalid_grant', description, reuse? }* The old token is revoked ('rotated') atomically: of two concurrent uses exactly one gets ok.*/--function rotate(db, presented, clientId, { now = Date.now() } = {}) {-- const row = find(db, presented);++async function rotate(db, presented, clientId, { now = Date.now() } = {}) {++ const row = await find(db, presented);const bad = (description, extra = {}) => ({ ok: false, error: 'invalid_grant', description, ...extra });if (!row) return bad('Invalid refresh token');if (row.client_id !== clientId) return bad('Client mismatch');if (row.revoked) {if (row.revoked_reason === 'rotated' && now - at(row.revoked_at) <= REUSE_GRACE_S * 1000) return bad('Refresh token already used');-- const current = db.prepare('SELECT MAX(generation) AS g FROM oauth_tokens WHERE family_id = ?').get(row.family_id).g;-- const n = revokeFamily(db, row.family_id, 'reuse');-- if (row.revoked_reason === 'rotated' || n) audit(db, row, { generation_presented: row.generation, generation_current: current, revoked: n });++ const current = (await db.prepare('SELECT MAX(generation) AS g FROM oauth_tokens WHERE family_id = ?').get(row.family_id)).g;++ const n = await revokeFamily(db, row.family_id, 'reuse');++ if (row.revoked_reason === 'rotated' || n) await audit(db, row, { generation_presented: row.generation, generation_current: current, revoked: n });return bad('Refresh token revoked', { reuse: row.revoked_reason === 'rotated' });}if (now > at(row.expires_at)) return bad('Refresh token expired');-- const won = db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = 'rotated', revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked = 0").run(row.id).changes === 1;++ const won = (await db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = 'rotated', revoked_at = ov_now() WHERE id = ? AND revoked = 0").run(row.id)).changes === 1;if (!won) return bad('Refresh token already used'); // a concurrent use of the same token got itreturn { ok: true, row, familyId: row.family_id || `fam_legacy_${row.id}`, generation: (row.generation || 0) + 1 };}@@@ -27,12 +27,12 @@ function buildBaseUrl(req) {* Returns { rawToken, resetUrl, expiresMinutes } — rawToken is the only time the* plaintext exists, so it must go straight into the email and never be stored.*/--function issueResetToken(db, req, userId) {++async function issueResetToken(db, req, userId) {const rawToken = crypto.randomBytes(32).toString('hex');const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();// One live token per user: issuing a new link revokes the previous one.-- db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);-- db.prepare(`++ await db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);++ await db.prepare(`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip, requested_user_agent)VALUES (?, ?, ?, ?, ?)`).run(userId, hashResetToken(rawToken), expiresAt, req.ip || null, req.headers['user-agent'] || null);@@@ -45,22 +45,22 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {*/// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:// the folded-in account's fresh sign-in token must not outlive the merge).--function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {++async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);-- return db.transaction(() => {-- const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);++ return await db.tx(async () => {++ const user = await db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);if (!user) throw new Error(`revocation: no user ${userId}`);-- db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(CURRENT_TIMESTAMP, '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);-- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);-- const validAfter = iso(db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id).t);++ await db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(ov_now(), '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = ov_now() WHERE id = ?').run(user.id);++ await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);++ const validAfter = iso((await db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id)).t);let event = null;if (ids.isSubjectId('user', user.subject_id)) {const by = actor || { type: 'user', id: user.subject_id };event = buildEnvelope({ subjectId: user.subject_id, validAfter, reason, actor: by, ctx });-- eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });++ await eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });}return { validAfter, event };-- })();++ });}/** After the commit: wake the relay if this process runs one. */@@@ -26,14 +26,14 @@ function getConfig(req) { return req.app.locals.config; }const { hashResetToken, issueResetToken } = require('./reset-tokens');--function findValidResetToken(db, token) {-- return db.prepare(`-- SELECT prt.id, prt.user_id, prt.expires_at, u.username, u.display_name, u.email++async function findValidResetToken(db, token) {++ return await db.prepare(`++ SELECT prt.id, prt.token_hash, prt.user_id, prt.expires_at, u.username, u.display_name, u.emailFROM password_reset_tokens prtJOIN users u ON u.id = prt.user_idWHERE prt.token_hash = ?AND prt.used_at IS NULL-- AND prt.expires_at > CURRENT_TIMESTAMP++ AND prt.expires_at > ov_now()LIMIT 1`).get(hashResetToken(token));}@@@ -96,14 +96,14 @@ function sanitizeUser(user) {const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).--const requireAuth = makeRequireAuth((req) => ({ db: getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);++const requireAuth = makeRequireAuth(async (req) => ({ db: await getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);// ── Register ─────────────────────────────────────────────────--router.post('/register', (req, res) => {-- const db = getDb(req);++router.post('/register', async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);-- if (!db.getSetting('registration_open')) {++ if (!await db.getSetting('registration_open')) {return res.status(403).json({ error: 'Registration is currently closed' });}@@@ -127,13 +127,13 @@ router.post('/register', (req, res) => {return res.status(400).json({ error: 'This username is reserved by the system and cannot be registered.' });}-- const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);++ const existing = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);if (existing) return res.status(409).json({ error: 'Username already taken' });// A name someone was renamed away from stays theirs for a while (links, mentions, reputation).-- if (require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });++ if (await require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });// Check if username is reserved (has an active verification key)-- const reserved = db.isUsernameReserved(username);++ const reserved = await db.isUsernameReserved(username);if (reserved) {if (!verification_key) {return res.status(403).json({@@@ -141,7 +141,7 @@ router.post('/register', (req, res) => {reserved: true,});}-- const vk = db.getVerificationKeyByKey(verification_key);++ const vk = await db.getVerificationKeyByKey(verification_key);if (!vk || vk.status !== 'active') {return res.status(403).json({ error: 'Invalid or expired verification key' });}@@@ -151,25 +151,25 @@ router.post('/register', (req, res) => {}if (email) {-- const emailExists = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);++ const emailExists = await db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);if (emailExists) return res.status(409).json({ error: 'Email already in use' });}const passwordHash = bcrypt.hashSync(password, 10);-- const result = db.prepare(`++ const result = await db.prepare(`INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)-- VALUES (?, ?, ?, ?, '#8b5cf6', ?)++ VALUES (?, ?, ?, ?, '#8b5cf6', ?) RETURNING id`).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());// Create default preferences-- db.prepare('INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)').run(result.lastInsertRowid);++ await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);// Redeem verification key if usedif (verification_key && reserved) {-- db.redeemVerificationKey(verification_key, result.lastInsertRowid);++ await db.redeemVerificationKey(verification_key, result.lastInsertRowid);}-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);const token = signToken(user, req.app.locals.privateKey, config);console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);@@@ -181,7 +181,7 @@ router.post('/register', (req, res) => {try {const notifService = req.app.locals.notificationService;if (notifService) {-- notifService.create({++ await notifService.create({user_id: user.id,type: 'WELCOME',title: 'Welcome to OpenVibe',@@@ -196,24 +196,24 @@ router.post('/register', (req, res) => {});// ── Login ────────────────────────────────────────────────────--router.post('/login', (req, res) => {-- const db = getDb(req);++router.post('/login', async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);const { username, password } = req.body;if (!username || !password) return res.status(400).json({ error: 'Username and password required' });-- const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);++ const user = await db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);if (!user) return res.status(401).json({ error: 'Invalid username or password' });if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });if (!bcrypt.compareSync(password, user.password_hash)) {return res.status(401).json({ error: 'Invalid username or password' });}// An account merged into another (ADR-029) signs in to the survivor.-- const signedIn = require('../identity/account-merge').effectiveUser(db, user);++ const signedIn = await require('../identity/account-merge').effectiveUser(db, user);if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });-- db.prepare('UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(signedIn.id);++ await db.prepare('UPDATE users SET last_seen = ov_now() WHERE id = ?').run(signedIn.id);const token = signToken(signedIn, req.app.locals.privateKey, config);console.log(`[Auth] Login: ${user.username}${signedIn.id !== user.id ? ` (merged into ${signedIn.username})` : ''}`);@@@ -223,7 +223,7 @@ router.post('/login', (req, res) => {// ── Forgot Password ─────────────────────────────────────────router.post('/forgot-password', async (req, res) => {-- const db = getDb(req);++ const db = await getDb(req);const emailService = req.app.locals.emailService;const genericResponse = {ok: true,@@@ -235,14 +235,14 @@ router.post('/forgot-password', async (req, res) => {return res.status(400).json({ error: 'Email required' });}-- const user = db.prepare('SELECT id, username, display_name, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);++ const user = await db.prepare('SELECT id, username, display_name, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);if (!user || !user.email || !emailService?.isEnabled) {return res.json(genericResponse);}// Shared with the owner-initiated admin flow so both mint tokens identically.-- const { resetUrl, expiresMinutes } = issueResetToken(db, req, user.id);++ const { resetUrl, expiresMinutes } = await issueResetToken(db, req, user.id);await emailService.sendPasswordResetEmail({to: user.email,username: user.display_name || user.username,@@@ -254,12 +254,12 @@ router.post('/forgot-password', async (req, res) => {});// ── Validate Reset Token ────────────────────────────────────--router.get('/reset-password/validate', (req, res) => {-- const db = getDb(req);++router.get('/reset-password/validate', async (req, res) => {++ const db = await getDb(req);const token = String(req.query.token || '').trim();if (!token) return res.status(400).json({ error: 'Reset token required' });-- const reset = findValidResetToken(db, token);++ const reset = await findValidResetToken(db, token);if (!reset) return res.status(400).json({ valid: false, error: 'Invalid or expired reset link' });res.json({@@@ -270,8 +270,8 @@ router.get('/reset-password/validate', (req, res) => {});// ── Complete Password Reset ────────────────────────────────--router.post('/reset-password', (req, res) => {-- const db = getDb(req);++router.post('/reset-password', async (req, res) => {++ const db = await getDb(req);const notifService = req.app.locals.notificationService;const token = String(req.body?.token || '').trim();const newPassword = String(req.body?.new_password || '');@@@ -279,21 +279,25 @@ router.post('/reset-password', (req, res) => {if (!token || !newPassword) return res.status(400).json({ error: 'Reset token and new password required' });if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });-- const reset = findValidResetToken(db, token);++ const reset = await findValidResetToken(db, token);if (!reset) return res.status(400).json({ error: 'Invalid or expired reset link' });const hash = bcrypt.hashSync(newPassword, 10);-- const tx = db.transaction(() => {-- db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, reset.user_id);++ // Claim the token first: of two concurrent uses of one reset link exactly one succeeds (decision 1).++ const won = await db.tx(async (t) => {++ const n = (await t.prepare('UPDATE password_reset_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL').run(reset.token_hash)).changes;++ if (!n) return false;++ await t.prepare('UPDATE users SET password_hash = ?, updated_at = ov_now() WHERE id = ?').run(hash, reset.user_id);// Every token and session from before the reset ends, on every site (network.user.token_valid_after).-- revocation.revokeTokens(db, reset.user_id, { reason: 'password_reset', ctx: req.ov });-- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);++ await revocation.revokeTokens(t, reset.user_id, { reason: 'password_reset', ctx: req.ov });++ await t.prepare('UPDATE password_reset_tokens SET used_at = ov_now() WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);++ return true;});-- tx();++ if (!won) return res.status(400).json({ error: 'Invalid or expired reset link' });revocation.kick(db);if (notifService) {-- notifService.create({++ await notifService.create({user_id: reset.user_id,type: 'PASSWORD_CHANGED',title: 'Password Reset Complete',@@@ -314,7 +318,7 @@ router.post('/reset-password', (req, res) => {// requiring a full OAuth2 refresh_token flow.// Also accepts tokens expired within the last 7 days (grace period), so// users who return after a day away can still get a fresh token.--router.post('/refresh', (req, res) => {++router.post('/refresh', async (req, res) => {const config = getConfig(req);const publicKey = req.app.locals.publicKey;const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';@@@ -353,8 +357,8 @@ router.post('/refresh', (req, res) => {if (!require('./session').isUserSessionClaims(decoded)) return res.status(401).json({ error: 'Invalid token' });// Verify user still exists and is valid-- const db = getDb(req);-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);++ const db = await getDb(req);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);if (!user) return res.status(401).json({ error: 'User not found' });if (user.is_banned) return res.status(403).json({ error: 'Account banned' });@@@ -379,10 +383,10 @@ router.post('/refresh', (req, res) => {});// ── Get Current User ─────────────────────────────────────────--router.get('/me', requireAuth, (req, res) => {-- const db = getDb(req);-- const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);-- subjects.ensureUserSubject(db, req.user);++router.get('/me', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);++ await subjects.ensureUserSubject(db, req.user);res.json({user: sanitizeUser(req.user),preferences: prefs || { theme_id: 'vibe' },@@@ -390,8 +394,8 @@ router.get('/me', requireAuth, (req, res) => {});// ── Update Profile ───────────────────────────────────────────--router.put('/profile', requireAuth, (req, res) => {-- const db = getDb(req);++router.put('/profile', requireAuth, async (req, res) => {++ const db = await getDb(req);const { display_name, bio, avatar_url, email, profile_color } = req.body;const updates = [];const params = [];@@@ -424,24 +428,24 @@ router.put('/profile', requireAuth, (req, res) => {if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });-- updates.push('updated_at = CURRENT_TIMESTAMP');++ updates.push('updated_at = ov_now()');params.push(req.user.id);try {-- db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);++ await db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);} catch (e) {if (/UNIQUE/.test(e.message)) return res.status(409).json({ error: 'That email address is already used by another account' });throw e;}// A new address is unverified until confirmed; kick off the confirmation email.-- if (emailChanged) { try { require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }++ if (emailChanged) { try { await require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);res.json({ user: sanitizeUser(user) });});// ── Change Password ──────────────────────────────────────────--router.post('/change-password', requireAuth, (req, res) => {-- const db = getDb(req);++router.post('/change-password', requireAuth, async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);const { current_password, new_password } = req.body;@@@ -452,22 +456,22 @@ router.post('/change-password', requireAuth, (req, res) => {}const hash = bcrypt.hashSync(new_password, 10);-- db.transaction(() => {-- db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);++ await db.tx(async () => {++ await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);// Every other token and session ends, on every site; this one gets a fresh token below.-- revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });-- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);-- })();++ await revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });++ await db.prepare('UPDATE password_reset_tokens SET used_at = ov_now() WHERE user_id = ? AND used_at IS NULL').run(req.user.id);++ });revocation.kick(db);// Issue fresh token-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);const token = signToken(user, req.app.locals.privateKey, config);// Notify about password changeconst notifService = req.app.locals.notificationService;if (notifService) {-- notifService.create({++ await notifService.create({user_id: user.id,type: 'PASSWORD_CHANGED',title: 'Password Changed',@@@ -509,14 +513,14 @@ function formatAnonUser(anon) {}/** Log an IP association for an anon user (upsert) */--function logAnonIp(db, anonId, ip) {++async function logAnonIp(db, anonId, ip) {if (!ip || ip === 'unknown') return;try {-- const existing = db.prepare('SELECT id FROM anon_ip_log WHERE anon_id = ? AND ip = ?').get(anonId, ip);++ const existing = await db.prepare('SELECT id FROM anon_ip_log WHERE anon_id = ? AND ip = ?').get(anonId, ip);if (existing) {-- db.prepare('UPDATE anon_ip_log SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(existing.id);++ await db.prepare('UPDATE anon_ip_log SET last_seen = ov_now() WHERE id = ?').run(existing.id);} else {-- db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(anonId, ip);++ await db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(anonId, ip);}} catch { /* non-critical */ }}@@@ -529,11 +533,11 @@ function logAnonIp(db, anonId, ip) {// POST /api/auth/sign-out-everywhere: every token and session this person holds ends, here and (via// network.user.token_valid_after) on every OpenVibe site; open chat and game sockets close. The page// then runs the sign-out fanout so this browser's site sessions are cleared too.--router.post('/sign-out-everywhere', requireAuth, (req, res) => {-- const db = getDb(req);-- const { validAfter } = revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });++router.post('/sign-out-everywhere', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const { validAfter } = await revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });revocation.kick(db);-- try { db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }++ try { await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }require('./session').clearSessionCookies(res);res.set('Cache-Control', 'no-store');res.json({ ok: true, valid_after: validAfter });@@@ -547,8 +551,8 @@ router.post('/logout', (req, res) => {res.json({ ok: true });});--router.post('/anon-session', (req, res) => {-- const db = getDb(req);++router.post('/anon-session', async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);const ip = getRequestIp(req);@@@ -559,12 +563,12 @@ router.post('/anon-session', (req, res) => {// Check if this fingerprint already has an anon user (unless forcing new)if (fingerprint && !forceNew) {-- const existing = db.prepare('SELECT * FROM anon_users WHERE fingerprint = ?').get(fingerprint);++ const existing = await db.prepare('SELECT * FROM anon_users WHERE fingerprint = ?').get(fingerprint);if (existing) {// Return existing anon user with a fresh session token-- db.prepare('UPDATE anon_users SET session_token = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?')++ await db.prepare('UPDATE anon_users SET session_token = ?, last_seen = ov_now() WHERE id = ?').run(sessionToken, existing.id);-- logAnonIp(db, existing.id, ip);++ await logAnonIp(db, existing.id, ip);return res.json({token: sessionToken,user: formatAnonUser({ ...existing, last_seen: new Date().toISOString() }),@@@ -574,11 +578,11 @@ router.post('/anon-session', (req, res) => {// Check if this IP already has a default anon (unless forcing new)if (!forceNew) {-- const byIp = db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);++ const byIp = await db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);if (byIp) {-- db.prepare('UPDATE anon_users SET session_token = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?')++ await db.prepare('UPDATE anon_users SET session_token = ?, last_seen = ov_now() WHERE id = ?').run(sessionToken, byIp.id);-- logAnonIp(db, byIp.id, ip);++ await logAnonIp(db, byIp.id, ip);return res.json({token: sessionToken,user: formatAnonUser({ ...byIp, last_seen: new Date().toISOString() }),@@@ -587,14 +591,14 @@ router.post('/anon-session', (req, res) => {}// Generate next anon number-- const maxNum = db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get().max || 0;++ const maxNum = (await db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get()).max || 0;const anonNumber = maxNum + 1;-- const result = db.prepare(-- 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip, subject_id) VALUES (?, ?, ?, ?, ?)'++ const result = await db.prepare(++ 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip, subject_id) VALUES (?, ?, ?, ?, ?) RETURNING id').run(anonNumber, fingerprint, sessionToken, ip, subjects.newGuestSubjectId());-- logAnonIp(db, result.lastInsertRowid, ip);++ await logAnonIp(db, result.lastInsertRowid, ip);console.log(`[Auth] New anonymous user: #${anonNumber} (IP: ${ip})`);res.json({@@@ -617,23 +621,23 @@ router.post('/anon-session', (req, res) => {// ── Get Anonymous User Info ──────────────────────────────────// GET /api/auth/anon/:token--router.get('/anon/:token', (req, res) => {-- const db = getDb(req);-- const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);++router.get('/anon/:token', async (req, res) => {++ const db = await getDb(req);++ const anon = await db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });-- db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(anon.id);++ await db.prepare('UPDATE anon_users SET last_seen = ov_now() WHERE id = ?').run(anon.id);const ip = getRequestIp(req);-- logAnonIp(db, anon.id, ip);++ await logAnonIp(db, anon.id, ip);res.json({ user: formatAnonUser(anon) });});// ── Update Anonymous User Preferences ────────────────────────// PUT /api/auth/anon/:token/preferences--router.put('/anon/:token/preferences', (req, res) => {-- const db = getDb(req);-- const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);++router.put('/anon/:token/preferences', async (req, res) => {++ const db = await getDb(req);++ const anon = await db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });const current = JSON.parse(anon.preferences || '{}');@@@ -645,7 +649,7 @@ router.put('/anon/:token/preferences', (req, res) => {if (updated[key] !== undefined) safe[key] = updated[key];}-- db.prepare('UPDATE anon_users SET preferences = ?, display_name = ? WHERE id = ?')++ await db.prepare('UPDATE anon_users SET preferences = ?, display_name = ? WHERE id = ?').run(JSON.stringify(safe), safe.display_name || anon.display_name, anon.id);res.json({ ok: true, preferences: safe });@@@ -654,20 +658,20 @@ router.put('/anon/:token/preferences', (req, res) => {// ── Link Anonymous to Registered Account ─────────────────────// POST /api/auth/anon/:token/link// Merges anon stats into the authenticated user's account.--router.post('/anon/:token/link', requireAuth, (req, res) => {-- const db = getDb(req);-- const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);++router.post('/anon/:token/link', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const anon = await db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });-- const userSubject = subjects.ensureUserSubject(db, req.user);-- db.transaction(() => {++ const userSubject = await subjects.ensureUserSubject(db, req.user);++ await db.tx(async () => {// Store anon number on the user for reference-- db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')++ await db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL').run(anon.anon_number, req.user.id);// The guest's user modules (portable preferences) move to the account; where the account already// has a record in a namespace, the account's is kept (server/identity/modules.js onSubjectMerged).-- if (anon.subject_id && userSubject) require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });-- })();++ if (anon.subject_id && userSubject) await require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });++ });console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);res.json({ ok: true, anon_number: anon.anon_number });@@@ -676,13 +680,13 @@ router.post('/anon/:token/link', requireAuth, (req, res) => {// ── List All Anon Identities for Current IP ──────────────────// GET /api/auth/anon-identities// Returns all anon identities associated with the caller's IP.--router.get('/anon-identities', (req, res) => {-- const db = getDb(req);++router.get('/anon-identities', async (req, res) => {++ const db = await getDb(req);const ip = getRequestIp(req);try {// Find all anon IDs that have been seen from this IP-- const anons = db.prepare(`++ const anons = await db.prepare(`SELECT DISTINCT a.* FROM anon_users aINNER JOIN anon_ip_log l ON l.anon_id = a.idWHERE l.ip = ?@@@ -690,7 +694,7 @@ router.get('/anon-identities', (req, res) => {`).all(ip);// Also include any anon whose creating IP matches-- const byCreatingIp = db.prepare('SELECT * FROM anon_users WHERE ip = ?').all(ip);++ const byCreatingIp = await db.prepare('SELECT * FROM anon_users WHERE ip = ?').all(ip);const seen = new Set(anons.map(a => a.id));for (const a of byCreatingIp) {if (!seen.has(a.id)) anons.push(a);@@@ -714,9 +718,9 @@ router.get('/anon-identities', (req, res) => {// ── List Active Sessions ─────────────────────────────────────// GET /api/auth/sessions--router.get('/sessions', requireAuth, (req, res) => {-- const db = getDb(req);-- const sessions = db.prepare(`++router.get('/sessions', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const sessions = await db.prepare(`SELECT id, device_name, ip, last_used, created_at FROM user_sessionsWHERE user_id = ? AND is_active = 1ORDER BY last_used DESC@@@ -727,8 +731,8 @@ router.get('/sessions', requireAuth, (req, res) => {// ── Create Session (for multi-account) ───────────────────────// POST /api/auth/sessions// Called when logging in with "add account" — stores session token.--router.post('/sessions', requireAuth, (req, res) => {-- const db = getDb(req);++router.post('/sessions', requireAuth, async (req, res) => {++ const db = await getDb(req);const config = getConfig(req);const sessionToken = uuidv4();@@@ -736,7 +740,7 @@ router.post('/sessions', requireAuth, (req, res) => {const ip = req.ip;const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days-- db.prepare(`++ await db.prepare(`INSERT INTO user_sessions (user_id, session_token, device_name, ip, user_agent, expires_at)VALUES (?, ?, ?, ?, ?, ?)`).run(req.user.id, sessionToken, deviceName, ip, req.headers['user-agent'] || '', expiresAt);@@@ -749,9 +753,9 @@ router.post('/sessions', requireAuth, (req, res) => {// ── Revoke Session ───────────────────────────────────────────// DELETE /api/auth/sessions/:id--router.delete('/sessions/:id', requireAuth, (req, res) => {-- const db = getDb(req);-- const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?')++router.delete('/sessions/:id', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const result = await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);res.json({ ok: true, revoked: result.changes > 0 });});@@@ -759,12 +763,12 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {// ── Sign out other devices ───────────────────────────────────// DELETE /api/auth/sessions: every token and session ends on every site (network.user.token_valid_after),// and this browser gets a fresh token so it stays signed in (same as a password change).--router.delete('/sessions', requireAuth, (req, res) => {-- const db = getDb(req);-- const active = db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id).n;-- revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });++router.delete('/sessions', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const active = (await db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id)).n;++ await revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });revocation.kick(db);-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);res.set('Cache-Control', 'no-store');res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req), { renew: req.tokenClaims || {} }) });});@@@ -774,11 +778,11 @@ router.delete('/sessions', requireAuth, (req, res) => {// ═══════════════════════════════════════════════════════════════// GET /api/users/:id/card — public user card data for context menus--router.get('/users/:id/card', (req, res) => {-- const db = getDb(req);++router.get('/users/:id/card', async (req, res) => {++ const db = await getDb(req);const userId = req.params.id;-- const user = db.prepare(`++ const user = await db.prepare(`SELECT id, username, display_name, avatar_url, bio, role, profile_color,name_effect, particle_effect, is_anon, anon_number, created_atFROM users WHERE id = ?@@@ -786,11 +790,11 @@ router.get('/users/:id/card', (req, res) => {if (!user) return res.status(404).json({ error: 'User not found' });// Linked services-- const linked = db.prepare('SELECT service, service_username FROM linked_accounts WHERE user_id = ?').all(userId);++ const linked = await db.prepare('SELECT service, service_username FROM linked_accounts WHERE user_id = ?').all(userId);// Follower count-- const followers = db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE followed_id = ?').get(userId)?.cnt || 0;-- const following = db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE follower_id = ?').get(userId)?.cnt || 0;++ const followers = (await db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE followed_id = ?').get(userId))?.cnt || 0;++ const following = (await db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE follower_id = ?').get(userId))?.cnt || 0;// Is current user following this user?let isFollowing = false;@@@ -801,13 +805,13 @@ router.get('/users/:id/card', (req, res) => {const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';const token = req.headers.authorization.replace('Bearer ', '');const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });-- const follow = db.prepare('SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?').get(decoded.sub || decoded.id, userId);++ const follow = await db.prepare('SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?').get(decoded.sub || decoded.id, userId);isFollowing = !!follow;} catch { /* not logged in, fine */ }}// Active effects-- const effects = db.prepare(`++ const effects = await db.prepare(`SELECT effect_type, effect_id FROM user_effectsWHERE user_id = ? AND is_active = 1`).all(userId);@@@ -826,18 +830,18 @@ router.get('/users/:id/card', (req, res) => {});// POST /api/users/:id/follow--router.post('/users/:id/follow', requireAuth, (req, res) => {-- const db = getDb(req);++router.post('/users/:id/follow', requireAuth, async (req, res) => {++ const db = await getDb(req);const targetId = parseInt(req.params.id);if (targetId === req.user.id) return res.status(400).json({ error: 'Cannot follow yourself' });try {-- db.prepare('INSERT OR IGNORE INTO follows (follower_id, followed_id) VALUES (?, ?)').run(req.user.id, targetId);++ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES(?, ?) ON CONFLICT DO NOTHING').run(req.user.id, targetId);// Notify targetconst notifService = req.app.locals.notificationService;if (notifService) {-- notifService.create({++ await notifService.create({user_id: targetId,type: 'FOLLOW',title: 'New Follower',@@@ -857,10 +861,10 @@ router.post('/users/:id/follow', requireAuth, (req, res) => {});// DELETE /api/users/:id/follow--router.delete('/users/:id/follow', requireAuth, (req, res) => {-- const db = getDb(req);++router.delete('/users/:id/follow', requireAuth, async (req, res) => {++ const db = await getDb(req);const targetId = parseInt(req.params.id);-- db.prepare('DELETE FROM follows WHERE follower_id = ? AND followed_id = ?').run(req.user.id, targetId);++ await db.prepare('DELETE FROM follows WHERE follower_id = ? AND followed_id = ?').run(req.user.id, targetId);res.json({ ok: true });});@@@ -18,7 +18,7 @@ function isUserSessionClaims(decoded) {return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;}--function verifySession(token, { db, publicKey, config }) {++async function verifySession(token, { db, publicKey, config }) {if (!token) return { error: 'Authentication required', status: 401 };const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';let decoded, expired = false;@@@ -36,7 +36,7 @@ function verifySession(token, { db, publicKey, config }) {// Same signing key, different token kinds: a FedCM assertion (minted for one RP origin, to be// exchanged by that RP's server) and service/app tokens are never a person's session.if (!isUserSessionClaims(decoded)) return { error: 'Invalid token', status: 401 };-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);if (!user) return { error: 'User not found', status: 401 };if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };// An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.@@@ -81,14 +81,14 @@ function requestToken(req) {}/** Express guard factory: attaches req.user / req.token; slides the session when due. */function makeRequireAuth(getCtx, signToken) {-- return function requireAuth(req, res, next) {++ return async function requireAuth(req, res, next) {const authHeader = req.headers.authorization;// Never ov_sso here: it is SameSite=None, so a hostile page could make the browser send it// on a cross-site POST. Only the read-only /sso/check and the prompt=none authorize// (which yields a code bound to a registered redirect_uri) may consult it.const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;-- const ctx = getCtx(req);-- const out = verifySession(token, ctx);++ const ctx = await getCtx(req);++ const out = await verifySession(token, ctx);if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });req.user = out.user;req.token = token;@@@ -35,13 +35,13 @@ function renderCheck(origin, state) {}function createSsoCheckRoute(getCtx) {-- return function ssoCheck(req, res) {++ return async function ssoCheck(req, res) {const origin = allowedOrigin(req.query.origin);if (!origin) return res.status(400).type('text').send('origin must be an OpenVibe origin');const { verifySession, requestToken } = require('./session');let signedIn = false, username = null;try {-- const out = verifySession(requestToken(req), getCtx(req));++ const out = await verifySession(requestToken(req), getCtx(req));if (!out.error && out.user && !out.user.is_anon) { signedIn = true; username = out.user.username || null; }} catch { /* not signed in */ }res.removeHeader('X-Frame-Options');@@@ -15,16 +15,16 @@ module.exports = function createCoinsRoutes(db, requireAuth) {// ── GET /api/coins/me ────────────────────────────────────// → { balance }-- router.get('/me', requireAuth, (req, res) => {-- res.json({ balance: wallet.getBalance(db, req.user.id) });++ router.get('/me', requireAuth, async (req, res) => {++ res.json({ balance: await wallet.getBalance(db, req.user.id) });});// ── GET /api/coins/me/history?limit=50&offset=0 ──────────// → { transactions: [{ id, app_id, delta, reason, created_at }] }-- router.get('/me/history', requireAuth, (req, res) => {++ router.get('/me/history', requireAuth, async (req, res) => {const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);-- const transactions = db.prepare(`++ const transactions = await db.prepare(`SELECT id, app_id, delta, reason, created_atFROM coin_transactionsWHERE user_id = ?@@@ -25,33 +25,33 @@ function isPositiveInt(value) {return Number.isInteger(value) && value > 0;}--function assertUser(db, userId, label = 'user_id') {++async function assertUser(db, userId, label = 'user_id') {const id = Number(userId);if (!Number.isInteger(id) || id <= 0) {throw new WalletError(400, { error: `invalid_${label}` });}-- const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);++ const user = await db.prepare('SELECT id FROM users WHERE id = ?').get(id);if (!user) {throw new WalletError(404, { error: 'user_not_found', [label]: id });}return id;}--function getBalance(db, userId) {-- const row = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(userId);++async function getBalance(db, userId) {++ const row = await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(userId);return row ? row.balance : 0;}--function balanceAfterTx(db, tx) {-- const row = db.prepare(++async function balanceAfterTx(db, tx) {++ const row = await db.prepare('SELECT COALESCE(SUM(delta), 0) AS bal FROM coin_transactions WHERE user_id = ? AND id <= ?').get(tx.user_id, tx.id);return row.bal;}--function findByIdempotencyKey(db, key) {++async function findByIdempotencyKey(db, key) {if (!key) return null;-- return db.prepare('SELECT * FROM coin_transactions WHERE idempotency_key = ?').get(key);++ return await db.prepare('SELECT * FROM coin_transactions WHERE idempotency_key = ?').get(key);}/**@@@ -60,40 +60,41 @@ function findByIdempotencyKey(db, key) {* Throws WalletError(409, insufficient_funds) on overdraft.* @returns {number} the new balance*/--function applyDelta(db, { user_id, app_id, delta, reason, ref, idempotency_key }) {-- db.prepare('INSERT OR IGNORE INTO wallets (user_id, balance) VALUES (?, 0)').run(user_id);-- const balance = getBalance(db, user_id);-- const next = balance + delta;-- if (next < 0) {-- throw new WalletError(409, { error: 'insufficient_funds', balance });++async function applyDelta(db, { user_id, app_id, delta, reason, ref, idempotency_key }) {++ await db.prepare('INSERT INTO wallets (user_id, balance) VALUES(?, 0) ON CONFLICT DO NOTHING').run(user_id);++ // The balance moves with one conditional UPDATE: of two concurrent spends of one wallet exactly one++ // succeeds even at the same starting balance (decision 1, plan T2). The WHERE keeps it from going++ // negative and RETURNING-less: the caller's transaction sees its own write below.++ const moved = (await db.prepare('UPDATE wallets SET balance = balance + ?, updated_at = ov_now() WHERE user_id = ? AND balance + ? >= 0').run(delta, user_id, delta)).changes;++ if (!moved) {++ throw new WalletError(409, { error: 'insufficient_funds', balance: await getBalance(db, user_id) });}-- db.prepare(`++ await db.prepare(`INSERT INTO coin_transactions (user_id, app_id, delta, reason, ref, idempotency_key)VALUES (?, ?, ?, ?, ?, ?)`).run(user_id, app_id || null, delta, reason || null, ref || null, idempotency_key || null);-- db.prepare('UPDATE wallets SET balance = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?').run(next, user_id);-- return next;++ return await getBalance(db, user_id);}/*** Credit (sign=+1) or debit (sign=-1) a wallet. Atomic + idempotent.* @returns {{ balance: number, deduped: boolean }}*/--function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempotency_key }) {++async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempotency_key }) {if (!isPositiveInt(amount)) {throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });}if (!idempotency_key || typeof idempotency_key !== 'string') {throw new WalletError(400, { error: 'missing_idempotency_key' });}-- const uid = assertUser(db, user_id);++ const uid = await assertUser(db, user_id);-- const run = db.transaction(() => {-- const existing = findByIdempotencyKey(db, idempotency_key);++ const run = db.txFn(async () => {++ const existing = await findByIdempotencyKey(db, idempotency_key);if (existing) {-- return { balance: balanceAfterTx(db, existing), deduped: true };++ return { balance: await balanceAfterTx(db, existing), deduped: true };}-- const balance = applyDelta(db, {++ const balance = await applyDelta(db, {user_id: uid,app_id,delta: sign * amount,@@@ -106,12 +107,12 @@ function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempotreturn run();}--function credit(db, opts) {-- return creditOrDebit(db, +1, opts);++async function credit(db, opts) {++ return await creditOrDebit(db, +1, opts);}--function debit(db, opts) {-- return creditOrDebit(db, -1, opts);++async function debit(db, opts) {++ return await creditOrDebit(db, -1, opts);}/**@@@ -120,34 +121,34 @@ function debit(db, opts) {* the credit side carries `${idempotency_key}:in`.* @returns {{ from_balance: number, to_balance: number, deduped: boolean }}*/--function transfer(db, { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key }) {++async function transfer(db, { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key }) {if (!isPositiveInt(amount)) {throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });}if (!idempotency_key || typeof idempotency_key !== 'string') {throw new WalletError(400, { error: 'missing_idempotency_key' });}-- const fromId = assertUser(db, from_user_id, 'from_user_id');-- const toId = assertUser(db, to_user_id, 'to_user_id');++ const fromId = await assertUser(db, from_user_id, 'from_user_id');++ const toId = await assertUser(db, to_user_id, 'to_user_id');if (fromId === toId) {throw new WalletError(400, { error: 'invalid_transfer', message: 'from_user_id and to_user_id must differ' });}const inKey = `${idempotency_key}:in`;-- const run = db.transaction(() => {-- const existingOut = findByIdempotencyKey(db, idempotency_key);++ const run = db.txFn(async () => {++ const existingOut = await findByIdempotencyKey(db, idempotency_key);if (existingOut) {-- const existingIn = findByIdempotencyKey(db, inKey);++ const existingIn = await findByIdempotencyKey(db, inKey);return {-- from_balance: balanceAfterTx(db, existingOut),-- to_balance: existingIn ? balanceAfterTx(db, existingIn) : getBalance(db, toId),++ from_balance: await balanceAfterTx(db, existingOut),++ to_balance: existingIn ? await balanceAfterTx(db, existingIn) : await getBalance(db, toId),deduped: true,};}-- const from_balance = applyDelta(db, {++ const from_balance = await applyDelta(db, {user_id: fromId, app_id, delta: -amount, reason, ref, idempotency_key,});-- const to_balance = applyDelta(db, {++ const to_balance = await applyDelta(db, {user_id: toId, app_id, delta: amount, reason, ref, idempotency_key: inKey,});return { from_balance, to_balance, deduped: false };@@@ -56,21 +56,24 @@ module.exports = {// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`-- // (PGLITE_DIR overrides it); production refuses to boot. `path` is the retired SQLite file: it is-- // read only by the one-time import (scripts/migrate-to-postgres.js) and reset-db, never by serving code.++ // (PGLITE_DIR overrides it); production refuses to boot. There is no SQLite file: DB_PATH is read only++ // by the one-time import tools (scripts/migrate-to-postgres.js, scripts/pg-preflight-collisions.js).db: {-- path: process.env.DB_PATH || './data/network.db',url: process.env.DATABASE_URL || '',directUrl: process.env.DATABASE_DIRECT_URL || '',pgliteDir: process.env.PGLITE_DIR || 'data/pglite',},++ // Local data directory (account exports, and the avatar path's default parent). DATA_DIR overrides it.++ dataDir: process.env.DATA_DIR || 'data',++// Valkey (ADR-035, plan T2): the shared store behind the per-actor limits and any short-lived shared// state, so every process and host counts one actor together. Unset = this process's own counters// (and, for the limits, a Valkey outage degrades to them rather than answering 500). PostgreSQL stays// the source of truth; nothing here is a cache of record.valkey: {url: process.env.VALKEY_URL || '',++ prefix: process.env.VALKEY_PREFIX || 'ov:network:',},// Admin auto-creation@@@ -1,14 +1,24 @@'use strict';// ═══════════════════════════════════════════════════════════════--// openvibe.network — Central Database--// Authoritative source for user accounts, OAuth2 clients,--// themes, the OpenCoins wallet, and cross-platform preferences.++// openvibe.network — Central Database (PostgreSQL through openvibe-sdk/db, ADR-035 / plan T2)++//++// The schema lives in migrations/NNNN_*.sql; this module opens the process-wide handle, runs the++// migrations as the owner (DATABASE_DIRECT_URL) and seeds the boot data that is not schema++// (OAuth clients, site settings, built-in themes). Every query is async; the handle is shaped like++// better-sqlite3's (db.prepare(sql).get/all/run) but must be awaited.// ═══════════════════════════════════════════════════════════════--const Database = require('better-sqlite3');const path = require('path');const fs = require('fs');++const { createDb } = require('openvibe-sdk/db');++const config = require('../config');++++const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', '..', 'data', 'pglite');++++let database = null;++let seeded = false;function previewFromVars(vars) {return JSON.stringify({@@@ -18,496 +28,66 @@ function previewFromVars(vars) {});}--function initDb(dbPath) {-- const dir = path.dirname(path.resolve(dbPath));-- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });---- const db = new Database(path.resolve(dbPath));-- db.pragma('journal_mode = WAL');-- db.pragma('foreign_keys = ON');-- db.pragma('busy_timeout = 5000');---- // ── Schema ───────────────────────────────────────────────-- db.exec(`-- -- Users (canonical identity for the OpenVibe)-- CREATE TABLE IF NOT EXISTS users (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- username TEXT UNIQUE NOT NULL,-- email TEXT UNIQUE,-- password_hash TEXT NOT NULL,-- display_name TEXT,-- avatar_url TEXT,-- bio TEXT DEFAULT '',-- role TEXT DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),-- profile_color TEXT DEFAULT '#8b5cf6',-- is_banned INTEGER DEFAULT 0,-- ban_reason TEXT,-- token_valid_after TEXT DEFAULT NULL,-- legacy_source TEXT, -- 'live' for migrated accounts-- legacy_id INTEGER, -- original user ID in source platform-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP-- );---- -- OAuth2 client registry-- CREATE TABLE IF NOT EXISTS oauth_clients (-- client_id TEXT PRIMARY KEY,-- client_secret TEXT NOT NULL,-- name TEXT NOT NULL,-- redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs-- is_first_party INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );---- -- OAuth2 authorization codes (short-lived, single-use)-- CREATE TABLE IF NOT EXISTS oauth_codes (-- code TEXT PRIMARY KEY,-- client_id TEXT NOT NULL,-- user_id INTEGER NOT NULL,-- redirect_uri TEXT NOT NULL,-- scope TEXT DEFAULT 'profile theme',-- expires_at DATETIME NOT NULL,-- used INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),-- FOREIGN KEY (user_id) REFERENCES users(id)-- );---- -- OAuth2 refresh tokens-- CREATE TABLE IF NOT EXISTS oauth_tokens (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- token TEXT UNIQUE NOT NULL,-- client_id TEXT NOT NULL,-- user_id INTEGER NOT NULL,-- scope TEXT DEFAULT 'profile theme',-- expires_at DATETIME NOT NULL,-- revoked INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),-- FOREIGN KEY (user_id) REFERENCES users(id)-- );---- -- User preferences (theme, language, notification settings)-- CREATE TABLE IF NOT EXISTS user_preferences (-- user_id INTEGER PRIMARY KEY,-- theme_id TEXT DEFAULT 'vibe',-- custom_theme_variables TEXT, -- JSON: custom CSS var overrides-- language TEXT DEFAULT 'en',-- notifications_enabled INTEGER DEFAULT 1,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id)-- );---- -- Theme catalog (built-in + community)-- CREATE TABLE IF NOT EXISTS themes (-- id TEXT PRIMARY KEY,-- name TEXT NOT NULL,-- slug TEXT UNIQUE NOT NULL,-- author_id INTEGER,-- description TEXT DEFAULT '',-- mode TEXT DEFAULT 'dark' CHECK(mode IN ('dark','light')),-- variables TEXT NOT NULL, -- JSON: CSS variable map-- preview_colors TEXT, -- JSON: preview color swatches-- is_builtin INTEGER DEFAULT 0,-- is_public INTEGER DEFAULT 1,-- downloads INTEGER DEFAULT 0,-- rating_sum INTEGER DEFAULT 0,-- rating_count INTEGER DEFAULT 0,-- tags TEXT DEFAULT '[]', -- JSON array-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (author_id) REFERENCES users(id)-- );---- -- Linked accounts (which OpenVibe user owns which service-specific account)-- CREATE TABLE IF NOT EXISTS linked_accounts (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- service TEXT NOT NULL, -- 'live', 'games', etc.-- service_user_id TEXT NOT NULL,-- linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id),-- UNIQUE(service, service_user_id)-- );---- -- Audit log-- CREATE TABLE IF NOT EXISTS audit_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER,-- action TEXT NOT NULL,-- details TEXT, -- JSON context-- ip TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );---- -- IP log (cross-platform)-- CREATE TABLE IF NOT EXISTS ip_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER,-- ip TEXT NOT NULL,-- action TEXT DEFAULT 'login',-- country TEXT,-- region TEXT,-- city TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id)-- );---- -- Site settings (key-value)-- CREATE TABLE IF NOT EXISTS site_settings (-- key TEXT PRIMARY KEY,-- value TEXT,-- type TEXT DEFAULT 'string'-- );---- -- URL registry for admin-managed first-party and protocol URLs-- CREATE TABLE IF NOT EXISTS url_registry (-- key TEXT PRIMARY KEY,-- label TEXT NOT NULL,-- category TEXT NOT NULL,-- service TEXT NOT NULL,-- scope TEXT NOT NULL,-- type TEXT NOT NULL,-- value TEXT,-- description TEXT,-- updated_by INTEGER,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );---- -- ═══════════════════════════════════════════════════════-- -- Notifications-- -- ═══════════════════════════════════════════════════════---- -- Central notification store-- CREATE TABLE IF NOT EXISTS notifications (-- id TEXT PRIMARY KEY, -- UUID-- user_id INTEGER NOT NULL,-- type TEXT NOT NULL, -- from TYPES enum (FOLLOW, MENTION, etc.)-- category TEXT NOT NULL, -- social, chat, game, stream, economy, etc.-- priority TEXT NOT NULL DEFAULT 'normal', -- low, normal, high, critical-- title TEXT NOT NULL,-- message TEXT,-- icon TEXT,-- sender_id INTEGER, -- who triggered this (nullable)-- sender_name TEXT, -- denormalized for display-- sender_avatar TEXT,-- service TEXT, -- originating service (openvibelive, etc.)-- url TEXT, -- click-through destination-- rich_content TEXT, -- JSON: images, actions, embeds-- is_read INTEGER DEFAULT 0,-- is_dismissed INTEGER DEFAULT 0,-- is_emailed INTEGER DEFAULT 0,-- expires_at DATETIME,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);-- CREATE INDEX IF NOT EXISTS idx_notif_user_cat ON notifications(user_id, category);-- CREATE INDEX IF NOT EXISTS idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;---- -- Per-user notification preferences (overrides per category)-- CREATE TABLE IF NOT EXISTS notification_preferences (-- user_id INTEGER NOT NULL,-- category TEXT NOT NULL, -- or '*' for global-- enabled INTEGER DEFAULT 1,-- sound INTEGER DEFAULT 1,-- toasts INTEGER DEFAULT 1,-- email INTEGER DEFAULT 0,-- PRIMARY KEY (user_id, category),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );---- -- Email delivery log (email metrics / admin visibility)-- CREATE TABLE IF NOT EXISTS email_delivery_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- email_type TEXT NOT NULL,-- recipient TEXT NOT NULL,-- subject TEXT,-- status TEXT NOT NULL CHECK(status IN ('sent', 'failed')),-- error_message TEXT,-- user_id INTEGER,-- notification_id TEXT,-- metadata TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,-- FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL-- );-- CREATE INDEX IF NOT EXISTS idx_email_delivery_created ON email_delivery_log(created_at DESC);-- CREATE INDEX IF NOT EXISTS idx_email_delivery_status ON email_delivery_log(status, created_at DESC);-- CREATE INDEX IF NOT EXISTS idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);---- -- Password reset tokens-- CREATE TABLE IF NOT EXISTS password_reset_tokens (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- token_hash TEXT UNIQUE NOT NULL,-- expires_at DATETIME NOT NULL,-- used_at DATETIME,-- requested_ip TEXT,-- requested_user_agent TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);-- CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at);---- -- ═══════════════════════════════════════════════════════-- -- Anonymous Users & Multi-Account Sessions-- -- ═══════════════════════════════════════════════════════---- -- Anonymous user tracking-- CREATE TABLE IF NOT EXISTS anon_users (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- anon_number INTEGER UNIQUE NOT NULL,-- fingerprint TEXT, -- browser fingerprint hash (optional)-- session_token TEXT UNIQUE NOT NULL,-- display_name TEXT,-- preferences TEXT DEFAULT '{}', -- JSON-- total_messages INTEGER DEFAULT 0,-- total_commands INTEGER DEFAULT 0,-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP-- );---- -- Multi-account session tracking (like Google multi-login)-- CREATE TABLE IF NOT EXISTS user_sessions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- session_token TEXT UNIQUE NOT NULL,-- device_name TEXT,-- ip TEXT,-- user_agent TEXT,-- is_active INTEGER DEFAULT 1,-- last_used DATETIME DEFAULT CURRENT_TIMESTAMP,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- expires_at DATETIME NOT NULL,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, is_active);-- CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(session_token);---- -- ═══════════════════════════════════════════════════════-- -- User Profile Extras-- -- ═══════════════════════════════════════════════════════---- -- Name & particle effects ownership-- CREATE TABLE IF NOT EXISTS user_effects (-- user_id INTEGER NOT NULL,-- effect_type TEXT NOT NULL, -- 'name' or 'particle'-- effect_id TEXT NOT NULL, -- e.g. 'rainbow', 'fire', 'neon'-- is_active INTEGER DEFAULT 0,-- acquired_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (user_id, effect_type, effect_id),-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );---- -- User followers-- CREATE TABLE IF NOT EXISTS follows (-- follower_id INTEGER NOT NULL,-- followed_id INTEGER NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (follower_id, followed_id),-- FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (followed_id) REFERENCES users(id) ON DELETE CASCADE-- );---- -- ═══════════════════════════════════════════════════════-- -- OpenCoins Wallet (network-wide currency)-- -- user_id is ALWAYS the Network (SSO) user id.-- -- ═══════════════════════════════════════════════════════---- CREATE TABLE IF NOT EXISTS wallets (-- user_id INTEGER PRIMARY KEY,-- balance INTEGER NOT NULL DEFAULT 0,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );---- CREATE TABLE IF NOT EXISTS coin_transactions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- app_id TEXT,-- delta INTEGER NOT NULL,-- reason TEXT,-- ref TEXT,-- idempotency_key TEXT UNIQUE,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);-- CREATE INDEX IF NOT EXISTS idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);---- -- Verification keys (reserved username claims)-- CREATE TABLE IF NOT EXISTS verification_keys (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- key TEXT UNIQUE NOT NULL,-- target_username TEXT NOT NULL,-- note TEXT DEFAULT '',-- created_by INTEGER NOT NULL,-- used_by INTEGER,-- status TEXT DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- used_at DATETIME,-- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,-- FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL-- );-- CREATE INDEX IF NOT EXISTS idx_vkeys_key ON verification_keys(key);-- CREATE INDEX IF NOT EXISTS idx_vkeys_target ON verification_keys(target_username);-- CREATE INDEX IF NOT EXISTS idx_vkeys_status ON verification_keys(status);-- `);---- // ── Anon IP tracking (unified cross-service anon resolution) ──-- try {-- db.exec(`-- CREATE TABLE IF NOT EXISTS anon_ip_log (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- anon_id INTEGER NOT NULL,-- ip TEXT NOT NULL,-- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,-- UNIQUE(anon_id, ip)-- );-- CREATE INDEX IF NOT EXISTS idx_anon_ip_log_ip ON anon_ip_log(ip);-- CREATE INDEX IF NOT EXISTS idx_anon_ip_log_anon ON anon_ip_log(anon_id);-- `);-- } catch (e) { /* already exists */ }++/**++ * The serving handle: DATABASE_URL through PgBouncer; in development without it, an embedded PGlite++ * database in config.db.pgliteDir. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the++ * embedded handle. Production without DATABASE_URL refuses to boot.++ */++async function openDb(cfg = config, { log = console, registry } = {}) {++ if (!cfg.db.url) {++ if (cfg.nodeEnv === 'production') throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh network)');++ const dir = cfg.db.pgliteDir || DEV_PGLITE;++ log.warn(`[DB] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'network', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!cfg.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: cfg.db.directUrl, service: 'network-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: cfg.db.url, service: 'network', registry, log });++}-- // ── Push Subscriptions (Web Push / VAPID) ──-- try {-- db.exec(`-- CREATE TABLE IF NOT EXISTS push_subscriptions (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- endpoint TEXT NOT NULL UNIQUE,-- keys_p256dh TEXT NOT NULL,-- keys_auth TEXT NOT NULL,-- user_agent TEXT,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id);-- CREATE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint);-- `);-- } catch (e) { /* already exists */ }++/** Open the process-wide database once, at boot (server/index.js, scripts). Seeds the boot data. */++async function initDb(cfg = config, opts) {++ if (!database && globalThis.__ovNetworkTestDb) database = globalThis.__ovNetworkTestDb; // tests (test/helpers/pg-preload.mjs)++ if (!database) database = await openDb(cfg, opts);++ await attachHelpers(database);++ if (!seeded) { seeded = true; await seedDb(database); }++ return database;++}-- // ── Migration: Add columns that may not exist ────────────-- const migrations = [-- { table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },-- { table: 'oauth_codes', column: 'code_challenge', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge TEXT" },-- { table: 'oauth_codes', column: 'code_challenge_method', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge_method TEXT" },-- // OpenID Connect: the nonce sent to /oauth/authorize, echoed in the id_token (server/auth/oidc.js).-- { table: 'oauth_codes', column: 'nonce', sql: "ALTER TABLE oauth_codes ADD COLUMN nonce TEXT" },-- { table: 'url_registry', column: 'source', sql: "ALTER TABLE url_registry ADD COLUMN source TEXT NOT NULL DEFAULT 'admin'" },-- { table: 'users', column: 'is_anon', sql: "ALTER TABLE users ADD COLUMN is_anon INTEGER DEFAULT 0" },-- { table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },-- { table: 'users', column: 'name_effect', sql: "ALTER TABLE users ADD COLUMN name_effect TEXT" },-- { table: 'users', column: 'particle_effect', sql: "ALTER TABLE users ADD COLUMN particle_effect TEXT" },-- { table: 'anon_users', column: 'ip', sql: "ALTER TABLE anon_users ADD COLUMN ip TEXT" },-- // Email deliverability state — verification gates opt-in mail, bounces suppress it.-- { table: 'users', column: 'email_verified', sql: "ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0" },-- { table: 'users', column: 'email_verified_at', sql: "ALTER TABLE users ADD COLUMN email_verified_at DATETIME" },-- { table: 'users', column: 'email_bounced_at', sql: "ALTER TABLE users ADD COLUMN email_bounced_at DATETIME" },-- { table: 'users', column: 'email_bounce_reason', sql: "ALTER TABLE users ADD COLUMN email_bounce_reason TEXT" },-- // When the account was last used on a service (bumped on every OAuth exchange).-- { table: 'linked_accounts', column: 'last_used_at', sql: "ALTER TABLE linked_accounts ADD COLUMN last_used_at DATETIME" },-- // Cross-site history can be paused by the user (server/history/routes.js).-- { table: 'user_preferences', column: 'display_prefs', sql: "ALTER TABLE user_preferences ADD COLUMN display_prefs TEXT" },-- { table: 'users', column: 'history_paused', sql: "ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0" },-- // Canonical subject ids (roadmap Wave 1): usr_/gst_ ULIDs alongside the integer ids.-- { table: 'users', column: 'subject_id', sql: "ALTER TABLE users ADD COLUMN subject_id TEXT" },-- { table: 'anon_users', column: 'subject_id', sql: "ALTER TABLE anon_users ADD COLUMN subject_id TEXT" },-- ];-- for (const m of migrations) {-- const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();-- if (!cols.find(c => c.name === m.column)) {-- try { db.exec(m.sql); console.log(`[DB] Migrated: ${m.table}.${m.column}`); }-- catch (e) { /* already exists — silently skip */ }-- }-- }++/** The process-wide database initDb() opened. */++function getDb() {++ if (!database && globalThis.__ovNetworkTestDb) database = globalThis.__ovNetworkTestDb;++ if (!database) throw new Error('the database is not open: await initDb() at boot');++ return database;++}-- // ── Refresh tokens: hash-only storage, families, generations (server/auth/refresh-tokens.js) ──-- // Columns only. Rows still holding a raw token work and are hashed on first use (the one-time-- // hash-refresh-tokens.js was retired in plan T2).-- require('../auth/refresh-tokens').ensureSchema(db);++/** Tests: use this handle as the process-wide database. */++async function setDb(db) { database = db; await attachHelpers(db); }-- // ── Email verification tokens + preference default semantics ──-- try {-- db.exec(`-- CREATE TABLE IF NOT EXISTS email_verification_tokens (-- token_hash TEXT PRIMARY KEY,-- user_id INTEGER NOT NULL,-- email TEXT NOT NULL,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- expires_at DATETIME NOT NULL,-- used_at DATETIME,-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE-- );-- CREATE INDEX IF NOT EXISTS idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);-- `);-- // notification_preferences.email: 0 used to be the implicit default written whenever a-- // user toggled anything else in the row. It now means an EXPLICIT "no email"; NULL is-- // "no choice → defaults apply" (go-live alerts email by default). One-time reset.-- const flagged = db.prepare("SELECT value FROM site_settings WHERE key = 'migr_pref_email_null'").get();-- if (!flagged) {-- const n = db.prepare('UPDATE notification_preferences SET email = NULL WHERE email = 0').run().changes;-- db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('migr_pref_email_null', '1', 'boolean')").run();-- if (n) console.log(`[DB] notification_preferences: ${n} email=0 rows reset to default (NULL)`);-- }-- const seed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');-- seed.run('email_user_daily_cap', '30', 'number');-- seed.run('email_daily_cap', '2000', 'number');-- seed.run('email_verify_user_daily_cap', '6', 'number');-- seed.run('resend_webhook_secret', '', 'string');-- seed.run('stream_live_cooldown_min', '60', 'number');-- seed.run('stream_live_daily_cap', '8', 'number');-- } catch (e) { console.warn('[DB] email verification migration:', e.message); }++// ── Boot data (the schema is the migration; this is what is not) ─────────────-- // ── Migration: one linked account per (user, service) ────────-- // The link-account upsert uses ON CONFLICT(user_id, service), which needs a-- // matching unique index (the table only declared UNIQUE(service, service_user_id)).-- try {-- // Drop any pre-existing duplicate (user_id, service) rows, keeping the newest.-- db.exec(`-- DELETE FROM linked_accounts WHERE id NOT IN (-- SELECT MAX(id) FROM linked_accounts GROUP BY user_id, service-- );-- CREATE UNIQUE INDEX IF NOT EXISTS idx_linked_user_service ON linked_accounts(user_id, service);-- `);-- } catch (e) { console.warn('[DB] linked_accounts unique index migration:', e.message); }++/**++ * Seed OAuth2 clients, site settings and the built-in theme catalogue, idempotently. Safe under++ * concurrent writers: every write is an upsert, and the redirect-URI merge takes the row with++ * SELECT … FOR UPDATE inside a transaction (decision 1).++ */++async function seedDb(db, { log = console } = {}) {++ const warn = (m) => log.warn(`[DB] ${m}`);-- // ── Seed OAuth2 Clients (per CONTRACTS) ──────────────────-- // Each first-party client is created if missing, with a fresh UUID secret-- // printed exactly once on creation.++ // ── Seed OAuth2 clients (per CONTRACTS) ──────────────────++ // Each first-party client is created if missing, with a fresh UUID secret printed exactly once.{const { v4: uuidv4 } = require('uuid');const contractClients = [-- {-- client_id: 'live',-- name: 'OpenVibe.Live',-- redirect_uris: ['https://openvibe.live/api/auth/callback'],-- },-- {-- client_id: 'tools',-- name: 'OpenVibe.Tools',-- redirect_uris: ['https://openvibe.tools/auth/callback'],-- },-- {-- client_id: 'games',-- name: 'OpenVibe.Games',-- redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'],-- },-- {-- client_id: 'media',-- name: 'OpenVibe.Media',-- redirect_uris: ['https://openvibe.media/auth/callback'],-- },-- {-- client_id: 'community',-- name: 'OpenVibe.Community',-- redirect_uris: ['https://openvibe.community/auth/callback'],-- },++ { client_id: 'live', name: 'OpenVibe.Live', redirect_uris: ['https://openvibe.live/api/auth/callback'] },++ { client_id: 'tools', name: 'OpenVibe.Tools', redirect_uris: ['https://openvibe.tools/auth/callback'] },++ { client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },++ { client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },++ { client_id: 'community', name: 'OpenVibe.Community', redirect_uris: ['https://openvibe.community/auth/callback'] },// Waves 9-16 products with a signed-in UI. Create each with server/setup/service-principal.js// first (secret into its env file); this only adds the redirect URI to that client.{ client_id: 'tips', name: 'OpenVibe.Tips', redirect_uris: ['https://openvibe.tips/auth/callback'] },@@@ -522,109 +102,80 @@ function initDb(dbPath) {{ client_id: 'coupons', name: 'OpenVibe.Coupons', redirect_uris: ['https://openvibe.coupons/auth/callback'] },{ client_id: 'host', name: 'OpenVibe.Host', redirect_uris: ['https://openvibe.host/auth/callback'] },{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },-- // Billing's staff console (Wave 8): Network admins listed in BILLING_STAFF_SUBJECTS.{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },-- // OpenVibe.AI's operator console (WS-O task 4): Network staff, capabilities from the staff role map.{ client_id: 'ai', name: 'OpenVibe.AI', redirect_uris: ['https://ai.openvibe.network/auth/callback'] },];-- const insert = db.prepare(-- 'INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)'-- );let seededAny = false;for (const c of contractClients) {-- const existing = db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);++ const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);if (!existing) {-- const secret = uuidv4();-- insert.run(c.client_id, secret, c.name, JSON.stringify(c.redirect_uris));-- console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret stored in oauth_clients; read it with sqlite, never from logs)`);++ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')++ .run(c.client_id, uuidv4(), c.name, JSON.stringify(c.redirect_uris));++ console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret stored in oauth_clients; read it from the database, never from logs)`);seededAny = true;continue;}-- // Ensure existing clients include the contract redirect URIs-- try {-- const uris = new Set(JSON.parse(existing.redirect_uris || '[]'));-- let changed = false;-- for (const uri of c.redirect_uris) {-- if (!uris.has(uri)) { uris.add(uri); changed = true; }-- }-- if (changed) {-- db.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?')-- .run(JSON.stringify([...uris]), c.client_id);-- console.log(`[DB] Updated ${c.client_id} redirect_uris to include contract URIs`);-- }-- } catch { /* malformed redirect_uris — leave untouched */ }++ await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });}if (seededAny) console.log('[DB] New OAuth2 clients seeded; their secrets are in oauth_clients (they are never logged).');}-- function ensureLocalOauthRedirects() {-- if (process.env.NODE_ENV === 'production' && process.env.BOOTSTRAP_PROFILE !== 'local-dev') return;--++ // ── Local development redirect URIs ──────────────────────++ if (!(process.env.NODE_ENV === 'production' && process.env.BOOTSTRAP_PROFILE !== 'local-dev')) {const localClients = [-- {-- clientId: 'live',-- extraUris: ['http://localhost:3000/auth/callback', 'http://localhost:3000/api/auth/callback'],-- },-- {-- clientId: 'tools',-- extraUris: ['http://localhost:4001/auth/callback'],-- },-- {-- clientId: 'games',-- extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'],-- },-- {-- clientId: 'media',-- extraUris: ['http://localhost:4100/auth/callback'],-- },++ { clientId: 'live', extraUris: ['http://localhost:3000/auth/callback', 'http://localhost:3000/api/auth/callback'] },++ { clientId: 'tools', extraUris: ['http://localhost:4001/auth/callback'] },++ { clientId: 'games', extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'] },++ { clientId: 'media', extraUris: ['http://localhost:4100/auth/callback'] },];--for (const { clientId, extraUris } of localClients) {-- try {-- const client = db.prepare('SELECT redirect_uris FROM oauth_clients WHERE client_id = ?').get(clientId);-- if (!client) continue;-- const uris = new Set(JSON.parse(client.redirect_uris || '[]'));-- let changed = false;-- for (const uri of extraUris) {-- if (!uris.has(uri)) {-- uris.add(uri);-- changed = true;-- }-- }-- if (changed) {-- db.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?')-- .run(JSON.stringify([...uris]), clientId);-- console.log(`[DB] Added local redirect_uris for ${clientId}: ${extraUris.join(', ')}`);-- }-- } catch (err) {-- console.warn(`[DB] Failed to add local redirect_uris for ${clientId}:`, err.message);-- }++ try { await mergeRedirectUris(db, clientId, extraUris, { log, local: true }); }++ catch (err) { warn(`Failed to add local redirect_uris for ${clientId}: ${err.message}`); }}}-- ensureLocalOauthRedirects();-- // ── Seed Default Settings ────────────────────────────────-- const settingsCount = db.prepare('SELECT COUNT(*) as cnt FROM site_settings').get().cnt;-- if (settingsCount === 0) {-- const defaults = [-- ['registration_open', 'true', 'boolean'],-- ['platform_name', 'OpenVibe', 'string'],-- ['default_theme', 'vibe', 'string'],-- // Email provider defaults-- ['email_enabled', 'false', 'boolean'],-- ['resend_api_key', '', 'string'],-- ['email_from_address', '[email protected]', 'string'],-- ['email_from_name', 'OpenVibe', 'string'],-- // Notification defaults-- ['notifications_enabled', 'true', 'boolean'],-- ['notification_max_age_days', '90', 'number'],-- ['notification_email_critical_only', 'true', 'boolean'],-- ];-- const insertSetting = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');-- for (const [k, v, t] of defaults) insertSetting.run(k, v, t);++ // ── Email verification preference reset + seeds ──────────++ try {++ const flagged = await db.prepare("SELECT value FROM site_settings WHERE key = 'migr_pref_email_null'").get();++ if (!flagged) {++ // notification_preferences.email: 0 used to be the implicit default written whenever a++ // user toggled anything else in the row. It now means an EXPLICIT "no email"; NULL is++ // "no choice → defaults apply". One-time reset.++ const n = (await db.prepare('UPDATE notification_preferences SET email = NULL WHERE email = 0').run()).changes;++ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('migr_pref_email_null', '1', 'boolean') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run();++ if (n) console.log(`[DB] notification_preferences: ${n} email=0 rows reset to default (NULL)`);++ }++ const seed = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING');++ await seed.run('email_user_daily_cap', '30', 'number');++ await seed.run('email_daily_cap', '2000', 'number');++ await seed.run('email_verify_user_daily_cap', '6', 'number');++ await seed.run('resend_webhook_secret', '', 'string');++ await seed.run('stream_live_cooldown_min', '60', 'number');++ await seed.run('stream_live_daily_cap', '8', 'number');++ } catch (e) { warn(`email verification migration: ${e.message}`); }++++ // ── Seed default settings (only on a fresh settings table) ──++ {++ const settingsCount = (await db.prepare('SELECT COUNT(*) AS cnt FROM site_settings').get()).cnt;++ if (Number(settingsCount) === 0) {++ const defaults = [++ ['registration_open', 'true', 'boolean'],++ ['platform_name', 'OpenVibe', 'string'],++ ['default_theme', 'vibe', 'string'],++ ['email_enabled', 'false', 'boolean'],++ ['resend_api_key', '', 'string'],++ ['email_from_address', '[email protected]', 'string'],++ ['email_from_name', 'OpenVibe', 'string'],++ ['notifications_enabled', 'true', 'boolean'],++ ['notification_max_age_days', '90', 'number'],++ ['notification_email_critical_only', 'true', 'boolean'],++ ];++ const insertSetting = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING');++ for (const [k, v, t] of defaults) await insertSetting.run(k, v, t);++ }}-- // ── Always-Seed Discord + Integration Settings (idempotent) ──++ // ── Always-seed Discord + integration settings (idempotent) ──{const alwaysSeed = [['discord_bot_token', '', 'secret'],@@@ -635,18 +186,18 @@ function initDb(dbPath) {['discord_alert_message', '', 'string'],['discord_oauth_client_id', '', 'string'],['discord_oauth_client_secret', '', 'secret'],-- ['github_token', '', 'secret'], // server/integrations/github.js (admin → Settings → GitHub)++ ['github_token', '', 'secret'],];-- const insertSeed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');-- for (const [k, v, t] of alwaysSeed) insertSeed.run(k, v, t);++ const insertSeed = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING');++ for (const [k, v, t] of alwaysSeed) await insertSeed.run(k, v, t);}-- // ── Sync Built-in Themes ─────────────────────────────────++ // ── Sync built-in themes ─────────────────────────────────{const { BUILTIN_THEMES } = require('openvibe-shared/theme-sync');const upsertTheme = db.prepare(`INSERT INTO themes (id, name, slug, description, mode, variables, preview_colors, is_builtin, is_public, tags, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, CURRENT_TIMESTAMP)++ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ov_now())ON CONFLICT(id) DO UPDATE SETname = excluded.name,slug = excluded.slug,@@@ -657,62 +208,96 @@ function initDb(dbPath) {is_builtin = 1,is_public = 1,tags = excluded.tags,-- updated_at = CURRENT_TIMESTAMP++ updated_at = ov_now()`);for (const t of BUILTIN_THEMES) {-- upsertTheme.run(-- t.id,-- t.name,-- t.slug,-- t.description,-- t.mode,-- JSON.stringify(t.variables || {}),-- previewFromVars(t.variables || {}),-- JSON.stringify(t.tags || [])-- );++ await upsertTheme.run(t.id, t.name, t.slug, t.description, t.mode,++ JSON.stringify(t.variables || {}), previewFromVars(t.variables || {}), JSON.stringify(t.tags || []));}// A built-in that was renamed or removed must not linger as a stale catalog row.const ids = BUILTIN_THEMES.map((t) => t.id);-- const gone = db.prepare(`DELETE FROM themes WHERE is_builtin = 1 AND id NOT IN (${ids.map(() => '?').join(',')})`).run(...ids).changes;++ const gone = (await db.prepare(`DELETE FROM themes WHERE is_builtin = 1 AND id NOT IN (${ids.map(() => '?').join(',')})`).run(...ids)).changes;console.log(`[DB] Synced ${BUILTIN_THEMES.length} built-in themes${gone ? ` (removed ${gone} stale)` : ''}`);}-- // Roles are Network's own data. Network never opens another service's database (ADR-007):-- // role changes made here reach Live as network.user.updated (server/identity/profile-events.js;-- // the key-only POST /internal/user-role push was retired, register C-54/C-55), and Live never-- // downgrades from a stale SSO token. The boot-time read of Live's database that used to live-- // here was removed (compatibility register C-58).++ // ── Per-module boot work (the tables are migrations/NNNN_*.sql; this is the idempotent data work++ // each module did at boot on SQLite) ─────────────────────────────────────────────────────────++ // Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed.++ await require('../identity/subjects').ensureSchema(db);++ // Service principals: grants per OAuth client (server/identity/principals.js) — seeds the default++ // grants and re-revokes the ones ADR-012 forbids.++ await require('../identity/principals').ensureSchema(db);++ // Versioned user modules (server/identity/modules.js).++ await require('../identity/modules').ensureSchema(db);++ // Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).++ await require('../developer/store').ensureSchema(db);++ // Their usage, from the services' rollups (server/developer/usage.js).++ await require('../developer/usage').ensure(db);++ // Platform blocks, keyed by subjects (server/identity/blocks.js).++ await require('../identity/blocks').ensureSchema(db);++ // The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).++ await require('../identity/follows').ensureSchema(db);++ // Incidents and maintenance on /status (server/status/incidents.js).++ await require('../status/incidents').ensureSchema(db);++ // Creator analytics from live.stream.ended (server/analytics/creators.js).++ await require('../analytics/creators').ensureSchema(db);++ // The platform's machines (server/registry/nodes.js; ADR-034 §12).++ await require('../registry/nodes').ensureSchema(db);++ // Refresh tokens: hash-only storage (server/auth/refresh-tokens.js).++ await require('../auth/refresh-tokens').ensureSchema(db);++++ console.log('[DB] Central database seeded');++}++++/** Union `uris` into a client's redirect_uris under SELECT … FOR UPDATE (decision 1: many writers). */++async function mergeRedirectUris(db, clientId, uris, { log = console, local = false } = {}) {++ await db.tx(async (t) => {++ const row = await t.prepare('SELECT redirect_uris FROM oauth_clients WHERE client_id = ? FOR UPDATE').get(clientId);++ if (!row) return;++ let current;++ try { current = new Set(JSON.parse(row.redirect_uris || '[]')); } catch { return; } // malformed: leave untouched++ let changed = false;++ for (const uri of uris) { if (!current.has(uri)) { current.add(uri); changed = true; } }++ if (!changed) return;++ await t.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?').run(JSON.stringify([...current]), clientId);++ if (local) console.log(`[DB] Added local redirect_uris for ${clientId}: ${uris.join(', ')}`);++ else console.log(`[DB] Updated ${clientId} redirect_uris to include contract URIs`);++ });++}++++// ── Handle helpers (attached to the database handle, async) ──────────────────-- // ── Helper: getSetting ───────────────────────────────────-- // Provider secrets (server/secrets.js) come from their environment variable when it is set.++function attachHelpers(db) {++ if (db._ovHelpers) return db;const secrets = require('../secrets');-- db.getSetting = function (key) {++++ // better-sqlite3's db.transaction(fn) returned a function; db.tx(fn) runs now. txFn keeps that++ // shape (a runner the caller invokes, possibly more than once, e.g. a dry-run path).++ db.txFn = (fn) => async (...args) => await db.tx(() => fn(...args));++++ // Provider secrets come from their environment variable when it is set.++ db.getSetting = async function (key) {const fromEnv = secrets.fromEnv(key);if (fromEnv !== null) return fromEnv;-- const row = this.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);++ const row = await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);if (!row) return null;if (row.type === 'boolean') return row.value === 'true';if (row.type === 'number') return Number(row.value);return row.value;};-- // ── Verification Key helpers ─────────────────────────────-- db.createVerificationKey = function ({ key, target_username, note, created_by }) {-- return this.prepare(-- 'INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)'-- ).run(key, target_username, note || '', created_by);++ db.createVerificationKey = async function ({ key, target_username, note, created_by }) {++ return await db.prepare('INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)')++ .run(key, target_username, note || '', created_by);};---- db.getVerificationKeyByKey = function (key) {-- return this.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);++ db.getVerificationKeyByKey = async function (key) {++ return await db.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);};---- db.getVerificationKeyByUsername = function (username) {-- return this.prepare("SELECT * FROM verification_keys WHERE target_username = ? COLLATE NOCASE AND status = 'active'").get(username);++ db.getVerificationKeyByUsername = async function (username) {++ return await db.prepare("SELECT * FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);};---- db.getAllVerificationKeys = function () {-- return this.prepare(`++ db.getAllVerificationKeys = async function () {++ return await db.prepare(`SELECT vk.*, u1.username as created_by_name, u2.username as used_by_nameFROM verification_keys vkLEFT JOIN users u1 ON vk.created_by = u1.id@@@ -720,45 +305,20 @@ function initDb(dbPath) {ORDER BY vk.created_at DESC`).all();};---- db.redeemVerificationKey = function (key, userId) {-- return this.prepare(-- "UPDATE verification_keys SET status = 'used', used_by = ?, used_at = CURRENT_TIMESTAMP WHERE key = ? AND status = 'active'"-- ).run(userId, key);++ db.redeemVerificationKey = async function (key, userId) {++ return await db.prepare("UPDATE verification_keys SET status = 'used', used_by = ?, used_at = ov_now() WHERE key = ? AND status = 'active'")++ .run(userId, key);};---- db.revokeVerificationKey = function (id) {-- return this.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);++ db.revokeVerificationKey = async function (id) {++ return await db.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);};---- db.isUsernameReserved = function (username) {-- const vk = this.prepare("SELECT id FROM verification_keys WHERE target_username = ? COLLATE NOCASE AND status = 'active'").get(username);++ db.isUsernameReserved = async function (username) {++ const vk = await db.prepare("SELECT id FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);return !!vk;};-- // Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.-- require('../identity/subjects').ensureSchema(db);-- // Service principals: grants per OAuth client + usage counters (server/identity/principals.js).-- require('../identity/principals').ensureSchema(db);-- // Versioned user modules (server/identity/modules.js).-- require('../identity/modules').ensureSchema(db);-- // Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).-- require('../developer/store').ensureSchema(db);-- // Their usage, from the services' rollups (server/developer/usage.js).-- require('../developer/usage').ensure(db);-- // Platform blocks, keyed by subjects (server/identity/blocks.js).-- require('../identity/blocks').ensureSchema(db);-- // The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).-- require('../identity/follows').ensureSchema(db);-- // Incidents and maintenance on /status (server/status/incidents.js).-- require('../status/incidents').ensureSchema(db);-- // Creator analytics from live.stream.ended (server/analytics/creators.js).-- require('../analytics/creators').ensureSchema(db);-- // The platform's machines (server/registry/nodes.js; ADR-034 §12).-- require('../registry/nodes').ensureSchema(db);---- console.log('[DB] Central database initialized');++ Object.defineProperty(db, '_ovHelpers', { value: true, enumerable: false });return db;}--module.exports = { initDb };++module.exports = { openDb, initDb, getDb, setDb, seedDb, attachHelpers, MIGRATIONS, mergeRedirectUris };@@@ -24,9 +24,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {next();}-- function auditLog(req, action, details) {++ async function auditLog(req, action, details) {try {-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(details));} catch (err) {@@@ -34,8 +34,8 @@ module.exports = function createDeployRoutes(db, requireAuth) {}}-- function getDeployConfig() {-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ async function getDeployConfig() {++ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);return {acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',@@@ -54,10 +54,10 @@ module.exports = function createDeployRoutes(db, requireAuth) {// System Prerequisites// ═══════════════════════════════════════════════════════-- router.get('/prerequisites', (req, res) => {++ router.get('/prerequisites', async (req, res) => {try {const prereqs = certManager.checkPrerequisites();-- const config = getDeployConfig();++ const config = await getDeployConfig();res.json({ok: true,prerequisites: prereqs,@@@ -78,9 +78,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {// Deploy Configuration// ═══════════════════════════════════════════════════════-- router.get('/config', (req, res) => {++ router.get('/config', async (req, res) => {try {-- const config = getDeployConfig();++ const config = await getDeployConfig();// Mask the Cloudflare tokenconst masked = { ...config };if (masked.cloudflareToken && masked.cloudflareToken.length > 4) {@@@ -92,7 +92,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {}});-- router.put('/config', (req, res) => {++ router.put('/config', async (req, res) => {try {const allowedKeys = {acmeEmail: 'DEPLOY_ACME_EMAIL',@@@ -121,11 +121,11 @@ module.exports = function createDeployRoutes(db, requireAuth) {if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {return res.status(400).json({ ok: false, error: 'nginxMode must be "preview", "apply", or "disabled"' });}-- const entry = urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);++ const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);updated[bodyKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;}-- auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });++ await auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });res.json({ ok: true, updated });} catch (err) {res.status(400).json({ ok: false, error: err.message });@@@ -139,7 +139,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {router.get('/certs', async (req, res) => {try {const status = await certManager.getCertificateStatus();-- const config = getDeployConfig();++ const config = await getDeployConfig();// Annotate certs with domain configfor (const cert of status.certs) {@@@ -166,7 +166,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {const { domain } = req.body;if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });-- const config = getDeployConfig();++ const config = await getDeployConfig();if (!config.acmeEmail) {return res.status(400).json({ ok: false, error: 'ACME email not configured. Set it in Deploy Config.' });}@@@ -174,7 +174,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {return res.status(400).json({ ok: false, error: 'Cloudflare token not configured. Set it in Deploy Config.' });}-- auditLog(req, 'cert_issue_cloudflare_start', { domain });++ await auditLog(req, 'cert_issue_cloudflare_start', { domain });const result = await certManager.issueWildcardCloudflare({domain,@@@ -182,7 +182,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {cloudflareToken: config.cloudflareToken,});-- auditLog(req, 'cert_issue_cloudflare_result', {++ await auditLog(req, 'cert_issue_cloudflare_result', {domain,ok: result.ok,certName: result.certName,@@@ -199,7 +199,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {if (!domains.find(d => d.domain === domain)) {domains.push({ domain, wildcard: true, certName: result.certName, services: [] });}-- urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);++ await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);}res.json({ ok: result.ok, ...result });@@@ -217,7 +217,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {const { domain } = req.body;if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });-- const config = getDeployConfig();++ const config = await getDeployConfig();if (!config.acmeEmail) {return res.status(400).json({ ok: false, error: 'ACME email not configured' });}@@@ -238,19 +238,19 @@ module.exports = function createDeployRoutes(db, requireAuth) {const { domain } = req.body;if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });-- const config = getDeployConfig();++ const config = await getDeployConfig();if (!config.acmeEmail) {return res.status(400).json({ ok: false, error: 'ACME email not configured' });}-- auditLog(req, 'cert_issue_manual_start', { domain });++ await auditLog(req, 'cert_issue_manual_start', { domain });const result = await certManager.issueWildcardManual({domain,email: config.acmeEmail,});-- auditLog(req, 'cert_issue_manual_result', {++ await auditLog(req, 'cert_issue_manual_result', {domain,ok: result.ok,certName: result.certName,@@@ -265,7 +265,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {if (!domains.find(d => d.domain === domain)) {domains.push({ domain, wildcard: true, certName: result.certName, services: [] });}-- urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);++ await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);}res.json({ ok: result.ok, ...result });@@@ -280,9 +280,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {router.post('/certs/renew', async (req, res) => {try {-- auditLog(req, 'cert_renew_start', {});++ await auditLog(req, 'cert_renew_start', {});const result = await certManager.renewCertificates();-- auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });++ await auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });res.json({ ok: result.ok, ...result });} catch (err) {res.status(500).json({ ok: false, error: err.message });@@@ -293,9 +293,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {// Nginx Config Preview// ═══════════════════════════════════════════════════════-- router.get('/nginx/preview', (req, res) => {++ router.get('/nginx/preview', async (req, res) => {try {-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);const configs = nginxGenerator.generateAllConfigs(resolved);res.json({ok: true,@@@ -311,9 +311,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {}});-- router.get('/nginx/preview/:serviceId', (req, res) => {++ router.get('/nginx/preview/:serviceId', async (req, res) => {try {-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);const configs = nginxGenerator.generateAllConfigs(resolved);const cfg = configs.find(c => c.serviceId === req.params.serviceId);if (!cfg) return res.status(404).json({ ok: false, error: 'Service not found' });@@@ -342,7 +342,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {router.post('/nginx/apply', async (req, res) => {try {-- const config = getDeployConfig();++ const config = await getDeployConfig();if (config.nginxMode === 'disabled') {return res.status(400).json({ ok: false, error: 'Nginx management is disabled' });}@@@ -350,9 +350,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {const dryRun = config.nginxMode === 'preview' || req.body.dryRun === true;const reload = req.body.reload !== false && !dryRun;-- auditLog(req, 'nginx_apply_start', { dryRun, reload });++ await auditLog(req, 'nginx_apply_start', { dryRun, reload });-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);const configs = nginxGenerator.generateAllConfigs(resolved);const result = await nginxGenerator.applyConfigs(configs, {@@@ -362,7 +362,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {dryRun,});-- auditLog(req, 'nginx_apply_result', {++ await auditLog(req, 'nginx_apply_result', {ok: result.ok,dryRun: result.dryRun,applied: result.applied,@@@ -391,7 +391,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {});}-- auditLog(req, 'nginx_reload', {});++ await auditLog(req, 'nginx_reload', {});const result = await nginxGenerator.reloadNginx();res.json({ ok: result.ok, ...result, validation });} catch (err) {@@@ -403,9 +403,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {// Managed Domains CRUD// ═══════════════════════════════════════════════════════-- router.get('/domains', (req, res) => {++ router.get('/domains', async (req, res) => {try {-- const config = getDeployConfig();++ const config = await getDeployConfig();// Annotate each domain with cert statusconst domains = (config.domains || []).map(d => {const cert = certManager.findBestCert(d.certName || d.domain);@@@ -423,7 +423,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {}});-- router.put('/domains', (req, res) => {++ router.put('/domains', async (req, res) => {try {const { domains } = req.body;if (!Array.isArray(domains)) {@@@ -438,8 +438,8 @@ module.exports = function createDeployRoutes(db, requireAuth) {return res.status(400).json({ ok: false, error: `Invalid domain: ${d.domain}` });}}-- const entry = urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);-- auditLog(req, 'deploy_domains_update', { count: domains.length });++ const entry = await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);++ await auditLog(req, 'deploy_domains_update', { count: domains.length });res.json({ ok: true, domains: entry.value });} catch (err) {res.status(400).json({ ok: false, error: err.message });@@@ -18,12 +18,13 @@const crypto = require('crypto');const { serviceAuth, assertValid } = require('openvibe-contracts');const { createClient } = require('openvibe-sdk/core');--const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');const TABLE = 'network_event_outbox';const TOKEN_TTL_S = 300;const outboxes = new WeakMap(); // db -> outbox, only while the relay is configuredconst passives = new WeakMap(); // db -> outbox that only writes rows (never started)++const writers = new WeakMap(); // db -> the enqueue facade writerFor() returns (one per database)/** The outbox registered for this database, or null (relay off). store.audit() enqueues into it. */function outboxFor(db) {@@@ -38,16 +39,28 @@ function outboxFor(db) {* (user modules: server/identity/module-events.js); dev_audit events keep their backfill.*/function writerFor(db) {++ const cached = writers.get(db);++ if (cached) return cached;const live = outboxes.get(db);-- if (live) return live;-- let p = passives.get(db);-- if (!p) {-- const events = createEventsClient(createClient({ network: 'https://openvibe.network', baseUrls: { events: 'http://127.0.0.1:1' } }), { source: 'network' });-- p = createOutbox(db, { events, table: TABLE });-- p.ensureSchema();-- passives.set(db, p);++ let outbox = live;++ if (!outbox) {++ outbox = passives.get(db);++ if (!outbox) {++ const events = createEventsClient(createClient({ network: 'https://openvibe.network', baseUrls: { events: 'http://127.0.0.1:1' } }), { source: 'network' });++ outbox = createPgOutbox(db, { events, table: TABLE });++ passives.set(db, outbox);++ }}-- return p;++ // The PostgreSQL outbox enqueues on the transaction handle (enqueue(t, envelope, opts)); caller sites++ // also pass the envelope first. Accept both shapes, always binding this database as the handle so the++ // row joins the caller's ambient db.tx. One facade per database, so a caller (a test) can swap enqueue.++ const writer = {++ enqueue: (a, b, c) => (a && typeof a.query === 'function' && typeof a.tx === 'function')++ ? outbox.enqueue(a, b, c)++ : outbox.enqueue(db, a, b),++ };++ writers.set(db, writer);++ return writer;}/** A self-signed Network service token for publishing to Events, cached until a minute before exp. */@@@ -70,17 +83,17 @@ function createSelfTokenSource({ privateKey, issuer }) {}/** Copy dev_audit events that are not in the outbox yet, oldest first. Returns how many were added. */--function backfill(db) {-- const rows = db.prepare(`SELECT id, event FROM dev_audit WHERE event IS NOT NULL-- AND json_extract(event, '$.event_id') NOT IN (SELECT event_id FROM ${TABLE}) ORDER BY id`).all();-- const insert = db.prepare(`INSERT OR IGNORE INTO ${TABLE} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, NULL, ?, 0)`);++async function backfill(db) {++ const rows = await db.prepare(`SELECT id, event FROM dev_audit WHERE event IS NOT NULL++ AND (event::jsonb ->> 'event_id') NOT IN (SELECT event_id FROM ${TABLE}) ORDER BY id`).all();++ const insert = db.prepare(`INSERT INTO ${TABLE} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, NULL, ?, 0) ON CONFLICT DO NOTHING`);let added = 0;-- db.transaction(() => {++ await db.tx(async () => {for (const r of rows) {const env = JSON.parse(r.event);-- added += insert.run(env.event_id, r.event, Date.now()).changes;++ added += (await insert.run(env.event_id, r.event, Date.now())).changes;}-- })();++ });return added;}@@@ -88,7 +101,7 @@ function backfill(db) {* Start the relay for `db` when `eventsUrl` is set; returns the outbox (or null when off).* fetch/intervalMs are injectable for tests.*/--function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, intervalMs = 2000, log = console, autoStart = true } = {}) {++async function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, intervalMs = 2000, log = console, autoStart = true } = {}) {if (!eventsUrl) return null;if (!privateKey || !String(privateKey).includes('BEGIN')) {log.warn('[events-relay] OV_EVENTS_INTERNAL_URL is set but Network has no RS256 private key; relay stays off');@@@ -103,13 +116,12 @@ function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, inter...(fetchImpl ? { fetch: fetchImpl } : {}),});const events = createEventsClient(client, { source: 'network' });-- const outbox = createOutbox(db, {++ const outbox = createPgOutbox(db, {events, table: TABLE, intervalMs,onError: (err) => log.warn(`[events-relay] publish failed: ${String((err && err.message) || err).slice(0, 200)}`),});-- outbox.ensureSchema();outboxes.set(db, outbox);-- const added = backfill(db);++ const added = await backfill(db);if (added) log.log(`[events-relay] backfilled ${added} developer-project event(s) from dev_audit`);if (autoStart) outbox.start();return outbox;@@@ -57,23 +57,23 @@ function router() {// Bearer user tokens only. An expired token is refused here even inside the session grace period:// this API hands out secrets, so it does not slide sessions.-- r.use((req, res, next) => {++ r.use(async (req, res, next) => {const h = String(req.headers.authorization || '');if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'auth.required', { detail: 'send Authorization: Bearer <Network access token>', ctx: req.ov });const { db, publicKey, config } = req.app.locals;-- const out = verifySession(h.slice(7).trim(), { db, publicKey, config });++ const out = await verifySession(h.slice(7).trim(), { db, publicKey, config });if (out.error) return http.sendProblem(res, out.status === 403 ? 403 : 401, out.status === 403 ? 'auth.banned' : 'auth.invalid', { detail: out.error, ctx: req.ov });if (out.decoded && typeof out.decoded.exp === 'number' && out.decoded.exp * 1000 < Date.now()) {return http.sendProblem(res, 401, 'auth.expired', { detail: 'access token expired; refresh it', ctx: req.ov });}-- try { req.actor = store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }++ try { req.actor = await store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }next();});const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });-- const handle = (fn, status = 200) => (req, res) => {++ const handle = (fn, status = 200) => async (req, res) => {try {-- const out = fn(req.app.locals.db, req.actor, req, ctxOf(req));++ const out = await fn(req.app.locals.db, req.actor, req, ctxOf(req));if (out === undefined) return res.status(204).end();res.status(status).json(out);} catch (err) { send(req, res, err); }@@@ -85,55 +85,55 @@ function router() {rule: 'only active capabilities with visibility public (or partner, by staff allowance) are grantable to apps; sandbox apps may hold sandbox_allowance without staff, production apps only the staff-set project allowance',}));-- r.post('/', handle((db, a, req, o) => store.createProject(db, a, req.body || {}, o), 201));-- r.get('/', handle((db, a, req, o) => ({ projects: store.listProjects(db, a, { all: req.query.all === '1', settings: o.settings }) })));-- r.get('/:project', handle((db, a, req, o) => {-- const { project, role } = store.access(db, a, req.params.project, { allowArchived: true });-- return store.projectView(db, project, role, o.settings);++ r.post('/', handle(async (db, a, req, o) => await store.createProject(db, a, req.body || {}, o), 201));++ r.get('/', handle(async (db, a, req, o) => ({ projects: await store.listProjects(db, a, { all: req.query.all === '1', settings: o.settings }) })));++ r.get('/:project', handle(async (db, a, req, o) => {++ const { project, role } = await store.access(db, a, req.params.project, { allowArchived: true });++ return await store.projectView(db, project, role, o.settings);}));-- r.patch('/:project', handle((db, a, req, o) => store.renameProject(db, a, req.params.project, req.body || {}, o)));-- r.post('/:project/archive', handle((db, a, req, o) => store.archiveProject(db, a, req.params.project, o)));-- r.put('/:project/allowance', handle((db, a, req, o) => store.setAllowance(db, a, req.params.project, req.body || {}, o)));-- r.put('/:project/environment-policy', handle((db, a, req, o) => store.setEnvironmentPolicy(db, a, req.params.project, req.body || {}, o)));---- r.get('/:project/members', handle((db, a, req) => ({ members: store.listMembers(db, a, req.params.project) })));-- r.post('/:project/members', handle((db, a, req, o) => store.addMember(db, a, req.params.project, req.body || {}, o), 201));-- r.patch('/:project/members/:subject', handle((db, a, req, o) => store.updateMember(db, a, req.params.project, req.params.subject, req.body || {}, o)));-- r.delete('/:project/members/:subject', handle((db, a, req, o) => store.removeMember(db, a, req.params.project, req.params.subject, o)));---- r.get('/:project/apps', handle((db, a, req) => ({ apps: store.listApps(db, a, req.params.project) })));-- r.post('/:project/apps', handle((db, a, req, o) => store.createApp(db, a, req.params.project, req.body || {}, o), 201));-- r.get('/:project/apps/:app', handle((db, a, req) => store.getApp(db, a, req.params.project, req.params.app)));-- r.patch('/:project/apps/:app', handle((db, a, req, o) => store.updateApp(db, a, req.params.project, req.params.app, req.body || {}, o)));-- r.delete('/:project/apps/:app', handle((db, a, req, o) => store.revokeApp(db, a, req.params.project, req.params.app, o)));---- r.get('/:project/apps/:app/credentials', handle((db, a, req) => ({ credentials: store.listCredentials(db, a, req.params.project, req.params.app) })));-- r.post('/:project/apps/:app/credentials/rotate', handle((db, a, req, o) => store.rotateCredential(db, a, req.params.project, req.params.app, req.body || {}, o), 201));-- r.post('/:project/apps/:app/credentials/:credential/revoke', handle((db, a, req, o) => store.revokeCredential(db, a, req.params.project, req.params.app, req.params.credential, o)));---- r.get('/:project/apps/:app/grants', handle((db, a, req) => ({ grants: store.listGrants(db, a, req.params.project, req.params.app) })));-- r.post('/:project/apps/:app/grants', handle((db, a, req, o) => store.requestGrant(db, a, req.params.project, req.params.app, req.body || {}, o), 201));-- r.post('/:project/apps/:app/grants/:capability/approve', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'approved', o)));-- r.post('/:project/apps/:app/grants/:capability/deny', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));-- r.delete('/:project/apps/:app/grants/:capability', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));---- r.get('/:project/quotas', handle((db, a, req) => ({ quotas: store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));-- r.put('/:project/quotas/:capability', handle((db, a, req, o) => store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));-- r.delete('/:project/quotas/:capability', handle((db, a, req, o) => store.deleteQuota(db, a, req.params.project, req.params.capability, o)));++ r.patch('/:project', handle(async (db, a, req, o) => await store.renameProject(db, a, req.params.project, req.body || {}, o)));++ r.post('/:project/archive', handle(async (db, a, req, o) => await store.archiveProject(db, a, req.params.project, o)));++ r.put('/:project/allowance', handle(async (db, a, req, o) => await store.setAllowance(db, a, req.params.project, req.body || {}, o)));++ r.put('/:project/environment-policy', handle(async (db, a, req, o) => await store.setEnvironmentPolicy(db, a, req.params.project, req.body || {}, o)));++++ r.get('/:project/members', handle(async (db, a, req) => ({ members: await store.listMembers(db, a, req.params.project) })));++ r.post('/:project/members', handle(async (db, a, req, o) => await store.addMember(db, a, req.params.project, req.body || {}, o), 201));++ r.patch('/:project/members/:subject', handle(async (db, a, req, o) => await store.updateMember(db, a, req.params.project, req.params.subject, req.body || {}, o)));++ r.delete('/:project/members/:subject', handle(async (db, a, req, o) => await store.removeMember(db, a, req.params.project, req.params.subject, o)));++++ r.get('/:project/apps', handle(async (db, a, req) => ({ apps: await store.listApps(db, a, req.params.project) })));++ r.post('/:project/apps', handle(async (db, a, req, o) => await store.createApp(db, a, req.params.project, req.body || {}, o), 201));++ r.get('/:project/apps/:app', handle(async (db, a, req) => await store.getApp(db, a, req.params.project, req.params.app)));++ r.patch('/:project/apps/:app', handle(async (db, a, req, o) => await store.updateApp(db, a, req.params.project, req.params.app, req.body || {}, o)));++ r.delete('/:project/apps/:app', handle(async (db, a, req, o) => await store.revokeApp(db, a, req.params.project, req.params.app, o)));++++ r.get('/:project/apps/:app/credentials', handle(async (db, a, req) => ({ credentials: await store.listCredentials(db, a, req.params.project, req.params.app) })));++ r.post('/:project/apps/:app/credentials/rotate', handle(async (db, a, req, o) => await store.rotateCredential(db, a, req.params.project, req.params.app, req.body || {}, o), 201));++ r.post('/:project/apps/:app/credentials/:credential/revoke', handle(async (db, a, req, o) => await store.revokeCredential(db, a, req.params.project, req.params.app, req.params.credential, o)));++++ r.get('/:project/apps/:app/grants', handle(async (db, a, req) => ({ grants: await store.listGrants(db, a, req.params.project, req.params.app) })));++ r.post('/:project/apps/:app/grants', handle(async (db, a, req, o) => await store.requestGrant(db, a, req.params.project, req.params.app, req.body || {}, o), 201));++ r.post('/:project/apps/:app/grants/:capability/approve', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'approved', o)));++ r.post('/:project/apps/:app/grants/:capability/deny', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));++ r.delete('/:project/apps/:app/grants/:capability', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));++++ r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));++ r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));++ r.delete('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.deleteQuota(db, a, req.params.project, req.params.capability, o)));// Usage (WS-N task 4): network.project-usage-result@1, for the owner and admins (and staff).-- r.get('/:project/usage', handle((db, a, req) => {-- const { project } = store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });-- return usage.summary(db, project.id, usage.parseQuery(req.query));++ r.get('/:project/usage', handle(async (db, a, req) => {++ const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });++ return await usage.summary(db, project.id, usage.parseQuery(req.query));}));-- r.get('/:project/audit', handle((db, a, req) => {-- const { project } = store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });-- return store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });++ r.get('/:project/audit', handle(async (db, a, req) => {++ const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });++ return await store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });}));// Project export (WS-N task 9): Codes asks with the person's token, once per audience and env.-- r.post('/:project/export-tokens', handle((db, a, req, o) => tokens.mintExportToken(db, a, req.params.project, req.body || {}, {++ r.post('/:project/export-tokens', handle(async (db, a, req, o) => await tokens.mintExportToken(db, a, req.params.project, req.body || {}, {privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,}), 201));@@@ -40,106 +40,7 @@ class DevError extends Error {const fail = (status, code, detail) => { throw new DevError(status, code, detail); };const nowIso = () => new Date().toISOString();--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS dev_projects (-- id TEXT PRIMARY KEY,-- owner_subject TEXT NOT NULL,-- name TEXT NOT NULL,-- environment_policy TEXT NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),-- allowance TEXT NOT NULL DEFAULT '[]',-- created_at TEXT NOT NULL,-- created_by TEXT NOT NULL,-- archived_at TEXT,-- archived_by TEXT-- );-- CREATE INDEX IF NOT EXISTS idx_dev_projects_owner ON dev_projects(owner_subject);-- CREATE TABLE IF NOT EXISTS dev_project_members (-- project_id TEXT NOT NULL REFERENCES dev_projects(id),-- subject_id TEXT NOT NULL,-- role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),-- added_at TEXT NOT NULL,-- added_by TEXT NOT NULL,-- PRIMARY KEY (project_id, subject_id)-- );-- CREATE INDEX IF NOT EXISTS idx_dev_members_subject ON dev_project_members(subject_id);-- CREATE TABLE IF NOT EXISTS dev_apps (-- id TEXT PRIMARY KEY,-- project_id TEXT NOT NULL REFERENCES dev_projects(id),-- name TEXT NOT NULL,-- environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),-- oauth_client_id TEXT NOT NULL UNIQUE,-- client_type TEXT NOT NULL CHECK (client_type IN ('confidential', 'public')),-- redirect_uris TEXT NOT NULL DEFAULT '[]',-- created_at TEXT NOT NULL,-- created_by TEXT NOT NULL,-- revoked_at TEXT,-- revoked_by TEXT-- );-- CREATE INDEX IF NOT EXISTS idx_dev_apps_project ON dev_apps(project_id);-- CREATE TABLE IF NOT EXISTS dev_credentials (-- id TEXT PRIMARY KEY,-- app_id TEXT NOT NULL REFERENCES dev_apps(id),-- secret_hash TEXT NOT NULL UNIQUE,-- hint TEXT NOT NULL,-- created_at TEXT NOT NULL,-- created_by TEXT NOT NULL,-- expires_at TEXT,-- revoked_at TEXT,-- revoked_by TEXT,-- last_used_at TEXT-- );-- CREATE INDEX IF NOT EXISTS idx_dev_credentials_app ON dev_credentials(app_id);-- CREATE TABLE IF NOT EXISTS dev_grants (-- app_id TEXT NOT NULL REFERENCES dev_apps(id),-- capability TEXT NOT NULL,-- audience TEXT NOT NULL,-- status TEXT NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),-- requested_by TEXT NOT NULL,-- requested_at TEXT NOT NULL,-- decided_by TEXT,-- decided_at TEXT,-- PRIMARY KEY (app_id, capability)-- );-- CREATE TABLE IF NOT EXISTS dev_quotas (-- project_id TEXT NOT NULL REFERENCES dev_projects(id),-- capability TEXT NOT NULL,-- limit_value INTEGER NOT NULL CHECK (limit_value >= 0),-- quota_window TEXT NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),-- unit TEXT NOT NULL DEFAULT 'requests',-- updated_at TEXT NOT NULL,-- updated_by TEXT NOT NULL,-- PRIMARY KEY (project_id, capability)-- );-- CREATE TABLE IF NOT EXISTS dev_auth_codes (-- code_hash TEXT PRIMARY KEY,-- app_id TEXT NOT NULL REFERENCES dev_apps(id),-- user_subject TEXT NOT NULL,-- redirect_uri TEXT NOT NULL,-- scope TEXT NOT NULL DEFAULT '',-- code_challenge TEXT NOT NULL,-- expires_at TEXT NOT NULL,-- used INTEGER NOT NULL DEFAULT 0-- );-- CREATE TABLE IF NOT EXISTS dev_audit (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- at TEXT NOT NULL,-- project_id TEXT,-- actor TEXT NOT NULL,-- action TEXT NOT NULL,-- target TEXT,-- detail TEXT NOT NULL DEFAULT '{}',-- request_id TEXT,-- event_type TEXT,-- event TEXT-- );-- CREATE INDEX IF NOT EXISTS idx_dev_audit_project ON dev_audit(project_id, id);-- CREATE TRIGGER IF NOT EXISTS dev_audit_append_only_update BEFORE UPDATE ON dev_audit-- BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;-- CREATE TRIGGER IF NOT EXISTS dev_audit_append_only_delete BEFORE DELETE ON dev_audit-- BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;-- `);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }// ── Audit and events ───────────────────────────────────────────@@@ -148,7 +49,7 @@ function ensureSchema(db) {* With `event` = { type, subject: { type, id }, payload }, the row also carries an event envelope.* `detail` must never contain secret material (callers pass ids, hints and names only).*/--function audit(db, { projectId, actor, action, target, detail, ctx, event }) {++async function audit(db, { projectId, actor, action, target, detail, ctx, event }) {let envelope = null;if (event) {const [atype, aid] = String(actor).split(':');@@@ -165,21 +66,21 @@ function audit(db, { projectId, actor, action, target, detail, ctx, event }) {// One transaction (a savepoint when the caller already has one): the audit row and, when the// Events relay is on, its outbox row exist together or not at all.const outbox = envelope ? eventRelay.outboxFor(db) : null;-- db.transaction(() => {-- db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(nowIso(), projectId || null, actor, action, target || null, JSON.stringify(detail || {}), (ctx && ctx.requestId) || null,envelope ? envelope.event_type : null, envelope ? JSON.stringify(envelope) : null);-- if (outbox) outbox.enqueue(envelope, { traceparent: ctx && ctx.traceparent });-- })();++ if (outbox) await outbox.enqueue(db, envelope, { traceparent: ctx && ctx.traceparent });++ });if (outbox) outbox.kick();}--function listAudit(db, projectId, { before, limit = 50 } = {}) {++async function listAudit(db, projectId, { before, limit = 50 } = {}) {const n = Math.min(200, Math.max(1, Number(limit) || 50));const rows = before-- ? db.prepare('SELECT * FROM dev_audit WHERE project_id = ? AND id < ? ORDER BY id DESC LIMIT ?').all(projectId, Number(before), n)-- : db.prepare('SELECT * FROM dev_audit WHERE project_id = ? ORDER BY id DESC LIMIT ?').all(projectId, n);++ ? await db.prepare('SELECT * FROM dev_audit WHERE project_id = ? AND id < ? ORDER BY id DESC LIMIT ?').all(projectId, Number(before), n)++ : await db.prepare('SELECT * FROM dev_audit WHERE project_id = ? ORDER BY id DESC LIMIT ?').all(projectId, n);return {entries: rows.map(r => ({ id: r.id, at: r.at, actor: r.actor, action: r.action, target: r.target, detail: JSON.parse(r.detail || '{}'),request_id: r.request_id, event_type: r.event_type || undefined, event_id: r.event ? JSON.parse(r.event).event_id : undefined })),@@@ -190,14 +91,14 @@ function listAudit(db, projectId, { before, limit = 50 } = {}) {// ── Actors ─────────────────────────────────────────────────────/** { subject, user, staff } for an authenticated Network user row. */--function actorOf(db, user) {-- const subject = subjects.ensureUserSubject(db, user);++async function actorOf(db, user) {++ const subject = await subjects.ensureUserSubject(db, user);if (!subject) fail(401, 'auth.no_subject', 'account has no subject id');return { subject, user, staff: user.role === 'admin', label: `user:${subject}` };}--function memberRole(db, projectId, subject) {-- const r = db.prepare('SELECT role FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(projectId, subject);++async function memberRole(db, projectId, subject) {++ const r = await db.prepare('SELECT role FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(projectId, subject);return r ? r.role : null;}@@@ -205,9 +106,9 @@ function memberRole(db, projectId, subject) {* Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.* `need` is the minimum member role; `staffOk` lets staff through regardless of membership.*/--function access(db, actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {-- const p = PROJECT_ID_RE.test(String(projectId)) ? db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(projectId) : null;-- const role = p ? memberRole(db, p.id, actor.subject) : null;++async function access(db, actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {++ const p = PROJECT_ID_RE.test(String(projectId)) ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(projectId) : null;++ const role = p ? await memberRole(db, p.id, actor.subject) : null;if (!p || (!role && !actor.staff)) fail(404, 'project.not_found', 'no such project');const ok = (role && RANK[role] >= RANK[need]) || (actor.staff && staffOk);if (!ok) fail(403, 'project.forbidden', `requires ${need} role`);@@@ -223,7 +124,7 @@ const cleanName = (v, field = 'name') => {return s;};--function projectView(db, p, role, settings) {++async function projectView(db, p, role, settings) {return {id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],@@@ -232,60 +133,60 @@ function projectView(db, p, role, settings) {sandbox_allowance: (settings || policy.settings()).sandboxAllowance,created_at: p.created_at, archived_at: p.archived_at || null,counts: {-- members: db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id).n,-- apps: db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(p.id).n,++ members: (await db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id)).n,++ apps: (await db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(p.id)).n,},};}--function createProject(db, actor, { name }, { settings, ctx }) {++async function createProject(db, actor, { name }, { settings, ctx }) {const n = cleanName(name);-- const owned = db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject).n;++ const owned = (await db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject)).n;if (owned >= settings.maxProjectsPerOwner) fail(409, 'project.limit', `at most ${settings.maxProjectsPerOwner} active projects per owner`);const id = `prj_${ids.ulid()}`;const t = nowIso();-- db.transaction(() => {-- db.prepare(`INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by)VALUES (?, ?, ?, 'sandbox', ?, ?, ?)`).run(id, actor.subject, n, JSON.stringify(settings.defaultAllowance), t, actor.label);-- db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)').run(id, actor.subject, 'owner', t, actor.label);-- audit(db, { projectId: id, actor: actor.label, action: 'project.created', target: id, detail: { name: n, allowance: settings.defaultAllowance }, ctx });-- })();-- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner', settings);++ await audit(db, { projectId: id, actor: actor.label, action: 'project.created', target: id, detail: { name: n, allowance: settings.defaultAllowance }, ctx });++ });++ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner', settings);}--function listProjects(db, actor, { all = false, settings } = {}) {++async function listProjects(db, actor, { all = false, settings } = {}) {const rows = all && actor.staff-- ? db.prepare('SELECT p.*, m.role FROM dev_projects p LEFT JOIN dev_project_members m ON m.project_id = p.id AND m.subject_id = ? ORDER BY p.id DESC').all(actor.subject)-- : db.prepare('SELECT p.*, m.role FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id = ? ORDER BY p.id DESC').all(actor.subject);-- return rows.map(r => projectView(db, r, r.role, settings));++ ? await db.prepare('SELECT p.*, m.role FROM dev_projects p LEFT JOIN dev_project_members m ON m.project_id = p.id AND m.subject_id = ? ORDER BY p.id DESC').all(actor.subject)++ : await db.prepare('SELECT p.*, m.role FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id = ? ORDER BY p.id DESC').all(actor.subject);++ return (await Promise.all(rows.map(async r => await projectView(db, r, r.role, settings))));}--function renameProject(db, actor, projectId, { name }, { ctx, settings }) {-- const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });++async function renameProject(db, actor, projectId, { name }, { ctx, settings }) {++ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });const n = cleanName(name);-- db.transaction(() => {-- db.prepare('UPDATE dev_projects SET name = ? WHERE id = ?').run(n, project.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'project.renamed', target: project.id, detail: { from: project.name, to: n }, ctx });-- })();-- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);++ await db.tx(async () => {++ await db.prepare('UPDATE dev_projects SET name = ? WHERE id = ?').run(n, project.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.renamed', target: project.id, detail: { from: project.name, to: n }, ctx });++ });++ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);}/** Archive: irreversible. Every app of the project is revoked (credentials included). */--function archiveProject(db, actor, projectId, { ctx, settings }) {-- const { project, role } = access(db, actor, projectId, { need: 'owner', staffOk: true });-- db.transaction(() => {-- for (const app of db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) revokeAppTx(db, actor, project, app, ctx, 'project archived');-- db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });-- })();-- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);++async function archiveProject(db, actor, projectId, { ctx, settings }) {++ const { project, role } = await access(db, actor, projectId, { need: 'owner', staffOk: true });++ await db.tx(async () => {++ for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');++ await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });++ });++ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);}/** Staff: the capabilities this project's apps may hold. Grants outside the new allowance are revoked. */--function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, settings }) {++async function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, settings }) {if (!actor.staff) fail(403, 'project.staff_only', 'only staff set a project allowance');-- const { project } = access(db, actor, projectId);++ const { project } = await access(db, actor, projectId);if (!Array.isArray(list) || list.length > 200) fail(422, 'project.invalid', 'capabilities must be an array (at most 200)');const wanted = [...new Set(list.map(String))].sort();for (const id of wanted) {@@@ -293,52 +194,52 @@ function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, settiif (!g.grantable) fail(422, g.code, g.reason);}const trimmed = [];-- db.transaction(() => {-- db.prepare('UPDATE dev_projects SET allowance = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'project.allowance_set', target: project.id, detail: { from: JSON.parse(project.allowance), to: wanted }, ctx });-- const rows = db.prepare(`SELECT g.*, a.project_id, a.environment FROM dev_grants g JOIN dev_apps a ON a.id = g.app_id++ await db.tx(async () => {++ await db.prepare('UPDATE dev_projects SET allowance = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.allowance_set', target: project.id, detail: { from: JSON.parse(project.allowance), to: wanted }, ctx });++ const rows = await db.prepare(`SELECT g.*, a.project_id, a.environment FROM dev_grants g JOIN dev_apps a ON a.id = g.app_idWHERE a.project_id = ? AND g.status IN ('approved', 'requested')`).all(project.id);// A sandbox app's grant that is still inside the sandbox allowance survives a staff change.const updated = { ...project, allowance: JSON.stringify(wanted) };for (const g of rows.filter(r => !policy.allowanceFor(updated, { environment: r.environment }, settings).has(r.capability))) {const next = g.status === 'approved' ? 'revoked' : 'denied';-- db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(next, actor.label, nowIso(), g.app_id, g.capability);-- grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');++ await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(next, actor.label, nowIso(), g.app_id, g.capability);++ await grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });}-- })();++ });return { allowance: wanted, trimmed };}--function setEnvironmentPolicy(db, actor, projectId, { environment_policy: value }, { ctx }) {++async function setEnvironmentPolicy(db, actor, projectId, { environment_policy: value }, { ctx }) {if (!actor.staff) fail(403, 'project.staff_only', 'only staff change the environment policy');-- const { project } = access(db, actor, projectId);++ const { project } = await access(db, actor, projectId);if (!policy.ENVIRONMENT_POLICIES[value]) fail(422, 'project.invalid', `environment_policy is one of ${Object.keys(policy.ENVIRONMENT_POLICIES).join(', ')}`);-- db.transaction(() => {-- db.prepare('UPDATE dev_projects SET environment_policy = ? WHERE id = ?').run(value, project.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'project.environment_policy_set', target: project.id, detail: { from: project.environment_policy, to: value }, ctx });-- })();++ await db.tx(async () => {++ await db.prepare('UPDATE dev_projects SET environment_policy = ? WHERE id = ?').run(value, project.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.environment_policy_set', target: project.id, detail: { from: project.environment_policy, to: value }, ctx });++ });return { environment_policy: value, environments: policy.ENVIRONMENT_POLICIES[value] };}// ── Members ────────────────────────────────────────────────────--function memberView(db, r) {-- const u = db.prepare('SELECT username, display_name FROM users WHERE subject_id = ?').get(r.subject_id);++async function memberView(db, r) {++ const u = await db.prepare('SELECT username, display_name FROM users WHERE subject_id = ?').get(r.subject_id);return { subject: { type: 'user', id: r.subject_id }, username: u ? u.username : null, display_name: u ? (u.display_name || u.username) : null, role: r.role, added_at: r.added_at };}--function listMembers(db, actor, projectId) {-- const { project } = access(db, actor, projectId, { allowArchived: true });-- return db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? ORDER BY added_at').all(project.id).map(r => memberView(db, r));++async function listMembers(db, actor, projectId) {++ const { project } = await access(db, actor, projectId, { allowArchived: true });++ return (await Promise.all((await db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? ORDER BY added_at').all(project.id)).map(async r => await memberView(db, r))));}--function resolveUserSubject(db, { subject_id, username }) {++async function resolveUserSubject(db, { subject_id, username }) {let u = null;-- if (subject_id && USER_SUBJECT_RE.test(String(subject_id))) u = db.prepare('SELECT id, subject_id, is_banned FROM users WHERE subject_id = ?').get(String(subject_id));-- else if (username) u = db.prepare('SELECT id, subject_id, created_at, is_banned FROM users WHERE username = ? COLLATE NOCASE').get(String(username));++ if (subject_id && USER_SUBJECT_RE.test(String(subject_id))) u = await db.prepare('SELECT id, subject_id, is_banned FROM users WHERE subject_id = ?').get(String(subject_id));++ else if (username) u = await db.prepare('SELECT id, subject_id, created_at, is_banned FROM users WHERE lower(username) = lower(?)').get(String(username));if (!u || u.is_banned) fail(404, 'member.user_not_found', 'no such account');-- return u.subject_id || subjects.ensureUserSubject(db, u);++ return u.subject_id || await subjects.ensureUserSubject(db, u);}/** Admins manage developers and viewers; only the owner manages admins. Ownership is not assignable here. */@@@ -348,46 +249,46 @@ function canAssign(role, targetRole) {return RANK[role] >= RANK.admin;}--function addMember(db, actor, projectId, body, { ctx }) {-- const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });++async function addMember(db, actor, projectId, body, { ctx }) {++ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });const newRole = String(body.role || 'developer');if (!ROLES.includes(newRole)) fail(422, 'member.invalid', `role is one of ${ROLES.filter(r => r !== 'owner').join(', ')}`);if (!canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot assign ${newRole}`);-- const subject = resolveUserSubject(db, body);-- if (memberRole(db, project.id, subject)) fail(409, 'member.exists', 'already a member');-- db.transaction(() => {-- db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)').run(project.id, subject, newRole, nowIso(), actor.label);-- audit(db, { projectId: project.id, actor: actor.label, action: 'member.added', target: `user:${subject}`, detail: { role: newRole }, ctx });-- })();-- return memberView(db, db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));--}----function updateMember(db, actor, projectId, subject, body, { ctx }) {-- const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });-- const current = memberRole(db, project.id, subject);++ const subject = await resolveUserSubject(db, body);++ if (await memberRole(db, project.id, subject)) fail(409, 'member.exists', 'already a member');++ await db.tx(async () => {++ await db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)').run(project.id, subject, newRole, nowIso(), actor.label);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'member.added', target: `user:${subject}`, detail: { role: newRole }, ctx });++ });++ return await memberView(db, await db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));++}++++async function updateMember(db, actor, projectId, subject, body, { ctx }) {++ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });++ const current = await memberRole(db, project.id, subject);if (!current) fail(404, 'member.not_found', 'not a member');const newRole = String(body.role || '');if (!ROLES.includes(newRole)) fail(422, 'member.invalid', 'unknown role');if (current === 'owner') fail(403, 'member.forbidden', 'the owner role is not changed here');if (!canAssign(role, current) || !canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot change ${current} to ${newRole}`);-- db.transaction(() => {-- db.prepare('UPDATE dev_project_members SET role = ? WHERE project_id = ? AND subject_id = ?').run(newRole, project.id, subject);-- audit(db, { projectId: project.id, actor: actor.label, action: 'member.role_changed', target: `user:${subject}`, detail: { from: current, to: newRole }, ctx });-- })();-- return memberView(db, db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));++ await db.tx(async () => {++ await db.prepare('UPDATE dev_project_members SET role = ? WHERE project_id = ? AND subject_id = ?').run(newRole, project.id, subject);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'member.role_changed', target: `user:${subject}`, detail: { from: current, to: newRole }, ctx });++ });++ return await memberView(db, await db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));}--function removeMember(db, actor, projectId, subject, { ctx }) {++async function removeMember(db, actor, projectId, subject, { ctx }) {const self = subject === actor.subject;-- const { project, role } = access(db, actor, projectId, { need: self ? 'viewer' : 'admin', staffOk: false, allowArchived: true });-- const current = memberRole(db, project.id, subject);++ const { project, role } = await access(db, actor, projectId, { need: self ? 'viewer' : 'admin', staffOk: false, allowArchived: true });++ const current = await memberRole(db, project.id, subject);if (!current) fail(404, 'member.not_found', 'not a member');if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);-- db.transaction(() => {-- db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);-- audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });-- })();++ await db.tx(async () => {++ await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);++ await audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });++ });}// ── Apps ───────────────────────────────────────────────────────@@@ -410,17 +311,17 @@ function validRedirects(list, environment) {/** Role needed to manage an app: developers manage sandbox apps, admins production ones. */const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');--function appView(db, a) {++async function appView(db, a) {return {id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment,client_id: a.oauth_client_id, client_type: a.client_type, redirect_uris: JSON.parse(a.redirect_uris || '[]'),created_at: a.created_at, revoked_at: a.revoked_at || null,-- grants: db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND status = 'approved' ORDER BY capability").all(a.id).map(g => g.capability),++ grants: (await db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND status = 'approved' ORDER BY capability").all(a.id)).map(g => g.capability),};}--function loadApp(db, projectId, appId) {-- const a = APP_ID_RE.test(String(appId)) ? db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ?').get(appId, projectId) : null;++async function loadApp(db, projectId, appId) {++ const a = APP_ID_RE.test(String(appId)) ? await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ?').get(appId, projectId) : null;if (!a) fail(404, 'app.not_found', 'no such app');return a;}@@@ -431,85 +332,85 @@ function newSecret() {}const hashSecret = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');--function insertCredential(db, app, actor) {++async function insertCredential(db, app, actor) {const s = newSecret();const id = `crd_${ids.ulid()}`;-- db.prepare('INSERT INTO dev_credentials (id, app_id, secret_hash, hint, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO dev_credentials (id, app_id, secret_hash, hint, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)').run(id, app.id, s.hash, s.hint, nowIso(), actor.label);return { id, secret: s.secret, hint: s.hint };}--function listApps(db, actor, projectId) {-- const { project } = access(db, actor, projectId, { allowArchived: true });-- return db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project.id).map(a => appView(db, a));++async function listApps(db, actor, projectId) {++ const { project } = await access(db, actor, projectId, { allowArchived: true });++ return (await Promise.all((await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project.id)).map(async a => await appView(db, a))));}--function getApp(db, actor, projectId, appId) {-- const { project } = access(db, actor, projectId, { allowArchived: true });-- return appView(db, loadApp(db, project.id, appId));++async function getApp(db, actor, projectId, appId) {++ const { project } = await access(db, actor, projectId, { allowArchived: true });++ return await appView(db, await loadApp(db, project.id, appId));}/** Create an app. Confidential apps get their first secret, returned here once and never again. */--function createApp(db, actor, projectId, body, { settings, ctx }) {++async function createApp(db, actor, projectId, body, { settings, ctx }) {const environment = String(body.environment || 'sandbox');if (!policy.ENVIRONMENTS.includes(environment)) fail(422, 'app.invalid', 'environment is sandbox or production');-- const { project } = access(db, actor, projectId, { need: manageRole(environment), staffOk: false });++ const { project } = await access(db, actor, projectId, { need: manageRole(environment), staffOk: false });if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(environment)) fail(403, 'app.environment_not_allowed', `this project may only have ${policy.ENVIRONMENT_POLICIES[project.environment_policy].join(', ')} apps (staff enable production)`);const clientType = String(body.type || body.client_type || 'confidential');if (!CLIENT_TYPES.includes(clientType)) fail(422, 'app.invalid', 'type is confidential or public');const name = cleanName(body.name);const redirects = validRedirects(body.redirect_uris, environment);if (clientType === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI (it can only use authorization code + PKCE)');-- const active = db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(project.id).n;++ const active = (await db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(project.id)).n;if (active >= settings.maxAppsPerProject) fail(409, 'app.limit', `at most ${settings.maxAppsPerProject} active apps per project`);const id = ids.newId('app');let credential = null;-- db.transaction(() => {-- db.prepare(`INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, redirect_uris, created_at, created_by)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, redirect_uris, created_at, created_by)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, project.id, name, environment, id, clientType, JSON.stringify(redirects), nowIso(), actor.label);-- const app = db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id);-- if (clientType === 'confidential') credential = insertCredential(db, app, actor);-- audit(db, { projectId: project.id, actor: actor.label, action: 'app.created', target: `app:${id}`,++ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id);++ if (clientType === 'confidential') credential = await insertCredential(db, app, actor);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'app.created', target: `app:${id}`,detail: { name, environment, client_type: clientType, credential_id: credential ? credential.id : undefined }, ctx,event: { type: 'network.app.created', subject: { type: 'app', id }, payload: { project_id: project.id, environment, client_type: clientType } } });-- })();-- const out = appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id));++ });++ const out = await appView(db, await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id));if (credential) out.credential = { id: credential.id, client_secret: credential.secret, hint: credential.hint, shown_once: true };return out;}--function updateApp(db, actor, projectId, appId, body, { ctx }) {-- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: false });-- const app = loadApp(db, project.id, appId);-- access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });++async function updateApp(db, actor, projectId, appId, body, { ctx }) {++ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: false });++ const app = await loadApp(db, project.id, appId);++ await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');const name = body.name !== undefined ? cleanName(body.name) : app.name;const redirects = body.redirect_uris !== undefined ? validRedirects(body.redirect_uris, app.environment) : JSON.parse(app.redirect_uris);if (app.client_type === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI');-- db.transaction(() => {-- db.prepare('UPDATE dev_apps SET name = ?, redirect_uris = ? WHERE id = ?').run(name, JSON.stringify(redirects), app.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'app.updated', target: `app:${app.id}`, detail: { name, redirect_uris: redirects }, ctx });-- })();-- return appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));++ await db.tx(async () => {++ await db.prepare('UPDATE dev_apps SET name = ?, redirect_uris = ? WHERE id = ?').run(name, JSON.stringify(redirects), app.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'app.updated', target: `app:${app.id}`, detail: { name, redirect_uris: redirects }, ctx });++ });++ return await appView(db, await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));}--function revokeAppTx(db, actor, project, app, ctx, reason) {++async function revokeAppTx(db, actor, project, app, ctx, reason) {const t = nowIso();-- db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);-- db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);-- db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,++ await db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);++ await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);++ await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });}/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */--function revokeApp(db, actor, projectId, appId, { ctx }) {-- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: true });-- const app = loadApp(db, project.id, appId);-- if (!actor.staff) access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });-- if (app.revoked_at) return appView(db, app);-- db.transaction(() => revokeAppTx(db, actor, project, app, ctx, actor.staff && !memberRole(db, project.id, actor.subject) ? 'staff' : null))();-- return appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));++async function revokeApp(db, actor, projectId, appId, { ctx }) {++ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: true });++ const app = await loadApp(db, project.id, appId);++ if (!actor.staff) await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });++ if (app.revoked_at) return await appView(db, app);++ await db.tx(async () => await revokeAppTx(db, actor, project, app, ctx, actor.staff && !await memberRole(db, project.id, actor.subject) ? 'staff' : null));++ return await appView(db, await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));}// ── Credentials ────────────────────────────────────────────────@@@ -524,20 +425,20 @@ function credentialView(c) {return { id: c.id, hint: c.hint, state: credentialState(c), created_at: c.created_at, expires_at: c.expires_at || null, revoked_at: c.revoked_at || null, last_used_at: c.last_used_at || null };}--function listCredentials(db, actor, projectId, appId) {-- const { project } = access(db, actor, projectId, { allowArchived: true });-- const app = loadApp(db, project.id, appId);-- return db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? ORDER BY id DESC').all(app.id).map(credentialView);++async function listCredentials(db, actor, projectId, appId) {++ const { project } = await access(db, actor, projectId, { allowArchived: true });++ const app = await loadApp(db, project.id, appId);++ return (await db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? ORDER BY id DESC').all(app.id)).map(credentialView);}/*** Rotate: a new secret (returned once); every still-valid previous secret stays valid for* `overlap_seconds` (default settings.credentialOverlapS, 0..7 days), never longer than it already had.*/--function rotateCredential(db, actor, projectId, appId, body, { settings, ctx }) {-- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: false });-- const app = loadApp(db, project.id, appId);-- access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });++async function rotateCredential(db, actor, projectId, appId, body, { settings, ctx }) {++ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: false });++ const app = await loadApp(db, project.id, appId);++ await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');if (app.client_type !== 'confidential') fail(409, 'credential.public_client', 'public apps have no client secret');let overlap = settings.credentialOverlapS;@@@ -549,47 +450,47 @@ function rotateCredential(db, actor, projectId, appId, body, { settings, ctx })const until = new Date(Date.now() + overlap * 1000).toISOString();let created;const kept = [];-- db.transaction(() => {-- for (const c of db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(app.id)) {++ await db.tx(async () => {++ for (const c of await db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(app.id)) {if (credentialState(c) === 'expired') continue;const exp = c.expires_at && Date.parse(c.expires_at) < Date.parse(until) ? c.expires_at : until;-- db.prepare('UPDATE dev_credentials SET expires_at = ? WHERE id = ?').run(exp, c.id);++ await db.prepare('UPDATE dev_credentials SET expires_at = ? WHERE id = ?').run(exp, c.id);kept.push({ id: c.id, expires_at: exp });}-- created = insertCredential(db, app, actor);-- audit(db, { projectId: project.id, actor: actor.label, action: 'credential.rotated', target: `app:${app.id}`,++ created = await insertCredential(db, app, actor);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'credential.rotated', target: `app:${app.id}`,detail: { credential_id: created.id, hint: created.hint, overlap_seconds: overlap, previous: kept }, ctx,event: { type: 'network.credential.rotated', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: created.id, previous_valid_until: kept.length ? until : null } } });-- })();++ });return { credential: { id: created.id, client_secret: created.secret, hint: created.hint, shown_once: true }, previous: kept };}/** Revoke one credential: it stops authenticating at once. */--function revokeCredential(db, actor, projectId, appId, credId, { ctx }) {-- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: true, allowArchived: true });-- const app = loadApp(db, project.id, appId);-- if (!actor.staff) access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false, allowArchived: true });-- const c = CRED_ID_RE.test(String(credId)) ? db.prepare('SELECT * FROM dev_credentials WHERE id = ? AND app_id = ?').get(credId, app.id) : null;++async function revokeCredential(db, actor, projectId, appId, credId, { ctx }) {++ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: true, allowArchived: true });++ const app = await loadApp(db, project.id, appId);++ if (!actor.staff) await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false, allowArchived: true });++ const c = CRED_ID_RE.test(String(credId)) ? await db.prepare('SELECT * FROM dev_credentials WHERE id = ? AND app_id = ?').get(credId, app.id) : null;if (!c) fail(404, 'credential.not_found', 'no such credential');if (!c.revoked_at) {-- db.transaction(() => {-- db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(nowIso(), actor.label, c.id);-- audit(db, { projectId: project.id, actor: actor.label, action: 'credential.revoked', target: `app:${app.id}`, detail: { credential_id: c.id, hint: c.hint }, ctx,++ await db.tx(async () => {++ await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(nowIso(), actor.label, c.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'credential.revoked', target: `app:${app.id}`, detail: { credential_id: c.id, hint: c.hint }, ctx,event: { type: 'network.credential.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: c.id } } });-- })();++ });}-- return credentialView(db.prepare('SELECT * FROM dev_credentials WHERE id = ?').get(c.id));++ return credentialView(await db.prepare('SELECT * FROM dev_credentials WHERE id = ?').get(c.id));}/** Token path: the credential row a presented secret matches, if it is still valid. Updates last_used_at. */--function matchSecret(db, appId, secret) {++async function matchSecret(db, appId, secret) {if (typeof secret !== 'string' || !secret.startsWith(SECRET_PREFIX) || secret.length > 200) return null;const want = Buffer.from(hashSecret(secret), 'hex');const now = Date.now();-- for (const c of db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(appId)) {++ for (const c of await db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(appId)) {if (credentialState(c, now) === 'expired') continue;if (crypto.timingSafeEqual(Buffer.from(c.secret_hash, 'hex'), want)) {-- db.prepare('UPDATE dev_credentials SET last_used_at = ? WHERE id = ?').run(nowIso(), c.id);++ await db.prepare('UPDATE dev_credentials SET last_used_at = ? WHERE id = ?').run(nowIso(), c.id);return c;}}@@@ -602,15 +503,15 @@ function grantView(g) {return { app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null };}--function grantEvent(db, actor, projectId, appId, capability, audience, from, to, ctx, reason) {-- audit(db, { projectId, actor: actor.label, action: `grant.${to}`, target: `app:${appId}`, detail: { capability, audience, from, reason: reason || undefined }, ctx,++async function grantEvent(db, actor, projectId, appId, capability, audience, from, to, ctx, reason) {++ await audit(db, { projectId, actor: actor.label, action: `grant.${to}`, target: `app:${appId}`, detail: { capability, audience, from, reason: reason || undefined }, ctx,event: { type: 'network.grant.changed', subject: { type: 'app', id: appId }, payload: { project_id: projectId, capability, audience, from, to } } });}--function listGrants(db, actor, projectId, appId) {-- const { project } = access(db, actor, projectId, { allowArchived: true });-- const app = loadApp(db, project.id, appId);-- return db.prepare('SELECT * FROM dev_grants WHERE app_id = ? ORDER BY capability').all(app.id).map(grantView);++async function listGrants(db, actor, projectId, appId) {++ const { project } = await access(db, actor, projectId, { allowArchived: true });++ const app = await loadApp(db, project.id, appId);++ return (await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? ORDER BY capability').all(app.id)).map(grantView);}/** Inside the project's allowance, or (sandbox apps) the sandbox allowance. */@@@ -622,34 +523,34 @@ function withinAllowance(project, app, capability, settings) {* Request a capability for an app (developer+). Owners and admins get it approved at once when it* is inside the allowance; otherwise it waits for an owner/admin.*/--function requestGrant(db, actor, projectId, appId, { capability }, { ctx, settings }) {-- const { project, role } = access(db, actor, projectId, { need: 'developer', staffOk: false });-- const app = loadApp(db, project.id, appId);++async function requestGrant(db, actor, projectId, appId, { capability }, { ctx, settings }) {++ const { project, role } = await access(db, actor, projectId, { need: 'developer', staffOk: false });++ const app = await loadApp(db, project.id, appId);if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');const g = policy.grantability(capability);if (!g.grantable) fail(g.code === 'grant.unknown_capability' ? 422 : 403, g.code, g.reason);const audience = policy.audienceOf(capability);-- const existing = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability);++ const existing = await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability);if (existing && ['approved', 'requested'].includes(existing.status)) return grantView(existing);const approveNow = RANK[role] >= RANK.admin && withinAllowance(project, app, capability, settings);const status = approveNow ? 'approved' : 'requested';const t = nowIso();-- db.transaction(() => {-- db.prepare(`INSERT INTO dev_grants (app_id, capability, audience, status, requested_by, requested_at, decided_by, decided_at)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO dev_grants (app_id, capability, audience, status, requested_by, requested_at, decided_by, decided_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?)ON CONFLICT(app_id, capability) DO UPDATE SET audience = excluded.audience, status = excluded.status, requested_by = excluded.requested_by,requested_at = excluded.requested_at, decided_by = excluded.decided_by, decided_at = excluded.decided_at`).run(app.id, capability, audience, status, actor.label, t, approveNow ? actor.label : null, approveNow ? t : null);-- if (approveNow) grantEvent(db, actor, project.id, app.id, capability, audience, existing ? existing.status : 'none', 'approved', ctx);-- else audit(db, { projectId: project.id, actor: actor.label, action: 'grant.requested', target: `app:${app.id}`, detail: { capability, audience }, ctx });-- })();-- return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability));++ if (approveNow) await grantEvent(db, actor, project.id, app.id, capability, audience, existing ? existing.status : 'none', 'approved', ctx);++ else await audit(db, { projectId: project.id, actor: actor.label, action: 'grant.requested', target: `app:${app.id}`, detail: { capability, audience }, ctx });++ });++ return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability));}--function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, settings }) {-- const { project } = access(db, actor, projectId, { need: 'admin', staffOk: decision === 'revoked', allowArchived: decision === 'revoked' });-- const app = loadApp(db, project.id, appId);-- const g = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, String(capability));++async function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, settings }) {++ const { project } = await access(db, actor, projectId, { need: 'admin', staffOk: decision === 'revoked', allowArchived: decision === 'revoked' });++ const app = await loadApp(db, project.id, appId);++ const g = await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, String(capability));if (!g) fail(404, 'grant.not_found', 'no such grant');if (decision === 'approved') {if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');@@@ -664,11 +565,11 @@ function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, s} else if (decision === 'revoked') {if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);}-- db.transaction(() => {-- db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);-- grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);-- })();-- return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));++ await db.tx(async () => {++ await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);++ await grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);++ });++ return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));}// ── Quotas ─────────────────────────────────────────────────────@@@ -677,14 +578,14 @@ function quotaView(q) {return { capability: q.capability, limit: q.limit_value, window: q.quota_window, unit: q.unit, enforced_by: policy.audienceOf(q.capability), updated_at: q.updated_at };}--function listQuotas(db, actor, projectId) {-- const { project } = access(db, actor, projectId, { allowArchived: true });-- return db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(project.id).map(quotaView);++async function listQuotas(db, actor, projectId) {++ const { project } = await access(db, actor, projectId, { allowArchived: true });++ return (await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(project.id)).map(quotaView);}--function setQuota(db, actor, projectId, capability, body, { ctx }) {++async function setQuota(db, actor, projectId, capability, body, { ctx }) {if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');-- const { project } = access(db, actor, projectId);++ const { project } = await access(db, actor, projectId);const g = policy.grantability(capability);if (!g.grantable) fail(422, g.code, g.reason);const limit = Number(body.limit);@@@ -693,22 +594,22 @@ function setQuota(db, actor, projectId, capability, body, { ctx }) {if (!QUOTA_WINDOWS.includes(window)) fail(422, 'quota.invalid', `window is one of ${QUOTA_WINDOWS.join(', ')}`);const unit = String(body.unit || 'requests');if (!/^[a-z][a-z0-9_]{0,31}$/.test(unit)) fail(422, 'quota.invalid', 'unit is a short lowercase word (requests, bytes, tokens, ...)');-- db.transaction(() => {-- db.prepare(`INSERT INTO dev_quotas (project_id, capability, limit_value, quota_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO dev_quotas (project_id, capability, limit_value, quota_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)ON CONFLICT(project_id, capability) DO UPDATE SET limit_value = excluded.limit_value, quota_window = excluded.quota_window, unit = excluded.unit,updated_at = excluded.updated_at, updated_by = excluded.updated_by`).run(project.id, capability, limit, window, unit, nowIso(), actor.label);-- audit(db, { projectId: project.id, actor: actor.label, action: 'quota.set', target: project.id, detail: { capability, limit, window, unit }, ctx });-- })();-- return quotaView(db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, capability));++ await audit(db, { projectId: project.id, actor: actor.label, action: 'quota.set', target: project.id, detail: { capability, limit, window, unit }, ctx });++ });++ return quotaView(await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, capability));}--function deleteQuota(db, actor, projectId, capability, { ctx }) {++async function deleteQuota(db, actor, projectId, capability, { ctx }) {if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');-- const { project } = access(db, actor, projectId);-- const n = db.prepare('DELETE FROM dev_quotas WHERE project_id = ? AND capability = ?').run(project.id, String(capability)).changes;++ const { project } = await access(db, actor, projectId);++ const n = (await db.prepare('DELETE FROM dev_quotas WHERE project_id = ? AND capability = ?').run(project.id, String(capability))).changes;if (!n) fail(404, 'quota.not_found', 'no quota for that capability');-- audit(db, { projectId: project.id, actor: actor.label, action: 'quota.deleted', target: project.id, detail: { capability }, ctx });++ await audit(db, { projectId: project.id, actor: actor.label, action: 'quota.deleted', target: project.id, detail: { capability }, ctx });}module.exports = {@@@ -33,10 +33,10 @@ const isAppClient = (clientId) => store.APP_ID_RE.test(String(clientId || ''));const oauthError = (status, error, description) => ({ status, body: { error, error_description: description } });/** The app plus its project, if both are usable for issuing tokens now. */--function usableApp(db, clientId) {-- const app = isAppClient(clientId) ? db.prepare('SELECT * FROM dev_apps WHERE oauth_client_id = ?').get(String(clientId)) : null;++async function usableApp(db, clientId) {++ const app = isAppClient(clientId) ? await db.prepare('SELECT * FROM dev_apps WHERE oauth_client_id = ?').get(String(clientId)) : null;if (!app || app.revoked_at) return { error: oauthError(401, 'invalid_client', 'unknown or revoked app') };-- const project = db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(app.project_id);++ const project = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(app.project_id);if (!project || project.archived_at) return { error: oauthError(401, 'invalid_client', 'project archived') };if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(app.environment)) {return { error: oauthError(400, 'unauthorized_client', `${app.environment} apps are not enabled for this project`) };@@@ -45,10 +45,10 @@ function usableApp(db, clientId) {}/** Capabilities the app holds for an audience right now (sandbox apps: allowance ∪ sandbox allowance). */--function effectiveGrants(db, app, project, audience, settings) {++async function effectiveGrants(db, app, project, audience, settings) {const allowance = policy.allowanceFor(project, app, settings);-- return db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND audience = ? AND status = 'approved' ORDER BY capability")-- .all(app.id, audience).map(r => r.capability)++ return (await db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND audience = ? AND status = 'approved' ORDER BY capability")++ .all(app.id, audience)).map(r => r.capability).filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);}@@@ -57,13 +57,13 @@ function projectNamespaces(projectId) {return [projectId, `app.${projectId}.*`];}--function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {++async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {const aud = String(audience || '').trim();if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {return oauthError(400, 'invalid_target', `${aud} does not accept sandbox tokens`);}-- const held = effectiveGrants(db, app, project, aud, settings).filter(c => !limit || limit.includes(c));++ const held = (await effectiveGrants(db, app, project, aud, settings)).filter(c => !limit || limit.includes(c));const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);@@@ -118,8 +118,8 @@ const exportSubject = (projectId) => `app:app_${String(projectId).replace(/^prj_* Every mint writes a dev_audit row (project.export_token_issued: audience, env, cap, jti, expiry);* the token itself is never stored or logged. Returns the token response; throws DevError.*/--function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }) {-- const { project } = store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });++async function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }) {++ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });const audience = String((body && body.audience) || '');const cap = EXPORT_CAPS[audience];if (!cap) throw new store.DevError(422, 'export.invalid_audience', `audience is one of ${Object.keys(EXPORT_CAPS).join(', ')}`);@@@ -134,7 +134,7 @@ function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }assertValid('identity.service-token-claims@1', claims);const token = serviceAuth.signServiceToken(claims, privateKey);const expiresAt = new Date(claims.exp * 1000).toISOString();-- store.audit(db, {++ await store.audit(db, {projectId: project.id, actor: actor.label, action: 'project.export_token_issued', target: claims.sub,detail: { audience, env, cap: claims.cap, jti: claims.jti, expires_at: expiresAt, purpose: EXPORT_PURPOSE }, ctx,});@@@ -145,47 +145,47 @@ function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }}/** Authenticate the client of a token request. Public apps present no secret. */--function authenticate(db, app, clientSecret) {++async function authenticate(db, app, clientSecret) {if (app.client_type === 'public') {if (clientSecret) return oauthError(401, 'invalid_client', 'public apps have no client secret');return null;}-- if (!store.matchSecret(db, app.id, clientSecret)) return oauthError(401, 'invalid_client', 'Invalid client credentials');++ if (!await store.matchSecret(db, app.id, clientSecret)) return oauthError(401, 'invalid_client', 'Invalid client credentials');return null;}/** /oauth/token for an app client id. Returns { status, body }. */--function handleTokenRequest(db, body, { privateKey, issuer, config }) {++async function handleTokenRequest(db, body, { privateKey, issuer, config }) {const settings = policy.settings(config);-- const found = usableApp(db, body.client_id);++ const found = await usableApp(db, body.client_id);if (found.error) return found.error;const { app, project } = found;if (body.grant_type === 'client_credentials') {if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');-- const bad = authenticate(db, app, body.client_secret);++ const bad = await authenticate(db, app, body.client_secret);if (bad) return bad;-- return mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });++ return await mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });}if (body.grant_type === 'authorization_code') {-- const bad = authenticate(db, app, body.client_secret);++ const bad = await authenticate(db, app, body.client_secret);if (bad) return bad;const code = String(body.code || '');if (!code) return oauthError(400, 'invalid_request', 'Missing code');const hash = store.hashSecret(code);-- const row = db.prepare('SELECT * FROM dev_auth_codes WHERE code_hash = ?').get(hash);++ const row = await db.prepare('SELECT * FROM dev_auth_codes WHERE code_hash = ?').get(hash);if (!row || row.app_id !== app.id) return oauthError(400, 'invalid_grant', 'Invalid authorization code');// Single use, atomically; a failed verification below still burns the code.-- if (db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE code_hash = ? AND used = 0').run(hash).changes !== 1) return oauthError(400, 'invalid_grant', 'Code already used');++ if ((await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE code_hash = ? AND used = 0').run(hash)).changes !== 1) return oauthError(400, 'invalid_grant', 'Code already used');if (Date.parse(row.expires_at) < Date.now()) return oauthError(400, 'invalid_grant', 'Authorization code expired');if (row.redirect_uri !== String(body.redirect_uri || '')) return oauthError(400, 'invalid_grant', 'Redirect URI mismatch');if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');-- const user = db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);++ const user = await db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');// The exchange may narrow what was authorized, never widen it.const authorized = row.scope ? row.scope.split(' ') : null;const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;if (authorized && asked && asked.some(s => !authorized.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');-- return mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });++ return await mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });}return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');}@@@ -201,8 +201,8 @@ function verifierMatches(verifier, challenge) {* /oauth/authorize and /oauth/confirm checks for an app client id: registered redirect URI and a* PKCE S256 challenge (required for every app). Returns { app, project } or { error: message }.*/--function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, code_challenge_method }) {-- const found = usableApp(db, client_id);++async function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, code_challenge_method }) {++ const found = await usableApp(db, client_id);if (found.error) return { error: 'Unknown client_id' };const uris = JSON.parse(found.app.redirect_uris || '[]');if (!redirect_uri || !uris.includes(String(redirect_uri))) return { error: 'Invalid redirect_uri' };@@@ -216,18 +216,18 @@ function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, co* Issue an authorization code to an app for a signed-in user. A sandbox app may only be authorized* by members of its project. Returns { code } or { status, error }.*/--function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {++async function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {const subjects = require('../identity/subjects');-- const subject = subjects.ensureUserSubject(db, user);++ const subject = await subjects.ensureUserSubject(db, user);if (!subject) return { status: 403, error: 'account has no subject id' };-- if (app.environment === 'sandbox' && !store.memberRole(db, project.id, subject)) {++ if (app.environment === 'sandbox' && !await store.memberRole(db, project.id, subject)) {return { status: 403, error: 'sandbox apps can only be authorized by members of their project' };}const code = crypto.randomBytes(32).toString('hex');-- db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)++ await db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());-- db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());++ await db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());return { code };}@@@ -45,59 +45,7 @@ const dayOf = (ms) => iso(ms).slice(0, 10);const parse = (s, d) => { try { return JSON.parse(s); } catch { return d; } };const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});--function ensure(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS dev_usage_windows (-- project_id TEXT NOT NULL,-- env TEXT NOT NULL CHECK (env IN ('sandbox', 'production')),-- service TEXT NOT NULL,-- capability TEXT NOT NULL,-- dimension TEXT NOT NULL DEFAULT '',-- unit TEXT NOT NULL,-- window_start TEXT NOT NULL,-- window_end TEXT NOT NULL,-- quantity INTEGER NOT NULL,-- errors INTEGER NOT NULL,-- error_codes TEXT NOT NULL DEFAULT '{}',-- revision INTEGER NOT NULL DEFAULT 1,-- event_id TEXT NOT NULL,-- recorded_at TEXT NOT NULL,-- PRIMARY KEY (project_id, env, service, capability, dimension, unit, window_start)-- );-- CREATE INDEX IF NOT EXISTS idx_dev_usage_windows_when ON dev_usage_windows(window_start);-- CREATE INDEX IF NOT EXISTS idx_dev_usage_windows_project ON dev_usage_windows(project_id, recorded_at);-- CREATE TABLE IF NOT EXISTS dev_usage_daily (-- project_id TEXT NOT NULL,-- env TEXT NOT NULL,-- service TEXT NOT NULL,-- capability TEXT NOT NULL,-- dimension TEXT NOT NULL DEFAULT '',-- unit TEXT NOT NULL,-- day TEXT NOT NULL,-- quantity INTEGER NOT NULL DEFAULT 0,-- errors INTEGER NOT NULL DEFAULT 0,-- error_codes TEXT NOT NULL DEFAULT '{}',-- updated_at TEXT NOT NULL,-- PRIMARY KEY (project_id, env, service, capability, dimension, unit, day)-- );-- CREATE INDEX IF NOT EXISTS idx_dev_usage_daily_day ON dev_usage_daily(project_id, day);-- CREATE TABLE IF NOT EXISTS dev_usage_errors (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- project_id TEXT NOT NULL,-- env TEXT NOT NULL,-- service TEXT NOT NULL,-- capability TEXT NOT NULL,-- at TEXT NOT NULL,-- code TEXT NOT NULL,-- status INTEGER,-- trace_id TEXT,-- ref TEXT NOT NULL DEFAULT '',-- event_id TEXT NOT NULL-- );-- CREATE UNIQUE INDEX IF NOT EXISTS idx_dev_usage_errors_once ON dev_usage_errors(project_id, env, service, capability, at, code, ref);-- CREATE INDEX IF NOT EXISTS idx_dev_usage_errors_recent ON dev_usage_errors(project_id, at);-- `);--}++function ensure(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }function mergeCodes(into, codes) {for (const [k, n] of Object.entries(obj(codes))) if (Number.isSafeInteger(n) && n > 0) into[k] = (into[k] || 0) + n;@@@ -126,15 +74,15 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})VALUES (@project_id, @env, @service, @capability, @dimension, @unit, @day, @quantity, @errors, @error_codes, @updated_at)ON CONFLICT(project_id, env, service, capability, dimension, unit, day) DO UPDATE SETquantity = excluded.quantity, errors = excluded.errors, error_codes = excluded.error_codes, updated_at = excluded.updated_at`),-- addError: db.prepare(`INSERT OR IGNORE INTO dev_usage_errors (project_id, env, service, capability, at, code, status, trace_id, ref, event_id)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),++ addError: db.prepare(`INSERT INTO dev_usage_errors (project_id, env, service, capability, at, code, status, trace_id, ref, event_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),capErrors: db.prepare(`DELETE FROM dev_usage_errors WHERE project_id = ? AND id NOT IN(SELECT id FROM dev_usage_errors WHERE project_id = ? ORDER BY at DESC, id DESC LIMIT ${ERRORS_PER_PROJECT})`),};let lastPrune = 0;/** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */-- function record(event) {++ async function record(event) {const type = String(event && event.event_type || '');if (!TOPICS.includes(type)) return 'ignored:type';const service = type.split('.')[0];@@@ -155,14 +103,14 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})const t = now();if (start > t + HOUR_MS) return 'ignored:future';if (start < t - WINDOW_DAYS * DAY_MS) return 'ignored:too_old';-- if (!q.project.get(p.project_id)) return 'ignored:project';++ if (!await q.project.get(p.project_id)) return 'ignored:project';const key = { project_id: p.project_id, env: p.env, service, capability: p.capability, dimension: p.dimension || '', unit: p.unit };const windowStart = iso(start);const revision = p.revision || 1;-- const prev = q.window.get(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, windowStart);++ const prev = await q.window.get(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, windowStart);if (prev && prev.revision > revision) return 'ignored:stale';const recordedAt = iso(t);-- q.upsertWindow.run({++ await q.upsertWindow.run({...key, window_start: windowStart, window_end: iso(end), quantity: p.quantity, errors: p.errors,error_codes: JSON.stringify(obj(p.error_codes)), revision, event_id: event.event_id, recorded_at: recordedAt,});@@@ -171,25 +119,25 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})const from = `${day}T00:00:00.000Z`;const to = iso(Date.parse(from) + DAY_MS);const sum = { quantity: 0, errors: 0, codes: {} };-- for (const w of q.dayWindows.all(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, from, to)) {++ for (const w of await q.dayWindows.all(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, from, to)) {sum.quantity += w.quantity; sum.errors += w.errors; mergeCodes(sum.codes, parse(w.error_codes, {}));}-- q.upsertDaily.run({ ...key, day, quantity: sum.quantity, errors: sum.errors, error_codes: JSON.stringify(sum.codes), updated_at: recordedAt });++ await q.upsertDaily.run({ ...key, day, quantity: sum.quantity, errors: sum.errors, error_codes: JSON.stringify(sum.codes), updated_at: recordedAt });for (const s of Array.isArray(p.samples) ? p.samples : []) {-- q.addError.run(key.project_id, key.env, service, key.capability, iso(Date.parse(s.at)), s.code, s.status || null, s.trace_id || null, s.ref || '', event.event_id);++ await q.addError.run(key.project_id, key.env, service, key.capability, iso(Date.parse(s.at)), s.code, s.status || null, s.trace_id || null, s.ref || '', event.event_id);}-- if (p.samples && p.samples.length) q.capErrors.run(key.project_id, key.project_id);-- if (t - lastPrune > HOUR_MS) { lastPrune = t; prune(t); }++ if (p.samples && p.samples.length) await q.capErrors.run(key.project_id, key.project_id);++ if (t - lastPrune > HOUR_MS) { lastPrune = t; await prune(t); }return 'recorded';}-- function prune(t = now()) {-- db.prepare('DELETE FROM dev_usage_windows WHERE window_start < ?').run(iso(t - WINDOW_DAYS * DAY_MS));-- db.prepare('DELETE FROM dev_usage_daily WHERE day < ?').run(dayOf(t - DAILY_DAYS * DAY_MS));-- db.prepare('DELETE FROM dev_usage_errors WHERE at < ?').run(iso(t - ERROR_DAYS * DAY_MS));++ async function prune(t = now()) {++ await db.prepare('DELETE FROM dev_usage_windows WHERE window_start < ?').run(iso(t - WINDOW_DAYS * DAY_MS));++ await db.prepare('DELETE FROM dev_usage_daily WHERE day < ?').run(dayOf(t - DAILY_DAYS * DAY_MS));++ await db.prepare('DELETE FROM dev_usage_errors WHERE at < ?').run(iso(t - ERROR_DAYS * DAY_MS));}-- return { record, prune, summary: (projectId, o) => summary(db, projectId, { now, ...o }) };++ return { record, prune, summary: async (projectId, o) => await summary(db, projectId, { now, ...o }) };}class UsageQueryError extends Error {@@@ -206,13 +154,13 @@ function parseQuery(query = {}) {}/** network.project-usage-result@1 for one project (the caller checked access). */--function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now() } = {}) {++async function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now() } = {}) {const t = now();const to = dayOf(t);const from = dayOf(t - (days - 1) * DAY_MS);const envSql = env === 'all' ? '' : ' AND env = ?';const envArgs = env === 'all' ? [] : [env];-- const rows = db.prepare(`SELECT * FROM dev_usage_daily WHERE project_id = ? AND day >= ?${envSql}++ const rows = await db.prepare(`SELECT * FROM dev_usage_daily WHERE project_id = ? AND day >= ?${envSql}ORDER BY day DESC, service, capability, dimension, unit, env`).all(projectId, from, ...envArgs);const daily = rows.map((r) => ({ day: r.day, service: r.service, capability: r.capability, dimension: r.dimension || null, unit: r.unit, env: r.env, quantity: r.quantity, errors: r.errors }));@@@ -234,9 +182,9 @@ function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now()}// Quotas (dev_quotas, staff-set): what their current window used, every environment together.-- const seen = new Set(db.prepare('SELECT DISTINCT capability, unit FROM dev_usage_daily WHERE project_id = ?').all(projectId).map((r) => `${r.capability}|${r.unit}`));++ const seen = new Set((await db.prepare('SELECT DISTINCT capability, unit FROM dev_usage_daily WHERE project_id = ?').all(projectId)).map((r) => `${r.capability}|${r.unit}`));const usedSince = db.prepare('SELECT COALESCE(SUM(quantity), 0) AS n FROM dev_usage_daily WHERE project_id = ? AND capability = ? AND unit = ? AND day >= ?');-- const quotas = db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(projectId).map((qr) => {++ const quotas = (await Promise.all((await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(projectId)).map(async (qr) => {const cap = capabilities.get(qr.capability);const out = { capability: qr.capability, limit: qr.limit_value, window: qr.quota_window, unit: qr.unit, enforced_by: cap ? `openvibe.${cap.owner}` : null, used: null, remaining: null, window_start: null, note: null };const measured = seen.has(`${qr.capability}|${qr.unit}`) || (REPORTED[qr.capability] || []).includes(qr.unit);@@@ -253,15 +201,15 @@ function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now()if (qr.quota_window === 'day') { since = to; out.window_start = `${to}T00:00:00.000Z`; }else if (qr.quota_window === 'month') { since = `${to.slice(0, 7)}-01`; out.window_start = `${since}T00:00:00.000Z`; }else { since = '0000-00-00'; out.note = `total of the last ${DAILY_DAYS} days kept`; }-- out.used = usedSince.get(projectId, qr.capability, qr.unit, since).n;-- out.remaining = Math.max(0, qr.limit_value - out.used);++ out.used = Number((await usedSince.get(projectId, qr.capability, qr.unit, since)).n);++ out.remaining = Math.max(0, Number(qr.limit_value) - out.used);return out;-- });++ })));-- const recent = db.prepare(`SELECT * FROM dev_usage_errors WHERE project_id = ? AND at >= ?${envSql} ORDER BY at DESC, id DESC LIMIT ${RECENT_ERRORS}`)-- .all(projectId, `${from}T00:00:00.000Z`, ...envArgs)++ const recent = (await db.prepare(`SELECT * FROM dev_usage_errors WHERE project_id = ? AND at >= ?${envSql} ORDER BY at DESC, id DESC LIMIT ${RECENT_ERRORS}`)++ .all(projectId, `${from}T00:00:00.000Z`, ...envArgs)).map((e) => ({ at: e.at, env: e.env, service: e.service, capability: e.capability, code: e.code, status: e.status == null ? null : e.status, trace_id: e.trace_id || null, ref: e.ref || null }));-- const last = db.prepare('SELECT MAX(recorded_at) AS at FROM dev_usage_windows WHERE project_id = ?').get(projectId).at || null;++ const last = (await db.prepare('SELECT MAX(recorded_at) AS at FROM dev_usage_windows WHERE project_id = ?').get(projectId)).at || null;return {project_id: projectId, env, range: { days, from, to }, generated_at: iso(t), last_recorded_at: last,@@@ -121,7 +121,7 @@ class DiscordService {const cooldownMin = parseInt(this._getSetting('discord_dedupe_minutes') || '15', 10);const cooldownMs = Math.max(cooldownMin, 1) * 60 * 1000;const key = streamer.username.toLowerCase();-- const lastAlert = this._liveAlertCooldowns.get(key);++ const lastAlert = await this._liveAlertCooldowns.get(key);if (lastAlert && (Date.now() - lastAlert) < cooldownMs) {return { sent: false, reason: 'cooldown', remaining_ms: cooldownMs - (Date.now() - lastAlert) };}@@@ -210,9 +210,9 @@ class DiscordService {// ─── Helpers ───────────────────────────────────────────-- _getSetting(key) {++ async _getSetting(key) {try {-- return this._db.getSetting(key) || null;++ return await this._db.getSetting(key) || null;} catch {return null;}@@@ -15,21 +15,21 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, rrouter.use(requireAuth, requireAdmin);/** GET /api/admin/discord — bot status + current config */-- router.get('/', (req, res) => {++ router.get('/', async (req, res) => {const status = discordService.getStatus();const settings = {-- discord_bot_token: db.getSetting('discord_bot_token') ? '••••••••' : '',-- discord_guild_id: db.getSetting('discord_guild_id') || '',-- discord_alerts_channel_id: db.getSetting('discord_alerts_channel_id') || '',-- discord_system_channel_id: db.getSetting('discord_system_channel_id') || '',-- discord_dedupe_minutes: db.getSetting('discord_dedupe_minutes') || '15',-- discord_alert_message: db.getSetting('discord_alert_message') || '',-- discord_oauth_client_id: db.getSetting('discord_oauth_client_id') || '',-- discord_oauth_client_secret: db.getSetting('discord_oauth_client_secret') ? '••••••••' : '',++ discord_bot_token: await db.getSetting('discord_bot_token') ? '••••••••' : '',++ discord_guild_id: await db.getSetting('discord_guild_id') || '',++ discord_alerts_channel_id: await db.getSetting('discord_alerts_channel_id') || '',++ discord_system_channel_id: await db.getSetting('discord_system_channel_id') || '',++ discord_dedupe_minutes: await db.getSetting('discord_dedupe_minutes') || '15',++ discord_alert_message: await db.getSetting('discord_alert_message') || '',++ discord_oauth_client_id: await db.getSetting('discord_oauth_client_id') || '',++ discord_oauth_client_secret: await db.getSetting('discord_oauth_client_secret') ? '••••••••' : '',};// env: the variable provides it and the form cannot change it (server/secrets.js).const sources = {};-- for (const k of ['discord_bot_token', 'discord_oauth_client_secret']) sources[k] = { source: secrets.source(db, k), env: secrets.envName(k) };++ for (const k of ['discord_bot_token', 'discord_oauth_client_secret']) sources[k] = { source: await secrets.source(db, k), env: secrets.envName(k) };res.json({ ok: true, status, settings, sources });});@@@ -54,7 +54,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, rfor (const [key, value] of Object.entries(settings)) {if (!allowedKeys.includes(key)) continue;// Never save a secret into the database while its environment variable provides it.-- if (secrets.isManaged(key) && secrets.source(db, key) === 'env') { if (String(value ?? '').trim() && String(value).trim() !== '••••••••') skipped.push(key); continue; }++ if (secrets.isManaged(key) && await secrets.source(db, key) === 'env') { if (String(value ?? '').trim() && String(value).trim() !== '••••••••') skipped.push(key); continue; }// Bot token / OAuth secret are owner-only — silently skip for admins.if (isSensitiveSettingKey(key) && !owner) continue;const strVal = String(value ?? '').trim();@@@ -70,7 +70,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, rif (Object.keys(changes.set).length) {const siteConfig = require('../admin/site-config');try {-- await siteConfig.forDb(db).change(changes, { actor: siteConfig.actorOf(req.user), reason: 'Discord settings' });++ await (await siteConfig.forDb(db)).change(changes, { actor: siteConfig.actorOf(req.user), reason: 'Discord settings' });} catch (err) {return res.status(err.status || 500).json({ ok: false, error: err.message, code: err.code, errors: err.errors });}@@@ -27,34 +27,8 @@ const dnsCheck = require('./dns-check');const NOTE_MAX = 500;--function ensureSchema(db) {-- // 'mirror' joined the roles after the table shipped; SQLite cannot alter a CHECK, so rebuild once.-- try {-- const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tool_domains'").get();-- if (row && /CHECK \(role IN/.test(row.sql) && !/'mirror'/.test(row.sql)) {-- db.exec('BEGIN; ALTER TABLE tool_domains RENAME TO tool_domains_old;');-- _createTable(db);-- db.exec('INSERT INTO tool_domains SELECT * FROM tool_domains_old; DROP TABLE tool_domains_old; COMMIT;');-- }-- } catch (err) { try { db.exec('ROLLBACK'); } catch { /* */ } console.error('[Domains] role migration failed:', err.message); }-- _createTable(db);--}--function _createTable(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS tool_domains (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- tool_id TEXT NOT NULL,-- host TEXT NOT NULL UNIQUE,-- role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),-- enabled INTEGER NOT NULL DEFAULT 1,-- note TEXT,-- created_by INTEGER,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );-- CREATE INDEX IF NOT EXISTS idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);-- `);--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }++function _createTable(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }function iso(sqliteTime) {if (!sqliteTime) return null;@@@ -72,8 +46,8 @@ function catalogOwnerOf(host, catalog) {return null;}--function createDomainRoutes(db, requireAuth, opts = {}) {-- ensureSchema(db);++async function createDomainRoutes(db, requireAuth, opts = {}) {++ await ensureSchema(db);const catalogSource = opts.catalog || toolsCatalog;const checkDomain = opts.checkDomain || dnsCheck.checkDomain;const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};@@@ -87,15 +61,15 @@ function createDomainRoutes(db, requireAuth, opts = {}) {byId: db.prepare('SELECT * FROM tool_domains WHERE id = ?'),byHost: db.prepare('SELECT * FROM tool_domains WHERE host = ?'),holders: db.prepare('SELECT * FROM tool_domains WHERE tool_id = ? AND role = ? AND enabled = 1 AND id != ?'),-- demote: db.prepare("UPDATE tool_domains SET role = 'alias', updated_at = CURRENT_TIMESTAMP WHERE id = ?"),-- insert: db.prepare('INSERT INTO tool_domains (tool_id, host, role, enabled, note, created_by) VALUES (?, ?, ?, ?, ?, ?)'),-- update: db.prepare('UPDATE tool_domains SET tool_id = ?, host = ?, role = ?, enabled = ?, note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),++ demote: db.prepare("UPDATE tool_domains SET role = 'alias', updated_at = ov_now() WHERE id = ?"),++ insert: db.prepare('INSERT INTO tool_domains (tool_id, host, role, enabled, note, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING id'),++ update: db.prepare('UPDATE tool_domains SET tool_id = ?, host = ?, role = ?, enabled = ?, note = ?, updated_at = ov_now() WHERE id = ?'),remove: db.prepare('DELETE FROM tool_domains WHERE id = ?'),};-- function audit(req, action, details) {++ async function audit(req, action, details) {try {-- db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(req.user.id, action, JSON.stringify(details), req.ip || null);++ await db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(req.user.id, action, JSON.stringify(details), req.ip || null);} catch (err) {console.error('[Domains] Audit log error:', err.message);}@@@ -139,7 +113,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {if (owner && owner !== next.tool_id) return { status: 409, error: `${next.host} already belongs to the tool "${owner}" in the Tools catalog`, warnings };}-- const taken = q.byHost.get(next.host);++ const taken = await q.byHost.get(next.host);if (taken && (!current || taken.id !== current.id)) {return { status: 409, error: `${next.host} is already registered for the tool "${taken.tool_id}" — a host belongs to one tool`, warnings };}@@@ -147,18 +121,18 @@ function createDomainRoutes(db, requireAuth, opts = {}) {}/** Write inside one transaction; returns the row plus whatever was demoted to make room. */-- const write = db.transaction((next, current, userId) => {++ const write = db.txFn(async (next, current, userId) => {const demoted = [];if (next.enabled && (next.role === 'canonical' || next.role === 'short')) { // one of each; aliases and mirrors can be many-- for (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {-- q.demote.run(row.id);++ for (const row of await q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {++ await q.demote.run(row.id);demoted.push({ id: row.id, host: row.host, from: row.role });}}let id;-- if (current) { q.update.run(next.tool_id, next.host, next.role, next.enabled, next.note, current.id); id = current.id; }-- else id = q.insert.run(next.tool_id, next.host, next.role, next.enabled, next.note, userId).lastInsertRowid;-- return { row: q.byId.get(id), demoted };++ if (current) { await q.update.run(next.tool_id, next.host, next.role, next.enabled, next.note, current.id); id = current.id; }++ else id = (await q.insert.run(next.tool_id, next.host, next.role, next.enabled, next.note, userId)).lastInsertRowid;++ return { row: await q.byId.get(id), demoted };});// ── public ───────────────────────────────────────────────@@@ -173,11 +147,11 @@ function createDomainRoutes(db, requireAuth, opts = {}) {}next();});-- publicRouter.get('/', (_req, res) => {++ publicRouter.get('/', async (_req, res) => {try {-- const domains = q.publicList.all();++ const domains = await q.publicList.all();res.set('Cache-Control', 'public, max-age=60');-- res.json({ updated: iso(q.lastChange.get().t) || new Date(0).toISOString(), domains });++ res.json({ updated: iso((await q.lastChange.get()).t) || new Date(0).toISOString(), domains });} catch (err) {console.error('[Domains] public list:', err.message);res.status(500).json({ error: 'Could not read the domain registry' });@@@ -198,7 +172,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {try {const { catalog, source } = await catalogSource.getCatalog();const known = new Set([...catalog.tools.map(t => t.id), ...catalog.families.map(f => f.id)]);-- const rows = q.all.all().map(r => ({ ...present(r), tool_known: source === 'fallback' ? null : known.has(r.tool_id) }));++ const rows = (await q.all.all()).map(r => ({ ...present(r), tool_known: source === 'fallback' ? null : known.has(r.tool_id) }));res.json({ ok: true, domains: rows, catalog_source: source });} catch (err) { res.status(500).json({ ok: false, error: err.message }); }});@@@ -225,8 +199,8 @@ function createDomainRoutes(db, requireAuth, opts = {}) {try {const v = await validate(req.body || {}, null);if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });-- const { row, demoted } = write(v.value, null, req.user.id);-- audit(req, 'tool_domain_create', { id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, demoted, tool_unverified: !v.toolVerified || undefined });++ const { row, demoted } = await write(v.value, null, req.user.id);++ await audit(req, 'tool_domain_create', { id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, demoted, tool_unverified: !v.toolVerified || undefined });onChange();res.status(201).json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });} catch (err) {@@@ -237,14 +211,14 @@ function createDomainRoutes(db, requireAuth, opts = {}) {adminRouter.put('/:id', async (req, res) => {try {-- const current = q.byId.get(Number(req.params.id));++ const current = await q.byId.get(Number(req.params.id));if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });const v = await validate(req.body || {}, current);if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });-- const { row, demoted } = write(v.value, current, req.user.id);++ const { row, demoted } = await write(v.value, current, req.user.id);const changed = {};for (const k of ['tool_id', 'host', 'role', 'enabled', 'note']) if (String(current[k] ?? '') !== String(row[k] ?? '')) changed[k] = { from: current[k], to: row[k] };-- audit(req, 'tool_domain_update', { id: row.id, host: row.host, tool_id: row.tool_id, changed, demoted, tool_unverified: !v.toolVerified || undefined });++ await audit(req, 'tool_domain_update', { id: row.id, host: row.host, tool_id: row.tool_id, changed, demoted, tool_unverified: !v.toolVerified || undefined });onChange();res.json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });} catch (err) {@@@ -253,12 +227,12 @@ function createDomainRoutes(db, requireAuth, opts = {}) {}});-- adminRouter.delete('/:id', (req, res) => {++ adminRouter.delete('/:id', async (req, res) => {try {-- const current = q.byId.get(Number(req.params.id));++ const current = await q.byId.get(Number(req.params.id));if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });-- q.remove.run(current.id);-- audit(req, 'tool_domain_delete', { id: current.id, tool_id: current.tool_id, host: current.host, role: current.role });++ await q.remove.run(current.id);++ await audit(req, 'tool_domain_delete', { id: current.id, tool_id: current.tool_id, host: current.host, role: current.role });onChange();res.json({ ok: true });} catch (err) { res.status(500).json({ ok: false, error: err.message }); }@@@ -58,24 +58,18 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {};}--function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {++async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;-- // Renamed from chrome_cache/chrome_hits (2026-09-24): carry the old tables over once.-- for (const [from, to] of [['chrome_cache', 'frame_cache'], ['chrome_hits', 'frame_hits']]) {-- const has = (t) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t));-- if (has(from) && !has(to)) db.exec(`ALTER TABLE ${from} RENAME TO ${to}`);-- }-- db.exec('CREATE TABLE IF NOT EXISTS frame_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)');-- // Anonymous page-view counts per host and day, sent by the shared navbar. No user, no IP, no path.-- db.exec('CREATE TABLE IF NOT EXISTS frame_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host))');-- const bump = db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (date('now'), ?, 1) ON CONFLICT(day, host) DO UPDATE SET hits = hits + 1");-- const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM frame_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };-- const save = (k, v) => db.prepare('INSERT INTO frame_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));---- const savedRank = load('rank') || {};++ // frame_cache and frame_hits are migrations/NNNN_*.sql (plan T2). The old chrome_cache/chrome_hits++ // rename was a SQLite-only step and is not repeated here.++ const bump = db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (substring(ov_now(), 1, 10), ?, 1) ON CONFLICT(day, host) DO UPDATE SET hits = frame_hits.hits + 1");++ const load = async (k) => { try { const r = await db.prepare('SELECT value, updated_at FROM frame_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };++ const save = async (k, v) => await db.prepare('INSERT INTO frame_cache (key, value, updated_at) VALUES (?, ?, ov_now()) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = ov_now()').run(k, JSON.stringify(v));++++ const savedRank = await load('rank') || {};let rank = savedRank.value || { scores: {}, tools: [] };let rankedAt = savedRank.value ? savedRank.at || null : null;-- let copy = (load('copy') || {}).value || { sites: {} };++ let copy = (await load('copy') || {}).value || { sites: {} };let version = Date.now();const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;@@@ -92,7 +86,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =// Everything else: the navbar's page-view beacon, summed per site over 7 days.let tools = rank.tools || [];try {-- const rows = db.prepare("SELECT host, SUM(hits) AS n FROM frame_hits WHERE day >= date('now', '-7 days') GROUP BY host").all();++ const rows = await db.prepare("SELECT host, SUM(hits) AS n FROM frame_hits WHERE day >= substring(datetime('now', '-7 days'), 1, 10) GROUP BY host").all();const perSite = {}; const perTool = new Map();const { catalog } = toolsCatalog.peek();const toolOfHost = new Map();@@@ -108,11 +102,11 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =} catch { /* */ }// Signed-in history is a second opinion that also covers hosts without the shared navbar.try {-- const bySvc = db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();++ const bySvc = await db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }} catch { /* table appears on first history write */ }rank = { scores, tools };-- save('rank', rank); version = Date.now(); rankedAt = Date.now();++ await save('rank', rank); version = Date.now(); rankedAt = Date.now();}const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);@@@ -155,7 +149,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy staysnext[site.id] = { blurb, picks };}-- if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; save('copy', copy); version = Date.now(); }++ if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; await save('copy', copy); version = Date.now(); }}function payloadFor(hostname) {@@@ -189,7 +183,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing-- router.post('/hit', (req, res) => {++ router.post('/hit', async (req, res) => {// sendBeacon is a no-cors request: without CORP cross-origin the browser logs every beacon's// (empty) answer as blocked on every page of every site (the count itself was never affected).res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'no-store' });@@@ -200,14 +194,14 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =if (!w || now - w[1] > 60_000) recent.set(k, [1, now]); else w[0]++;if (recent.size > 5000) recent.clear();const known = host && (siteForHost(host) || toolsCatalog.peek().catalog.tools.some(t => t.hosts && (t.hosts.canonical === host || t.hosts.short === host)));-- if (known && (recent.get(k) || [0])[0] <= 40 && !/bot|crawl|spider|slurp|headless|preview|monitor|curl|wget|python|node|go-http/i.test(ua)) bump.run(host);++ if (known && (recent.get(k) || [0])[0] <= 40 && !/bot|crawl|spider|slurp|headless|preview|monitor|curl|wget|python|node|go-http/i.test(ua)) await bump.run(host);} catch { /* counting must never fail a page */ }res.status(204).end();});let timers = [];-- function start() {-- const rankAge = Date.now() - ((load('rank') || {}).at || 0), copyAge = Date.now() - ((load('copy') || {}).at || 0);++ async function start() {++ const rankAge = Date.now() - ((await load('rank') || {}).at || 0), copyAge = Date.now() - ((await load('copy') || {}).at || 0);const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();@@@ -1,89 +1,64 @@'use strict';++// ═══════════════════════════════════════════════════════════════++// grant-admin — make one person an admin from the operator's shell (roadmap §18.2(12)).++//++// node server/grant-admin.js --username <username>++// node server/grant-admin.js --email <email>++// node server/grant-admin.js --id <userId>++//++// The users live in the service's own PostgreSQL (DATABASE_URL, plan T2); run this where Network runs,++// or with DATABASE_DIRECT_URL/DATABASE_URL pointing at a copy. The change is audited in audit_log.++// ═══════════════════════════════════════════════════════════════++const { initDb } = require('./db/database');--const path = require('path');--const fs = require('fs');--const Database = require('better-sqlite3');--const dotenv = require('dotenv');++const USAGE = 'Usage: node server/grant-admin.js --username <username> | --email <email> | --id <userId>';--function loadEnv() {-- const envPath = path.resolve(process.cwd(), '.env');-- if (fs.existsSync(envPath)) {-- const parsed = dotenv.config({ path: envPath });-- if (parsed.error) {-- throw parsed.error;-- }-- return parsed.parsed || {};++function parseArgs(argv) {++ const args = {};++ for (let i = 0; i < argv.length; i += 1) {++ const a = argv[i];++ if (a === '--username' && argv[i + 1]) args.username = argv[++i];++ else if (a === '--email' && argv[i + 1]) args.email = argv[++i];++ else if (a === '--id' && argv[i + 1]) args.id = argv[++i];++ else if (a === '--help') args.help = true;}-- return {};++ return args;}--function resolveDatabasePath(env) {-- if (env.DB_PATH) {-- return path.resolve(process.cwd(), env.DB_PATH);-- }-- return path.resolve(__dirname, '..', 'data', 'network.db');--}----function parseArgs() {-- const args = process.argv.slice(2);-- const parsed = {};-- for (let i = 0; i < args.length; i += 1) {-- const arg = args[i];-- if (arg === '--username' && args[i + 1]) {-- parsed.username = args[++i];-- } else if (arg === '--email' && args[i + 1]) {-- parsed.email = args[++i];-- } else if (arg === '--id' && args[i + 1]) {-- parsed.id = args[++i];-- } else if (arg === '--help') {-- parsed.help = true;-- }-- }-- return parsed;--}----function showUsage() {-- console.log('Usage: node server/grant-admin.js --username <username> | --email <email> | --id <userId>');-- process.exit(1);--}----function main() {-- const args = parseArgs();++/**++ * @returns {number} process exit code: 0 granted, 1 usage, 3 no such user.++ */++async function main(argv = process.argv.slice(2), { db: injected, log = console } = {}) {++ const args = parseArgs(argv);if (args.help || (!args.username && !args.email && !args.id)) {-- showUsage();++ log.error(USAGE);++ return 1;}-- const env = loadEnv();-- const dbPath = resolveDatabasePath(env);-- if (!fs.existsSync(dbPath)) {-- console.error(`Database file not found at ${dbPath}`);-- process.exit(2);-- }++ const db = injected || await initDb();-- const db = new Database(dbPath);-- const query = [];let row;--if (args.id) {-- row = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(args.id);++ row = await db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(args.id);} else if (args.username) {-- row = db.prepare('SELECT id, username, email, role FROM users WHERE LOWER(username) = LOWER(?)').get(args.username);-- } else if (args.email) {-- row = db.prepare('SELECT id, username, email, role FROM users WHERE LOWER(email) = LOWER(?)').get(args.email);++ row = await db.prepare('SELECT id, username, email, role FROM users WHERE lower(username) = lower(?)').get(args.username);++ } else {++ row = await db.prepare('SELECT id, username, email, role FROM users WHERE lower(email) = lower(?)').get(args.email);}if (!row) {-- console.error('User not found. Please verify username, email, or id.');-- process.exit(3);++ log.error('User not found. Please verify username, email, or id.');++ return 3;}-- db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', row.id);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')++ await db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', row.id);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(null, 'grant_admin', JSON.stringify({ targetId: row.id, targetUsername: row.username, method: args.id ? 'id' : args.email ? 'email' : 'username' }));-- console.log(`User ${row.username} (id=${row.id}) has been granted admin privileges.`);-- db.close();-- process.exit(0);++ log.log(`User ${row.username} (id=${row.id}) has been granted admin privileges.`);++ return 0;}--main();++if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });++++module.exports = { main, parseArgs, USAGE };@@@ -21,31 +21,7 @@ const SERVICE_LABELS = {vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',};--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS user_history (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- service TEXT,-- sub TEXT,-- type TEXT NOT NULL DEFAULT 'page',-- title TEXT NOT NULL,-- url TEXT NOT NULL,-- icon TEXT,-- meta TEXT,-- hits INTEGER NOT NULL DEFAULT 1,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- FOREIGN KEY (user_id) REFERENCES users(id)-- );-- CREATE INDEX IF NOT EXISTS idx_user_history_user_time ON user_history(user_id, updated_at DESC);-- CREATE INDEX IF NOT EXISTS idx_user_history_user_url ON user_history(user_id, url);-- `);-- const cols = db.prepare('PRAGMA table_info(users)').all();-- if (!cols.find(c => c.name === 'history_paused')) {-- try { db.exec('ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0'); } catch { /* exists */ }-- }--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** Only http(s) URLs on the network's own domains are recorded; everything else is dropped. */function allowedUrl(raw) {@@@ -78,8 +54,8 @@ function rowOut(r) {};}--function createHistoryRoutes(db, requireAuth) {-- ensureSchema(db);++async function createHistoryRoutes(db, requireAuth) {++ await ensureSchema(db);const router = express.Router();router.use(requireAuth);@@@ -89,24 +65,24 @@ function createHistoryRoutes(db, requireAuth) {next();});-- router.get('/', (req, res) => {++ router.get('/', async (req, res) => {const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);const where = ['user_id = ?']; const params = [req.user.id];if (req.query.service) { where.push('service = ?'); params.push(String(req.query.service).slice(0, 32)); }if (req.query.type) { where.push('type = ?'); params.push(String(req.query.type).slice(0, 32)); }if (req.query.q) { where.push('(title LIKE ? OR url LIKE ?)'); const q = `%${String(req.query.q).slice(0, 80)}%`; params.push(q, q); }-- const rows = db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);-- const total = db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;-- const paused = !!db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id)?.history_paused;-- const services = db.prepare('SELECT service, COUNT(*) AS c FROM user_history WHERE user_id = ? GROUP BY service ORDER BY c DESC').all(req.user.id)++ const rows = await db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);++ const total = (await db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${where.join(' AND ')}`).get(...params))?.c || 0;++ const paused = !!(await db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id))?.history_paused;++ const services = (await db.prepare('SELECT service, COUNT(*) AS c FROM user_history WHERE user_id = ? GROUP BY service ORDER BY c DESC').all(req.user.id)).map(r => ({ service: r.service, label: SERVICE_LABELS[r.service] || r.service, count: r.c }));res.set('Cache-Control', 'no-store');res.json({ items: rows.map(rowOut), total, paused, services });});-- router.post('/', (req, res) => {-- const u = db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id);++ router.post('/', async (req, res) => {++ const u = await db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id);if (u?.history_paused) return res.json({ ok: true, paused: true });const url = allowedUrl(req.body?.url);if (!url) return res.status(400).json({ error: 'A URL on the OpenVibe network is required' });@@@ -122,31 +98,31 @@ function createHistoryRoutes(db, requireAuth) {const s = JSON.stringify(req.body.meta);if (s.length <= 1000) meta = s;}-- const recent = db.prepare(`SELECT id FROM user_history WHERE user_id = ? AND url = ? AND updated_at > datetime('now', '-${DEDUPE_WINDOW_MIN} minutes')`).get(req.user.id, url);++ const recent = await db.prepare(`SELECT id FROM user_history WHERE user_id = ? AND url = ? AND updated_at > datetime('now', '-${DEDUPE_WINDOW_MIN} minutes')`).get(req.user.id, url);if (recent) {-- db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(title, recent.id);++ await db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, updated_at = ov_now() WHERE id = ?').run(title, recent.id);return res.json({ ok: true, id: recent.id, merged: true });}-- const r = db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')++ const r = await db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id').run(req.user.id, service, sub, type, title, url, icon, meta);// Trim: keep the newest MAX_PER_USER rows.-- db.prepare(`DELETE FROM user_history WHERE user_id = ? AND id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER})`).run(req.user.id, req.user.id);++ await db.prepare(`DELETE FROM user_history WHERE user_id = ? AND id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER})`).run(req.user.id, req.user.id);res.status(201).json({ ok: true, id: r.lastInsertRowid });});-- router.put('/settings', (req, res) => {++ router.put('/settings', async (req, res) => {const paused = req.body?.paused ? 1 : 0;-- db.prepare('UPDATE users SET history_paused = ? WHERE id = ?').run(paused, req.user.id);++ await db.prepare('UPDATE users SET history_paused = ? WHERE id = ?').run(paused, req.user.id);res.json({ ok: true, paused: !!paused });});-- router.delete('/', (req, res) => {-- const r = db.prepare('DELETE FROM user_history WHERE user_id = ?').run(req.user.id);++ router.delete('/', async (req, res) => {++ const r = await db.prepare('DELETE FROM user_history WHERE user_id = ?').run(req.user.id);res.json({ ok: true, deleted: r.changes });});-- router.delete('/:id', (req, res) => {-- const r = db.prepare('DELETE FROM user_history WHERE id = ? AND user_id = ?').run(parseInt(req.params.id, 10) || 0, req.user.id);++ router.delete('/:id', async (req, res) => {++ const r = await db.prepare('DELETE FROM user_history WHERE id = ? AND user_id = ?').run(parseInt(req.params.id, 10) || 0, req.user.id);if (!r.changes) return res.status(404).json({ error: 'Not found' });res.json({ ok: true });});@@@ -51,68 +51,16 @@ class AccountDataError extends Error {const iso = (ms) => new Date(ms).toISOString();--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS account_exports (-- id TEXT PRIMARY KEY,-- user_id INTEGER NOT NULL,-- subject TEXT NOT NULL,-- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),-- expected TEXT NOT NULL DEFAULT '[]',-- requested_at TEXT NOT NULL,-- deadline TEXT NOT NULL,-- ready_at TEXT,-- expires_at TEXT,-- size_bytes INTEGER,-- services TEXT,-- error TEXT-- );-- CREATE INDEX IF NOT EXISTS idx_account_exports_user ON account_exports(user_id, requested_at);-- CREATE TABLE IF NOT EXISTS account_export_parts (-- export_id TEXT NOT NULL,-- service TEXT NOT NULL,-- received_at TEXT NOT NULL,-- files INTEGER NOT NULL,-- bytes INTEGER NOT NULL,-- body TEXT,-- PRIMARY KEY (export_id, service)-- );-- CREATE TABLE IF NOT EXISTS account_deletions (-- id TEXT PRIMARY KEY,-- user_id INTEGER NOT NULL,-- subject TEXT NOT NULL,-- status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),-- requested_at TEXT NOT NULL,-- delete_after TEXT NOT NULL,-- cancelled_at TEXT,-- cancelled_by TEXT,-- deleted_at TEXT,-- expected TEXT,-- erased TEXT-- );-- CREATE UNIQUE INDEX IF NOT EXISTS idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';-- CREATE TABLE IF NOT EXISTS account_deletion_confirmations (-- deletion_id TEXT NOT NULL,-- service TEXT NOT NULL,-- received_at TEXT NOT NULL,-- completed_at TEXT NOT NULL,-- erased TEXT NOT NULL,-- retained TEXT NOT NULL,-- PRIMARY KEY (deletion_id, service)-- );-- `);-- const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);-- if (!cols.includes('deleted_at')) db.exec('ALTER TABLE users ADD COLUMN deleted_at TEXT');--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }--const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);--const columnsOf = (db, table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);++const tableExists = async (db, name) => !!await db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name);++const columnsOf = async (db, table) => (await db.prepare('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?').all(table)).map((c) => c.column_name);/** The services expected to answer: every principal holding the capability for Network. */--function expectedServices(db, capability) {-- if (!tableExists(db, 'principal_grants')) return [];-- return db.prepare(`SELECT DISTINCT client_id FROM principal_grants WHERE capability = ? AND audience = ? AND revoked_at IS NULL-- AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY client_id`).all(capability, SELF_AUDIENCE).map((r) => r.client_id);++async function expectedServices(db, capability) {++ if (!await tableExists(db, 'principal_grants')) return [];++ return (await db.prepare(`SELECT DISTINCT client_id FROM principal_grants WHERE capability = ? AND audience = ? AND revoked_at IS NULL++ AND (expires_at IS NULL OR expires_at > ov_now()) ORDER BY client_id`).all(capability, SELF_AUDIENCE)).map((r) => r.client_id);}function envelope(type, payload, subject, at) {@@@ -137,8 +85,8 @@ function assertPerson(user) {// ── Export ─────────────────────────────────────────────────────--function exportView(db, row) {-- const parts = db.prepare('SELECT service, files, bytes FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);++async function exportView(db, row) {++ const parts = await db.prepare('SELECT service, files, bytes FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);const got = new Map(parts.map((p) => [p.service, p]));const built = row.services ? JSON.parse(row.services) : null;const services = built || [...new Set([...JSON.parse(row.expected || '[]'), ...got.keys()])].sort().map((s) => (got.has(s)@@@ -152,12 +100,12 @@ function exportView(db, row) {}/** The person starts an export → { status: 201|200, body: network.account-export@1 }. */--function startExport(db, user, { now = Date.now() } = {}) {-- ensureSchema(db);++async function startExport(db, user, { now = Date.now() } = {}) {++ await ensureSchema(db);assertPerson(user);-- const open = db.prepare("SELECT * FROM account_exports WHERE user_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1").get(user.id);-- if (open) return { status: 200, row: open, body: exportView(db, open) };-- const recent = db.prepare("SELECT requested_at FROM account_exports WHERE user_id = ? AND status != 'failed' AND requested_at > ? ORDER BY requested_at DESC LIMIT 1")++ const open = await db.prepare("SELECT * FROM account_exports WHERE user_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1").get(user.id);++ if (open) return { status: 200, row: open, body: await exportView(db, open) };++ const recent = await db.prepare("SELECT requested_at FROM account_exports WHERE user_id = ? AND status != 'failed' AND requested_at > ? ORDER BY requested_at DESC LIMIT 1").get(user.id, iso(now - EXPORT_EVERY_MS));if (recent) {const retry = Math.ceil((Date.parse(recent.requested_at) + EXPORT_EVERY_MS - now) / 1000);@@@ -166,23 +114,23 @@ function startExport(db, user, { now = Date.now() } = {}) {const id = `exp_${ids.ulid(now)}`;const requestedAt = iso(now);const deadline = iso(now + EXPORT_DEADLINE_MS);-- const expected = expectedServices(db, CONTRIBUTE);-- db.transaction(() => {-- db.prepare('INSERT INTO account_exports (id, user_id, subject, expected, requested_at, deadline) VALUES (?, ?, ?, ?, ?, ?)')++ const expected = await expectedServices(db, CONTRIBUTE);++ await db.tx(async () => {++ await db.prepare('INSERT INTO account_exports (id, user_id, subject, expected, requested_at, deadline) VALUES (?, ?, ?, ?, ?, ?)').run(id, user.id, user.subject_id, JSON.stringify(expected), requestedAt, deadline);-- eventRelay.writerFor(db).enqueue(envelope('network.account.export_requested', { export_id: id, subject: user.subject_id, requested_at: requestedAt, deadline }, user.subject_id, requestedAt));-- })();++ await eventRelay.writerFor(db).enqueue(envelope('network.account.export_requested', { export_id: id, subject: user.subject_id, requested_at: requestedAt, deadline }, user.subject_id, requestedAt));++ });kick(db);-- const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(id);-- return { status: 201, row, body: exportView(db, row) };++ const row = await db.prepare('SELECT * FROM account_exports WHERE id = ?').get(id);++ return { status: 201, row, body: await exportView(db, row) };}/** A service's part → network.account-data-receipt@1. */--function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {-- ensureSchema(db);++async function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {++ await ensureSchema(db);const v = validate('network.account-export-part@1', body);if (!v.valid) throw new AccountDataError(400, 'export.invalid_part', JSON.stringify(v.errors).slice(0, 300));-- const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId) : null;++ const row = ID_RE.exp.test(String(exportId)) ? await db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId) : null;if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');if (body.subject !== row.subject) throw new AccountDataError(400, 'export.wrong_subject', "the part's subject is not the export's");if (row.status !== 'pending' || now > Date.parse(row.deadline)) throw new AccountDataError(409, 'export.closed', 'this export was already built');@@@ -191,24 +139,26 @@ function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {const text = JSON.stringify(body);const bytes = Buffer.byteLength(text);if (bytes > PART_MAX_BYTES) throw new AccountDataError(413, 'export.part_too_large', 'a part is at most 20 MB');-- const had = !!db.prepare('SELECT 1 FROM account_export_parts WHERE export_id = ? AND service = ?').get(row.id, service);-- db.prepare('INSERT OR REPLACE INTO account_export_parts (export_id, service, received_at, files, bytes, body) VALUES (?, ?, ?, ?, ?, ?)')++ const had = !!await db.prepare('SELECT 1 FROM account_export_parts WHERE export_id = ? AND service = ?').get(row.id, service);++ await db.prepare('INSERT INTO account_export_parts (export_id, service, received_at, files, bytes, body) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (export_id, service) DO UPDATE SET received_at = excluded.received_at, files = excluded.files, bytes = excluded.bytes, body = excluded.body').run(row.id, service, iso(now), body.files.length, bytes, text);return { id: row.id, service, received_at: iso(now), replaced: had };}/** Rows about a person from one table, secrets left out, newest first, at most ROW_LIMIT. */--function rowsOf(db, table, where, params, { omit = [] } = {}) {-- if (!tableExists(db, table)) return { rows: [], truncated: false };-- const cols = columnsOf(db, table).filter((c) => !SECRET_COL.test(c) && !omit.includes(c));++async function rowsOf(db, table, where, params, { omit = [] } = {}) {++ if (!await tableExists(db, table)) return { rows: [], truncated: false };++ const cols = (await columnsOf(db, table)).filter((c) => !SECRET_COL.test(c) && !omit.includes(c));if (!cols.length) return { rows: [], truncated: false };-- const rows = db.prepare(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${where} ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(...params);++ // Newest first: the `seq` identity when the table has one (migrations 0003), else the primary key.++ const order = cols.includes('seq') ? 'ORDER BY seq DESC ' : cols.includes('id') ? 'ORDER BY id DESC ' : '';++ const rows = await db.prepare(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${where} ${order}LIMIT ${ROW_LIMIT + 1}`).all(...params);return { rows: rows.slice(0, ROW_LIMIT), truncated: rows.length > ROW_LIMIT };}/** Network's own part: { files: [{ name, content }], truncated: [] }. */--function networkPart(db, userId) {-- const u = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);++async function networkPart(db, userId) {++ const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);const files = [];const truncated = [];const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };@@@ -216,40 +166,40 @@ function networkPart(db, userId) {'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'subject_id'];const account = {};for (const k of keep) if (k in u) account[k] = u[k];-- account.merged_in = tableExists(db, 'subject_aliases')-- ? db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];++ account.merged_in = await tableExists(db, 'subject_aliases')++ ? await db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];add('account.json', account);-- const byUser = (name, table, opts) => { const r = rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };-- byUser('sign_in_providers.json', 'linked_accounts');-- byUser('sessions.json', 'user_sessions');-- byUser('oauth_grants.json', 'oauth_tokens');-- byUser('notifications.json', 'notifications');++ const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };++ await byUser('sign_in_providers.json', 'linked_accounts');++ await byUser('sessions.json', 'user_sessions');++ await byUser('oauth_grants.json', 'oauth_tokens');++ await byUser('notifications.json', 'notifications');add('preferences.json', {-- display: rowsOf(db, 'user_preferences', 'user_id = ?', [u.id]).rows[0] || null,-- notifications: rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id]).rows,-- effects: rowsOf(db, 'user_effects', 'user_id = ?', [u.id]).rows,-- push_devices: rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] }).rows,++ display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,++ notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,++ effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,++ push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,});-- byUser('history.json', 'user_history');-- add('username_history.json', rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] }).rows);-- byUser('profile_changes.json', 'user_profile_changes');-- byUser('sign_in_log.json', 'ip_log');-- byUser('emails_sent.json', 'email_delivery_log');-- const coins = rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);-- add('opencoins.json', { balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);-- add('modules.json', tableExists(db, 'user_modules') ? db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id)++ await byUser('history.json', 'user_history');++ add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);++ await byUser('profile_changes.json', 'user_profile_changes');++ await byUser('sign_in_log.json', 'ip_log');++ await byUser('emails_sent.json', 'email_delivery_log');++ const coins = await rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);++ add('opencoins.json', { balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);++ add('modules.json', await tableExists(db, 'user_modules') ? (await db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id)).map((m) => { let data = m.data; try { data = JSON.parse(m.data); } catch { /* kept as text */ } return { ...m, data }; }) : []);-- const follows = rowsOf(db, 'user_follows', 'follower_subject = ?', [u.subject_id]);-- const followers = tableExists(db, 'user_follows') ? db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id).n : 0;++ const follows = await rowsOf(db, 'user_follows', 'follower_subject = ?', [u.subject_id]);++ const followers = await tableExists(db, 'user_follows') ? (await db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id)).n : 0;add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);-- add('blocks.json', rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id]).rows);++ add('blocks.json', (await rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id])).rows);add('developer_projects.json', {-- owned: rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id]).rows,-- memberships: rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] }).rows,++ owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,++ memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,});-- add('account_merges.json', tableExists(db, 'account_merges')-- ? db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);-- add('moderation.json', rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] }).rows);++ add('account_merges.json', await tableExists(db, 'account_merges')++ ? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);++ add('moderation.json', (await rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] })).rows);return { files, truncated };}@@@ -270,16 +220,16 @@ function readme(row, sections, now) {}/** Build the archive when every expected service answered, at the deadline, or when forced. → true if built. */--function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify } = {}) {-- ensureSchema(db);-- const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId);++async function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify } = {}) {++ await ensureSchema(db);++ const row = await db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId);if (!row || row.status !== 'pending') return false;const expected = JSON.parse(row.expected || '[]');-- const parts = db.prepare('SELECT service, received_at, files, bytes, body FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);++ const parts = await db.prepare('SELECT service, received_at, files, bytes, body FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);const got = new Set(parts.map((p) => p.service));if (!force && now < Date.parse(row.deadline) && !expected.every((s) => got.has(s))) return false;try {-- const own = networkPart(db, row.user_id);++ const own = await networkPart(db, row.user_id);const sections = [{ service: 'network', status: 'received', files: own.files.length, truncated: own.truncated }];const entries = own.files.map((f) => ({ name: `network/${f.name}`, data: JSON.stringify(f.content, null, 2) }));for (const p of parts) {@@@ -297,11 +247,11 @@ function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notifyconst services = sections.map((s) => (s.status === 'received'? { service: s.service, status: 'received', files: s.files, bytes: s.service === 'network' ? Buffer.byteLength(JSON.stringify(own.files)) : parts.find((p) => p.service === s.service).bytes }: { service: s.service, status: 'missing' }));-- db.transaction(() => {-- db.prepare('UPDATE account_exports SET status = ?, ready_at = ?, expires_at = ?, size_bytes = ?, services = ? WHERE id = ?')++ await db.tx(async () => {++ await db.prepare('UPDATE account_exports SET status = ?, ready_at = ?, expires_at = ?, size_bytes = ?, services = ? WHERE id = ?').run(status, iso(now), iso(now + EXPORT_KEEP_MS), buf.length, JSON.stringify(services), row.id);-- db.prepare('UPDATE account_export_parts SET body = NULL WHERE export_id = ?').run(row.id);-- })();++ await db.prepare('UPDATE account_export_parts SET body = NULL WHERE export_id = ?').run(row.id);++ });if (notify) {try {notify(row.user_id, { title: 'Your data export is ready', message: status === 'ready' ? 'Download it from your account page within 7 days.' : 'Some services did not answer in time; the archive says which. Download it from your account page within 7 days.' });@@@ -309,15 +259,15 @@ function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify}return true;} catch (e) {-- db.prepare("UPDATE account_exports SET status = 'failed', error = ? WHERE id = ?").run(String(e.message).slice(0, 300), row.id);++ await db.prepare("UPDATE account_exports SET status = 'failed', error = ? WHERE id = ?").run(String(e.message).slice(0, 300), row.id);console.error('[AccountData] export build failed:', row.id, e.message);return false;}}--function exportFile(db, user, exportId, { dir, now = Date.now() } = {}) {-- ensureSchema(db);-- const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(exportId, user.id) : null;++async function exportFile(db, user, exportId, { dir, now = Date.now() } = {}) {++ await ensureSchema(db);++ const row = ID_RE.exp.test(String(exportId)) ? await db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(exportId, user.id) : null;if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');if (row.status === 'pending') throw new AccountDataError(409, 'export.pending', 'this export is still being gathered');if (!['ready', 'partial'].includes(row.status) || Date.parse(row.expires_at) < now) throw new AccountDataError(410, 'export.expired', 'this export is no longer available');@@@ -333,10 +283,10 @@ function deletionView(row) {return out;}--function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now() } = {}) {-- ensureSchema(db);++async function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now() } = {}) {++ await ensureSchema(db);assertPerson(user);-- const open = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(user.id);++ const open = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(user.id);if (open) return { status: 200, body: deletionView(open) };const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();if (String(user.username || '').toLowerCase() === owner) throw new AccountDataError(403, 'deletion.owner', "the owner's account cannot be deleted here");@@@ -348,123 +298,123 @@ function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now()throw new AccountDataError(400, 'deletion.confirm_username', 'type your username exactly to confirm');}const id = `del_${ids.ulid(now)}`;-- db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')++ await db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)').run(id, user.id, user.subject_id, iso(now), iso(now + GRACE_MS));-- return { status: 201, body: deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(id)) };++ return { status: 201, body: deletionView(await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(id)) };}--function cancelDeletion(db, userId, { by = 'person', now = Date.now() } = {}) {-- ensureSchema(db);-- const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(userId);++async function cancelDeletion(db, userId, { by = 'person', now = Date.now() } = {}) {++ await ensureSchema(db);++ const row = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(userId);if (!row) return null;-- db.prepare("UPDATE account_deletions SET status = 'cancelled', cancelled_at = ?, cancelled_by = ? WHERE id = ?").run(iso(now), by, row.id);-- return deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(row.id));++ await db.prepare("UPDATE account_deletions SET status = 'cancelled', cancelled_at = ?, cancelled_by = ? WHERE id = ?").run(iso(now), by, row.id);++ return deletionView(await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(row.id));}/** Erase what Network owns for one due deletion, in one transaction, and queue network.account.deleted. → counts. */--function erase(db, deletion, { now = Date.now() } = {}) {++async function erase(db, deletion, { now = Date.now() } = {}) {const at = iso(now);const erased = {};const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };-- const run = db.transaction(() => {-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);++ const run = db.txFn(async () => {++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);const subject = user.subject_id;-- const folded = db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);-- const aliases = tableExists(db, 'subject_aliases') ? db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject).map((r) => r.alias_id) : [];++ const folded = await db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);++ const aliases = await tableExists(db, 'subject_aliases') ? (await db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject)).map((r) => r.alias_id) : [];const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];const userIds = [user.id, ...folded.map((f) => f.id)];const inU = `(${userIds.map(() => '?').join(',')})`;const inS = `(${subjects.map(() => '?').join(',')})`;-- const del = (table, where, params, key = table) => { if (tableExists(db, table)) count(key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes); };++ const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications','notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',-- 'ip_log', 'email_delivery_log']) del(t, `user_id IN ${inU}`, userIds);-- if (tableExists(db, 'account_merge_intents')) del('account_merge_intents', `into_user_id IN ${inU}`, userIds);++ 'ip_log', 'email_delivery_log']) await del(t, `user_id IN ${inU}`, userIds);++ if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);// Notifications this person sent others stay, without their name or picture.-- if (tableExists(db, 'notifications')) db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);-- if (tableExists(db, 'analytics_events')) db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);-- del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');-- del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');-- del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');-- if (tableExists(db, 'dev_projects')) {-- for (const p of db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {-- const heir = db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')++ if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);++ if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);++ await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');++ await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');++ await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');++ if (await tableExists(db, 'dev_projects')) {++ for (const p of await db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {++ const heir = await db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, added_at LIMIT 1`).get(p.id, ...subjects);-- if (heir) { db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE id = ?').run(heir.subject_id, p.id); count('projects_passed_on', 1); continue; }-- for (const a of db.prepare('SELECT id FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(p.id)) {-- db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);-- db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);++ if (heir) { await db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE id = ?').run(heir.subject_id, p.id); count('projects_passed_on', 1); continue; }++ for (const a of await db.prepare('SELECT id FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(p.id)) {++ await db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);++ await db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);}-- db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);++ await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);count('projects_archived', 1);}-- del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');++ await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');}-- if (tableExists(db, 'account_merges')) db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);++ if (await tableExists(db, 'account_merges')) await db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.for (const id of userIds) {-- const bal = (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;++ const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;if (bal > 0) {-- wallet.debit(db, { user_id: id, app_id: 'network', amount: bal, reason: 'account_deleted', ref: deletion.id, idempotency_key: `delete:${deletion.id}:${id}` });++ await wallet.debit(db, { user_id: id, app_id: 'network', amount: bal, reason: 'account_deleted', ref: deletion.id, idempotency_key: `delete:${deletion.id}:${id}` });count('opencoins_closed', bal);}}// The tombstones: the username is released, the rest cleared; the subject stays (never reused).-- const cols = columnsOf(db, 'users');++ const cols = await columnsOf(db, 'users');for (const id of userIds) {-- const u = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);++ const u = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };const keys = Object.keys(set).filter((k) => cols.includes(k));-- db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);++ await db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);count('accounts', 1);}-- revocation.revokeTokens(db, user.id, { reason: 'account_deleted', actor: { type: 'user', id: subject }, strict: true });-- const expected = expectedServices(db, CONFIRM);-- db.prepare("UPDATE account_deletions SET status = 'deleted', deleted_at = ?, expected = ?, erased = ? WHERE id = ?")++ await revocation.revokeTokens(db, user.id, { reason: 'account_deleted', actor: { type: 'user', id: subject }, strict: true });++ const expected = await expectedServices(db, CONFIRM);++ await db.prepare("UPDATE account_deletions SET status = 'deleted', deleted_at = ?, expected = ?, erased = ? WHERE id = ?").run(at, JSON.stringify(expected), JSON.stringify(erased), deletion.id);const payload = { deletion_id: deletion.id, subject, requested_at: deletion.requested_at, deleted_at: at };const others = subjects.filter((s) => s !== subject).slice(0, 50);if (others.length) payload.aliases = others;-- eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));++ await eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));});-- run();++ await run();kick(db);return erased;}/** A service confirms a deletion → network.account-data-receipt@1. */--function receiveConfirmation(db, deletionId, service, body, { now = Date.now() } = {}) {-- ensureSchema(db);++async function receiveConfirmation(db, deletionId, service, body, { now = Date.now() } = {}) {++ await ensureSchema(db);const v = validate('network.account-deletion-confirmation@1', body);if (!v.valid) throw new AccountDataError(400, 'deletion.invalid_confirmation', JSON.stringify(v.errors).slice(0, 300));-- const row = ID_RE.del.test(String(deletionId)) ? db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId) : null;++ const row = ID_RE.del.test(String(deletionId)) ? await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId) : null;if (!row) throw new AccountDataError(404, 'deletion.not_found', 'no such deletion');if (body.subject !== row.subject) throw new AccountDataError(400, 'deletion.wrong_subject', "the confirmation's subject is not the deletion's");if (row.status !== 'deleted') throw new AccountDataError(409, 'deletion.not_deleted', 'this deletion has not happened');-- const had = !!db.prepare('SELECT 1 FROM account_deletion_confirmations WHERE deletion_id = ? AND service = ?').get(row.id, service);-- db.prepare('INSERT OR REPLACE INTO account_deletion_confirmations (deletion_id, service, received_at, completed_at, erased, retained) VALUES (?, ?, ?, ?, ?, ?)')++ const had = !!await db.prepare('SELECT 1 FROM account_deletion_confirmations WHERE deletion_id = ? AND service = ?').get(row.id, service);++ await db.prepare('INSERT INTO account_deletion_confirmations (deletion_id, service, received_at, completed_at, erased, retained) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (deletion_id, service) DO UPDATE SET received_at = excluded.received_at, completed_at = excluded.completed_at, erased = excluded.erased, retained = excluded.retained').run(row.id, service, iso(now), body.completed_at, JSON.stringify(body.erased), JSON.stringify(body.retained || {}));return { id: row.id, service, received_at: iso(now), replaced: had };}/** The periodic sweep: build exports past their deadline, delete expired archives, carry out due deletions. */--function sweep(db, { dir, now = Date.now(), notify } = {}) {-- ensureSchema(db);++async function sweep(db, { dir, now = Date.now(), notify } = {}) {++ await ensureSchema(db);const out = { built: 0, expired: 0, deleted: 0 };-- for (const r of db.prepare("SELECT id FROM account_exports WHERE status = 'pending' AND deadline <= ?").all(iso(now))) {-- if (maybeBuild(db, r.id, { dir, now, force: true, notify })) out.built++;++ for (const r of await db.prepare("SELECT id FROM account_exports WHERE status = 'pending' AND deadline <= ?").all(iso(now))) {++ if (await maybeBuild(db, r.id, { dir, now, force: true, notify })) out.built++;}-- for (const r of db.prepare("SELECT id FROM account_exports WHERE status IN ('ready', 'partial') AND expires_at <= ?").all(iso(now))) {++ for (const r of await db.prepare("SELECT id FROM account_exports WHERE status IN ('ready', 'partial') AND expires_at <= ?").all(iso(now))) {try { fs.rmSync(path.join(dir, `${r.id}.zip`), { force: true }); } catch { /* gone already */ }-- db.prepare("UPDATE account_exports SET status = 'expired' WHERE id = ?").run(r.id);++ await db.prepare("UPDATE account_exports SET status = 'expired' WHERE id = ?").run(r.id);out.expired++;}-- for (const d of db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' AND delete_after <= ?").all(iso(now))) {-- try { const e = erase(db, d, { now }); out.deleted++; console.log(`[AccountData] deleted account ${d.subject} (${d.id}): ${JSON.stringify(e)}`); }++ for (const d of await db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' AND delete_after <= ?").all(iso(now))) {++ try { const e = await erase(db, d, { now }); out.deleted++; console.log(`[AccountData] deleted account ${d.subject} (${d.id}): ${JSON.stringify(e)}`); }catch (e) { console.error('[AccountData] deletion failed:', d.id, e.message); }}return out;@@@ -487,28 +437,28 @@ function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir,const me = express.Router();me.use(express.json({ limit: '8kb' }));const noStore = (res) => res.set('Cache-Control', 'private, no-store');-- me.post('/export', requireAuth, (req, res) => {++ me.post('/export', requireAuth, async (req, res) => {const db = req.app.locals.db;try {-- const out = startExport(db, req.user);-- if (out.status === 201) setImmediate(() => maybeBuild(db, out.row.id, { dir, notify }));++ const out = await startExport(db, req.user);++ if (out.status === 201) setImmediate(async () => await maybeBuild(db, out.row.id, { dir, notify }));noStore(res).status(out.status).json(out.body);} catch (e) { sendError(res, e); }});-- me.get('/export', requireAuth, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);-- const rows = db.prepare('SELECT * FROM account_exports WHERE user_id = ? ORDER BY requested_at DESC LIMIT 5').all(req.user.id);-- noStore(res).json({ exports: rows.map((r) => exportView(db, r)) });++ me.get('/export', requireAuth, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);++ const rows = await db.prepare('SELECT * FROM account_exports WHERE user_id = ? ORDER BY requested_at DESC LIMIT 5').all(req.user.id);++ noStore(res).json({ exports: (await Promise.all(rows.map(async (r) => await exportView(db, r)))) });});-- me.get('/export/:id', requireAuth, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);-- const row = ID_RE.exp.test(req.params.id) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id) : null;++ me.get('/export/:id', requireAuth, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);++ const row = ID_RE.exp.test(req.params.id) ? await db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id) : null;if (!row) return res.status(404).json({ error: 'export.not_found' });-- noStore(res).json(exportView(db, row));++ noStore(res).json(await exportView(db, row));});-- me.get('/export/:id/download', requireAuth, (req, res) => {++ me.get('/export/:id/download', requireAuth, async (req, res) => {try {-- const { row, file } = exportFile(req.app.locals.db, req.user, req.params.id, { dir });++ const { row, file } = await exportFile(req.app.locals.db, req.user, req.params.id, { dir });if (!fs.existsSync(file)) return res.status(410).json({ error: 'export.expired' });const day = row.ready_at.slice(0, 10);noStore(res).set('Content-Type', 'application/zip')@@@ -517,59 +467,61 @@ function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir,fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);} catch (e) { sendError(res, e); }});-- me.get('/deletion', requireAuth, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);-- const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(req.user.id);++ me.get('/deletion', requireAuth, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);++ const row = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(req.user.id);noStore(res).json({ deletion: row ? deletionView(row) : null });});-- me.post('/deletion', requireAuth, (req, res) => {++ me.post('/deletion', requireAuth, async (req, res) => {try {-- const out = scheduleDeletion(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.confirm_username);++ const out = await scheduleDeletion(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.confirm_username);noStore(res).status(out.status).json(out.body);} catch (e) { sendError(res, e); }});-- me.delete('/deletion', requireAuth, (req, res) => {-- const out = cancelDeletion(req.app.locals.db, req.user.id, { by: 'person' });++ me.delete('/deletion', requireAuth, async (req, res) => {++ const out = await cancelDeletion(req.app.locals.db, req.user.id, { by: 'person' });if (!out) return res.status(404).json({ error: 'deletion.not_scheduled' });noStore(res).json(out);});const internal = express.Router();const service = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');-- internal.post('/account-exports/:id/parts', contributeGuard, express.json({ limit: '21mb' }), (req, res) => {++ internal.post('/account-exports/:id/parts', contributeGuard, express.json({ limit: '21mb' }), async (req, res) => {const db = req.app.locals.db;try {-- const receipt = receivePart(db, req.params.id, service(req), req.body);-- setImmediate(() => maybeBuild(db, receipt.id, { dir, notify }));++ const receipt = await receivePart(db, req.params.id, service(req), req.body);++ // Build before answering: a part that completes the export must read back as ready, not race the++ // caller (the deadline path still builds in sweep).++ await maybeBuild(db, receipt.id, { dir, notify });res.json(receipt);} catch (e) { sendError(res, e); }});-- internal.post('/account-deletions/:id/confirmations', confirmGuard, express.json({ limit: '64kb' }), (req, res) => {-- try { res.json(receiveConfirmation(req.app.locals.db, req.params.id, service(req), req.body)); } catch (e) { sendError(res, e); }++ internal.post('/account-deletions/:id/confirmations', confirmGuard, express.json({ limit: '64kb' }), async (req, res) => {++ try { res.json(await receiveConfirmation(req.app.locals.db, req.params.id, service(req), req.body)); } catch (e) { sendError(res, e); }});const admin = express.Router();admin.use(express.json({ limit: '8kb' }));const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.users.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.users.manage required' }));-- admin.get('/', requireAuth, staffOnly, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);-- const rows = db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' OR deleted_at > ? ORDER BY requested_at DESC LIMIT 200").all(iso(Date.now() - 90 * 86400000));++ admin.get('/', requireAuth, staffOnly, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);++ const rows = await db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' OR deleted_at > ? ORDER BY requested_at DESC LIMIT 200").all(iso(Date.now() - 90 * 86400000));const confirmed = db.prepare('SELECT service FROM account_deletion_confirmations WHERE deletion_id = ?');noStore(res).json({-- deletions: rows.map((r) => {-- const got = confirmed.all(r.id).map((c) => c.service);++ deletions: (await Promise.all(rows.map(async (r) => {++ const got = (await confirmed.all(r.id)).map((c) => c.service);return { ...deletionView(r), subject: r.subject, confirmed: got, outstanding: JSON.parse(r.expected || '[]').filter((s) => !got.includes(s)) };-- }),++ }))),});});-- admin.post('/:id/cancel', requireAuth, staffOnly, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);++ admin.post('/:id/cancel', requireAuth, staffOnly, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);const reason = String((req.body && req.body.reason) || '').trim();if (reason.length < 10) return res.status(400).json({ error: 'deletion.reason_required', detail: 'a written reason (10 characters or more)' });-- const row = ID_RE.del.test(req.params.id) ? db.prepare("SELECT * FROM account_deletions WHERE id = ? AND status = 'scheduled'").get(req.params.id) : null;++ const row = ID_RE.del.test(req.params.id) ? await db.prepare("SELECT * FROM account_deletions WHERE id = ? AND status = 'scheduled'").get(req.params.id) : null;if (!row) return res.status(404).json({ error: 'deletion.not_scheduled' });-- const out = cancelDeletion(db, row.user_id, { by: `staff:${req.user.subject_id || req.user.id}` });-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'account_deletion_cancelled', JSON.stringify({ deletion_id: row.id, subject: row.subject, reason: reason.slice(0, 500) }));++ const out = await cancelDeletion(db, row.user_id, { by: `staff:${req.user.subject_id || req.user.id}` });++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'account_deletion_cancelled', JSON.stringify({ deletion_id: row.id, subject: row.subject, reason: reason.slice(0, 500) }));res.json(out);});return { me, internal, admin };@@@ -43,55 +43,20 @@ class MergeError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS subject_aliases (-- alias_id TEXT PRIMARY KEY,-- subject_id TEXT NOT NULL,-- merge_id TEXT NOT NULL,-- merged_at TEXT NOT NULL,-- merged_by TEXT NOT NULL-- );-- CREATE INDEX IF NOT EXISTS idx_subject_aliases_subject ON subject_aliases(subject_id);-- CREATE TABLE IF NOT EXISTS account_merges (-- id TEXT PRIMARY KEY,-- from_subject TEXT NOT NULL UNIQUE,-- into_subject TEXT NOT NULL,-- from_user_id INTEGER NOT NULL,-- into_user_id INTEGER NOT NULL,-- initiated_by TEXT NOT NULL CHECK (initiated_by IN ('person', 'staff')),-- actor_subject TEXT NOT NULL,-- reason TEXT,-- moved TEXT NOT NULL,-- pre_state TEXT,-- merged_at TEXT NOT NULL,-- split_until TEXT NOT NULL,-- reduced_at TEXT-- );-- CREATE TABLE IF NOT EXISTS account_merge_intents (-- id TEXT PRIMARY KEY,-- into_user_id INTEGER NOT NULL,-- created_at INTEGER NOT NULL,-- expires_at INTEGER NOT NULL,-- used_at INTEGER-- );-- `);-- const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);-- if (!cols.includes('merged_into')) db.exec('ALTER TABLE users ADD COLUMN merged_into INTEGER');--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */--function survivorOf(db, subjectId) {++async function survivorOf(db, subjectId) {try {-- const a = db.prepare('SELECT subject_id FROM subject_aliases WHERE alias_id = ?').get(String(subjectId || ''));++ const a = await db.prepare('SELECT subject_id FROM subject_aliases WHERE alias_id = ?').get(String(subjectId || ''));return a ? a.subject_id : subjectId;} catch { return subjectId; } // before the first merge the table may not exist}/** A user row, or the survivor's when it was merged (signing in to a folded-in account signs in to the survivor). */--function effectiveUser(db, user) {++async function effectiveUser(db, user) {if (!user || !user.merged_into) return user;-- return db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;}const iso = (ms) => new Date(ms).toISOString();@@@ -114,41 +79,41 @@ function buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, ac* per user); a row whose key the survivor already holds is dropped, or with keepOnClash left on the folded-in* account (a linked provider: signing in with it still lands on the survivor through merged_into).*/--function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {++async function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {let moved = 0; let dropped = 0;-- if (!tableExists(db, table)) return { moved, dropped };-- const rows = db.prepare(`SELECT rowid AS _rid, * FROM ${table} WHERE ${column} = ?`).all(fromId);++ if (!await tableExists(db, table)) return { moved, dropped };++ const rows = await db.prepare(`SELECT seq AS _rid, * FROM ${table} WHERE ${column} = ?`).all(fromId);for (const r of rows) {if (keyCols) {const where = keyCols.map((k) => ` AND ${k} = ?`).join('');-- const clash = db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));++ const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));if (clash) {-- if (!keepOnClash) db.prepare(`DELETE FROM ${table} WHERE rowid = ?`).run(r._rid);++ if (!keepOnClash) await db.prepare(`DELETE FROM ${table} WHERE seq = ?`).run(r._rid);dropped++;continue;}}-- db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`).run(intoId, r._rid);++ await db.prepare(`UPDATE ${table} SET ${column} = ? WHERE seq = ?`).run(intoId, r._rid);moved++;}return { moved, dropped };}--function tableExists(db, name) {-- return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);++async function tableExists(db, name) {++ return !!await db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name);}/*** Fold `fromUser` into `intoUser`. opts: { initiatedBy: 'person'|'staff', actorSubject, reason, now }.* → network.account-merge-result@1. Throws MergeError.*/--function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {-- ensureSchema(db);++async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {++ await ensureSchema(db);if (!fromUser || !intoUser) throw new MergeError(404, 'merge.unknown_account', 'no such account');if (fromUser.id === intoUser.id) throw new MergeError(400, 'merge.same_account', 'an account cannot be merged into itself');const from = fromUser.subject_id; const into = intoUser.subject_id;if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new MergeError(400, 'merge.no_subject', 'both accounts need a subject');-- const done = db.prepare('SELECT * FROM account_merges WHERE from_subject = ?').get(from);++ const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ?').get(from);if (done) {if (done.into_subject !== into) throw new MergeError(409, 'merge.already_merged', 'that account was already merged into another');return resultOf(done, true);@@@ -163,75 +128,75 @@ function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, rconst mergedAt = iso(now);const splitUntil = iso(now + SPLIT_DAYS * 86400000);const actor = actorSubject || into;-- const run = db.transaction(() => {++ const run = db.txFn(async () => {const pre = {user: { ...fromUser, password_hash: undefined },-- linked_accounts: db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),-- sessions: db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id).map((r) => r.id),-- balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,-- projects: tableExists(db, 'dev_projects') ? db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from).map((r) => r.id) : [],-- modules: db.prepare('SELECT namespace, revision FROM user_modules WHERE subject_id = ?').all(from),++ linked_accounts: await db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),++ sessions: (await db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id)).map((r) => r.id),++ balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,++ projects: await tableExists(db, 'dev_projects') ? (await db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from)).map((r) => r.id) : [],++ modules: await db.prepare('SELECT namespace, revision FROM user_modules WHERE subject_id = ?').all(from),};const moved = { providers: 0, sessions: 0, oauth_grants: 0, projects: 0, coins: 0, modules: 0 };const dropped = {};const note = (k, r) => { if (r.dropped) dropped[k] = (dropped[k] || 0) + r.dropped; return r.moved; };-- moved.providers = note('providers', moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));-- moved.sessions = note('sessions', moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));-- moved.oauth_grants = moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id).moved-- + moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id).moved;-- if (tableExists(db, 'dev_projects')) {-- moved.projects = db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from).changes;-- for (const m of db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {-- const has = db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);-- if (has) { db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(m.project_id, from); dropped.project_members = (dropped.project_members || 0) + 1; }-- else db.prepare('UPDATE dev_project_members SET subject_id = ? WHERE project_id = ? AND subject_id = ?').run(into, m.project_id, from);++ moved.providers = note('providers', await moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));++ moved.sessions = note('sessions', await moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));++ moved.oauth_grants = (await moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id)).moved++ + (await moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id)).moved;++ if (await tableExists(db, 'dev_projects')) {++ moved.projects = (await db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from)).changes;++ for (const m of await db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {++ const has = await db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);++ if (has) { await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(m.project_id, from); dropped.project_members = (dropped.project_members || 0) + 1; }++ else await db.prepare('UPDATE dev_project_members SET subject_id = ? WHERE project_id = ? AND subject_id = ?').run(into, m.project_id, from);}}// OpenCoins: loyalty (ADR-012), moved by Network itself, one ledger entry per side, once per merge.if (pre.balance > 0) {-- wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });-- wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });++ await wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });++ await wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });moved.coins = pre.balance;}-- const mods = modules.onSubjectMerged(db, { from, into });++ const mods = await modules.onSubjectMerged(db, { from, into });moved.modules = mods.moved + (mods.filled || 0);// Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.-- if (tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? follows.onSubjectMerged(db, { from, into }).dropped : 0;-- if (tableExists(db, 'user_blocks')) {++ if (await tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? (await follows.onSubjectMerged(db, { from, into })).dropped : 0;++ if (await tableExists(db, 'user_blocks')) {for (const col of ['blocker_subject', 'blocked_subject']) {const other = col === 'blocker_subject' ? 'blocked_subject' : 'blocker_subject';-- for (const b of db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {-- const clash = b.o === into || db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);-- if (clash) db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);-- else db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);++ for (const b of await db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {++ const clash = b.o === into || await db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);++ if (clash) await db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);++ else await db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);}}}-- moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);-- moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);-- note('preferences', moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));-- note('preferences', moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));-- note('effects', moveByUserId(db, 'user_effects', 'user_id', fromUser.id, intoUser.id, ['effect_type', 'effect_id']));++ await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);++ await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);++ note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));++ note('preferences', await moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));++ note('effects', await moveByUserId(db, 'user_effects', 'user_id', fromUser.id, intoUser.id, ['effect_type', 'effect_id']));// The alias (and any alias of the folded-in subject now points at the survivor: aliases stay flat).-- db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);-- db.prepare('INSERT INTO subject_aliases (alias_id, subject_id, merge_id, merged_at, merged_by) VALUES (?, ?, ?, ?, ?)').run(from, into, mergeId, mergedAt, actor);++ await db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);++ await db.prepare('INSERT INTO subject_aliases (alias_id, subject_id, merge_id, merged_at, merged_by) VALUES (?, ?, ?, ?, ?)').run(from, into, mergeId, mergedAt, actor);// The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason// account_merged: sites close its sockets). Its sessions already belong to the survivor.-- db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);-- revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });-- db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)++ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);++ await revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });++ await db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);if (initiatedBy === 'staff') {-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(null, 'account_merge', JSON.stringify({ merge_id: mergeId, from, into, actor, reason }));}-- eventRelay.writerFor(db).enqueue(buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));-- return db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);++ await eventRelay.writerFor(db).enqueue(db, buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));++ return await db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);});-- const row = run();++ const row = await run();try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }return resultOf(row, false);}@@@ -244,35 +209,35 @@ function resultOf(row, replayed) {}/** After 30 days a merge record keeps only the alias facts: its pre-merge state is dropped. */--function reduceExpired(db, { now = Date.now() } = {}) {-- ensureSchema(db);-- return db.prepare('UPDATE account_merges SET pre_state = NULL, reduced_at = ? WHERE reduced_at IS NULL AND split_until < ?').run(iso(now), iso(now)).changes;++async function reduceExpired(db, { now = Date.now() } = {}) {++ await ensureSchema(db);++ return (await db.prepare('UPDATE account_merges SET pre_state = NULL, reduced_at = ? WHERE reduced_at IS NULL AND split_until < ?').run(iso(now), iso(now))).changes;}--function createIntent(db, intoUser, { now = Date.now() } = {}) {-- ensureSchema(db);++async function createIntent(db, intoUser, { now = Date.now() } = {}) {++ await ensureSchema(db);const id = `mgi_${ids.ulid(now)}`;-- db.prepare('INSERT INTO account_merge_intents (id, into_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(id, intoUser.id, now, now + INTENT_TTL_MS);++ await db.prepare('INSERT INTO account_merge_intents (id, into_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(id, intoUser.id, now, now + INTENT_TTL_MS);return { intent: id, expires_at: iso(now + INTENT_TTL_MS) };}/** The person's merge: the caller is the account being folded in (freshly signed in), the intent names the survivor. */--function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {-- ensureSchema(db);++async function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {++ await ensureSchema(db);const authTime = Number(claims && claims.auth_time);if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');}-- const intent = db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));++ const intent = await db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));if (!intent || intent.expires_at < now) throw new MergeError(410, 'merge.intent_expired', 'that merge request expired: start again from the account that stays');-- const into = db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);++ const into = await db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);if (intent.used_at) {-- const done = db.prepare('SELECT * FROM account_merges WHERE from_subject = ? AND into_user_id = ?').get(caller.subject_id, intent.into_user_id);++ const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ? AND into_user_id = ?').get(caller.subject_id, intent.into_user_id);if (done) return resultOf(done, true);throw new MergeError(410, 'merge.intent_used', 'that merge request was already used');}-- const out = merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });-- db.prepare('UPDATE account_merge_intents SET used_at = ? WHERE id = ?').run(now, intent.id);++ const out = await merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });++ await db.prepare('UPDATE account_merge_intents SET used_at = ? WHERE id = ?').run(now, intent.id);return out;}@@@ -286,37 +251,37 @@ function sendError(res, e) {function routers({ requireAuth, staffClaims }) {const me = express.Router();me.use(express.json({ limit: '8kb' }));-- me.post('/merge/intents', requireAuth, (req, res) => {++ me.post('/merge/intents', requireAuth, async (req, res) => {if (req.user.merged_into) return res.status(409).json({ error: 'merge.already_merged' });-- res.status(201).json(createIntent(req.app.locals.db, req.user));++ res.status(201).json(await createIntent(req.app.locals.db, req.user));});-- me.post('/merge', requireAuth, (req, res) => {-- try { res.json(mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }++ me.post('/merge', requireAuth, async (req, res) => {++ try { res.json(await mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }});-- me.get('/merges', requireAuth, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);-- const rows = db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);++ me.get('/merges', requireAuth, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);++ const rows = await db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);res.set('Cache-Control', 'private, no-store').json({ merges: rows.map((r) => resultOf(r, false)) });});const admin = express.Router();admin.use(express.json({ limit: '8kb' }));-- admin.post('/', requireAuth, (req, res) => {++ admin.post('/', requireAuth, async (req, res) => {const db = req.app.locals.db;if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });const b = req.body || {};const reason = String(b.reason || '').trim();if (reason.length < 10) return res.status(400).json({ error: 'merge.reason_required', detail: 'a staff merge needs a written reason (10 characters or more)' });-- const byRef = (ref) => (SUBJECT_RE.test(String(ref || '')) ? db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(String(ref || '')));++ const byRef = async (ref) => (SUBJECT_RE.test(String(ref || '')) ? await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : await db.prepare('SELECT * FROM users WHERE lower(username) = lower(?)').get(String(ref || '')));try {-- const out = merge(db, byRef(b.from), byRef(b.into), { initiatedBy: 'staff', actorSubject: req.user.subject_id, reason: reason.slice(0, 500) });++ const out = await merge(db, await byRef(b.from), await byRef(b.into), { initiatedBy: 'staff', actorSubject: req.user.subject_id, reason: reason.slice(0, 500) });res.json(out);} catch (e) { sendError(res, e); }});-- admin.get('/', requireAuth, (req, res) => {-- const db = req.app.locals.db; ensureSchema(db);++ admin.get('/', requireAuth, async (req, res) => {++ const db = req.app.locals.db; await ensureSchema(db);if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });-- const rows = db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();++ const rows = await db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();res.set('Cache-Control', 'private, no-store').json({ merges: rows });});return { me, admin };@@@ -37,34 +37,22 @@ class BlockError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS user_blocks (-- blocker_subject TEXT NOT NULL,-- blocked_subject TEXT NOT NULL,-- active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),-- revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),-- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),-- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),-- PRIMARY KEY (blocker_subject, blocked_subject),-- CHECK (blocker_subject <> blocked_subject)-- );-- CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_subject, active);`);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** A person who can block or be blocked: a users row with a usr_ subject that is not a guest. */--const personRow = (db, where, value) => db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);++const personRow = async (db, where, value) => await db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);/** The person a path segment names: a usr_ subject, or a current username (any case). */--function findTarget(db, key) {++async function findTarget(db, key) {const k = String(key || '').trim();-- if (SUBJECT_RE.test(k)) return personRow(db, 'subject_id = ?', k) || null;++ if (SUBJECT_RE.test(k)) return await personRow(db, 'subject_id = ?', k) || null;if (/^gst_/.test(k)) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');if (!NAME_RE.test(k)) return null;-- return personRow(db, 'username = ? COLLATE NOCASE', k) || null;++ return await personRow(db, 'lower(username) = lower(?)', k) || null;}--function isBlocked(db, blockerSubject, blockedSubject) {-- return !!db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(String(blockerSubject), String(blockedSubject));++async function isBlocked(db, blockerSubject, blockedSubject) {++ return !!await db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(String(blockerSubject), String(blockedSubject));}/** The envelope for one change; throws if it would not match the contract (a bug, never input). */@@@ -86,38 +74,38 @@ function buildEnvelope({ blocker, blocked, active, revision, at }) {* Set (blocker, blocked) to `active`, in one transaction with its event. Both must be usr_ subjects* (the caller checked who they are). → { changed, active, revision, at }*/--function setBlock(db, blocker, blocked, active) {++async function setBlock(db, blocker, blocked, active) {if (!SUBJECT_RE.test(String(blocker)) || !SUBJECT_RE.test(String(blocked))) throw new BlockError(400, 'blocks.bad_subject', 'blocks are between usr_ subjects');if (blocker === blocked) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');-- return db.transaction(() => {-- const prev = db.prepare('SELECT active, revision, updated_at FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?').get(blocker, blocked);++ return await db.tx(async () => {++ const prev = await db.prepare('SELECT active, revision, updated_at FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?').get(blocker, blocked);if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };if (active) {-- const n = db.prepare('SELECT COUNT(*) AS n FROM user_blocks WHERE blocker_subject = ? AND active = 1').get(blocker).n;++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_blocks WHERE blocker_subject = ? AND active = 1').get(blocker)).n;if (n >= MAX_ACTIVE) throw new BlockError(409, 'blocks.limit', `at most ${MAX_ACTIVE} people can be blocked`);}const at = new Date().toISOString();const revision = prev ? prev.revision + 1 : 1;if (prev) {-- db.prepare('UPDATE user_blocks SET active = ?, revision = ?, updated_at = ? WHERE blocker_subject = ? AND blocked_subject = ?')++ await db.prepare('UPDATE user_blocks SET active = ?, revision = ?, updated_at = ? WHERE blocker_subject = ? AND blocked_subject = ?').run(active ? 1 : 0, revision, at, blocker, blocked);} else {-- db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')++ await db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)').run(blocker, blocked, at, at);}-- eventRelay.writerFor(db).enqueue(buildEnvelope({ blocker, blocked, active, revision, at }));++ await eventRelay.writerFor(db).enqueue(buildEnvelope({ blocker, blocked, active, revision, at }));return { changed: true, active: !!active, revision, at };-- })();++ });}function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }/** Who `subject` blocked (with names, newest first), for the person's own list. */--function listFor(db, subject) {-- return db.prepare(`SELECT b.blocked_subject, b.revision, b.updated_at, u.username, u.display_name, u.avatar_url++async function listFor(db, subject) {++ return (await db.prepare(`SELECT b.blocked_subject, b.revision, b.updated_at, u.username, u.display_name, u.avatar_urlFROM user_blocks b LEFT JOIN users u ON u.subject_id = b.blocked_subject-- WHERE b.blocker_subject = ? AND b.active = 1 ORDER BY b.updated_at DESC, b.blocked_subject`).all(String(subject))++ WHERE b.blocker_subject = ? AND b.active = 1 ORDER BY b.updated_at DESC, b.blocked_subject`).all(String(subject))).map((r) => ({subject: r.blocked_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,avatar_url: r.avatar_url || null, blocked_at: r.updated_at, revision: r.revision,@@@ -125,19 +113,19 @@ function listFor(db, subject) {}/** What a service needs: who `subject` blocked and who blocked them (active blocks only). */--function edgesOf(db, subject) {++async function edgesOf(db, subject) {const s = String(subject);return {subject: s,-- blocks: db.prepare('SELECT blocked_subject AS s FROM user_blocks WHERE blocker_subject = ? AND active = 1 ORDER BY blocked_subject').all(s).map((r) => r.s),-- blocked_by: db.prepare('SELECT blocker_subject AS s FROM user_blocks WHERE blocked_subject = ? AND active = 1 ORDER BY blocker_subject').all(s).map((r) => r.s),++ blocks: (await db.prepare('SELECT blocked_subject AS s FROM user_blocks WHERE blocker_subject = ? AND active = 1 ORDER BY blocked_subject').all(s)).map((r) => r.s),++ blocked_by: (await db.prepare('SELECT blocker_subject AS s FROM user_blocks WHERE blocked_subject = ? AND active = 1 ORDER BY blocker_subject').all(s)).map((r) => r.s),};}/** The signed-in person as a blocker, or a BlockError. */--function meOf(db, user) {++async function meOf(db, user) {if (!user || user.is_anon) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');-- const sid = require('./subjects').ensureUserSubject(db, user);++ const sid = await require('./subjects').ensureUserSubject(db, user);if (!SUBJECT_RE.test(String(sid || ''))) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');return sid;}@@@ -147,38 +135,38 @@ const view = (u, out) => ({ subject: u.subject_id, username: u.username, displayfunction userRouter(requireAuth) {const router = express.Router();router.use(http.middleware());-- const send = (req, res, fn) => {++ const send = async (req, res, fn) => {res.set('Cache-Control', 'private, no-store');-- try { return fn(req.app.locals.db); } catch (err) {++ try { return await fn(req.app.locals.db); } catch (err) {if (err instanceof BlockError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });console.error('[Blocks]', err.message);return http.sendProblem(res, 500, 'blocks.failed', { detail: 'the block could not be changed', ctx: req.ov });}};-- router.get('/', requireAuth, (req, res) => send(req, res, (db) => {-- const me = meOf(db, req.user);-- res.json({ subject: me, blocks: listFor(db, me) });++ router.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ res.json({ subject: me, blocks: await listFor(db, me) });}));-- router.put('/:target', requireAuth, (req, res) => send(req, res, (db) => {-- const me = meOf(db, req.user);-- const u = findTarget(db, req.params.target);++ router.put('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);++ const u = await findTarget(db, req.params.target);if (!u) throw new BlockError(404, 'blocks.unknown_person', 'no such person');if (u.is_anon) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');if (u.subject_id === me) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');-- const out = setBlock(db, me, u.subject_id, true);++ const out = await setBlock(db, me, u.subject_id, true);if (out.changed) kick(db);res.status(out.changed ? 201 : 200).json({ changed: out.changed, block: view(u, out) });}));-- router.delete('/:target', requireAuth, (req, res) => send(req, res, (db) => {-- const me = meOf(db, req.user);++ router.delete('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const me = await meOf(db, req.user);const key = String(req.params.target || '');// A subject can be unblocked even if its account is gone since.-- const u = findTarget(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null, avatar_url: null } : null);-- if (!u || !u.subject_id || !isBlocked(db, me, u.subject_id)) throw new BlockError(404, 'blocks.not_blocked', 'that person is not blocked');-- const out = setBlock(db, me, u.subject_id, false);++ const u = await findTarget(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null, avatar_url: null } : null);++ if (!u || !u.subject_id || !await isBlocked(db, me, u.subject_id)) throw new BlockError(404, 'blocks.not_blocked', 'that person is not blocked');++ const out = await setBlock(db, me, u.subject_id, false);if (out.changed) kick(db);res.json({ changed: out.changed, block: view(u, out) });}));@@@ -188,11 +176,11 @@ function userRouter(requireAuth) {/** GET /internal/blocks?subject=usr_… (mounted behind principals.guard('network.blocks.read')). */function internalHandler(db) {-- return (req, res) => {++ return async (req, res) => {res.set('Cache-Control', 'no-store');const subject = String(req.query.subject || '');if (!SUBJECT_RE.test(subject)) return http.sendProblem(res, 400, 'blocks.bad_subject', { detail: 'subject must be a usr_ subject id' });-- res.json(edgesOf(db, subject));++ res.json(await edgesOf(db, subject));};}@@@ -45,41 +45,17 @@ class FollowError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS user_follows (-- follower_subject TEXT NOT NULL,-- target_type TEXT NOT NULL CHECK (target_type IN ('channel')),-- target_id TEXT NOT NULL,-- active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),-- notify_email INTEGER NOT NULL DEFAULT 1 CHECK (notify_email IN (0, 1)),-- notify_push INTEGER NOT NULL DEFAULT 1 CHECK (notify_push IN (0, 1)),-- revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),-- source TEXT NOT NULL DEFAULT 'network',-- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),-- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),-- PRIMARY KEY (follower_subject, target_type, target_id),-- CHECK (follower_subject <> target_id)-- );-- CREATE INDEX IF NOT EXISTS idx_user_follows_target ON user_follows(target_type, target_id, active, created_at);-- CREATE TABLE IF NOT EXISTS follow_import_holds (-- source TEXT NOT NULL,-- follower_ref TEXT NOT NULL,-- target_ref TEXT NOT NULL,-- reason TEXT NOT NULL,-- seen_at TEXT NOT NULL,-- PRIMARY KEY (source, follower_ref, target_ref)-- );`);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }--const personRow = (db, where, value) => db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);++const personRow = async (db, where, value) => await db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);/** The target a path names: a usr_ subject or a current username (any case) → the person row, or null. */--function findTarget(db, type, key) {++async function findTarget(db, type, key) {if (!TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);const k = String(key || '').trim();-- if (SUBJECT_RE.test(k)) return personRow(db, 'subject_id = ?', k) || null;++ if (SUBJECT_RE.test(k)) return await personRow(db, 'subject_id = ?', k) || null;if (!NAME_RE.test(k)) return null;-- return personRow(db, 'username = ? COLLATE NOCASE', k) || null;++ return await personRow(db, 'lower(username) = lower(?)', k) || null;}function buildEnvelope({ follower, type, target, active, notifyEmail, notifyPush, revision, at, reason = null, actor = null }) {@@@ -105,19 +81,19 @@ function buildEnvelope({ follower, type, target, active, notifyEmail, notifyPush* opts: notifyEmail/notifyPush (undefined keeps the stored value, else true), reason and actor (deletes),* emit (false for an import: no event), source, at.*/--function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush, reason = null, actor = null, emit = true, source = 'network', at: atIn = null } = {}) {++async function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush, reason = null, actor = null, emit = true, source = 'network', at: atIn = null } = {}) {if (!SUBJECT_RE.test(String(follower)) || !SUBJECT_RE.test(String(target))) throw new FollowError(400, 'follows.bad_subject', 'follows are between usr_ subjects');if (!TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);if (follower === target) throw new FollowError(400, 'follows.self', 'you cannot follow yourself');-- return db.transaction(() => {-- const prev = db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, type, target);++ return await db.tx(async () => {++ const prev = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, type, target);const email = notifyEmail === undefined ? (prev ? !!prev.notify_email : true) : !!notifyEmail;const push = notifyPush === undefined ? (prev ? !!prev.notify_push : true) : !!notifyPush;const same = prev && !!prev.active === !!active && (!active || (!!prev.notify_email === email && !!prev.notify_push === push));if (same) return { changed: false, active: !!active, notify_email: email, notify_push: push, revision: prev.revision, created_at: prev.created_at, at: prev.updated_at };if (!prev && !active) return { changed: false, active: false, notify_email: email, notify_push: push, revision: 0, created_at: null, at: null };if (active && !(prev && prev.active)) {-- const n = db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? AND active = 1').get(follower).n;++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? AND active = 1').get(follower)).n;if (n >= MAX_FOLLOWING) throw new FollowError(409, 'follows.limit', `at most ${MAX_FOLLOWING} follows`);}const at = atIn || new Date().toISOString();@@@ -125,32 +101,32 @@ function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush// A follow that starts again starts its "since" again; flag changes keep it.const createdAt = prev && prev.active && active ? prev.created_at : at;if (prev) {-- db.prepare(`UPDATE user_follows SET active = ?, notify_email = ?, notify_push = ?, revision = ?, source = ?, created_at = ?, updated_at = ?++ await db.prepare(`UPDATE user_follows SET active = ?, notify_email = ?, notify_push = ?, revision = ?, source = ?, created_at = ?, updated_at = ?WHERE follower_subject = ? AND target_type = ? AND target_id = ?`).run(active ? 1 : 0, email ? 1 : 0, push ? 1 : 0, revision, source, createdAt, at, follower, type, target);} else {-- db.prepare(`INSERT INTO user_follows (follower_subject, target_type, target_id, active, notify_email, notify_push, revision, source, created_at, updated_at)++ await db.prepare(`INSERT INTO user_follows (follower_subject, target_type, target_id, active, notify_email, notify_push, revision, source, created_at, updated_at)VALUES (?, ?, ?, 1, ?, ?, 1, ?, ?, ?)`).run(follower, type, target, email ? 1 : 0, push ? 1 : 0, source, at, at);}-- if (emit) eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));++ if (emit) await eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));if (emit && active && !(prev && prev.active) && notifier) {-- try { notifier(db, { follower, type, target, at }); } catch (err) { console.warn('[Follows] follow notification not created:', err.message); }++ try { await notifier(db, { follower, type, target, at }); } catch (err) { console.warn('[Follows] follow notification not created:', err.message); }}return { changed: true, active: !!active, notify_email: email, notify_push: push, revision, created_at: active ? createdAt : null, at };-- })();++ });}function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }--function count(db, type, target) {-- return db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE target_type = ? AND target_id = ? AND active = 1').get(type, target).n;++async function count(db, type, target) {++ return (await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE target_type = ? AND target_id = ? AND active = 1').get(type, target)).n;}/** network.follow-status-result@1 for (type, target), with the viewer's own follow when given. */--function status(db, type, target, viewer = null) {-- const out = { target_type: type, target_id: target, followers: count(db, type, target) };++async function status(db, type, target, viewer = null) {++ const out = { target_type: type, target_id: target, followers: await count(db, type, target) };if (viewer && SUBJECT_RE.test(viewer)) {-- const r = db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ? AND active = 1').get(viewer, type, target);++ const r = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ? AND active = 1').get(viewer, type, target);out.following = !!r;if (r) Object.assign(out, { notify_email: !!r.notify_email, notify_push: !!r.notify_push, since: r.created_at });}@@@ -167,7 +143,7 @@ function decodeCursor(c) {const item = (r) => ({ follower: r.follower_subject, target_type: r.target_type, target_id: r.target_id, notify_email: !!r.notify_email, notify_push: !!r.notify_push, created_at: r.created_at });/** A page of follows (newest first): by follower (what they follow) or by target (who follows it). */--function list(db, { follower = null, type = null, target = null, cursor = null, limit = 50 } = {}) {++async function list(db, { follower = null, type = null, target = null, cursor = null, limit = 50 } = {}) {const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);const c = decodeCursor(cursor);const where = ['active = 1'];@@@ -176,7 +152,7 @@ function list(db, { follower = null, type = null, target = null, cursor = null,if (type) { where.push('target_type = ?'); args.push(type); }if (target) { where.push('target_id = ?'); args.push(target); }if (c) { where.push('(created_at < ? OR (created_at = ? AND (follower_subject, target_id) < (?, ?)))'); args.push(c.at, c.at, c.a, c.b); }-- const rows = db.prepare(`SELECT * FROM user_follows WHERE ${where.join(' AND ')} ORDER BY created_at DESC, follower_subject DESC, target_id DESC LIMIT ?`).all(...args, n + 1);++ const rows = await db.prepare(`SELECT * FROM user_follows WHERE ${where.join(' AND ')} ORDER BY created_at DESC, follower_subject DESC, target_id DESC LIMIT ?`).all(...args, n + 1);const more = rows.length > n;if (more) rows.pop();return { items: rows.map(item), next_cursor: more ? encodeCursor(rows[rows.length - 1]) : null };@@@ -186,10 +162,10 @@ function list(db, { follower = null, type = null, target = null, cursor = null,* An account's follows go with it (both directions), each announced as a delete. Call inside the* transaction that removes the account (account deletion, WS-B task 7). → the pairs removed*/--function onSubjectRemoved(db, subject) {-- const rows = db.prepare('SELECT * FROM user_follows WHERE active = 1 AND (follower_subject = ? OR target_id = ?)').all(subject, subject);++async function onSubjectRemoved(db, subject) {++ const rows = await db.prepare('SELECT * FROM user_follows WHERE active = 1 AND (follower_subject = ? OR target_id = ?)').all(subject, subject);for (const r of rows) {-- setFollow(db, r.follower_subject, r.target_type, r.target_id, false, {++ await setFollow(db, r.follower_subject, r.target_type, r.target_id, false, {reason: r.follower_subject === subject ? 'account_removed' : 'target_removed', actor: { type: 'system', id: 'network' },});}@@@ -203,9 +179,9 @@ function onSubjectRemoved(db, subject) {* No events (the source system already has these follows); dryRun changes nothing.* → { imported, unchanged, held: [{ follower_ref, target_ref, reason }] }*/--function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new Date().toISOString() } = {}) {++async function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new Date().toISOString() } = {}) {const out = { imported: 0, unchanged: 0, held: [] };-- const run = () => {++ const run = async () => {for (const r of rows) {const follower = subjectOf(r.follower_ref);const target = subjectOf(r.target_ref);@@@ -215,13 +191,13 @@ function importFollows(db, source, rows, subjectOf, { dryRun = false, now = newelse if (follower === target) reason = 'self_follow';if (reason) {out.held.push({ follower_ref: String(r.follower_ref), target_ref: String(r.target_ref), reason });-- if (!dryRun) db.prepare('INSERT OR REPLACE INTO follow_import_holds (source, follower_ref, target_ref, reason, seen_at) VALUES (?, ?, ?, ?, ?)').run(source, String(r.follower_ref), String(r.target_ref), reason, now);++ if (!dryRun) await db.prepare('INSERT INTO follow_import_holds (source, follower_ref, target_ref, reason, seen_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (source, follower_ref, target_ref) DO UPDATE SET reason = excluded.reason, seen_at = excluded.seen_at').run(source, String(r.follower_ref), String(r.target_ref), reason, now);continue;}-- const prev = db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, 'channel', target);++ const prev = await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, 'channel', target);if (prev && prev.active) { out.unchanged++; continue; }if (!dryRun) {-- setFollow(db, follower, 'channel', target, true, {++ await setFollow(db, follower, 'channel', target, true, {notifyEmail: r.notify_email == null ? true : !!r.notify_email, notifyPush: r.notify_push == null ? true : !!r.notify_push,emit: false, source, at: r.created_at ? new Date(r.created_at).toISOString() : now,});@@@ -229,81 +205,81 @@ function importFollows(db, source, rows, subjectOf, { dryRun = false, now = newout.imported++;}};-- if (dryRun) run(); else db.transaction(run)();++ if (dryRun) await run(); else await db.tx(run);return out;}/** The signed-in person as a follower, or a FollowError. */--function meOf(db, user) {++async function meOf(db, user) {if (!user || user.is_anon) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');-- const sid = require('./subjects').ensureUserSubject(db, user);++ const sid = await require('./subjects').ensureUserSubject(db, user);if (!SUBJECT_RE.test(String(sid || ''))) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');return sid;}/** The session user of a request, or null (never refuses: for public reads). */--function viewerOf(req) {++async function viewerOf(req) {const h = req.headers.authorization || '';const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);if (!token) return null;try {-- const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });++ const out = await require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });return out && out.user ? out.user : null;} catch { return null; }}function routers({ requireAuth, followsGuard }) {-- const send = (req, res, fn) => {++ const send = async (req, res, fn) => {res.set('Cache-Control', 'private, no-store');-- try { return fn(req.app.locals.db); } catch (err) {++ try { return await fn(req.app.locals.db); } catch (err) {if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });console.error('[Follows]', err.message);return http.sendProblem(res, 500, 'follows.failed', { detail: 'the follow could not be changed', ctx: req.ov });}};-- const targetOrFail = (db, type, key) => {-- const u = findTarget(db, type, key);++ const targetOrFail = async (db, type, key) => {++ const u = await findTarget(db, type, key);if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) throw new FollowError(404, 'follows.unknown_target', 'no such channel');return u;};const me = express.Router();me.use(http.middleware());-- me.get('/', requireAuth, (req, res) => send(req, res, (db) => {-- const sid = meOf(db, req.user);++ me.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const sid = await meOf(db, req.user);const type = req.query.type ? String(req.query.type) : null;if (type && !TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);-- res.json(list(db, { follower: sid, type, cursor: req.query.cursor, limit: req.query.limit }));++ res.json(await list(db, { follower: sid, type, cursor: req.query.cursor, limit: req.query.limit }));}));-- me.put('/:type/:target', requireAuth, express.json({ limit: '4kb' }), (req, res) => send(req, res, (db) => {-- const sid = meOf(db, req.user);-- const t = targetOrFail(db, req.params.type, req.params.target);++ me.put('/:type/:target', requireAuth, express.json({ limit: '4kb' }), async (req, res) => await send(req, res, async (db) => {++ const sid = await meOf(db, req.user);++ const t = await targetOrFail(db, req.params.type, req.params.target);const b = req.body && typeof req.body === 'object' ? req.body : {};-- const out = setFollow(db, sid, req.params.type, t.subject_id, true, {++ const out = await setFollow(db, sid, req.params.type, t.subject_id, true, {notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,});if (out.changed) kick(db);-- res.status(out.changed && out.revision === 1 ? 201 : 200).json(status(db, req.params.type, t.subject_id, sid));++ res.status(out.changed && out.revision === 1 ? 201 : 200).json(await status(db, req.params.type, t.subject_id, sid));}));-- me.delete('/:type/:target', requireAuth, (req, res) => send(req, res, (db) => {-- const sid = meOf(db, req.user);++ me.delete('/:type/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {++ const sid = await meOf(db, req.user);const key = String(req.params.target || '');// A subject can be unfollowed even if its account is gone since.-- const t = findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);++ const t = await findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');-- const out = setFollow(db, sid, req.params.type, t.subject_id, false);++ const out = await setFollow(db, sid, req.params.type, t.subject_id, false);if (out.changed) kick(db);-- res.json(status(db, req.params.type, t.subject_id, sid));++ res.json(await status(db, req.params.type, t.subject_id, sid));}));const pub = express.Router();pub.use(http.middleware());-- pub.get('/:type/:target', (req, res) => {++ pub.get('/:type/:target', async (req, res) => {try {const db = req.app.locals.db;-- const t = targetOrFail(db, req.params.type, req.params.target);-- const viewer = viewerOf(req);-- const body = status(db, req.params.type, t.subject_id, viewer && !viewer.is_anon ? viewer.subject_id : null);++ const t = await targetOrFail(db, req.params.type, req.params.target);++ const viewer = await viewerOf(req);++ const body = await status(db, req.params.type, t.subject_id, viewer && !viewer.is_anon ? viewer.subject_id : null);res.set('Cache-Control', viewer ? 'private, no-store' : 'public, max-age=30').set('Vary', 'Authorization, Cookie').json(body);} catch (err) {if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });@@@ -311,13 +287,13 @@ function routers({ requireAuth, followsGuard }) {}});// Who follows a target: its owner (signed in), or a service holding network.follows.read.-- pub.get('/:type/:target/followers', (req, res, next) => {++ pub.get('/:type/:target/followers', async (req, res, next) => {const db = req.app.locals.db;let t;-- try { t = targetOrFail(db, req.params.type, req.params.target); } catch (err) { return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov }); }-- const answer = () => send(req, res, () => res.json(list(db, { type: req.params.type, target: t.subject_id, cursor: req.query.cursor, limit: req.query.limit })));-- const viewer = viewerOf(req);-- if (viewer && viewer.subject_id === t.subject_id) return answer();++ try { t = await targetOrFail(db, req.params.type, req.params.target); } catch (err) { return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov }); }++ const answer = async () => await send(req, res, async () => res.json(await list(db, { type: req.params.type, target: t.subject_id, cursor: req.query.cursor, limit: req.query.limit })));++ const viewer = await viewerOf(req);++ if (viewer && viewer.subject_id === t.subject_id) return await answer();if (viewer) return http.sendProblem(res, 403, 'follows.not_yours', { detail: 'only the channel owner sees who follows it', ctx: req.ov });return followsGuard(req, res, answer);});@@@ -326,32 +302,32 @@ function routers({ requireAuth, followsGuard }) {// on a person's behalf (network.follow-write-request@1). The follower must be a Network account.const internal = express.Router();internal.use(http.middleware());-- const followerOrFail = (db, subject) => {-- const u = SUBJECT_RE.test(String(subject || '')) ? personRow(db, 'subject_id = ?', subject) : null;++ const followerOrFail = async (db, subject) => {++ const u = SUBJECT_RE.test(String(subject || '')) ? await personRow(db, 'subject_id = ?', subject) : null;if (!u || u.is_anon) throw new FollowError(404, 'follows.unknown_follower', 'the follower is not a Network account');return u.subject_id;};-- internal.put('/:type/:target', express.json({ limit: '4kb' }), (req, res) => send(req, res, (db) => {++ internal.put('/:type/:target', express.json({ limit: '4kb' }), async (req, res) => await send(req, res, async (db) => {const b = req.body && typeof req.body === 'object' ? req.body : {};const v = validate('network.follow-write-request@1', b);if (!v.valid) throw new FollowError(400, 'follows.invalid_request', 'the body does not match network.follow-write-request@1');-- const follower = followerOrFail(db, b.follower);-- const t = targetOrFail(db, req.params.type, req.params.target);-- const out = setFollow(db, follower, req.params.type, t.subject_id, true, {++ const follower = await followerOrFail(db, b.follower);++ const t = await targetOrFail(db, req.params.type, req.params.target);++ const out = await setFollow(db, follower, req.params.type, t.subject_id, true, {notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network',});if (out.changed) kick(db);-- res.status(out.changed && out.revision === 1 ? 201 : 200).json(status(db, req.params.type, t.subject_id, follower));++ res.status(out.changed && out.revision === 1 ? 201 : 200).json(await status(db, req.params.type, t.subject_id, follower));}));-- internal.delete('/:type/:target', (req, res) => send(req, res, (db) => {-- const follower = followerOrFail(db, req.query.follower);++ internal.delete('/:type/:target', async (req, res) => await send(req, res, async (db) => {++ const follower = await followerOrFail(db, req.query.follower);const key = String(req.params.target || '');-- const t = findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);++ const t = await findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');-- const out = setFollow(db, follower, req.params.type, t.subject_id, false, { source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network' });++ const out = await setFollow(db, follower, req.params.type, t.subject_id, false, { source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network' });if (out.changed) kick(db);-- res.json(status(db, req.params.type, t.subject_id, follower));++ res.json(await status(db, req.params.type, t.subject_id, follower));}));return { me, pub, internal };@@@ -363,20 +339,20 @@ function routers({ requireAuth, followsGuard }) {* is dropped. No follow events: services repoint on network.subject.merged. Inside the merge's transaction.* → { moved, dropped }*/--function onSubjectMerged(db, { from, into }) {++async function onSubjectMerged(db, { from, into }) {ensureSchema(db);const out = { moved: 0, dropped: 0 };-- const rows = db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? OR target_id = ?').all(from, from);++ const rows = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? OR target_id = ?').all(from, from);for (const r of rows) {const follower = r.follower_subject === from ? into : r.follower_subject;const target = r.target_id === from ? into : r.target_id;const clash = follower === target-- || db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);++ || await db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);if (clash) {-- db.prepare('DELETE FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(r.follower_subject, r.target_type, r.target_id);++ await db.prepare('DELETE FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(r.follower_subject, r.target_type, r.target_id);out.dropped++;} else {-- db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = CURRENT_TIMESTAMP WHERE follower_subject = ? AND target_type = ? AND target_id = ?')++ await db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = ov_now() WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(follower, target, r.follower_subject, r.target_type, r.target_id);out.moved++;}@@@ -30,26 +30,7 @@ class GrantError extends Error {constructor(status, message) { super(message); this.status = status; }}--function ensureSchema(db) {-- const cols = new Set(db.prepare('PRAGMA table_info(principal_grants)').all().map((c) => c.name));-- if (!cols.has('expires_at')) db.exec('ALTER TABLE principal_grants ADD COLUMN expires_at DATETIME');-- if (!cols.has('reason')) db.exec('ALTER TABLE principal_grants ADD COLUMN reason TEXT');-- if (!cols.has('revoked_by')) db.exec('ALTER TABLE principal_grants ADD COLUMN revoked_by TEXT');-- db.exec(`CREATE TABLE IF NOT EXISTS principal_grant_changes (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- client_id TEXT NOT NULL,-- capability TEXT NOT NULL,-- audience TEXT NOT NULL,-- change TEXT NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),-- namespaces TEXT NOT NULL DEFAULT '[]',-- expires_at TEXT,-- reason TEXT,-- actor TEXT,-- event_id TEXT,-- at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))-- );-- CREATE INDEX IF NOT EXISTS idx_grant_changes_client ON principal_grant_changes(client_id, id);`);--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }const nowIso = () => new Date().toISOString();const sqlNow = () => nowIso().replace('T', ' ').slice(0, 19);@@@ -60,7 +41,7 @@ function isoOrNull(v) {if (Number.isNaN(d.getTime())) throw new GrantError(400, 'expires_at is not a date');return d.toISOString();}--// principal_grants keeps SQLite's 'YYYY-MM-DD HH:MM:SS' (UTC) so it compares with CURRENT_TIMESTAMP.++// principal_grants keeps SQLite's 'YYYY-MM-DD HH:MM:SS' (UTC) so it compares with ov_now().const toSql = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : null);const fromSql = (v) => (v ? new Date(`${String(v).replace(' ', 'T')}Z`).toISOString() : null);@@@ -70,10 +51,10 @@ function stateOf(r, now = sqlNow()) {return 'active';}--function list(db, { client = null } = {}) {++async function list(db, { client = null } = {}) {const rows = client-- ? db.prepare('SELECT * FROM principal_grants WHERE client_id = ? ORDER BY client_id, capability, audience').all(String(client))-- : db.prepare('SELECT * FROM principal_grants ORDER BY client_id, capability, audience').all();++ ? await db.prepare('SELECT * FROM principal_grants WHERE client_id = ? ORDER BY client_id, capability, audience').all(String(client))++ : await db.prepare('SELECT * FROM principal_grants ORDER BY client_id, capability, audience').all();const now = sqlNow();return rows.map((r) => ({client_id: r.client_id, capability: r.capability, audience: r.audience, namespaces: JSON.parse(r.namespaces || '[]'),@@@ -82,16 +63,16 @@ function list(db, { client = null } = {}) {}));}--function changes(db, { client = null, limit = 100 } = {}) {++async function changes(db, { client = null, limit = 100 } = {}) {const n = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500);const rows = client-- ? db.prepare('SELECT * FROM principal_grant_changes WHERE client_id = ? ORDER BY id DESC LIMIT ?').all(String(client), n)-- : db.prepare('SELECT * FROM principal_grant_changes ORDER BY id DESC LIMIT ?').all(n);++ ? await db.prepare('SELECT * FROM principal_grant_changes WHERE client_id = ? ORDER BY id DESC LIMIT ?').all(String(client), n)++ : await db.prepare('SELECT * FROM principal_grant_changes ORDER BY id DESC LIMIT ?').all(n);return rows.map((r) => ({ ...r, namespaces: JSON.parse(r.namespaces || '[]') }));}/** The audit row and the event, inside the caller's transaction. */--function record(db, { client_id, capability, audience, change, namespaces, expires_at, reason, actor }) {++async function record(db, { client_id, capability, audience, change, namespaces, expires_at, reason, actor }) {const ms = Date.now();const env = {event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',@@@ -103,19 +84,19 @@ function record(db, { client_id, capability, audience, change, namespaces, expirconst v = validate('events.event-envelope@1', env);const pv = validate(`${EVENT_TYPE}@1`, env.payload);if (!v.valid || !pv.valid) throw new Error(`grants: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);-- eventRelay.writerFor(db).enqueue(env);-- db.prepare(`INSERT INTO principal_grant_changes (client_id, capability, audience, change, namespaces, expires_at, reason, actor, event_id)++ await eventRelay.writerFor(db).enqueue(env);++ await db.prepare(`INSERT INTO principal_grant_changes (client_id, capability, audience, change, namespaces, expires_at, reason, actor, event_id)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(client_id, capability, audience, change, JSON.stringify(namespaces), expires_at || null, reason || null, actor || null, env.event_id);}function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }/** Check a request's grant target. → { client_id, capability, audience } */--function target(db, body) {++async function target(db, body) {const client_id = String(body.client_id || '');const capability = String(body.capability || '');if (!CLIENT_RE.test(client_id)) throw new GrantError(400, 'client_id is malformed');-- if (!db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ? AND is_first_party = 1').get(client_id)) throw new GrantError(404, `${client_id} is not a service principal`);++ if (!await db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ? AND is_first_party = 1').get(client_id)) throw new GrantError(404, `${client_id} is not a service principal`);if (!CAP_RE.test(capability)) throw new GrantError(400, 'capability is malformed');const cap = capabilities.get(capability);if (!cap) throw new GrantError(400, `${capability} is not in openvibe-contracts`);@@@ -124,8 +105,8 @@ function target(db, body) {return { client_id, capability, audience, status: cap.status };}--function grant(db, body, actor) {-- const t = target(db, body);++async function grant(db, body, actor) {++ const t = await target(db, body);if (!GRANTABLE.has(t.status)) throw new GrantError(400, `${t.capability} is ${t.status}`);const reason = clean(body.reason, 500);if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');@@@ -133,44 +114,44 @@ function grant(db, body, actor) {if (namespaces.some((n) => !/^[a-z][a-z0-9_.*-]{0,199}$/.test(n))) throw new GrantError(400, 'a namespace is malformed');const expires_at = isoOrNull(body.expires_at);if (expires_at && expires_at <= nowIso()) throw new GrantError(400, 'expires_at is in the past');-- return db.transaction(() => {-- const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);++ return await db.tx(async () => {++ const prev = await db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);const wasActive = prev && stateOf(prev) === 'active';-- db.prepare(`INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by, granted_at, expires_at, reason, revoked_at, revoked_by)-- VALUES (@client_id, @capability, @audience, @namespaces, @actor, CURRENT_TIMESTAMP, @expires, @reason, NULL, NULL)++ await db.prepare(`INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by, granted_at, expires_at, reason, revoked_at, revoked_by)++ VALUES (@client_id, @capability, @audience, @namespaces, @actor, ov_now(), @expires, @reason, NULL, NULL)ON CONFLICT(client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by,granted_at = excluded.granted_at, expires_at = excluded.expires_at, reason = excluded.reason, revoked_at = NULL, revoked_by = NULL`).run({ ...t, namespaces: JSON.stringify(namespaces), actor: actor || 'owner', expires: toSql(expires_at), reason });const change = wasActive ? 'updated' : 'granted';-- record(db, { ...t, change, namespaces, expires_at, reason, actor });++ await record(db, { ...t, change, namespaces, expires_at, reason, actor });return { ...t, change, namespaces, expires_at };-- })();++ });}--function revoke(db, body, actor) {-- const t = target(db, body);++async function revoke(db, body, actor) {++ const t = await target(db, body);const reason = clean(body.reason, 500);if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');-- return db.transaction(() => {-- const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);++ return await db.tx(async () => {++ const prev = await db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);if (!prev || stateOf(prev) !== 'active') throw new GrantError(404, 'no active grant to revoke');-- db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP, revoked_by = ?, reason = ? WHERE client_id = ? AND capability = ? AND audience = ?")++ await db.prepare("UPDATE principal_grants SET revoked_at = ov_now(), revoked_by = ?, reason = ? WHERE client_id = ? AND capability = ? AND audience = ?").run(actor || 'owner', reason, t.client_id, t.capability, t.audience);const namespaces = JSON.parse(prev.namespaces || '[]');-- record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });++ await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });return { ...t, change: 'revoked' };-- })();++ });}/** Record every grant whose expiry has passed, once. → how many */--function expireDue(db) {-- const due = db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP').all();++async function expireDue(db) {++ const due = await db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ov_now()').all();for (const g of due) {-- db.transaction(() => {-- const n = db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")-- .run(g.client_id, g.capability, g.audience).changes;-- if (n) record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });-- })();++ await db.tx(async () => {++ const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")++ .run(g.client_id, g.capability, g.audience)).changes;++ if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });++ });}if (due.length) kick(db);return due.length;@@@ -179,25 +160,25 @@ function expireDue(db) {function router(db) {const r = express.Router();r.use((req, res, next) => (req.user && isOwner(req.user) ? next() : res.status(403).json({ ok: false, error: 'Only the owner manages service grants' })));-- const send = (res, fn) => {-- try { res.json({ ok: true, ...fn() }); } catch (err) {++ const send = async (res, fn) => {++ try { res.json({ ok: true, ...await fn() }); } catch (err) {if (err instanceof GrantError) return res.status(err.status).json({ ok: false, error: err.message });console.error('[Grants]', err.message);res.status(500).json({ ok: false, error: 'grant change failed' });}};const actorOf = (req) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.user.subject_id || '')) ? req.user.subject_id : null);-- r.get('/', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ grants: list(db, { client: req.query.client }) })); });-- r.get('/changes', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ changes: changes(db, { client: req.query.client, limit: req.query.limit }) })); });-- r.post('/', (req, res) => send(res, () => { const out = grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));-- r.post('/revoke', (req, res) => send(res, () => { const out = revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));++ r.get('/', async (req, res) => { res.set('Cache-Control', 'private, no-store'); await send(res, async () => ({ grants: await list(db, { client: req.query.client }) })); });++ r.get('/changes', async (req, res) => { res.set('Cache-Control', 'private, no-store'); await send(res, async () => ({ changes: await changes(db, { client: req.query.client, limit: req.query.limit }) })); });++ r.post('/', async (req, res) => await send(res, async () => { const out = await grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));++ r.post('/revoke', async (req, res) => await send(res, async () => { const out = await revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));return r;}let timer = null;function start(db, { intervalMs = 10 * 60 * 1000 } = {}) {if (timer) return;-- const run = () => { try { expireDue(db); } catch (err) { console.warn('[Grants] expiry:', err.message); } };++ const run = async () => { try { await expireDue(db); } catch (err) { console.warn('[Grants] expiry:', err.message); } };timer = setInterval(run, intervalMs);if (timer.unref) timer.unref();setImmediate(run);@@@ -15,13 +15,13 @@ const router = express.Router();router.use(http.middleware());const MAX_ENTRIES = 1000;--router.get('/resolve', (req, res) => {++router.get('/resolve', async (req, res) => {const db = req.app.locals.db;const q = req.query;if (!q.subject_id && !(q.system && q.id)) {return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, or system + id (+ type, default user)', ctx: req.ov });}-- const out = subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });++ const out = await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });res.json(out);});@@@ -32,7 +32,7 @@ router.get('/resolve', (req, res) => {* -> { results: { <subject_id | id>: projection | null } }* Used by services that show authors (Community pastes) and by importers mapping legacy ids.*/--router.post('/resolve-batch', express.json({ limit: '256kb' }), (req, res) => {++router.post('/resolve-batch', express.json({ limit: '256kb' }), async (req, res) => {const db = req.app.locals.db;const b = req.body || {};const list = Array.isArray(b.subject_ids) ? b.subject_ids : Array.isArray(b.ids) ? b.ids : null;@@@ -43,13 +43,13 @@ router.post('/resolve-batch', express.json({ limit: '256kb' }), (req, res) => {for (const raw of list) {const key = String(raw);results[key] = b.subject_ids-- ? subjects.resolve(db, { subject_id: key })-- : subjects.resolve(db, { source_system: String(b.system), source_type: String(b.type || 'user'), source_id: key });++ ? await subjects.resolve(db, { subject_id: key })++ : await subjects.resolve(db, { source_system: String(b.system), source_type: String(b.type || 'user'), source_id: key });}res.json({ results });});--router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {++router.post('/legacy-map', express.json({ limit: '512kb' }), async (req, res) => {const entries = req.body && req.body.entries;if (!Array.isArray(entries) || !entries.length) {return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'entries must be a non-empty array', ctx: req.ov });@@@ -57,7 +57,7 @@ router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {if (entries.length > MAX_ENTRIES) {return http.sendProblem(res, 413, 'identity.too_many_entries', { detail: `at most ${MAX_ENTRIES} entries per call`, ctx: req.ov });}-- res.json(subjects.upsertLegacy(req.app.locals.db, entries));++ res.json(await subjects.upsertLegacy(req.app.locals.db, entries));});module.exports = router;@@@ -28,41 +28,14 @@ class ModPrincipalError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS mod_principals (-- mod_id TEXT PRIMARY KEY,-- owner TEXT NOT NULL,-- runtime TEXT NOT NULL,-- name TEXT,-- version TEXT,-- publisher TEXT,-- manifest TEXT NOT NULL,-- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),-- revision INTEGER NOT NULL DEFAULT 1,-- created_at TEXT NOT NULL,-- created_by TEXT,-- updated_at TEXT NOT NULL,-- revoked_at TEXT,-- revoked_by TEXT-- );-- CREATE TABLE IF NOT EXISTS mod_principal_grants (-- mod_id TEXT NOT NULL,-- capability TEXT NOT NULL,-- state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),-- changed_at TEXT NOT NULL,-- changed_by TEXT,-- PRIMARY KEY (mod_id, capability)-- );-- `);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }const iso = (ms) => new Date(ms).toISOString();--function view(db, modId) {-- const p = db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId);++async function view(db, modId) {++ const p = await db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId);if (!p) return null;-- const grants = db.prepare('SELECT capability, state FROM mod_principal_grants WHERE mod_id = ? ORDER BY capability').all(modId);++ const grants = await db.prepare('SELECT capability, state FROM mod_principal_grants WHERE mod_id = ? ORDER BY capability').all(modId);const by = (s) => grants.filter((g) => g.state === s).map((g) => g.capability);const out = {principal: `mod:${p.mod_id}`, mod_id: p.mod_id, owner: p.owner, runtime: p.runtime, status: p.status,@@@ -74,7 +47,7 @@ function view(db, modId) {return out;}--function announce(db, v, change, by, reason) {++async function announce(db, v, change, by, reason) {const at = v.updated_at;const payload = { mod_id: v.mod_id, owner: v.owner, status: v.status, approved: v.approved, pending: v.pending, revision: v.revision, change, by };if (reason) payload.reason = String(reason).slice(0, 500);@@@ -86,13 +59,13 @@ function announce(db, v, change, by, reason) {const ev = validate('events.event-envelope@1', env);const pv = validate('network.mod.grants_changed@1', payload);if (!ev.valid || !pv.valid) throw new Error(`mod-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);-- eventRelay.writerFor(db).enqueue(env);++ return eventRelay.writerFor(db).enqueue(env);}function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls */ } }/** Register an install's principal (idempotent). → { status: 201|200, body: network.mod-principal@1 } */--function register(db, owner, body, { now = Date.now() } = {}) {++async function register(db, owner, body, { now = Date.now() } = {}) {ensureSchema(db);const v = validate('network.mod-install-request@1', body);if (!v.valid) throw new ModPrincipalError(400, 'mod.invalid_request', JSON.stringify(v.errors).slice(0, 300));@@@ -103,65 +76,65 @@ function register(db, owner, body, { now = Date.now() } = {}) {const approve = [...new Set(body.approve || [])];const unrequested = approve.filter((c) => !requested.includes(c));if (unrequested.length) throw new ModPrincipalError(422, 'mod.not_requested', `not requested by the manifest: ${unrequested.join(', ')}`);-- const existing = db.prepare('SELECT owner FROM mod_principals WHERE mod_id = ?').get(m.id);++ const existing = await db.prepare('SELECT owner FROM mod_principals WHERE mod_id = ?').get(m.id);if (existing) {if (existing.owner !== owner) throw new ModPrincipalError(409, 'mod.other_owner', 'that mod is registered by another runtime');-- return { status: 200, body: view(db, m.id) };++ return { status: 200, body: await view(db, m.id) };}const at = iso(now);-- db.transaction(() => {-- db.prepare(`INSERT INTO mod_principals (mod_id, owner, runtime, name, version, publisher, manifest, created_at, created_by, updated_at)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO mod_principals (mod_id, owner, runtime, name, version, publisher, manifest, created_at, created_by, updated_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(m.id, owner, m.runtime, m.name || null, m.version || null, m.publisher && m.publisher.id ? String(m.publisher.id).slice(0, 80) : null,JSON.stringify(m), at, body.actor ? String(body.actor).slice(0, 80) : owner, at);const g = db.prepare('INSERT INTO mod_principal_grants (mod_id, capability, state, changed_at, changed_by) VALUES (?, ?, ?, ?, ?)');-- for (const c of requested) g.run(m.id, c, approve.includes(c) ? 'approved' : 'pending', at, body.actor || owner);-- announce(db, view(db, m.id), { action: 'register' }, 'runtime');-- })();++ for (const c of requested) await g.run(m.id, c, approve.includes(c) ? 'approved' : 'pending', at, body.actor || owner);++ await announce(db, await view(db, m.id), { action: 'register' }, 'runtime');++ });kick(db);-- return { status: 201, body: view(db, m.id) };++ return { status: 201, body: await view(db, m.id) };}--function load(db, modId, owner) {++async function load(db, modId, owner) {ensureSchema(db);-- const p = MOD_RE.test(String(modId)) ? db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId) : null;++ const p = MOD_RE.test(String(modId)) ? await db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId) : null;if (!p) throw new ModPrincipalError(404, 'mod.not_found', 'no such mod principal');if (owner && p.owner !== owner) throw new ModPrincipalError(403, 'mod.other_owner', 'that mod is registered by another runtime');return p;}/** Approve or revoke one capability. by: 'runtime' (owner given) or 'staff'. → network.mod-principal@1 */--function change(db, modId, { capability, action, actor, reason }, { owner = null, by = 'runtime', now = Date.now() } = {}) {++async function change(db, modId, { capability, action, actor, reason }, { owner = null, by = 'runtime', now = Date.now() } = {}) {const v = validate('network.mod-grant-change@1', { capability, action, ...(actor ? { actor: String(actor).slice(0, 80) } : {}), ...(reason ? { reason: String(reason).slice(0, 500) } : {}) });if (!v.valid || !CAP_RE.test(String(capability))) throw new ModPrincipalError(400, 'mod.invalid_request', 'capability and action (approve|revoke) are required');-- const p = load(db, modId, owner);++ const p = await load(db, modId, owner);if (p.status === 'revoked') throw new ModPrincipalError(409, 'mod.revoked', 'a revoked install is never granted anything again');-- const g = db.prepare('SELECT state FROM mod_principal_grants WHERE mod_id = ? AND capability = ?').get(modId, capability);++ const g = await db.prepare('SELECT state FROM mod_principal_grants WHERE mod_id = ? AND capability = ?').get(modId, capability);if (!g) throw new ModPrincipalError(422, 'mod.not_requested', 'the manifest does not request that capability');const to = action === 'approve' ? 'approved' : 'revoked';-- if (g.state === to || (action === 'revoke' && g.state === 'pending')) return view(db, modId);++ if (g.state === to || (action === 'revoke' && g.state === 'pending')) return await view(db, modId);const at = iso(now);-- db.transaction(() => {-- db.prepare('UPDATE mod_principal_grants SET state = ?, changed_at = ?, changed_by = ? WHERE mod_id = ? AND capability = ?').run(to, at, actor || by, modId, capability);-- db.prepare('UPDATE mod_principals SET revision = revision + 1, updated_at = ? WHERE mod_id = ?').run(at, modId);-- announce(db, view(db, modId), { action, capability }, by, reason);-- })();++ await db.tx(async () => {++ await db.prepare('UPDATE mod_principal_grants SET state = ?, changed_at = ?, changed_by = ? WHERE mod_id = ? AND capability = ?').run(to, at, actor || by, modId, capability);++ await db.prepare('UPDATE mod_principals SET revision = revision + 1, updated_at = ? WHERE mod_id = ?').run(at, modId);++ await announce(db, await view(db, modId), { action, capability }, by, reason);++ });kick(db);-- return view(db, modId);++ return await view(db, modId);}/** End the install: every grant revoked, for good. → network.mod-principal@1 */--function revokeAll(db, modId, { actor, reason } = {}, { owner = null, by = 'runtime', now = Date.now() } = {}) {-- const p = load(db, modId, owner);-- if (p.status === 'revoked') return view(db, modId);++async function revokeAll(db, modId, { actor, reason } = {}, { owner = null, by = 'runtime', now = Date.now() } = {}) {++ const p = await load(db, modId, owner);++ if (p.status === 'revoked') return await view(db, modId);const at = iso(now);-- db.transaction(() => {-- db.prepare("UPDATE mod_principal_grants SET state = 'revoked', changed_at = ?, changed_by = ? WHERE mod_id = ? AND state = 'approved'").run(at, actor || by, modId);-- db.prepare("UPDATE mod_principals SET status = 'revoked', revision = revision + 1, updated_at = ?, revoked_at = ?, revoked_by = ? WHERE mod_id = ?").run(at, at, actor || by, modId);-- announce(db, view(db, modId), { action: 'revoke_all' }, by, reason);-- })();++ await db.tx(async () => {++ await db.prepare("UPDATE mod_principal_grants SET state = 'revoked', changed_at = ?, changed_by = ? WHERE mod_id = ? AND state = 'approved'").run(at, actor || by, modId);++ await db.prepare("UPDATE mod_principals SET status = 'revoked', revision = revision + 1, updated_at = ?, revoked_at = ?, revoked_by = ? WHERE mod_id = ?").run(at, at, actor || by, modId);++ await announce(db, await view(db, modId), { action: 'revoke_all' }, by, reason);++ });kick(db);-- return view(db, modId);++ return await view(db, modId);}function sendError(res, e) {@@@ -175,15 +148,15 @@ function routers({ guard, requireAuth, staffClaims }) {const owner = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');const internal = express.Router();internal.use(guard);-- internal.get('/', (req, res) => {++ internal.get('/', async (req, res) => {const db = req.app.locals.db; ensureSchema(db);-- const rows = db.prepare('SELECT mod_id FROM mod_principals WHERE owner = ? ORDER BY created_at').all(owner(req));-- res.json({ mods: rows.map((r) => view(db, r.mod_id)) });++ const rows = await db.prepare('SELECT mod_id FROM mod_principals WHERE owner = ? ORDER BY created_at').all(owner(req));++ res.json({ mods: (await Promise.all(rows.map(async (r) => await view(db, r.mod_id)))) });});-- internal.get('/:id', (req, res) => { try { load(req.app.locals.db, req.params.id, owner(req)); res.json(view(req.app.locals.db, req.params.id)); } catch (e) { sendError(res, e); } });-- internal.post('/', (req, res) => { try { const out = register(req.app.locals.db, owner(req), req.body || {}); res.status(out.status).json(out.body); } catch (e) { sendError(res, e); } });-- internal.post('/:id/grants', (req, res) => { try { res.json(change(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });-- internal.post('/:id/revoke', (req, res) => { try { res.json(revokeAll(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });++ internal.get('/:id', async (req, res) => { try { await load(req.app.locals.db, req.params.id, owner(req)); res.json(await view(req.app.locals.db, req.params.id)); } catch (e) { sendError(res, e); } });++ internal.post('/', async (req, res) => { try { const out = await register(req.app.locals.db, owner(req), req.body || {}); res.status(out.status).json(out.body); } catch (e) { sendError(res, e); } });++ internal.post('/:id/grants', async (req, res) => { try { res.json(await change(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });++ internal.post('/:id/revoke', async (req, res) => { try { res.json(await revokeAll(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });const admin = express.Router();admin.use(express.json({ limit: '8kb' }));@@@ -193,25 +166,25 @@ function routers({ guard, requireAuth, staffClaims }) {if (reason.length < 10) { res.status(400).json({ error: 'mod.reason_required', detail: 'a written reason (10 characters or more)' }); return null; }return reason.slice(0, 500);};-- const audit = (req, action, detail) => req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));-- admin.get('/', requireAuth, staffOnly, (req, res) => {++ const audit = async (req, action, detail) => await req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));++ admin.get('/', requireAuth, staffOnly, async (req, res) => {const db = req.app.locals.db; ensureSchema(db);-- res.set('Cache-Control', 'private, no-store').json({ mods: db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all().map((r) => view(db, r.mod_id)) });++ res.set('Cache-Control', 'private, no-store').json({ mods: (await Promise.all((await db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all()).map(async (r) => await view(db, r.mod_id)))) });});-- admin.post('/:id/grants', requireAuth, staffOnly, (req, res) => {++ admin.post('/:id/grants', requireAuth, staffOnly, async (req, res) => {const reason = reasoned(req, res); if (!reason) return;const actor = req.user.subject_id || `user:${req.user.id}`;try {-- const out = change(req.app.locals.db, req.params.id, { capability: req.body.capability, action: req.body.action, actor, reason }, { by: 'staff' });-- audit(req, 'mod_grant_change', { mod_id: req.params.id, capability: req.body.capability, action: req.body.action, reason });++ const out = await change(req.app.locals.db, req.params.id, { capability: req.body.capability, action: req.body.action, actor, reason }, { by: 'staff' });++ await audit(req, 'mod_grant_change', { mod_id: req.params.id, capability: req.body.capability, action: req.body.action, reason });res.json(out);} catch (e) { sendError(res, e); }});-- admin.post('/:id/revoke', requireAuth, staffOnly, (req, res) => {++ admin.post('/:id/revoke', requireAuth, staffOnly, async (req, res) => {const reason = reasoned(req, res); if (!reason) return;try {-- const out = revokeAll(req.app.locals.db, req.params.id, { actor: req.user.subject_id || `user:${req.user.id}`, reason }, { by: 'staff' });-- audit(req, 'mod_revoke', { mod_id: req.params.id, reason });++ const out = await revokeAll(req.app.locals.db, req.params.id, { actor: req.user.subject_id || `user:${req.user.id}`, reason }, { by: 'staff' });++ await audit(req, 'mod_revoke', { mod_id: req.params.id, reason });res.json(out);} catch (e) { sendError(res, e); }});@@@ -73,14 +73,12 @@ function buildEnvelope({ subjectId, namespace, namespaceOwner, schemaVersion, re}/** Create network_event_outbox if no relay has yet (boot), so a module write never has to. */--function ensureSchema(db) {-- eventRelay.writerFor(db);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** Build and enqueue; call INSIDE the transaction that makes the change. Returns the envelope. */--function record(db, change) {++async function record(db, change) {const env = buildEnvelope(change);-- eventRelay.writerFor(db).enqueue(env, { traceparent: change.ctx && change.ctx.traceparent });++ await eventRelay.writerFor(db).enqueue(db, env, { traceparent: change.ctx && change.ctx.traceparent });return env;}@@@ -68,48 +68,7 @@ function ownerRetired(namespace) {return !!(m && m.status === 'retired');}--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS user_modules (-- subject_id TEXT NOT NULL,-- namespace TEXT NOT NULL,-- version INTEGER NOT NULL,-- revision INTEGER NOT NULL DEFAULT 0,-- data TEXT NOT NULL,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- updated_by TEXT,-- PRIMARY KEY (subject_id, namespace)-- );-- -- The last revision issued per (subject, namespace), kept across deletes.-- CREATE TABLE IF NOT EXISTS user_module_revisions (-- subject_id TEXT NOT NULL,-- namespace TEXT NOT NULL,-- revision INTEGER NOT NULL,-- PRIMARY KEY (subject_id, namespace)-- );-- -- When Network first saw a namespace's owning service retired (onOwnerRemoved).-- CREATE TABLE IF NOT EXISTS user_module_retirements (-- namespace TEXT PRIMARY KEY,-- owner TEXT NOT NULL,-- retired_seen_at INTEGER NOT NULL-- );-- `);-- moduleEvents.ensureSchema(db);-- // An account cannot disappear, or change subject, while module rows remain: the code that deletes or-- // merges it must call onSubjectRemoved/onSubjectMerged (same transaction), which also emit the events.-- for (const table of ['users', 'anon_users']) {-- if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) continue;-- db.exec(`-- CREATE TRIGGER IF NOT EXISTS user_modules_guard_${table}_delete BEFORE DELETE ON ${table}-- WHEN OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)-- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'); END;-- CREATE TRIGGER IF NOT EXISTS user_modules_guard_${table}_rekey BEFORE UPDATE OF subject_id ON ${table}-- WHEN OLD.subject_id IS NOT NULL AND NEW.subject_id IS NOT OLD.subject_id-- AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)-- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectMerged first'); END;-- `);-- }--}++async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });@@@ -125,16 +84,16 @@ function toRecord(row) {data, updated_at: row.updated_at, updated_by: row.updated_by || undefined };}--function read(db, subjectId, namespace) {-- const row = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++async function read(db, subjectId, namespace) {++ const row = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);return row ? toRecord(row) : null;}/** The next revision for (subject, namespace): one above both the current row and the last issued. */--function nextRevision(db, subjectId, namespace, current) {-- const hw = db.prepare('SELECT revision FROM user_module_revisions WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++async function nextRevision(db, subjectId, namespace, current) {++ const hw = await db.prepare('SELECT revision FROM user_module_revisions WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);const next = Math.max(current || 0, hw ? hw.revision : 0) + 1;-- db.prepare(`INSERT INTO user_module_revisions (subject_id, namespace, revision) VALUES (?, ?, ?)++ await db.prepare(`INSERT INTO user_module_revisions (subject_id, namespace, revision) VALUES (?, ?, ?)ON CONFLICT(subject_id, namespace) DO UPDATE SET revision = excluded.revision`).run(subjectId, namespace, next);return next;}@@@ -151,7 +110,7 @@ function actorOf(subjectId, writer) {* the writer read (0 = "I expect no record"); undefined = unconditional (allowed for the owner service only).* Returns { status, record } or { status, code, detail, errors? }.*/--function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx } = {}) {++async function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx } = {}) {const ns = modules.get(namespace);if (!ns) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };if (!canWrite(namespace, writer)) return { status: 403, code: 'modules.write_denied', detail: `${writer && writer.type === 'user' ? 'users' : writer && writer.id} may not write ${namespace}` };@@@ -160,19 +119,19 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }const v = modules.validateData(namespace, data);if (!v.valid) return { status: 422, code: 'modules.invalid_data', detail: `does not match ${namespace} v${ns.version}`, errors: v.errors };const by = writer.type === 'user' ? `user:${subjectId}` : `svc:${writer.id}`;-- const out = db.transaction(() => {-- const cur = db.prepare('SELECT revision, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++ const out = await db.tx(async () => {++ const cur = await db.prepare('SELECT revision, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);const have = cur ? cur.revision : 0;if (expectedRevision !== undefined && Number(expectedRevision) !== have) {return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${have}, not ${expectedRevision}` };}-- const revision = nextRevision(db, subjectId, namespace, have);-- db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)-- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)++ const revision = await nextRevision(db, subjectId, namespace, have);++ await db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)++ VALUES (?, ?, ?, ?, ?, ov_now(), ?)ON CONFLICT(subject_id, namespace) DO UPDATE SET version = excluded.version, revision = excluded.revision,-- data = excluded.data, updated_at = CURRENT_TIMESTAMP, updated_by = excluded.updated_by`)++ data = excluded.data, updated_at = ov_now(), updated_by = excluded.updated_by`).run(subjectId, namespace, ns.version, revision, JSON.stringify(data), by);-- const record = read(db, subjectId, namespace);++ const record = await read(db, subjectId, namespace);assertValid('modules.module-record@1', record);moduleEvents.record(db, {subjectId, namespace, namespaceOwner: ownerOf(namespace), schemaVersion: ns.version, revision,@@@ -180,7 +139,7 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }actor: actorOf(subjectId, writer), ctx,});return { status: cur ? 200 : 201, record };-- })();++ });if (out.record) moduleEvents.kick(db);return out;}@@@ -189,11 +148,11 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }* Delete one record inside the caller's transaction (or its own) and emit its event.* Returns the revision of the delete, or 0 when there was no record.*/--function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx }) {-- const row = db.prepare('SELECT revision, version, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++async function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx }) {++ const row = await db.prepare('SELECT revision, version, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);if (!row) return 0;-- db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjectId, namespace);-- const revision = nextRevision(db, subjectId, namespace, row.revision);++ await db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjectId, namespace);++ const revision = await nextRevision(db, subjectId, namespace, row.revision);moduleEvents.record(db, {subjectId, namespace, namespaceOwner: ownerOf(namespace) || 'network', schemaVersion: row.version, revision,change: 'deleted', reason, before: JSON.parse(row.data), after: {}, actor, mergedInto, ctx,@@@ -206,18 +165,18 @@ function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx })* only in the namespaces it owns. expectedRevision optional (412 if it moved).* Returns { status: 204, revision } | { status, code, detail }.*/--function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}) {++async function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}) {if (!modules.get(namespace)) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };if (writer.type === 'service' && writer.id !== ownerOf(namespace)) return { status: 403, code: 'modules.write_denied', detail: `${writer.id} may not delete ${namespace}` };-- const out = db.transaction(() => {-- const cur = db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);++ const out = await db.tx(async () => {++ const cur = await db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);if (!cur) return { status: 404, code: 'modules.not_found' };if (expectedRevision !== undefined && Number(expectedRevision) !== cur.revision) {return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${cur.revision}, not ${expectedRevision}` };}-- const revision = removeRow(db, subjectId, namespace, { actor: actorOf(subjectId, writer), reason: writer.type === 'user' ? 'delete' : 'owner_delete', ctx });++ const revision = await removeRow(db, subjectId, namespace, { actor: actorOf(subjectId, writer), reason: writer.type === 'user' ? 'delete' : 'owner_delete', ctx });return { status: 204, revision };-- })();++ });if (out.status === 204) moduleEvents.kick(db);return out;}@@@ -227,14 +186,14 @@ function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}* reason subject_removed) each, and forget its revisions. Call inside the transaction that deletes the* account (the users/anon_users triggers refuse the delete otherwise). Returns how many were deleted.*/--function onSubjectRemoved(db, subjectId, { ctx } = {}) {++async function onSubjectRemoved(db, subjectId, { ctx } = {}) {if (!SUBJECT_RE.test(String(subjectId || ''))) throw new TypeError('onSubjectRemoved: a usr_/gst_ subject id is required');-- const n = db.transaction(() => {-- const rows = db.prepare('SELECT namespace FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(subjectId);-- for (const r of rows) removeRow(db, subjectId, r.namespace, { actor: { type: 'system', id: 'network' }, reason: 'subject_removed', ctx });-- db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(subjectId);++ const n = await db.tx(async () => {++ const rows = await db.prepare('SELECT namespace FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(subjectId);++ for (const r of rows) await removeRow(db, subjectId, r.namespace, { actor: { type: 'system', id: 'network' }, reason: 'subject_removed', ctx });++ await db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(subjectId);return rows.length;-- })();++ });if (n) moduleEvents.kick(db);return n;}@@@ -247,18 +206,18 @@ function onSubjectRemoved(db, subjectId, { ctx } = {}) {* (reason subject_merged, merged_into). Moved and filled records say updated_by svc:network.* Call inside the merge's transaction, before `from`'s row goes. Returns { moved, filled, dropped }.*/--function onSubjectMerged(db, { from, into, ctx } = {}) {++async function onSubjectMerged(db, { from, into, ctx } = {}) {if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onSubjectMerged: from and into must be usr_/gst_ subject ids');if (from === into) throw new TypeError('onSubjectMerged: from and into are the same subject');const system = { type: 'system', id: 'network' };-- const out = db.transaction(() => {++ const out = await db.tx(async () => {const res = { moved: 0, filled: 0, dropped: 0 };-- for (const row of db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(from)) {-- const has = db.prepare('SELECT 1 FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);++ for (const row of await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(from)) {++ const has = await db.prepare('SELECT 1 FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);if (!has) {-- const revision = nextRevision(db, into, row.namespace, 0);-- db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)-- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'svc:network')`).run(into, row.namespace, row.version, revision, row.data);++ const revision = await nextRevision(db, into, row.namespace, 0);++ await db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)++ VALUES (?, ?, ?, ?, ?, ov_now(), 'svc:network')`).run(into, row.namespace, row.version, revision, row.data);moduleEvents.record(db, {subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: row.version, revision,change: 'created', reason: 'subject_merged', before: {}, after: JSON.parse(row.data), actor: system, mergedFrom: from, ctx,@@@ -266,7 +225,7 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {res.moved++;} else {// The survivor's fields win; the other record fills only what it lacks (objects only).-- const mine = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);++ const mine = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);let a = null; let b = null;try { a = JSON.parse(mine.data); b = JSON.parse(row.data); } catch { /* unreadable: keep the survivor's */ }const isObj = (x) => x && typeof x === 'object' && !Array.isArray(x);@@@ -274,8 +233,8 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {if (gaps.length) {const next = { ...a };for (const k of gaps) next[k] = b[k];-- const revision = nextRevision(db, into, row.namespace, mine.revision);-- db.prepare("UPDATE user_modules SET data = ?, revision = ?, updated_at = CURRENT_TIMESTAMP, updated_by = 'svc:network' WHERE subject_id = ? AND namespace = ?")++ const revision = await nextRevision(db, into, row.namespace, mine.revision);++ await db.prepare("UPDATE user_modules SET data = ?, revision = ?, updated_at = ov_now(), updated_by = 'svc:network' WHERE subject_id = ? AND namespace = ?").run(JSON.stringify(next), revision, into, row.namespace);moduleEvents.record(db, {subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: mine.version, revision,@@@ -284,11 +243,11 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {res.filled++;} else res.dropped++;}-- removeRow(db, from, row.namespace, { actor: system, reason: 'subject_merged', mergedInto: into, ctx });++ await removeRow(db, from, row.namespace, { actor: system, reason: 'subject_merged', mergedInto: into, ctx });}-- db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(from);++ await db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(from);return res;-- })();++ });if (out.moved || out.filled || out.dropped) moduleEvents.kick(db);return out;}@@@ -298,23 +257,23 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {* and once retentionDays have passed since, delete the namespace's records (reason owner_delete, actor* system:network). retain-readonly namespaces only become read-only (write()). Returns how many were deleted.*/--function sweepRetired(db, { now = Date.now(), ctx } = {}) {++async function sweepRetired(db, { now = Date.now(), ctx } = {}) {let deleted = 0;for (const ns of modules.namespaces) {const owner = ownerOf(ns.namespace);if (!ownerRetired(ns.namespace)) {-- db.prepare('DELETE FROM user_module_retirements WHERE namespace = ?').run(ns.namespace); // un-retired++ await db.prepare('DELETE FROM user_module_retirements WHERE namespace = ?').run(ns.namespace); // un-retiredcontinue;}-- db.prepare('INSERT OR IGNORE INTO user_module_retirements (namespace, owner, retired_seen_at) VALUES (?, ?, ?)').run(ns.namespace, owner, now);++ await db.prepare('INSERT INTO user_module_retirements (namespace, owner, retired_seen_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(ns.namespace, owner, now);if (ns.onOwnerRemoved !== 'delete-after-retention') continue;-- const seen = db.prepare('SELECT retired_seen_at FROM user_module_retirements WHERE namespace = ?').get(ns.namespace).retired_seen_at;++ const seen = (await db.prepare('SELECT retired_seen_at FROM user_module_retirements WHERE namespace = ?').get(ns.namespace)).retired_seen_at;if (now - seen < (ns.retentionDays || 0) * 86400000) continue;-- deleted += db.transaction(() => {-- const rows = db.prepare('SELECT subject_id FROM user_modules WHERE namespace = ?').all(ns.namespace);-- for (const r of rows) removeRow(db, r.subject_id, ns.namespace, { actor: { type: 'system', id: 'network' }, reason: 'owner_delete', ctx });++ deleted += await db.tx(async () => {++ const rows = await db.prepare('SELECT subject_id FROM user_modules WHERE namespace = ?').all(ns.namespace);++ for (const r of rows) await removeRow(db, r.subject_id, ns.namespace, { actor: { type: 'system', id: 'network' }, reason: 'owner_delete', ctx });return rows.length;-- })();++ });}if (deleted) moduleEvents.kick(db);return deleted;@@@ -334,42 +293,42 @@ function userRouter(requireAuth) {const router = express.Router();router.use(http.middleware());-- router.get('/:ns/public/:subject', (req, res) => {++ router.get('/:ns/public/:subject', async (req, res) => {if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(res, { status: 404, code: 'modules.not_found' }, req);-- const rec = read(req.app.locals.db, req.params.subject, req.params.ns);++ const rec = await read(req.app.locals.db, req.params.subject, req.params.ns);if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);res.set('Cache-Control', 'public, max-age=60').json({ subject: rec.subject, namespace: rec.namespace, version: rec.version, data: modules.publicView(rec.namespace, rec.data) });});-- router.get('/', requireAuth, (req, res) => {++ router.get('/', requireAuth, async (req, res) => {const db = req.app.locals.db;-- const sid = subjects.ensureUserSubject(db, req.user);-- const rows = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);++ const sid = await subjects.ensureUserSubject(db, req.user);++ const rows = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);res.set('Cache-Control', 'private, no-store').json({ subject: subjectRef(sid), modules: rows.map(toRecord),namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: ownerOf(n.namespace), userWritable: n.writers.includes('user') && !ownerRetired(n.namespace), description: n.description })) });});-- router.get('/:ns', requireAuth, (req, res) => {++ router.get('/:ns', requireAuth, async (req, res) => {const db = req.app.locals.db;if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);-- const rec = read(db, subjects.ensureUserSubject(db, req.user), req.params.ns);++ const rec = await read(db, await subjects.ensureUserSubject(db, req.user), req.params.ns);if (!rec) return problem(res, { status: 404, code: 'modules.not_found', detail: 'no record yet (write with If-Match: 0)' }, req);withEtag(res, rec).json(rec);});-- router.put('/:ns', requireAuth, (req, res) => {++ router.put('/:ns', requireAuth, async (req, res) => {const db = req.app.locals.db;const rev = revisionHeader(req);if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision', detail: 'If-Match must be a revision number' }, req);-- const out = write(db, subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev, ctx: req.ov });++ const out = await write(db, await subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev, ctx: req.ov });if (!out.record) return problem(res, out, req);withEtag(res, out.record).status(out.status).json(out.record);});-- router.delete('/:ns', requireAuth, (req, res) => {++ router.delete('/:ns', requireAuth, async (req, res) => {const db = req.app.locals.db;-- const out = remove(db, subjects.ensureUserSubject(db, req.user), req.params.ns, { writer: { type: 'user' }, ctx: req.ov });++ const out = await remove(db, await subjects.ensureUserSubject(db, req.user), req.params.ns, { writer: { type: 'user' }, ctx: req.ov });if (out.status === 404 && out.code === 'modules.unknown_namespace') return problem(res, out, req);res.status(out.status === 204 ? 204 : 404).end();});@@@ -379,14 +338,14 @@ function userRouter(requireAuth) {/** Service routes; mounted under /internal (after requireInternalKey) with principal guards. */function serviceRoutes(router, principals) {const nsOf = (req) => req.params.ns;-- const resolveSubject = (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);++ const resolveSubject = async (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && await db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);const serviceOf = (req) => String(req.principal.sub).replace(/^svc:/, '');-- router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf }), (req, res) => {++ router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf }), async (req, res) => {const db = req.app.locals.db;if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);-- const sid = resolveSubject(db, req.params.subject);-- const rec = sid && read(db, sid, req.params.ns);++ const sid = await resolveSubject(db, req.params.subject);++ const rec = sid && await read(db, sid, req.params.ns);if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);// Field-level read rules: the owner reads everything, another service what `readers` lists for it.const svc = serviceOf(req);@@@ -394,24 +353,24 @@ function serviceRoutes(router, principals) {withEtag(res, rec).json(rec);});-- router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), (req, res) => {++ router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), async (req, res) => {const db = req.app.locals.db;-- const sid = resolveSubject(db, req.params.subject);++ const sid = await resolveSubject(db, req.params.subject);if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);const rev = revisionHeader(req);if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);-- const out = write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });++ const out = await write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });if (!out.record) return problem(res, out, req);withEtag(res, out.record).status(out.status).json(out.record);});-- router.delete('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), (req, res) => {++ router.delete('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), async (req, res) => {const db = req.app.locals.db;-- const sid = resolveSubject(db, req.params.subject);++ const sid = await resolveSubject(db, req.params.subject);if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);const rev = revisionHeader(req);if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);-- const out = remove(db, sid, req.params.ns, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });++ const out = await remove(db, sid, req.params.ns, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });if (out.status !== 204) return problem(res, out, req);res.set('Cache-Control', 'private, no-store').status(204).end();});@@@ -262,56 +262,32 @@ const CHANGED_DEFAULT_NAMESPACES = [['live', 'ai.run.read', 'openvibe.ai', ['live.*', 'network.site_copy'], ['live.*', 'network.site_copy', 'media.analyze']],];--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS principal_grants (-- client_id TEXT NOT NULL,-- capability TEXT NOT NULL,-- audience TEXT NOT NULL,-- namespaces TEXT NOT NULL DEFAULT '[]',-- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- granted_by TEXT,-- revoked_at DATETIME,-- PRIMARY KEY (client_id, capability, audience)-- );-- CREATE TABLE IF NOT EXISTS principal_usage (-- principal TEXT NOT NULL,-- route TEXT NOT NULL,-- capability TEXT NOT NULL,-- auth TEXT NOT NULL,-- allowed INTEGER NOT NULL,-- code TEXT NOT NULL DEFAULT '',-- count INTEGER NOT NULL DEFAULT 0,-- first_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- last_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- PRIMARY KEY (principal, route, auth, allowed, code)-- );-- `);-- // ADR-012 rule 5: loyalty is not transferable between people, so nobody holds the transfer grant.++async function ensureSchema(db) {++ // adr-012 rule 5: loyalty is not transferable between people, so nobody holds the transfer grant.for (const [client, cap, aud] of REVOKED_GRANTS) {-- db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);++ await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);}for (const [client, cap, aud, was, now] of CHANGED_DEFAULT_NAMESPACES) {-- db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = ?")++ await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = ?").run(JSON.stringify(now), client, cap, aud, JSON.stringify(was));}-- const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");++ const seed = db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default') ON CONFLICT DO NOTHING");// A default grant that later gained namespaces fills them in on a row still seeded without any// (INSERT OR IGNORE alone would leave it at []); a row someone edited is left alone.const fillNs = db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = '[]'");for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {-- if (!db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) continue;-- seed.run(client, cap, aud, JSON.stringify(ns));-- if (ns.length) fillNs.run(JSON.stringify(ns), client, cap, aud);++ if (!await db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) continue;++ await seed.run(client, cap, aud, JSON.stringify(ns));++ if (ns.length) await fillNs.run(JSON.stringify(ns), client, cap, aud);}// Owner changes (expiry, reason, who revoked) and their audit trail (WS-D task 3).-- require('./grants-admin').ensureSchema(db);++ await require('./grants-admin').ensureSchema(db);}--function grantsFor(db, clientId, audience) {++async function grantsFor(db, clientId, audience) {// An expired grant stops counting at once; grants-admin.expireDue() records the expiry.-- return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY capability')-- .all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));++ return (await db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ov_now()) ORDER BY capability')++ .all(clientId, audience)).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));}function sameSecret(a, b) {@@@ -324,13 +300,13 @@ function sameSecret(a, b) {* grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).* `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.*/--function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {-- const client = db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));++async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {++ const client = await db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };const aud = String(audience || '').trim();if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };-- const grants = grantsFor(db, client.client_id, aud);++ const grants = await grantsFor(db, client.client_id, aud);const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;const chosen = wanted ? grants.filter(g => wanted.includes(g.capability)) : grants;if (wanted && wanted.some(w => !chosen.find(g => g.capability === w))) {@@@ -351,12 +327,12 @@ function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, i/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */function recordDecision(db) {const up = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES (?, ?, ?, ?, ?, ?, 1)-- ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);-- return ({ req, capability, principal, allowed, code }) => {++ ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = principal_usage.count + 1, last_at = ov_now()`);++ return async ({ req, capability, principal, allowed, code }) => {const bearer = String(req.headers.authorization || '').startsWith('Bearer ');const auth = bearer ? 'service-token' : 'none';const who = principal ? principal.sub : 'unknown';-- try { up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }++ try { await up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }};}@@@ -370,6 +346,18 @@ function guard(capability, { ownApp, namespace } = {}) {let check = null;let record = null;return function principalGuard(req, res, next) {++ // The audit row for this request is written before its response leaves: the contracts' decision hooks++ // are synchronous while the insert is async, so the first res.end waits for the request's audits.++ const audits = [];++ req._ovAudits = audits;++ const end = res.end.bind(res);++ let ended = false;++ res.end = (...args) => {++ if (ended) return end(...args);++ ended = true;++ Promise.allSettled(audits).then(() => end(...args));++ return res;++ };if (!check) {record = recordDecision(req.app.locals.db);check = serviceAuth.requireCapability(capability, {@@@ -378,7 +366,12 @@ function guard(capability, { ownApp, namespace } = {}) {audience: SELF_AUDIENCE,namespace,// Denials are final here; an allow is recorded below, after the ownership check.-- onDecision: (d) => { if (!d.allowed) { record(d); require('../observability').principalDenied(d); } },++ onDecision: (d) => {++ if (d.allowed) return;++ const list = d.req && d.req._ovAudits;++ if (list) list.push(record(d));++ require('../observability').principalDenied(d);++ },});}check(req, res, () => {@@@ -390,7 +383,7 @@ function guard(capability, { ownApp, namespace } = {}) {const claims = devPolicy.unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());const env = devPolicy.environmentDecision(claims, { acceptSandbox: devPolicy.settings(req.app.locals.config).sandboxAudiences.has(SELF_AUDIENCE) });if (!env.ok) {-- record({ req, capability, principal, allowed: false, code: env.code });++ audits.push(record({ req, capability, principal, allowed: false, code: env.code }));require('../observability').principalDenied({ req, code: env.code });return http.sendProblem(res, 401, env.code, { detail: env.reason, ctx: req.ov });}@@@ -399,12 +392,12 @@ function guard(capability, { ownApp, namespace } = {}) {const app = ownApp(req);const self = String(principal.sub).replace(/^svc:/, '');if (app !== undefined && app !== self) {-- record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' });++ audits.push(record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' }));require('../observability').principalDenied({ req, code: 'capability.owner_denied' });return http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${principal.sub} may only act for app_id '${self}'` });}}-- record({ req, capability, principal, allowed: true, code: null });++ audits.push(record({ req, capability, principal, allowed: true, code: null }));next();});};@@@ -22,30 +22,18 @@ const ROLES = ['user', 'streamer', 'global_mod', 'admin'];const FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned'];const NAME = { is_banned: 'banned' };--function ensureSchema(db) {-- const cols = new Set(db.prepare('PRAGMA table_info(users)').all().map((c) => c.name));-- const first = !cols.has('profile_revision');-- if (first) db.exec('ALTER TABLE users ADD COLUMN profile_revision INTEGER NOT NULL DEFAULT 0');-- const diff = FIELDS.map((f) => `(CASE WHEN OLD.${f} IS NOT NEW.${f} THEN '${NAME[f] || f} ' ELSE '' END)`).join(' || ');-- const any = FIELDS.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(' OR ');-- db.exec(`-- CREATE TABLE IF NOT EXISTS user_profile_changes (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- changed TEXT NOT NULL,-- at INTEGER NOT NULL-- );-- CREATE INDEX IF NOT EXISTS idx_user_profile_changes_user ON user_profile_changes(user_id);-- CREATE TRIGGER IF NOT EXISTS users_profile_changed AFTER UPDATE OF ${FIELDS.join(', ')} ON users-- WHEN COALESCE(NEW.is_anon, 0) = 0 AND (${any})-- BEGIN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, trim(${diff}), CAST(strftime('%s', 'now') AS INTEGER) * 1000); END;-- CREATE TRIGGER IF NOT EXISTS users_profile_created AFTER INSERT ON users-- WHEN COALESCE(NEW.is_anon, 0) = 0-- BEGIN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, 'created', CAST(strftime('%s', 'now') AS INTEGER) * 1000); END;-- `);-- // The first time: every existing account is announced once ('created'), so consumers can build their-- // projection of everyone, not only of people who change something later.-- if (first) db.prepare("INSERT INTO user_profile_changes (user_id, changed, at) SELECT id, 'created', ? FROM users WHERE COALESCE(is_anon, 0) = 0").run(Date.now());++/**++ * The table and the two users triggers are migrations/NNNN_*.sql (plan T2). What is left is the data++ * work: an account nothing has recorded yet — one the import brought in, or one from before this module++ * existed — is announced once ('created'), so consumers can build their projection of everyone, not only++ * of people who change something later. Idempotent: once announced, the row exists and profile_revision++ * has moved.++ */++async function ensureSchema(db) {++ await db.prepare(`INSERT INTO user_profile_changes (user_id, changed, at)++ SELECT u.id, 'created', ? FROM users u++ WHERE COALESCE(u.is_anon, 0) = 0 AND COALESCE(u.profile_revision, 0) = 0++ AND NOT EXISTS (SELECT 1 FROM user_profile_changes c WHERE c.user_id = u.id)`).run(Date.now());eventRelay.writerFor(db);}@@@ -60,25 +48,25 @@ function payloadOf(u, revision, changed) {}/** Turn recorded changes into events. → how many events were queued */--function drain(db, { limit = 500 } = {}) {-- const rows = db.prepare('SELECT id, user_id, changed FROM user_profile_changes ORDER BY id LIMIT ?').all(limit);++async function drain(db, { limit = 500 } = {}) {++ const rows = await db.prepare('SELECT id, user_id, changed FROM user_profile_changes ORDER BY id LIMIT ?').all(limit);if (!rows.length) return 0;const byUser = new Map();for (const r of rows) { if (!byUser.has(r.user_id)) byUser.set(r.user_id, []); byUser.get(r.user_id).push(r); }let queued = 0;for (const [userId, list] of byUser) {-- db.transaction(() => {++ await db.tx(async () => {const del = db.prepare('DELETE FROM user_profile_changes WHERE id = ?');-- const u = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);++ const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);// A deleted, guest or not-yet-subject account: nothing to announce (a subject assignment is an update// of subject_id, which the created event waits for below).if (!u || u.is_anon || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(u.subject_id || ''))) {-- if (!u || u.is_anon) for (const r of list) del.run(r.id);++ if (!u || u.is_anon) for (const r of list) await del.run(r.id);return;}const changed = [...new Set(list.flatMap((r) => r.changed.split(' ').filter(Boolean)))];const revision = (Number(u.profile_revision) || 0) + 1;-- db.prepare('UPDATE users SET profile_revision = ? WHERE id = ?').run(revision, u.id);++ await db.prepare('UPDATE users SET profile_revision = ? WHERE id = ?').run(revision, u.id);const ms = Date.now();const env = {event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',@@@ -88,20 +76,20 @@ function drain(db, { limit = 500 } = {}) {const v = validate('events.event-envelope@1', env);const pv = validate('network.user.updated@1', env.payload);if (!v.valid || !pv.valid) throw new Error(`profile-events: bad event for user ${u.id}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);-- eventRelay.writerFor(db).enqueue(env);-- for (const r of list) del.run(r.id);++ await eventRelay.writerFor(db).enqueue(env);++ for (const r of list) await del.run(r.id);queued++;-- })();++ });}if (queued) { const live = eventRelay.outboxFor(db); if (live) live.kick(); }return queued;}let timer = null;--function start(db, { intervalMs = 3000, log = console } = {}) {++async function start(db, { intervalMs = 3000, log = console } = {}) {if (timer) return;-- ensureSchema(db);-- timer = setInterval(() => { try { drain(db); } catch (err) { log.warn && log.warn('[Profile events]', err.message); } }, intervalMs);++ await ensureSchema(db);++ timer = setInterval(async () => { try { await drain(db); } catch (err) { log.warn && log.warn('[Profile events]', err.message); } }, intervalMs);if (timer.unref) timer.unref();}function stop() { if (timer) clearInterval(timer); timer = null; }@@@ -14,46 +14,30 @@ const { ids, validate } = require('openvibe-contracts');const SELF = 'network';--/** SQLite CURRENT_TIMESTAMP ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */++/** SQLite ov_now() ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */function createdMs(value) {const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;return Number.isFinite(t) ? t : Date.now();}--function ensureSchema(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS identity_legacy_map (-- source_system TEXT NOT NULL,-- source_type TEXT NOT NULL,-- source_id TEXT NOT NULL,-- subject_id TEXT NOT NULL,-- first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,-- verified_at DATETIME,-- metadata TEXT,-- PRIMARY KEY (source_system, source_type, source_id)-- );-- CREATE INDEX IF NOT EXISTS idx_identity_legacy_subject ON identity_legacy_map(subject_id);-- CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject_id ON users(subject_id);-- CREATE UNIQUE INDEX IF NOT EXISTS idx_anon_users_subject_id ON anon_users(subject_id);-- `);-- const filled = backfill(db);-- const seeded = seedLegacyMap(db);++async function ensureSchema(db) {++ const filled = await backfill(db);++ const seeded = await seedLegacyMap(db);if (filled.users || filled.guests || seeded) {console.log(`[Identity] subject ids: ${filled.users} users, ${filled.guests} guests assigned; ${seeded} legacy map rows seeded`);}}/** Give every row without a subject id one. Idempotent; runs at boot. */--function backfill(db) {++async function backfill(db) {const out = { users: 0, guests: 0 };-- const run = db.transaction(() => {++ await db.tx(async () => {for (const [table, kind, key, born] of [['users', 'user', 'users', 'created_at'], ['anon_users', 'guest', 'guests', 'first_seen']]) {-- const rows = db.prepare(`SELECT id, ${born} AS created_at FROM ${table} WHERE subject_id IS NULL`).all();++ const rows = await db.prepare(`SELECT id, ${born} AS created_at FROM ${table} WHERE subject_id IS NULL`).all();const set = db.prepare(`UPDATE ${table} SET subject_id = ? WHERE id = ? AND subject_id IS NULL`);-- for (const r of rows) out[key] += set.run(ids.newId(kind, createdMs(r.created_at)), r.id).changes;++ for (const r of rows) out[key] += (await set.run(ids.newId(kind, createdMs(r.created_at)), r.id)).changes;}});-- run();return out;}@@@ -62,40 +46,40 @@ function backfill(db) {* accounts migrated from an older platform (users.legacy_source/legacy_id), and site-local ids* reported through /internal/link-account.*/--function seedLegacyMap(db) {-- const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)-- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`);++async function seedLegacyMap(db) {++ const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)++ VALUES (?, ?, ?, ?, ov_now()) ON CONFLICT DO NOTHING`);let n = 0;-- db.transaction(() => {-- for (const u of db.prepare('SELECT id, subject_id, legacy_source, legacy_id FROM users WHERE subject_id IS NOT NULL').all()) {-- n += ins.run(SELF, 'user', String(u.id), u.subject_id).changes;-- if (u.legacy_source && u.legacy_id != null) n += ins.run(String(u.legacy_source), 'user', String(u.legacy_id), u.subject_id).changes;++ await db.tx(async () => {++ for (const u of await db.prepare('SELECT id, subject_id, legacy_source, legacy_id FROM users WHERE subject_id IS NOT NULL').all()) {++ n += (await ins.run(SELF, 'user', String(u.id), u.subject_id)).changes;++ if (u.legacy_source && u.legacy_id != null) n += (await ins.run(String(u.legacy_source), 'user', String(u.legacy_id), u.subject_id)).changes;}-- for (const g of db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {-- n += ins.run(SELF, 'anon_user', String(g.id), g.subject_id).changes;++ for (const g of await db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {++ n += (await ins.run(SELF, 'anon_user', String(g.id), g.subject_id)).changes;}// Sites that report their own local id through /internal/link-account (Live does). Rows written by// the OAuth exchange hold 'network:<id>' instead and say nothing about the site's ids.-- for (const l of db.prepare(`SELECT la.service, la.service_user_id, u.subject_id FROM linked_accounts la JOIN users u ON u.id = la.user_id++ for (const l of await db.prepare(`SELECT la.service, la.service_user_id, u.subject_id FROM linked_accounts la JOIN users u ON u.id = la.user_idWHERE u.subject_id IS NOT NULL AND la.service_user_id NOT LIKE 'network:%'`).all()) {-- if (/^[a-z][a-z0-9-]{1,39}$/.test(l.service)) n += ins.run(l.service, 'user', String(l.service_user_id), l.subject_id).changes;++ if (/^[a-z][a-z0-9-]{1,39}$/.test(l.service)) n += (await ins.run(l.service, 'user', String(l.service_user_id), l.subject_id)).changes;}-- })();++ });return n;}/** Subject id for a users row, assigning one if an insert path skipped it. */--function ensureUserSubject(db, user) {++async function ensureUserSubject(db, user) {if (!user) return null;if (user.subject_id) return user.subject_id;-- const row = db.prepare('SELECT subject_id, created_at FROM users WHERE id = ?').get(user.id);++ const row = await db.prepare('SELECT subject_id, created_at FROM users WHERE id = ?').get(user.id);if (!row) return null;if (!row.subject_id) {const sid = ids.newId('user', createdMs(row.created_at));-- db.prepare('UPDATE users SET subject_id = ? WHERE id = ? AND subject_id IS NULL').run(sid, user.id);-- db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)-- VALUES (?, 'user', ?, ?, CURRENT_TIMESTAMP)`).run(SELF, String(user.id), sid);-- row.subject_id = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id).subject_id;++ await db.prepare('UPDATE users SET subject_id = ? WHERE id = ? AND subject_id IS NULL').run(sid, user.id);++ await db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)++ VALUES (?, 'user', ?, ?, ov_now()) ON CONFLICT DO NOTHING`).run(SELF, String(user.id), sid);++ row.subject_id = (await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id)).subject_id;}user.subject_id = row.subject_id;return row.subject_id;@@@ -106,10 +90,10 @@ const newUserSubjectId = () => ids.newId('user');const newGuestSubjectId = () => ids.newId('guest');/** Public projection of a subject. Never includes email, role internals or secrets. */--function projection(db, subjectId) {++async function projection(db, subjectId) {if (typeof subjectId !== 'string') return null;if (subjectId.startsWith('usr_')) {-- const u = db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);++ const u = await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);if (!u) return null;// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };@@@ -117,7 +101,7 @@ function projection(db, subjectId) {display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };}if (subjectId.startsWith('gst_')) {-- const g = db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);++ const g = await db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);if (!g) return null;return { subject: { type: 'guest', id: g.subject_id }, network_anon_id: g.id, username: `anon${g.anon_number}`,display_name: g.display_name || `anon${g.anon_number}`, avatar_url: null, banned: false };@@@ -126,24 +110,24 @@ function projection(db, subjectId) {}/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */--function resolve(db, { subject_id, source_system, source_type, source_id }) {++async function resolve(db, { subject_id, source_system, source_type, source_id }) {let sid = subject_id;if (!sid) {if (source_system === SELF && source_type === 'user') {-- const u = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?').get(Number(source_id));-- sid = u ? ensureUserSubject(db, u) : null;++ const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?').get(Number(source_id));++ sid = u ? await ensureUserSubject(db, u) : null;} else {-- const row = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?')++ const row = await db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(String(source_system), String(source_type || 'user'), String(source_id));sid = row && row.subject_id;}}// A subject merged into another (ADR-029) resolves to the survivor, saying where it came from.-- const survivor = sid ? require('./account-merge').survivorOf(db, sid) : sid;-- const p = survivor ? projection(db, survivor) : null;++ const survivor = sid ? await require('./account-merge').survivorOf(db, sid) : sid;++ const p = survivor ? await projection(db, survivor) : null;if (!p) return null;if (survivor !== sid) p.merged_from = sid;-- p.legacy_ids = db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')++ p.legacy_ids = await db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type').all(p.subject.id);return p;}@@@ -154,35 +138,35 @@ function resolve(db, { subject_id, source_system, source_type, source_id }) {* are refused: Network is the authority for its own ids.* Returns { inserted, unchanged, conflicts: [...], rejected: [...] }.*/--function upsertLegacy(db, entries) {++async function upsertLegacy(db, entries) {const out = { inserted: 0, unchanged: 0, conflicts: [], rejected: [] };const get = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?');const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at, metadata)-- VALUES (?, ?, ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?)`);++ VALUES (?, ?, ?, ?, CASE WHEN ? THEN ov_now() END, ?)`);const byNetworkId = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?');-- db.transaction(() => {-- entries.forEach((e, i) => {++ await db.tx(async () => {++ for (const [i, e] of entries.entries()) {let subjectId = e.subject_id;if (!subjectId && e.network_user_id != null) {-- const u = byNetworkId.get(Number(e.network_user_id));-- subjectId = u ? ensureUserSubject(db, u) : null;++ const u = await byNetworkId.get(Number(e.network_user_id));++ subjectId = u ? await ensureUserSubject(db, u) : null;}const row = { subject_id: subjectId, source_system: e.source_system, source_type: e.source_type || 'user', source_id: e.source_id == null ? '' : String(e.source_id) };const v = validate('identity.legacy-identity-map@1', row);-- if (!subjectId || !v.valid || row.source_system === SELF || !projection(db, subjectId)) {++ if (!subjectId || !v.valid || row.source_system === SELF || !await projection(db, subjectId)) {out.rejected.push({ index: i, reason: !subjectId ? 'unknown network account' : row.source_system === SELF ? 'network ids are not writable' : !v.valid ? v.errors.map(x => `${x.path} ${x.message}`).join('; ') : 'unknown subject' });-- return;++ continue;}-- const existing = get.get(row.source_system, row.source_type, row.source_id);++ const existing = await get.get(row.source_system, row.source_type, row.source_id);if (existing) {if (existing.subject_id === subjectId) out.unchanged++;else out.conflicts.push({ index: i, source: `${row.source_system}:${row.source_type}:${row.source_id}`, mapped_to: existing.subject_id, requested: subjectId });-- return;++ continue;}-- ins.run(row.source_system, row.source_type, row.source_id, subjectId, e.verified ? 1 : 0, e.metadata ? JSON.stringify(e.metadata) : null);++ await ins.run(row.source_system, row.source_type, row.source_id, subjectId, e.verified ? 1 : 0, e.metadata ? JSON.stringify(e.metadata) : null);out.inserted++;-- });-- })();++ }++ });return out;}@@@ -16,35 +16,24 @@ const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;const RESERVE_DAYS = 180;const SYSTEM_NAMES = new Set(['live', 'system', 'openvibelivebot']);--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS username_history (-- id INTEGER PRIMARY KEY AUTOINCREMENT,-- user_id INTEGER NOT NULL,-- old_username TEXT NOT NULL,-- new_username TEXT NOT NULL,-- changed_by INTEGER,-- changed_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );-- CREATE INDEX IF NOT EXISTS idx_username_history_old ON username_history(old_username COLLATE NOCASE);-- CREATE INDEX IF NOT EXISTS idx_username_history_user ON username_history(user_id);`);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** Whether `name` is an old username someone other than `exceptUserId` gave up within RESERVE_DAYS. */--function isReserved(db, name, exceptUserId = null) {++async function isReserved(db, name, exceptUserId = null) {ensureSchema(db);-- const row = db.prepare(`SELECT user_id FROM username_history WHERE old_username = ? COLLATE NOCASE++ const row = await db.prepare(`SELECT user_id FROM username_history WHERE lower(old_username) = lower(?)AND changed_at > datetime('now', ?) ORDER BY id DESC LIMIT 1`).get(String(name), `-${RESERVE_DAYS} days`);return Boolean(row && row.user_id !== exceptUserId);}/** Why `name` cannot be used, or null. */--function problemWith(db, name, userId = null) {++async function problemWith(db, name, userId = null) {if (!NAME_RE.test(name)) return 'Usernames are 3-24 letters, numbers and underscores';if (/^anon/i.test(name)) return 'Usernames cannot start with "anon"';if (SYSTEM_NAMES.has(name.toLowerCase())) return 'That name is reserved by the system';-- const taken = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(name);++ const taken = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(name);if (taken && taken.id !== userId) return 'That username is taken';-- if (isReserved(db, name, userId)) return `That name belonged to someone else in the last ${RESERVE_DAYS} days`;++ if (await isReserved(db, name, userId)) return `That name belonged to someone else in the last ${RESERVE_DAYS} days`;return null;}@@@ -52,30 +41,30 @@ function problemWith(db, name, userId = null) {* Rename a user. A display name that was just the old username (any capitalisation) follows it.* @returns {{ from: string, to: string }}*/--function rename(db, userId, newName, { actorId = null } = {}) {++async function rename(db, userId, newName, { actorId = null } = {}) {ensureSchema(db);const to = String(newName || '').trim();-- return db.transaction(() => {-- const user = db.prepare('SELECT id, username, display_name FROM users WHERE id = ?').get(userId);++ return await db.tx(async () => {++ const user = await db.prepare('SELECT id, username, display_name FROM users WHERE id = ?').get(userId);if (!user) throw Object.assign(new Error('No such user'), { status: 404 });if (to === user.username) throw Object.assign(new Error('That is already the username'), { status: 400 });-- const problem = problemWith(db, to, user.id);++ const problem = await problemWith(db, to, user.id);if (problem) throw Object.assign(new Error(problem), { status: 409 });-- db.prepare('INSERT INTO username_history (user_id, old_username, new_username, changed_by) VALUES (?, ?, ?, ?)').run(user.id, user.username, to, actorId);++ await db.prepare('INSERT INTO username_history (user_id, old_username, new_username, changed_by) VALUES (?, ?, ?, ?)').run(user.id, user.username, to, actorId);const followDisplay = !user.display_name || user.display_name.toLowerCase() === user.username.toLowerCase();-- db.prepare(`UPDATE users SET username = ?${followDisplay ? ', display_name = ?' : ''}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)++ await db.prepare(`UPDATE users SET username = ?${followDisplay ? ', display_name = ?' : ''}, updated_at = ov_now() WHERE id = ?`).run(...(followDisplay ? [to, to, user.id] : [to, user.id]));return { from: user.username, to };-- })();++ });}/** The current username for an old one, or null (never for a name that is someone's current name). */--function renamedTo(db, oldName) {++async function renamedTo(db, oldName) {ensureSchema(db);if (!NAME_RE.test(String(oldName || ''))) return null;-- if (db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?)').get(oldName)) return null;-- const row = db.prepare(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id-- WHERE h.old_username = ? COLLATE NOCASE ORDER BY h.id DESC LIMIT 1`).get(oldName);++ if (await db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?)').get(oldName)) return null;++ const row = await db.prepare(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id++ WHERE lower(h.old_username) = lower(?) ORDER BY h.id DESC LIMIT 1`).get(oldName);return row ? row.username : null;}@@@ -83,26 +72,26 @@ function renamedTo(db, oldName) {* The public record for a name: { current, network_id, renamed, previous_names } when it is someone's* current name or one they were renamed away from; null otherwise.*/--function lookup(db, name) {++async function lookup(db, name) {ensureSchema(db);if (!NAME_RE.test(String(name || ''))) return null;-- let user = db.prepare('SELECT id, username FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0').get(name);++ let user = await db.prepare('SELECT id, username FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0').get(name);let renamed = false;if (!user) {-- const h = db.prepare('SELECT user_id FROM username_history WHERE old_username = ? COLLATE NOCASE ORDER BY id DESC LIMIT 1').get(name);++ const h = await db.prepare('SELECT user_id FROM username_history WHERE lower(old_username) = lower(?) ORDER BY id DESC LIMIT 1').get(name);if (!h) return null;-- user = db.prepare('SELECT id, username FROM users WHERE id = ? AND COALESCE(is_banned, 0) = 0').get(h.user_id);++ user = await db.prepare('SELECT id, username FROM users WHERE id = ? AND COALESCE(is_banned, 0) = 0').get(h.user_id);if (!user) return null;renamed = true;}-- const previous = db.prepare('SELECT old_username FROM username_history WHERE user_id = ? ORDER BY id DESC').all(user.id).map(r => r.old_username);++ const previous = (await db.prepare('SELECT old_username FROM username_history WHERE user_id = ? ORDER BY id DESC').all(user.id)).map(r => r.old_username);return { current: user.username, network_id: user.id, renamed, previous_names: previous };}/** A user's past usernames, newest first. */--function historyOf(db, userId) {++async function historyOf(db, userId) {ensureSchema(db);-- return db.prepare('SELECT old_username, new_username, changed_at FROM username_history WHERE user_id = ? ORDER BY id DESC').all(userId);++ return await db.prepare('SELECT old_username, new_username, changed_at FROM username_history WHERE user_id = ? ORDER BY id DESC').all(userId);}module.exports = { ensureSchema, isReserved, problemWith, rename, renamedTo, lookup, historyOf, RESERVE_DAYS, NAME_RE };@@@ -32,6 +32,9 @@ const createDeployRoutes = require('./deploy/routes');const createCoinsRoutes = require('./coins/routes');const { signToken } = require('./auth/routes');++// The boot runs asynchronously: opening PostgreSQL (migrations, seeds) is async (plan T2, ADR-035).++const ready = (async () => {++const app = express();// What this server is running (ADR-016, registry.release-manifest@1); open tabs poll it through@@@ -50,8 +53,8 @@ function getRequestHost(req) {return String(req.headers.host || '').split(':')[0].toLowerCase();}--function ensureAdminUser(db, config) {-- const adminExists = db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();++async function ensureAdminUser(db, config) {++ const adminExists = await db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();if (adminExists) return;const username = config.admin.username || (config.nodeEnv !== 'production' ? 'admin' : null);const password = config.admin.password || (config.nodeEnv !== 'production' ? 'admin' : null);@@@ -60,10 +63,10 @@ function ensureAdminUser(db, config) {return;}const passwordHash = bcrypt.hashSync(password, 10);-- db.prepare(`++ await db.prepare(`INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)-- ON CONFLICT(username) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash++ ON CONFLICT (lower(username)) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash`).run(username, null, passwordHash, username, require('./identity/subjects').newUserSubjectId());console.log(`[Setup] Admin user created or elevated: ${username}`);}@@@ -292,11 +295,11 @@ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));app.use('/api/auth/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessfulRequests: true }));// ── Database ─────────────────────────────────────────────────--const db = initDb(config.db.path);--urlRegistry.initializeUrlRegistry(db);--urlRegistry.seedBootstrapRegistry(db, process.env, config.bootstrapProfile);--ensureAdminUser(db, config);--const resolvedRegistry = urlRegistry.getResolvedRegistry(db, process.env);++const db = await initDb();++await urlRegistry.initializeUrlRegistry(db);++await urlRegistry.seedBootstrapRegistry(db, process.env, config.bootstrapProfile);++await ensureAdminUser(db, config);++const resolvedRegistry = await urlRegistry.getResolvedRegistry(db, process.env);// Apply resolved network registry values to runtime configif (resolvedRegistry.OV_NETWORK_URL?.value) {@@@ -321,11 +324,11 @@ app.locals.urlRegistry = resolvedRegistry;config._registry = resolvedRegistry;// ── Analytics Tracking (ADR-021) ──────────────────────────────--// openvibe-shared/analytics; same tables in network.db, on a connection of the tracker's own--// (server/analytics/network.js). Raw rows: route template, rotating session id, user-agent class,--// referer origin; never an IP or a user id. A Sec-GPC: 1 / DNT: 1 request is not recorded. Pruned after--// 30 days by the analytics-prune job below; rollups are kept.--const analytics = networkAnalytics.openAnalytics(config.db.path);++// openvibe-shared/analytics on PostgreSQL (server/analytics/network.js; migrations/0002_analytics.sql).++// Raw rows: route template, rotating session id, user-agent class, referer origin; never an IP or a++// user id. A Sec-GPC: 1 / DNT: 1 request is not recorded. Pruned after 30 days by the tracker itself;++// rollups are kept.++const analytics = networkAnalytics.openAnalytics(db);app.locals.analytics = analytics;app.use(analytics.middleware());@@@ -362,11 +365,19 @@ app.locals.selfToken = selfToken;app.locals.publicKey = publicKey;app.locals.config = config;++// Shared counters on Valkey when VALKEY_URL is set (ADR-035, plan T2): every process and host counts one++// actor together. Unset — or unreachable — the limits fall back to this process's own counters (never 500).++const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null;++app.locals.valkey = valkey;++// Per-actor limits on API writes, by the person whose session makes them (server/auth/actor-limits.js; WS-R task 4).--app.use('/api/', require('./auth/actor-limits').createNetworkActorLimits({ publicKey, issuer: config.jwt.issuer, registry: observability.registry }));++app.use('/api/', require('./auth/actor-limits').createNetworkActorLimits({++ publicKey, issuer: config.jwt.issuer, registry: observability.registry,++ store: valkey ? require('openvibe-sdk/limits').createValkeyLimitStore(valkey) : null,++}));// Provider secrets: environment first, database fallback (server/secrets.js). Names and sources only.--console.log(`[Secrets] ${require('./secrets').summary(db)}`);++console.log(`[Secrets] ${await require('./secrets').summary(db)}`);// ── Initialize Services ──────────────────────────────────────const notificationService = new NotificationService(db);@@@ -381,7 +392,7 @@ const liveFollowers = privateKey.includes('BEGIN')const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);// Developer projects' usage (WS-N task 4): the services' hourly rollups, per project and day.const projectUsage = require('./developer/usage').createProjectUsage(db);--eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({++eventsConsumer = await require('./notifications/events-consumer').createEventsConsumer({db, notifications: notificationService, secrets: config.eventsWebhookSecrets,liveFollowers, discord: () => app.locals.discordService || null, moderationAudit, projectUsage,followsAuthority: process.env.FOLLOWS_AUTHORITY === 'network' ? 'network' : 'live', // ADR-030 step 4@@@ -404,8 +415,8 @@ app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router())app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a// new name before they have seen it. Banned accounts and unknown names are 404.--app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), (req, res) => {-- const rec = require('./identity/usernames').lookup(db, req.params.name);++app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), async (req, res) => {++ const rec = await require('./identity/usernames').lookup(db, req.params.name);res.set('Cache-Control', 'public, max-age=120');if (!rec) return res.status(404).json({ error: 'not_found' });res.json(rec);@@@ -420,7 +431,7 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('// (staff.identity.merge) for account recovery, with a reason.{const accountMerge = require('./identity/account-merge');-- accountMerge.ensureSchema(db);++ await accountMerge.ensureSchema(db);const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);app.use('/api/admin/account-merges', mergeRouters.admin);@@@ -437,12 +448,12 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('}// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is--// outstanding. Archives live next to the database for 7 days.--const ACCOUNT_EXPORT_DIR = path.join(path.dirname(path.resolve(config.db.path)), 'account-exports');--const notifyAccountData = (userId, n) => notificationService.create({ user_id: userId, type: 'GENERIC', category: 'system', service: 'network', url: 'https://openvibe.network/my#accounts', ...n });++// outstanding. Archives live under the data directory for 7 days.++const ACCOUNT_EXPORT_DIR = path.join(path.resolve(config.dataDir), 'account-exports');++const notifyAccountData = async (userId, n) => await notificationService.create({ user_id: userId, type: 'GENERIC', category: 'system', service: 'network', url: 'https://openvibe.network/my#accounts', ...n });{const accountData = require('./identity/account-data');-- accountData.ensureSchema(db);++ await accountData.ensureSchema(db);const guard = require('./identity/principals').guard;const dataRouters = accountData.routers({requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,@@@ -490,13 +501,13 @@ const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issu// Production drift: each running service's deployed commit against its repository's main (WS-S task 7).const deployDrift = require('./registry/deploy-drift').createDeployDrift({services: () => ecosystem.releases().services.filter((r) => r.release).map((r) => ({ id: r.id, release: r.release, repository: ((require('openvibe-contracts').services.manifests.find((m) => m.id === r.id)) || {}).repository })),-- token: () => process.env.GITHUB_TOKEN || require('./integrations/github').tokenOf(db) || '',++ token: async () => process.env.GITHUB_TOKEN || await require('./integrations/github').tokenOf(db) || '',});deployDrift.start();app.use(ecosystem.router());ecosystem.start();// The released libraries' latest published tags (not the versions Network installs) for the registry.--const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) });++const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: async () => await require('./integrations/github').tokenOf(db) });libraryTags.start();// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.// Creator analytics (WS-E task 6): from live.stream.ended, counts only; the full figures for the creator or@@@ -565,7 +576,7 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.--require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });++await require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });// network.user.updated (WS-B task 2): profile, role and ban changes, recorded by triggers on users and relayed// through the same outbox (server/identity/profile-events.js).require('./identity/profile-events').start(db);@@@ -576,11 +587,11 @@ app.use('/api/notifications', createNotificationRoutes(db, notificationService,// Push Notifications APIconst pushService = require('./push/push-service');--pushService.initVapid(db);++await pushService.initVapid(db);app.use('/api/push', requireAuth, require('./push/routes'));// Cross-site history (what the account touched anywhere on the network)--app.use('/api/history', rateLimit({ windowMs: 60_000, max: 60 }), require('./history/routes').createHistoryRoutes(db, requireAuth));++app.use('/api/history', rateLimit({ windowMs: 60_000, max: 60 }), await require('./history/routes').createHistoryRoutes(db, requireAuth));// "Sign in everywhere" chain targets (public: the fanout page reads them before hopping)app.get('/api/sso/targets', (req, res) => {@@@ -615,7 +626,7 @@ app.locals.avatarService = avatarService;// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.--const frameService = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });++const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });app.use('/api/frame', frameLimit, frameService.router);app.use('/api/chrome', frameLimit, frameService.router);@@@ -624,7 +635,7 @@ frameService.start();ecosystem.setRanking(() => frameService.ranking());// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).--const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);++const toolDomains = await require('./domains/routes').createDomainRoutes(db, requireAuth);app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.publicRouter);app.use('/api/admin/domains', toolDomains.adminRouter);app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));@@@ -675,7 +686,7 @@ app.use('/api/admin/streamer', requireAuth, (req, res, next) => {}next();}, async (req, res) => {-- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');++ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');});// Proxy /api/mod/* for moderator routes@@@ -685,7 +696,7 @@ app.use('/api/admin/streamer-mod', requireAuth, (req, res, next) => {}next();}, async (req, res) => {-- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');++ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');});app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {@@@ -694,7 +705,7 @@ app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {}next();}, async (req, res) => {-- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');++ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');});app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {@@@ -703,7 +714,7 @@ app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {}next();}, async (req, res) => {-- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');++ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');});app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {@@@ -712,7 +723,7 @@ app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {}next();}, async (req, res) => {-- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');++ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');});// Internal API (server-to-server, capability-scoped service tokens only)@@@ -845,7 +856,6 @@ app.use(require('./not-found').notFound);// ── Start ────────────────────────────────────────────────────const timers = []; // the periodic maintenance below, cleared on stop--let analyticsPrune = null;const server = app.listen(config.port, config.host, () => {// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.require('./domains/catalog').refresh().catch(() => {});@@@ -864,34 +874,33 @@ const server = app.listen(config.port, config.host, () => {// Process email queue every 2 minutestimers.push(setInterval(() => emailService.processQueue(notificationService), 2 * 60 * 1000));-- // Raw analytics retention (ADR-021), job analytics-prune: events older than 30 days go, in-- // bounded batches; hourly/daily rollups stay. First run 5 minutes after boot, then every 24 h.-- analyticsPrune = networkAnalytics.schedulePrune(analytics);++ // Raw analytics retention (ADR-021) is scheduled by the PostgreSQL tracker itself: events older++ // than 30 days go, in bounded batches; hourly/daily rollups stay.// User modules of a retired owning service (onOwnerRemoved, server/identity/modules.js): writes stop at// once; delete-after-retention records go retentionDays after Network first saw the retirement.-- const sweepModules = () => {++ const sweepModules = async () => {try {-- const n = require('./identity/modules').sweepRetired(db);++ const n = await require('./identity/modules').sweepRetired(db);if (n) console.log(`[Modules] Deleted ${n} record(s) of retired namespace owners`);} catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }};timers.push(setTimeout(sweepModules, 5 * 60 * 1000), setInterval(sweepModules, 24 * 60 * 60 * 1000));// Account merges older than 30 days keep only the alias facts (ADR-029).-- const reduceMerges = () => { try { const n = require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };++ const reduceMerges = async () => { try { const n = await require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));// Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).-- const sweepAccountData = () => {++ const sweepAccountData = async () => {try {-- const n = require('./identity/account-data').sweep(db, { dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData });++ const n = await require('./identity/account-data').sweep(db, { dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData });if (n.built || n.expired || n.deleted) console.log(`[AccountData] sweep: ${JSON.stringify(n)}`);} catch (e) { console.warn('[AccountData] sweep failed:', e.message); }};timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));// Clean expired sessions daily-- timers.push(setInterval(() => {-- const cleaned = db.prepare("DELETE FROM user_sessions WHERE expires_at < datetime('now') OR is_active = 0").run().changes;++ timers.push(setInterval(async () => {++ const cleaned = (await db.prepare("DELETE FROM user_sessions WHERE expires_at < datetime('now') OR is_active = 0").run()).changes;if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);}, 24 * 60 * 60 * 1000));});@@@ -901,14 +910,14 @@ const server = app.listen(config.port, config.host, () => {// pollers, the frame refreshes, the profile-event and grant-expiry timers stop (nothing new starts); the// server stops taking connections, closes idle keep-alive ones and lets requests in flight finish (8 s at// most, Connection: close); then the developer/profile/grant event relay finishes its send in progress--// (unsent rows stay in the outbox), analytics flush and close, network.db closes, and the process exits 0,--// within 10 s (well inside the unit's stop timeout). The email queue resumes on the next start.++// (unsent rows stay in the outbox), analytics flush and close, the database and the Valkey connection++// close, and the process exits 0, within 10 s (well inside the unit's stop timeout). The email queue++// resumes on the next start.const { within } = require('./graceful');require('./graceful').gracefulStop({name: 'Network', server, drainMs: 8000, deadlineMs: 10000,stop: [() => { for (const t of timers) { clearTimeout(t); clearInterval(t); } },-- () => { if (analyticsPrune) analyticsPrune.stop(); },() => ecosystem.stop(),() => deployDrift.stop(),() => libraryTags.stop(),@@@ -918,7 +927,17 @@ require('./graceful').gracefulStop({],close: [() => within(1500, require('./developer/event-relay').stopRelay(db)),-- () => { analytics.destroy(); analytics.db.close(); },++ () => analytics.destroy().catch(() => {}),() => db.close(),++ () => valkey && valkey.close().catch(() => {}),],});++++return { app, server };++})();++ready.catch((err) => {++ console.error('[Network] failed to start:', err);++ process.exit(1);++});++++module.exports = { ready };@@@ -20,14 +20,14 @@ const KEY = 'github_token';// Classic (ghp_/gho_/ghs_/ghu_/ghr_), fine-grained (github_pat_) and legacy 40-hex tokens.const TOKEN_RE = /^(gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{40,255}|[0-9a-f]{40})$/;--function tokenOf(db) {-- const v = db.getSetting ? db.getSetting(KEY) : null;++async function tokenOf(db) {++ const v = db.getSetting ? await db.getSetting(KEY) : null;return v && String(v).trim() ? String(v).trim() : null;}--function status(db) {-- const t = tokenOf(db);-- return { source: secrets.source(db, KEY), env: secrets.envName(KEY), set: Boolean(t), last4: t ? t.slice(-4) : null,++async function status(db) {++ const t = await tokenOf(db);++ return { source: await secrets.source(db, KEY), env: secrets.envName(KEY), set: Boolean(t), last4: t ? t.slice(-4) : null,used_by: ['the registry library versions (Network)', 'the network changelog and Patch notes (OpenVibe.Blog)'] };}@@@ -47,23 +47,23 @@ async function test(token, fetchImpl = globalThis.fetch) {function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {const r = express.Router();r.use(requireOwner);-- r.get('/', (req, res) => res.json({ ok: true, github: status(db) }));-- r.put('/', express.json({ limit: '4kb' }), (req, res) => {-- if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });++ r.get('/', async (req, res) => res.json({ ok: true, github: await status(db) }));++ r.put('/', express.json({ limit: '4kb' }), async (req, res) => {++ if (await secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });const token = String((req.body && req.body.token) || '').trim();if (!TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'That does not look like a GitHub token (ghp_…, github_pat_…)' });-- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(KEY, token, 'secret');-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));-- res.json({ ok: true, github: status(db) });++ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(KEY, token, 'secret');++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));++ res.json({ ok: true, github: await status(db) });});-- r.delete('/', (req, res) => {-- if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });-- db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);-- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));-- res.json({ ok: true, github: status(db) });++ r.delete('/', async (req, res) => {++ if (await secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });++ await db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));++ res.json({ ok: true, github: await status(db) });});r.post('/test', async (req, res) => {-- try { res.json({ ok: true, test: await test(tokenOf(db), fetchImpl) }); }++ try { res.json({ ok: true, test: await test(await tokenOf(db), fetchImpl) }); }catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }});return r;@@@ -71,11 +71,11 @@ function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {/** GET /internal/integrations/github-token for a service holding network.integration.github.read. */function internalHandler(db) {-- return (req, res) => {-- const t = tokenOf(db);++ return async (req, res) => {++ const t = await tokenOf(db);res.set('Cache-Control', 'no-store');if (!t) return res.status(404).json({ error: 'not_configured', detail: 'No GitHub token is set (admin → Settings → GitHub, or GITHUB_TOKEN)' });-- res.json({ token: t, source: secrets.source(db, KEY) });++ res.json({ token: t, source: await secrets.source(db, KEY) });};}@@@ -56,27 +56,27 @@ router.use('/identity', require('../identity/internal-routes'));// When a user connects their OpenVibe.Live or OpenVibe.Games account,// the service reports the link here.// A service token may only report links for its own service (svc:live -> service 'live').--router.post('/link-account', principals.guard('identity.subject.resolve', { ownApp: forService }), (req, res) => {++router.post('/link-account', principals.guard('identity.subject.resolve', { ownApp: forService }), async (req, res) => {const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;if (!user_id || !service || !service_user_id) {return res.status(400).json({ error: 'user_id, service, and service_user_id required' });}-- const db = getDb(req);-- db.prepare(`++ const db = await getDb(req);++ await db.prepare(`INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)-- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)++ VALUES (?, ?, ?, ?, ov_now())ON CONFLICT(user_id, service) DO UPDATE SETservice_user_id = ?,service_username = ?,-- linked_at = CURRENT_TIMESTAMP++ linked_at = ov_now()`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);// The site told us its own id for this account: record it against the canonical subject too, so// other services can resolve "<service> user N" without asking that site (Wave 1 legacy map).if (!String(service_user_id).startsWith('network:')) {try {-- const r = require('../identity/subjects').upsertLegacy(db, [{ network_user_id: user_id, source_system: String(service), source_type: 'user', source_id: String(service_user_id), verified: true }]);++ const r = await require('../identity/subjects').upsertLegacy(db, [{ network_user_id: user_id, source_system: String(service), source_type: 'user', source_id: String(service_user_id), verified: true }]);if (r.conflicts.length) console.warn(`[Identity] ${service} user ${service_user_id} is already mapped to another subject; not repointed`);} catch (err) { console.warn('[Identity] legacy map from link-account failed:', err.message); }}@@@ -85,13 +85,13 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA// so every other site shows the same face. Never overwrites a picture or name the user set here, and only// accepts https URLs on our own sites.try {-- const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);++ const me = await db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);if (me) {const svc = req.app.locals.avatarService;if (svc && avatar_url && !me.avatar_url) svc.fromSite({ user_id, avatar_url, origin: service });const name = String(display_name || '').trim().slice(0, 60);// Same rule the profile form enforces: a display name only re-cases the username.-- if (name && name !== me.display_name && name.toLowerCase() === String(me.username || '').toLowerCase()) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);++ if (name && name !== me.display_name && name.toLowerCase() === String(me.username || '').toLowerCase()) await db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);}} catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }@@@ -99,21 +99,21 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA});// ── A site changed someone's avatar (Live's avatar picker) ───--router.post('/user-avatar', principals.guard('network.avatar.write'), (req, res) => {++router.post('/user-avatar', principals.guard('network.avatar.write'), async (req, res) => {const svc = req.app.locals.avatarService;if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });-- const r = svc.fromSite(req.body || {});++ const r = await svc.fromSite(req.body || {});res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });});// Service URLs only (no secrets). Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the// owner's, not the services'.--router.get('/url-registry/resolved', principals.guard('network.registry.read'), (req, res) => {++router.get('/url-registry/resolved', principals.guard('network.registry.read'), async (req, res) => {try {-- const db = getDb(req);++ const db = await getDb(req);const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');const { isSensitiveSettingKey } = require('../auth/owner-guard');-- const resolved = Object.fromEntries(Object.entries(urlRegistry.getResolvedRegistry(db, process.env))++ const resolved = Object.fromEntries(Object.entries(await urlRegistry.getResolvedRegistry(db, process.env)).filter(([key]) => !((URL_DEFINITIONS[key] || {}).type === 'secret' || isSensitiveSettingKey(key))));res.json({ ok: true, registry: resolved });} catch (err) {@@@ -142,31 +142,31 @@ function handleWalletError(res, err) {// so the answer is held for a minute.let _coinStats = { at: 0, data: null };// Guarded by the ledger capability its caller (Live's home hero) holds. TODO(contracts): network.coins.read.--router.get('/coins/stats', principals.guard('network.coins.read'), (req, res) => {++router.get('/coins/stats', principals.guard('network.coins.read'), async (req, res) => {try {if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);-- const db = getDb(req);-- const one = (sql) => { try { return db.prepare(sql).get()?.n || 0; } catch { return 0; } };++ const db = await getDb(req);++ const one = async (sql) => { try { const row = await db.prepare(sql).get(); return Number(row?.n ?? 0); } catch { return 0; } };const data = {-- earned: one('SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0'),-- spent: one('SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0'),-- circulating: one('SELECT COALESCE(SUM(balance), 0) AS n FROM wallets'),-- holders: one('SELECT COUNT(*) AS n FROM wallets WHERE balance > 0'),-- transactions: one('SELECT COUNT(*) AS n FROM coin_transactions'),++ earned: await one('SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0'),++ spent: await one('SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0'),++ circulating: await one('SELECT COALESCE(SUM(balance), 0) AS n FROM wallets'),++ holders: await one('SELECT COUNT(*) AS n FROM wallets WHERE balance > 0'),++ transactions: await one('SELECT COUNT(*) AS n FROM coin_transactions'),// Rolling windows so the display can say whether the economy is speeding up. Same// shape the Live stats use: this seven days, and the seven before it.recent: {earned: {-- w: one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-7 days')"),-- pw: one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),++ w: await one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-7 days')"),++ pw: await one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),},spent: {-- w: one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-7 days')"),-- pw: one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),++ w: await one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-7 days')"),++ pw: await one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),},holders: {-- w: one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-7 days')"),-- pw: one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),++ w: await one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-7 days')"),++ pw: await one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),},},};@@@ -183,10 +183,10 @@ router.get('/coins/stats', principals.guard('network.coins.read'), (req, res) =>// User modules for services (token only; server/identity/modules.js).require('../identity/modules').serviceRoutes(router, principals);--router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {++router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), async (req, res) => {try {const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};-- const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });++ const result = await wallet.credit(await getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });res.json({ balance: result.balance });} catch (err) {handleWalletError(res, err);@@@ -195,10 +195,10 @@ router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp:// ── POST /internal/coins/debit ───────────────────────────────// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }--router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), (req, res) => {++router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), async (req, res) => {try {const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};-- const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });++ const result = await wallet.debit(await getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });res.json({ balance: result.balance });} catch (err) {handleWalletError(res, err);@@@ -208,10 +208,10 @@ router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: fo// ── POST /internal/coins/transfer ────────────────────────────// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }// → { from_balance, to_balance } (atomic)--router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), (req, res) => {++router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), async (req, res) => {try {const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};-- const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });++ const result = await wallet.transfer(await getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });res.json({ from_balance: result.from_balance, to_balance: result.to_balance });} catch (err) {handleWalletError(res, err);@@@ -241,7 +241,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'// email AND Discord. `force:true` (admin/manual) bypasses the cooldown, not the cap.// The same window is claimed by the live.stream.started consumer (../notifications/stream-live.js).{-- const claim = streamLive.claimAnnouncement(getDb(req), { streamerKey: streamer.network_id || streamer.id || streamer.username, streamId: stream.id, force: !!req.body.force });++ const claim = await streamLive.claimAnnouncement(await getDb(req), { streamerKey: streamer.network_id || streamer.id || streamer.username, streamId: stream.id, force: !!req.body.force });if (claim.skipped && claim.reason === 'daily-cap') {console.log(`[StreamLive] ${streamer.username}: daily cap (${claim.cap}) reached — not announcing`);return res.json({ ok: true, ...claim });@@@ -265,7 +265,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'// ── Push Notifications to Followers ──────────────────────const notifService = req.app.locals.notificationService;if (notifService) {-- const db = getDb(req);++ const db = await getDb(req);const notifData = streamLive.streamLiveNotification({username: streamer.username, displayName: streamer.display_name, avatarUrl: streamer.avatar_url,senderId: streamer.id || null, stream, url: streamLive.channelUrl(streamer.username),@@@ -280,26 +280,26 @@ router.post('/events/stream-live', principals.guard('network.notifications.push': [];if (!Array.isArray(follower_network_ids)) {// Legacy caller: fall back to Network's graph, resolving the streamer's NETWORK id first.-- const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;-- if (link) followerIds = db.prepare('SELECT follower_id FROM follows WHERE followed_id = ?').all(link.user_id).map(r => r.follower_id);++ const link = streamer.id ? await db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;++ if (link) followerIds = (await db.prepare('SELECT follower_id FROM follows WHERE followed_id = ?').all(link.user_id)).map(r => r.follower_id);}// sender_id must be the streamer's NETWORK id for dedupe + "who is this" lookups.try {-- const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;++ const link = streamer.id ? await db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;if (link) notifData.sender_id = link.user_id;// A Live id is not a Network person: no block check against whoever has that Network id.else notifData.actor_subject = null;} catch { notifData.actor_subject = null; /* keep Live id */ }// Find users who opted into "all live" notifications-- const allLiveUserIds = streamLive.allLiveSubscribers(db);++ const allLiveUserIds = await streamLive.allLiveSubscribers(db);// Merge and deduplicateconst targetIds = [...new Set([...followerIds, ...allLiveUserIds])];if (targetIds.length > 0) {try {-- const created = notifService.createBulk(targetIds, notifData);++ const created = await notifService.createBulk(targetIds, notifData);results.notifications = { sent: created.length, total: targetIds.length };} catch (err) {results.notifications = { error: err.message };@@@ -321,7 +321,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'// ── Push Single Notification ─────────────────────────────────// POST /internal/notifications/push// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }--router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {++router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });@@@ -329,7 +329,7 @@ router.post('/notifications/push', principals.guard('network.notifications.push'if (!user_id) return res.status(400).json({ error: 'user_id required' });try {-- const notification = notifService.create({ user_id, ...data });++ const notification = await notifService.create({ user_id, ...data });if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });res.json({ ok: true, notification });} catch (err) {@@@ -341,7 +341,7 @@ router.post('/notifications/push', principals.guard('network.notifications.push'// ── Push Bulk Notifications ──────────────────────────────────// POST /internal/notifications/push-bulk// Body: { user_ids: [], type, title, message, ... }--router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {++router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });@@@ -354,7 +354,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.}try {-- const results = notifService.createBulk(user_ids, data);++ const results = await notifService.createBulk(user_ids, data);res.json({ ok: true, sent: results.length, total: user_ids.length });} catch (err) {console.error('[Internal] Bulk notification push error:', err);@@@ -366,7 +366,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.// POST /internal/operator/alerts — network.operator-alerts-request@1 → network.operator-alerts-result@1.// Host's relay (ovhost alerts relay) sends the complete set of alerts firing now; server/operator/alerts.js// pages the owner when one opens, once a day while it stays open, and when it resolves. Token only.--router.post('/operator/alerts', principals.guard('network.operator.alert'), (req, res) => {++router.post('/operator/alerts', principals.guard('network.operator.alert'), async (req, res) => {const contracts = require('openvibe-contracts');const v = contracts.validate('network.operator-alerts-request@1', req.body);if (!v.valid) return res.status(400).json({ error: 'The body does not match network.operator-alerts-request@1', details: (v.errors || []).slice(0, 5) });@@@ -374,7 +374,7 @@ router.post('/operator/alerts', principals.guard('network.operator.alert'), (reqconst notify = notifService ? (userId, n) => notifService.create({ user_id: userId, ...n }) : null;const revise = notifService ? (userId, id, fields) => notifService.revise(id, userId, fields) : null;try {-- res.json(require('../operator/alerts').receive(getDb(req), req.body, { notify, revise }));++ res.json(await require('../operator/alerts').receive(await getDb(req), req.body, { notify, revise }));} catch (err) {console.error('[Internal] Operator alerts error:', err.message);res.status(500).json({ error: 'Operator alerts failed' });@@@ -409,25 +409,25 @@ router.post('/notifications/mark-read', principals.guard('network.notifications.// Body: { ip }// Called by first-party services to get or create a unified// anon identity for a given IP address. Single source of truth.--router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req, res) => {++router.post('/resolve-anon', principals.guard('identity.subject.resolve'), async (req, res) => {const { ip } = req.body;if (!ip) return res.status(400).json({ error: 'ip required' });-- const db = getDb(req);++ const db = await getDb(req);const { v4: uuidv4 } = require('uuid');try {// Check if this IP already has an anon-- const byIpLog = db.prepare(`++ const byIpLog = await db.prepare(`SELECT a.* FROM anon_users aINNER JOIN anon_ip_log l ON l.anon_id = a.idWHERE l.ip = ?ORDER BY a.id ASC LIMIT 1`).get(ip);if (byIpLog) {-- db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(byIpLog.id);-- db.prepare(`-- UPDATE anon_ip_log SET last_seen = CURRENT_TIMESTAMP++ await db.prepare('UPDATE anon_users SET last_seen = ov_now() WHERE id = ?').run(byIpLog.id);++ await db.prepare(`++ UPDATE anon_ip_log SET last_seen = ov_now()WHERE anon_id = ? AND ip = ?`).run(byIpLog.id, ip);return res.json({@@@ -440,12 +440,12 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,}// Check by creating IP-- const byCreatingIp = db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);++ const byCreatingIp = await db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);if (byCreatingIp) {-- db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(byCreatingIp.id);++ await db.prepare('UPDATE anon_users SET last_seen = ov_now() WHERE id = ?').run(byCreatingIp.id);// Ensure IP log entry existstry {-- db.prepare('INSERT OR IGNORE INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(byCreatingIp.id, ip);++ await db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES(?, ?) ON CONFLICT DO NOTHING').run(byCreatingIp.id, ip);} catch { /* ok */ }return res.json({anon_number: byCreatingIp.anon_number,@@@ -457,17 +457,17 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,}// Create new anon-- const maxNum = db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get().max || 0;++ const maxNum = (await db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get()).max || 0;const anonNumber = maxNum + 1;const sessionToken = uuidv4();-- const result = db.prepare(-- 'INSERT INTO anon_users (anon_number, session_token, ip, subject_id) VALUES (?, ?, ?, ?)'++ const result = await db.prepare(++ 'INSERT INTO anon_users (anon_number, session_token, ip, subject_id) VALUES (?, ?, ?, ?) RETURNING id').run(anonNumber, sessionToken, ip, require('../identity/subjects').newGuestSubjectId());// Log IPtry {-- db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(result.lastInsertRowid, ip);++ await db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(result.lastInsertRowid, ip);} catch { /* ok */ }console.log(`[Internal] New unified anon #${anonNumber} for IP ${ip}`);@@@ -69,7 +69,7 @@ class EmailService {* One-time migration: copy legacy email settings to the new email_* keys if they exist* and the new keys haven't been set yet.*/-- _migrateFromSES() {++ async _migrateFromSES() {const migrations = [['ses_enabled', 'email_enabled', 'boolean'],['ses_from_email', 'email_from_address', 'string'],@@@ -78,20 +78,20 @@ class EmailService {['ses_from_email_openvibegames', 'email_from_openvibegames', 'string'],['ses_from_email_openvibenetwork', 'email_from_openvibenetwork', 'string'],];-- const setSetting = this.db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');++ const setSetting = this.db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT DO NOTHING');for (const [oldKey, newKey, type] of migrations) {-- const oldVal = this.db.getSetting(oldKey);++ const oldVal = await this.db.getSetting(oldKey);if (oldVal != null && oldVal !== '') {-- setSetting.run(newKey, String(oldVal), type);++ await setSetting.run(newKey, String(oldVal), type);}}}-- _loadConfig() {-- this._enabled = this.db.getSetting('email_enabled') === true;-- this._apiKey = this.db.getSetting('resend_api_key') || null;-- this._fromEmail = this.db.getSetting('email_from_address') || '[email protected]';-- this._fromName = this.db.getSetting('email_from_name') || 'OpenVibe';++ async _loadConfig() {++ this._enabled = await this.db.getSetting('email_enabled') === true;++ this._apiKey = await this.db.getSetting('resend_api_key') || null;++ this._fromEmail = await this.db.getSetting('email_from_address') || '[email protected]';++ this._fromName = await this.db.getSetting('email_from_name') || 'OpenVibe';if (this._enabled && this._apiKey) {console.log(`[Email] Resend initialized (from: ${this._fromName} <${this._fromEmail}>)`);@@@ -113,10 +113,10 @@ class EmailService {* Resolve the from-address for a given service.* Falls back to the global from-email if no per-service override.*/-- getFromEmail(service) {++ async getFromEmail(service) {if (service) {const key = `email_from_${service.replace(/[^a-z0-9]/gi, '').toLowerCase()}`;-- const override = this.db.getSetting(key);++ const override = await this.db.getSetting(key);if (override) return override;}return this._fromEmail;@@@ -126,9 +126,9 @@ class EmailService {* Diagnose why the email service isn't ready.* Returns a user-friendly error string, or null if ready.*/-- diagnose() {++ async diagnose() {if (!this._enabled) {-- const dbEnabled = this.db.getSetting('email_enabled');++ const dbEnabled = await this.db.getSetting('email_enabled');if (dbEnabled !== true) return 'Email is disabled — enable the toggle in admin panel and save';return 'Email service failed to initialize after being enabled — check server logs';}@@@ -264,7 +264,7 @@ class EmailService {async processQueue(notifService) {if (!this.isEnabled) return;-- const pending = notifService.getPendingEmails();++ const pending = await notifService.getPendingEmails();if (pending.length === 0) return;console.log(`[Email] Processing ${pending.length} pending email(s)...`);@@@ -272,13 +272,13 @@ class EmailService {let skipped = 0;for (const notif of pending) {if (!notifService.shouldEmail(notif)) {-- notifService.markEmailed(notif.id);++ await notifService.markEmailed(notif.id);continue;}const why = notifService.emailGuard ? notifService.emailGuard(notif) : null;if (why) {// Caps: leave the row un-emailed only if the cap may lift (it won't for stale/dup).-- if (why === 'stale' || why === 'dup') notifService.markEmailed(notif.id);++ if (why === 'stale' || why === 'dup') await notifService.markEmailed(notif.id);skipped++;continue;}@@@ -291,7 +291,7 @@ class EmailService {subject,notification: notif,});-- if (sent) notifService.markEmailed(notif.id);++ if (sent) await notifService.markEmailed(notif.id);}if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);}@@@ -412,7 +412,7 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B].join('\n');}-- _escapeHtml(value) {++ async _escapeHtml(value) {return String(value ?? '').replace(/&/g, '&').replace(/</g, '<')@@@ -424,8 +424,8 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B/*** Get current email service configuration status (for admin panel).*/-- getStatus() {-- const apiKey = this.db.getSetting('resend_api_key') || '';++ async getStatus() {++ const apiKey = await this.db.getSetting('resend_api_key') || '';const issue = this.diagnose();const secrets = require('../secrets');return {@@@ -435,14 +435,14 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, Bprovider: 'resend',from_email: this._fromEmail,from_name: this._fromName,-- from_email_openvibelive: this.db.getSetting('email_from_openvibelive') || '',-- from_email_openvibegames: this.db.getSetting('email_from_openvibegames') || '',-- from_email_openvibenetwork: this.db.getSetting('email_from_openvibenetwork') || '',++ from_email_openvibelive: await this.db.getSetting('email_from_openvibelive') || '',++ from_email_openvibegames: await this.db.getSetting('email_from_openvibegames') || '',++ from_email_openvibenetwork: await this.db.getSetting('email_from_openvibenetwork') || '',hasApiKey: !!apiKey,// Masked API key for display (show first 6 chars + last 4)api_key: apiKey ? apiKey.slice(0, 6) + '••••' + apiKey.slice(-4) : '',// Where the key comes from: env (RESEND_API_KEY, the admin UI cannot change it), database, unset.-- api_key_source: secrets.source(this.db, 'resend_api_key'),++ api_key_source: await secrets.source(this.db, 'resend_api_key'),api_key_env: secrets.envName('resend_api_key'),};}@@@ -476,9 +476,9 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, Breturn true;}-- _recordDelivery({ emailType, recipient, subject, status, errorMessage = null, userId = null, notificationId = null, metadata = null }) {++ async _recordDelivery({ emailType, recipient, subject, status, errorMessage = null, userId = null, notificationId = null, metadata = null }) {try {-- this._logDelivery.run(++ await this._logDelivery.run(emailType,recipient,subject || null,@@@ -31,7 +31,7 @@* Each event becomes one inbox notification for its person through NotificationService.create(),* which applies their preferences: a muted category ('service') creates nothing, and email follows* the same per-category choice (shouldEmail). Exactly once: the openvibe-sdk inbox claims-- * (consumer, event_id) in the same SQLite transaction as the notification, so a redelivery does++ * (consumer, event_id) in the same PostgreSQL transaction as the notification, so a redelivery does* nothing and a failure rolls both back (Events retries).** Signature: v2 only (openvibe-sdk parseDelivery with requireV2) — HMAC over "<t>.<raw body>" with t@@@ -46,7 +46,7 @@*/const express = require('express');const { http, ids } = require('openvibe-contracts');--const { parseDelivery, createInbox } = require('openvibe-sdk/events');++const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const { siteForHost } = require('../frame/sites');const streamLive = require('./stream-live');const { LiveFollowersError } = require('./live-followers');@@@ -162,10 +162,9 @@ function liveStarted(event, { now, maxAgeMs }) {* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()*/--function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, followsAuthority = 'live', discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {++async function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, followsAuthority = 'live', discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);-- const inbox = createInbox(db, { table: INBOX_TABLE, now });-- inbox.ensureSchema();++ const inbox = createPgInbox(db, { table: INBOX_TABLE, now });const userBySubject = db.prepare('SELECT id FROM users WHERE subject_id = ?');const streamerBySubject = db.prepare('SELECT id, username, display_name, avatar_url FROM users WHERE subject_id = ?');const userById = db.prepare('SELECT id FROM users WHERE id = ?');@@@ -178,11 +177,11 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = nullasync 'live.stream.started'(event) {const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });if (typeof v === 'string') return { skip: v };-- if (!streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce++ if (!await streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce// ADR-030 step 4: with FOLLOWS_AUTHORITY=network the followers come from Network's own graph// (server/identity/follows.js), not from Live. Rollback: unset it.if (followsAuthority === 'network') {-- const rows = db.prepare("SELECT follower_subject FROM user_follows WHERE target_type = 'channel' AND target_id = ? AND active = 1").all(v.subject);++ const rows = await db.prepare("SELECT follower_subject FROM user_follows WHERE target_type = 'channel' AND target_id = ? AND active = 1").all(v.subject);return { channelSubject: v.subject, followers: rows.map((r) => ({ subject: r.follower_subject })) };}if (!liveFollowers) throw new LiveFollowersError('no Live followers client (RS256 signing key and OV_LIVE_INTERNAL_URL needed)');@@@ -191,32 +190,32 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null};/** live.stream.started inside the inbox transaction: the announcement window, then one notification per person. */-- function liveStreamStarted(event, prep) {++ async function liveStreamStarted(event, prep) {if (prep && prep.skip) return prep.skip;const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });if (typeof v === 'string') return v;if (!prep || prep.missing) return 'ignored:stream';if (prep.channelSubject !== v.subject) return 'ignored:channel-mismatch'; // that stream is not this channel's-- const streamer = streamerBySubject.get(v.subject);++ const streamer = await streamerBySubject.get(v.subject);if (!streamer) return 'ignored:channel';-- const claim = streamLive.claimAnnouncement(db, { streamerKey: streamer.id, streamId: v.streamId, now: now() });++ const claim = await streamLive.claimAnnouncement(db, { streamerKey: streamer.id, streamId: v.streamId, now: now() });if (claim.skipped) return { outcome: `skipped:${claim.reason}`, detail: { next_allowed_at: claim.next_allowed_at || null } };const targets = new Set();let unresolved = 0;for (const f of prep.followers || []) {let uid = null;-- if (f.subject && ids.isSubjectId('user', f.subject)) { const u = userBySubject.get(f.subject); if (u) uid = u.id; }-- if (uid == null && f.network_user_id) { const u = userById.get(f.network_user_id); if (u) uid = u.id; }++ if (f.subject && ids.isSubjectId('user', f.subject)) { const u = await userBySubject.get(f.subject); if (u) uid = u.id; }++ if (uid == null && f.network_user_id) { const u = await userById.get(f.network_user_id); if (u) uid = u.id; }if (uid == null) { unresolved++; continue; }targets.add(uid);}-- for (const uid of streamLive.allLiveSubscribers(db)) targets.add(uid);++ for (const uid of await streamLive.allLiveSubscribers(db)) targets.add(uid);targets.delete(streamer.id); // never tell streamers about themselvesconst notification = streamLive.streamLiveNotification({username: v.username, displayName: v.displayName, avatarUrl: streamer.avatar_url, senderId: streamer.id,stream: { id: v.streamId, title: v.title, protocol: v.protocol, event_id: event.event_id }, url: v.url,});-- const created = targets.size ? notifications.createBulk([...targets], notification) : [];++ const created = targets.size ? await notifications.createBulk([...targets], notification) : [];const detail = { followers: (prep.followers || []).length, unresolved, targets: targets.size, notified: created.length, ...(prep.truncated ? { truncated: true } : {}) };const after = () => {const d = discord && discord();@@@ -229,9 +228,9 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null}/** Apply one envelope (with what PREPARE fetched for it). Returns { duplicate, outcome, detail }. Throws only on a storage failure. */-- function apply(event, prep) {++ async function apply(event, prep) {let after = null;-- const r = inbox.once(CONSUMER, event.event_id, () => {++ const r = await inbox.once(CONSUMER, event.event_id, async () => {// Staff actions go to the moderation audit log (ADR-022), never to anyone's inbox.if (AUDIT_TOPICS.includes(event.event_type)) return moderationAudit ? moderationAudit.record(event) : 'ignored:audit_off';// Developer projects' usage rollups go to their dashboards (WS-N task 4), never to an inbox.@@@ -239,7 +238,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null// Creator analytics (WS-E task 6): each ended stream's totals, counts only.if (event.event_type === 'live.stream.ended') return require('../analytics/creators').record(db, event);if (event.event_type === 'live.stream.started') {-- const out = liveStreamStarted(event, prep);++ const out = await liveStreamStarted(event, prep);if (typeof out === 'string') return out;after = out.after || null;return { outcome: out.outcome, detail: out.detail };@@@ -249,10 +248,10 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = nullconst out = handler(event);if (typeof out === 'string') return out;if (!ids.isSubjectId('user', out.subject)) return 'ignored:recipient';-- const user = userBySubject.get(out.subject);++ const user = await userBySubject.get(out.subject);if (!user) return 'ignored:recipient';// create() returns null when the person turned the category off.-- return notifications.create({ ...out.notification, user_id: user.id }) ? 'notified' : 'suppressed:preference';++ return await notifications.create({ ...out.notification, user_id: user.id }) ? 'notified' : 'suppressed:preference';});if (r.duplicate) return { duplicate: true, outcome: null };// Side effects outside the database run only after the commit, once per event.@@@ -275,7 +274,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = nulllet prep;if (Object.prototype.hasOwnProperty.call(PREPARE, event.event_type)) {// Already handled: answer without asking anyone anything.-- if (inbox.seen(CONSUMER, event.event_id)) return res.status(200).json({ event_id: event.event_id, duplicate: true, outcome: null });++ if (await inbox.seen(CONSUMER, event.event_id)) return res.status(200).json({ event_id: event.event_id, duplicate: true, outcome: null });try {prep = await PREPARE[event.event_type](event);} catch (err) {@@@ -286,7 +285,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null}let out;try {-- out = apply(event, prep);++ out = await apply(event, prep);} catch (err) {// Not acknowledged: the inbox claim rolled back with the notification, and Events retries.log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);@@@ -12,14 +12,14 @@const { channelUrl } = require('./stream-live');function followNotifier(notifications) {-- return (db, { follower, type, target }) => {++ return async (db, { follower, type, target }) => {if (type !== 'channel') return null;const who = db.prepare('SELECT id, username, display_name, avatar_url, is_anon FROM users WHERE subject_id = ?');-- const f = who.get(follower);-- const t = who.get(target);++ const f = await who.get(follower);++ const t = await who.get(target);if (!f || !t || t.is_anon) return null;const name = f.display_name || f.username || 'Someone';-- return notifications.create({++ return await notifications.create({user_id: t.id, type: 'FOLLOW', title: 'New Follower', message: `${name} followed you`,url: f.username ? channelUrl(f.username) : 'https://openvibe.live/', service: 'live',sender_id: f.id, sender_name: name, sender_avatar: f.avatar_url || null, actor_subject: follower,@@@ -156,7 +156,8 @@ class NotificationService {* Create a notification. Returns the created notification object.* @param {Object} data - { user_id, type, title, message, sender_id, sender_name, sender_avatar, service, url, rich_content, expires_at }*/-- create(data) {++ async create(data) {++ const db = this.db;const typeDef = TYPES[data.type] || {};const id = uuidv4();const category = data.category || typeDef.category || 'system';@@@ -165,15 +166,15 @@ class NotificationService {const title = data.title || typeDef.title || 'Notification';// Check user's preferences — skip if disabled-- const pref = this._getPrefByCategory.get(data.user_id, category);++ const pref = await this._getPrefByCategory.get(data.user_id, category);if (pref && !pref.enabled) return null;// Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a// staff action: moderation, system and admin notices are always created.-- if (!BLOCK_EXEMPT_CATEGORIES.has(category) && this.fromBlockedActor(data)) return null;++ if (!BLOCK_EXEMPT_CATEGORIES.has(category) && await this.fromBlockedActor(data)) return null;// Go-live and follow dedupe (see _recentFromSender): one an hour per sender, so following again after an// unfollow does not notify again.if ((data.type === 'STREAM_LIVE' || data.type === 'FOLLOW') && data.sender_id != null) {-- try { if (this._recentFromSender.get(data.user_id, data.type, data.sender_id)) return null; } catch { /* */ }++ try { if (await this._recentFromSender.get(data.user_id, data.type, data.sender_id)) return null; } catch { /* */ }}const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;@@@ -181,17 +182,17 @@ class NotificationService {// The notification and its network.notification.created event: one transaction.let announced = false;-- this.db.transaction(() => {-- this._insertNotif.run(++ await db.tx(async () => {++ await this._insertNotif.run(id, data.user_id, data.type || 'GENERIC', category, priority,title, data.message || null, icon,data.sender_id || null, data.sender_name || null, data.sender_avatar || null,data.service || null, data.url || null, richContent,data.expires_at || null,);-- announced = this._announce({ id, userId: data.user_id, type: data.type, category, priority, service: data.service, createdAt });-- })();-- // Wake the relay (it reads after the outermost transaction commits: better-sqlite3 is synchronous).++ announced = await this._announce({ id, userId: data.user_id, type: data.type, category, priority, service: data.service, createdAt });++ });++ // Wake the relay after the commit (the outbox row committed with the notification).if (announced) { const live = eventRelay.outboxFor(this.db); if (live) live.kick(); }// Fire browser push notification (async, non-blocking); `silent` keeps it to the bell.@@@ -215,8 +216,8 @@ class NotificationService {* given one) gets none; an envelope that would not match its contract is skipped with a warning rather* than losing the notification (the badge's polling still finds it).*/-- _announce({ id, userId, type, category, priority, service, createdAt }) {-- const r = this._recipient.get(userId);++ async _announce({ id, userId, type, category, priority, service, createdAt }) {++ const r = await this._recipient.get(userId);if (!r || r.is_anon || !SUBJECT_RE.test(String(r.subject_id || ''))) return false;const payload = {notification_id: String(id),@@@ -225,7 +226,7 @@ class NotificationService {priority: PRIORITIES.has(priority) ? priority : 'normal',service: /^[a-z][a-z0-9-]{1,39}$/.test(String(service || '')) ? String(service) : null,created_at: createdAt,-- unread_count: this._unreadCount.get(userId)?.count || 0,++ unread_count: (await this._unreadCount.get(userId))?.count || 0,};const ms = Date.parse(createdAt) || Date.now();const env = {@@@ -239,7 +240,7 @@ class NotificationService {console.warn(`[Notifications] ${NOTIFICATION_EVENT} not queued for notification ${id}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 200)}`);return false;}-- eventRelay.writerFor(this.db).enqueue(env);++ await eventRelay.writerFor(this.db).enqueue(this.db, env);return true;}@@@ -247,35 +248,35 @@ class NotificationService {* Whether the recipient (data.user_id) blocked the notification's actor: data.actor_subject (a usr_* subject; null = no known person) when given, else data.sender_id as a Network user id.*/-- fromBlockedActor(data) {++ async fromBlockedActor(data) {try {if (Object.prototype.hasOwnProperty.call(data, 'actor_subject')) {-- return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(data.actor_subject || '')) && !!this._blockedActorSubject.get(data.user_id, data.actor_subject);++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(data.actor_subject || '')) && !!await this._blockedActorSubject.get(data.user_id, data.actor_subject);}const sender = Number(data.sender_id);-- return Number.isInteger(sender) && sender > 0 && Number(data.user_id) !== sender && !!this._blockedActorId.get(data.user_id, sender);++ return Number.isInteger(sender) && sender > 0 && Number(data.user_id) !== sender && !!await this._blockedActorId.get(data.user_id, sender);} catch { return false; }}/*** Bulk-create notifications for multiple users (e.g., broadcast).*/-- createBulk(userIds, data) {++ async createBulk(userIds, data) {++ const db = this.db;const results = [];-- const tx = this.db.transaction(() => {++ await db.tx(async () => {for (const uid of userIds) {-- const notif = this.create({ ...data, user_id: uid });++ const notif = await this.create({ ...data, user_id: uid });if (notif) results.push(notif);}});-- tx();return results;}// ─── Read ──────────────────────────────────────────────────-- getById(id) {-- const n = this._getById.get(id);++ async getById(id) {++ const n = await this._getById.get(id);if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);return n;}@@@ -287,7 +288,7 @@ class NotificationService {* - since: ISO timestamp, only rows created after it (toasts)* Fetches limit+1 so the client knows whether another page exists.*/-- getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {++ async getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {const where = ['user_id = ?', 'is_dismissed = 0'];const params = [userId];if (category) { where.push('category = ?'); params.push(category); }@@@ -299,11 +300,11 @@ class NotificationService {where.push("(title LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\' OR sender_name LIKE ? ESCAPE '\\')");params.push(like, like, like);}-- const sql = `SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`;-- const rows = this.db.prepare(sql).all(...params, limit + 1, offset);++ const sql = `SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY created_at DESC, seq DESC LIMIT ? OFFSET ?`;++ const rows = await this.db.prepare(sql).all(...params, limit + 1, offset);const has_more = rows.length > limit;if (has_more) rows.length = limit;-- const total = this.db.prepare(`SELECT COUNT(*) AS c FROM notifications WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;++ const total = (await this.db.prepare(`SELECT COUNT(*) AS c FROM notifications WHERE ${where.join(' AND ')}`).get(...params))?.c || 0;const notifications = rows.map(n => {// Defensive: a single corrupt row must never 500 the whole dropdown.if (n.rich_content) {@@@ -315,25 +316,26 @@ class NotificationService {return { notifications, has_more, total };}-- markReadMany(ids, userId) {++ async markReadMany(ids, userId) {++ const db = this.db;if (!Array.isArray(ids) || !ids.length) return 0;const stmt = this.db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND is_read = 0');let n = 0;-- const tx = this.db.transaction(() => { for (const id of ids.slice(0, 500)) n += stmt.run(String(id), userId).changes; });-- tx();++ const tx = db.txFn(async () => { for (const id of ids.slice(0, 500)) n += (await stmt.run(String(id), userId)).changes; });++ await tx();return n;}-- getUnreadCount(userId) {-- return this._unreadCount.get(userId)?.count || 0;++ async getUnreadCount(userId) {++ return (await this._unreadCount.get(userId))?.count || 0;}-- getUnreadByCategory(userId) {-- return this._unreadCountByCategory.all(userId);++ async getUnreadByCategory(userId) {++ return await this._unreadCountByCategory.all(userId);}-- getNewest(userId) {-- const n = this._newestForUser.get(userId);++ async getNewest(userId) {++ const n = await this._newestForUser.get(userId);if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);return n;}@@@ -341,51 +343,51 @@ class NotificationService {// ─── Update ────────────────────────────────────────────────/** Edit one of a user's notifications in place (an operator alert that resolves or fires again). */-- revise(id, userId, { title, message, icon, is_read: isRead } = {}) {++ async revise(id, userId, { title, message, icon, is_read: isRead } = {}) {const sets = []; const args = [];if (title != null) { sets.push('title = ?'); args.push(String(title).slice(0, 200)); }if (message != null) { sets.push('message = ?'); args.push(String(message)); }if (icon != null) { sets.push('icon = ?'); args.push(String(icon)); }if (isRead != null) { sets.push('is_read = ?'); args.push(isRead ? 1 : 0); }if (!sets.length) return false;-- const r = this.db.prepare(`UPDATE notifications SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...args, id, userId);++ const r = await this.db.prepare(`UPDATE notifications SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...args, id, userId);return r.changes > 0;}-- markRead(id, userId) {-- return this._markRead.run(id, userId).changes > 0;++ async markRead(id, userId) {++ return (await this._markRead.run(id, userId)).changes > 0;}-- markAllRead(userId, category = null) {-- if (category) return this._markReadByCategory.run(userId, category).changes;-- return this._markAllRead.run(userId).changes;++ async markAllRead(userId, category = null) {++ if (category) return (await this._markReadByCategory.run(userId, category)).changes;++ return (await this._markAllRead.run(userId)).changes;}-- dismiss(id, userId) {-- return this._dismiss.run(id, userId).changes > 0;++ async dismiss(id, userId) {++ return (await this._dismiss.run(id, userId)).changes > 0;}-- dismissAll(userId) {-- return this._dismissAll.run(userId).changes;++ async dismissAll(userId) {++ return (await this._dismissAll.run(userId)).changes;}-- markReadByType(userId, type, urlPattern = null) {-- if (urlPattern) return this._markReadByTypeAndUrl.run(userId, type, urlPattern).changes;-- return this._markReadByType.run(userId, type).changes;++ async markReadByType(userId, type, urlPattern = null) {++ if (urlPattern) return (await this._markReadByTypeAndUrl.run(userId, type, urlPattern)).changes;++ return (await this._markReadByType.run(userId, type)).changes;}-- markEmailed(id) {-- return this._markEmailed.run(id).changes > 0;++ async markEmailed(id) {++ return (await this._markEmailed.run(id)).changes > 0;}// ─── Preferences ──────────────────────────────────────────-- getPreferences(userId) {-- return this._getPrefs.all(userId);++ async getPreferences(userId) {++ return await this._getPrefs.all(userId);}-- setPreference(userId, category, updates = {}) {-- const current = this._getPrefByCategory.get(userId, category) || {++ async setPreference(userId, category, updates = {}) {++ const current = await this._getPrefByCategory.get(userId, category) || {enabled: 1,sound: 1,toasts: 1,@@@ -397,7 +399,7 @@ class NotificationService {toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,};-- this._upsertPref.run(++ await this._upsertPref.run(userId,category,next.enabled,@@@ -411,15 +413,15 @@ class NotificationService {);}-- resetPreference(userId, category) {-- this._deletePrefs.run(userId, category);++ async resetPreference(userId, category) {++ await this._deletePrefs.run(userId, category);}// ─── Email Queue ──────────────────────────────────────────/**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */-- getPendingEmails() {-- return this._getPendingEmails.all();++ async getPendingEmails() {++ return await this._getPendingEmails.all();}/**@@@ -428,8 +430,8 @@ class NotificationService {* Built-in: CRITICAL + system/moderation/admin always email.* User opt-in: any category the user explicitly enabled email for.*/-- shouldEmail(notification) {-- const pref = this._getPrefByCategory.get(notification.user_id, notification.category);++ async shouldEmail(notification) {++ const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);// If user disabled the whole category, no emailif (pref && !pref.enabled) return false;// LOW priority never emails@@@ -454,37 +456,37 @@ class NotificationService {* - user cap / global cap: site_settings email_user_daily_cap (default 30),* email_daily_cap (default 2000)*/-- emailGuard(notification) {++ async emailGuard(notification) {try {if (notification.type === 'STREAM_LIVE') {const ageMs = Date.now() - new Date(String(notification.created_at).replace(' ', 'T') + 'Z').getTime();if (ageMs > 2 * 3600 * 1000) return 'stale';-- if (notification.sender_id != null && this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';++ if (notification.sender_id != null && await this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';}-- const userCap = parseInt(this.db.getSetting?.('email_user_daily_cap'), 10) || 30;-- if ((this._emailsSentToUserToday.get(notification.user_id)?.c || 0) >= userCap) return 'user-cap';-- const globalCap = parseInt(this.db.getSetting?.('email_daily_cap'), 10) || 2000;-- if ((this._emailsSentToday.get()?.c || 0) >= globalCap) return 'global-cap';++ const userCap = parseInt(await this.db.getSetting?.('email_user_daily_cap'), 10) || 30;++ if (((await this._emailsSentToUserToday.get(notification.user_id))?.c || 0) >= userCap) return 'user-cap';++ const globalCap = parseInt(await this.db.getSetting?.('email_daily_cap'), 10) || 2000;++ if (((await this._emailsSentToday.get())?.c || 0) >= globalCap) return 'global-cap';} catch { /* guards are best-effort */ }return null;}// ─── Cleanup ──────────────────────────────────────────────-- cleanExpired() {-- return this._deleteExpired.run().changes;++ async cleanExpired() {++ return (await this._deleteExpired.run()).changes;}-- cleanOld(days = 90) {-- return this._deleteOld.run(`-${days} days`).changes;++ async cleanOld(days = 90) {++ return (await this._deleteOld.run(`-${days} days`)).changes;}/*** Run periodic maintenance (call from setInterval in main server).*/-- maintenance() {++ async maintenance() {const expired = this.cleanExpired();-- const maxAge = this.db.getSetting?.('notification_max_age_days') || 90;++ const maxAge = await this.db.getSetting?.('notification_max_age_days') || 90;const old = this.cleanOld(maxAge);if (expired + old > 0) {console.log(`[Notifications] Cleaned ${expired} expired + ${old} old notifications`);@@@ -23,9 +23,9 @@ function verifySvix(secret, headers, rawBody) {module.exports = function resendWebhook() {const router = express.Router();-- router.post('/resend', express.raw({ type: '*/*', limit: '256kb' }), (req, res) => {++ router.post('/resend', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {const db = req.app.locals.db;-- const secret = db.getSetting('resend_webhook_secret');++ const secret = await db.getSetting('resend_webhook_secret');const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body || '');if (!secret) { console.warn('[Resend webhook] received but resend_webhook_secret is not configured — ignored'); return res.status(503).json({ ok: false }); }if (!verifySvix(secret, req.headers, raw)) return res.status(401).json({ ok: false, error: 'bad signature' });@@@ -35,11 +35,11 @@ module.exports = function resendWebhook() {if (!to.length) return res.json({ ok: true });if (type === 'email.bounced' || type === 'email.complained') {const reason = type === 'email.complained' ? 'complaint' : (evt?.data?.bounce?.message || evt?.data?.bounce?.type || 'bounce');-- const stmt = db.prepare("UPDATE users SET email_bounced_at = CURRENT_TIMESTAMP, email_bounce_reason = ?, email_verified = CASE WHEN ? = 'complaint' THEN email_verified ELSE 0 END WHERE LOWER(email) = ?");++ const stmt = db.prepare("UPDATE users SET email_bounced_at = ov_now(), email_bounce_reason = ?, email_verified = CASE WHEN ? = 'complaint' THEN email_verified ELSE 0 END WHERE LOWER(email) = ?");let n = 0;-- for (const addr of to) n += stmt.run(String(reason).slice(0, 200), type === 'email.complained' ? 'complaint' : 'bounce', addr).changes;++ for (const addr of to) n += (await stmt.run(String(reason).slice(0, 200), type === 'email.complained' ? 'complaint' : 'bounce', addr)).changes;console.warn(`[Resend webhook] ${type} for ${to.join(',')} — ${n} account(s) suppressed`);-- try { db.prepare("INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, metadata) VALUES (?, ?, ?, 'failed', ?, ?)").run(`webhook:${type}`, to[0], null, String(reason).slice(0, 200), JSON.stringify({ event: type })); } catch { /* */ }++ try { await db.prepare("INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, metadata) VALUES (?, ?, ?, 'failed', ?, ?)").run(`webhook:${type}`, to[0], null, String(reason).slice(0, 200), JSON.stringify({ event: type })); } catch { /* */ }}res.json({ ok: true });});@@@ -13,7 +13,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ// ─── GET /api/notifications ────────────────────────────// Query params: limit, offset, category, unread_only-- router.get('/', requireAuth, (req, res) => {++ router.get('/', requireAuth, async (req, res) => {try {const userId = req.user.id;const limit = Math.min(parseInt(req.query.limit) || 50, 200);@@@ -25,7 +25,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requconst q = req.query.q ? String(req.query.q).trim() : null;const since = req.query.since ? String(req.query.since) : null;-- const out = notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });++ const out = await notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });res.json({ ok: true, ...out, limit, offset });} catch (err) {console.error('[Notifications] GET / error:', err);@@@ -34,9 +34,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── GET /api/notifications/unread-count ───────────────-- router.get('/unread-count', requireAuth, (req, res) => {++ router.get('/unread-count', requireAuth, async (req, res) => {try {-- const count = notificationService.getUnreadCount(req.user.id);++ const count = await notificationService.getUnreadCount(req.user.id);res.json({ ok: true, count });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to get count' });@@@ -44,9 +44,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── GET /api/notifications/unread-by-category ─────────-- router.get('/unread-by-category', requireAuth, (req, res) => {++ router.get('/unread-by-category', requireAuth, async (req, res) => {try {-- const categories = notificationService.getUnreadByCategory(req.user.id);++ const categories = await notificationService.getUnreadByCategory(req.user.id);res.json({ ok: true, categories });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to get counts' });@@@ -54,9 +54,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── GET /api/notifications/newest ─────────────────────-- router.get('/newest', requireAuth, (req, res) => {++ router.get('/newest', requireAuth, async (req, res) => {try {-- const newest = notificationService.getNewest(req.user.id);++ const newest = await notificationService.getNewest(req.user.id);res.json({ ok: true, notification: newest || null });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to get newest' });@@@ -64,10 +64,10 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── POST /api/notifications/:id/read ──────────────────-- router.post('/:id/read', requireAuth, (req, res) => {++ router.post('/:id/read', requireAuth, async (req, res) => {try {-- const ok = notificationService.markRead(req.params.id, req.user.id);-- res.json({ ok, unread: notificationService.getUnreadCount(req.user.id) });++ const ok = await notificationService.markRead(req.params.id, req.user.id);++ res.json({ ok, unread: await notificationService.getUnreadCount(req.user.id) });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to mark read' });}@@@ -75,21 +75,21 @@ module.exports = function createNotificationRoutes(db, notificationService, requ// ─── POST /api/notifications/read-batch ────────────────// Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.-- router.post('/read-batch', requireAuth, (req, res) => {++ router.post('/read-batch', requireAuth, async (req, res) => {try {const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];-- const changed = notificationService.markReadMany(ids, req.user.id);-- res.json({ ok: true, changed, unread: notificationService.getUnreadCount(req.user.id) });++ const changed = await notificationService.markReadMany(ids, req.user.id);++ res.json({ ok: true, changed, unread: await notificationService.getUnreadCount(req.user.id) });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to mark read' });}});// ─── POST /api/notifications/read-all ──────────────────-- router.post('/read-all', requireAuth, (req, res) => {++ router.post('/read-all', requireAuth, async (req, res) => {try {const { category } = req.body || {};-- const changed = notificationService.markAllRead(req.user.id, category || null);++ const changed = await notificationService.markAllRead(req.user.id, category || null);res.json({ ok: true, changed });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to mark all read' });@@@ -97,9 +97,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── POST /api/notifications/:id/dismiss ───────────────-- router.post('/:id/dismiss', requireAuth, (req, res) => {++ router.post('/:id/dismiss', requireAuth, async (req, res) => {try {-- const ok = notificationService.dismiss(req.params.id, req.user.id);++ const ok = await notificationService.dismiss(req.params.id, req.user.id);res.json({ ok });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to dismiss' });@@@ -108,9 +108,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ// ─── DELETE /api/notifications ─────────────────────────// Dismiss all-- router.delete('/', requireAuth, (req, res) => {++ router.delete('/', requireAuth, async (req, res) => {try {-- const changed = notificationService.dismissAll(req.user.id);++ const changed = await notificationService.dismissAll(req.user.id);res.json({ ok: true, changed });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to dismiss all' });@@@ -118,9 +118,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── GET /api/notifications/preferences ────────────────-- router.get('/preferences', requireAuth, (req, res) => {++ router.get('/preferences', requireAuth, async (req, res) => {try {-- const prefs = notificationService.getPreferences(req.user.id);++ const prefs = await notificationService.getPreferences(req.user.id);res.json({ ok: true, preferences: prefs });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to get preferences' });@@@ -129,11 +129,11 @@ module.exports = function createNotificationRoutes(db, notificationService, requ// ─── PUT /api/notifications/preferences ────────────────// Body: { category, enabled, sound, toasts, email }-- router.put('/preferences', requireAuth, (req, res) => {++ router.put('/preferences', requireAuth, async (req, res) => {try {const { category, enabled, sound, toasts, email } = req.body;if (!category) return res.status(400).json({ ok: false, error: 'Category required' });-- notificationService.setPreference(req.user.id, category, {++ await notificationService.setPreference(req.user.id, category, {enabled,sound,toasts,@@@ -146,9 +146,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ});// ─── DELETE /api/notifications/preferences/:category ───-- router.delete('/preferences/:category', requireAuth, (req, res) => {++ router.delete('/preferences/:category', requireAuth, async (req, res) => {try {-- notificationService.resetPreference(req.user.id, req.params.category);++ await notificationService.resetPreference(req.user.id, req.params.category);res.json({ ok: true });} catch (err) {res.status(500).json({ ok: false, error: 'Failed to reset preference' });@@@ -27,40 +27,36 @@ function canonicalChannelUrl(url, username) {}/** One fan-out per streamer per `stream_live_cooldown_min` (60) and at most `stream_live_daily_cap` (8) per 24h. */--function ensureAnnouncements(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS stream_live_announcements (-- id INTEGER PRIMARY KEY AUTOINCREMENT, streamer_key TEXT NOT NULL, stream_id TEXT, sent_at DATETIME DEFAULT CURRENT_TIMESTAMP);-- CREATE INDEX IF NOT EXISTS idx_sla_key ON stream_live_announcements(streamer_key, sent_at DESC)`);--}++function ensureAnnouncements(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }--function setting(db, key) {-- if (typeof db.getSetting === 'function') return db.getSetting(key);-- try { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return r ? r.value : null; } catch { return null; }++async function setting(db, key) {++ if (typeof db.getSetting === 'function') return await db.getSetting(key);++ try { const r = await db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return r ? r.value : null; } catch { return null; }}/*** Claim the announcement for a streamer, or say why not. `force` (admin/manual) bypasses the* cooldown, never the daily cap. Returns { ok: true } or { skipped: true, reason, ... }.*/--function claimAnnouncement(db, { streamerKey, streamId, force = false, now = Date.now() }) {++async function claimAnnouncement(db, { streamerKey, streamId, force = false, now = Date.now() }) {try { ensureAnnouncements(db); } catch { /* exists */ }const key = String(streamerKey).toLowerCase();-- const cooldownMin = Math.max(1, parseInt(setting(db, 'stream_live_cooldown_min'), 10) || 60);-- const dailyCap = Math.max(1, parseInt(setting(db, 'stream_live_daily_cap'), 10) || 8);-- const last = db.prepare('SELECT sent_at FROM stream_live_announcements WHERE streamer_key = ? ORDER BY sent_at DESC, id DESC LIMIT 1').get(key);-- const today = db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE streamer_key = ? AND sent_at > datetime('now','-1 day')").get(key)?.c || 0;++ const cooldownMin = Math.max(1, parseInt(await setting(db, 'stream_live_cooldown_min'), 10) || 60);++ const dailyCap = Math.max(1, parseInt(await setting(db, 'stream_live_daily_cap'), 10) || 8);++ const last = await db.prepare('SELECT sent_at FROM stream_live_announcements WHERE streamer_key = ? ORDER BY sent_at DESC, id DESC LIMIT 1').get(key);++ const today = (await db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE streamer_key = ? AND sent_at > datetime('now','-1 day')").get(key))?.c || 0;const lastMs = last ? new Date(String(last.sent_at).replace(' ', 'T') + 'Z').getTime() : 0;const sinceMin = last ? (now - lastMs) / 60000 : Infinity;if (today >= dailyCap) return { skipped: true, reason: 'daily-cap', announced_today: today, cap: dailyCap };if (!force && sinceMin < cooldownMin) return { skipped: true, reason: 'cooldown', next_allowed_at: new Date(lastMs + cooldownMin * 60000).toISOString() };-- db.prepare('INSERT INTO stream_live_announcements (streamer_key, stream_id) VALUES (?, ?)').run(key, streamId != null ? String(streamId) : null);-- db.prepare("DELETE FROM stream_live_announcements WHERE sent_at < datetime('now','-7 days')").run();++ await db.prepare('INSERT INTO stream_live_announcements (streamer_key, stream_id) VALUES (?, ?)').run(key, streamId != null ? String(streamId) : null);++ await db.prepare("DELETE FROM stream_live_announcements WHERE sent_at < datetime('now','-7 days')").run();return { ok: true };}/** People who asked for every go-live on the network (preference category stream_live_all). */--function allLiveSubscribers(db) {-- return db.prepare("SELECT user_id FROM notification_preferences WHERE category = 'stream_live_all' AND enabled = 1").all().map(r => r.user_id);++async function allLiveSubscribers(db) {++ return (await db.prepare("SELECT user_id FROM notification_preferences WHERE category = 'stream_live_all' AND enabled = 1").all()).map(r => r.user_id);}/** The STREAM_LIVE notification (category 'stream' from openvibe-shared, so the person's mute and email choice apply). */@@@ -60,7 +60,7 @@ function principalDenied({ req, code }) {*/function createNetworkReadiness({ db, getKeys, release, ecosystem = null, discordService = null, production = process.env.NODE_ENV === 'production', pollMs = 60000 }) {const checks = [-- { name: 'db', required: true, check: () => { const r = db.prepare('SELECT COUNT(*) AS n FROM oauth_clients').get(); return { detail: { oauth_clients: r.n } }; } },++ { name: 'db', required: true, check: async () => { const r = await db.prepare('SELECT COUNT(*) AS n FROM oauth_clients').get(); return { detail: { oauth_clients: r.n } }; } },{name: 'signing_key', required: production, cacheMs: 60000,check: () => {@@@ -27,34 +27,13 @@ const FLAP_MS = 12 * 3600 * 1000;const KEEP_RESOLVED_MS = 30 * 24 * 3600 * 1000;const STATUS_URL = 'https://openvibe.network/status';--function ensureTables(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS operator_alerts (-- source TEXT NOT NULL,-- fingerprint TEXT NOT NULL,-- name TEXT NOT NULL,-- severity TEXT NOT NULL,-- summary TEXT NOT NULL,-- description TEXT,-- service TEXT,-- state TEXT NOT NULL CHECK (state IN ('firing', 'resolved')),-- started_at TEXT NOT NULL,-- opened_at INTEGER NOT NULL,-- last_seen_at INTEGER NOT NULL,-- notified_at INTEGER,-- resolved_at INTEGER,-- PRIMARY KEY (source, fingerprint)-- )`);-- // The notifications of the current episode ([[userId, notificationId], …]) and how often it fired again.-- for (const col of ['notices TEXT', 'reopened INTEGER NOT NULL DEFAULT 0']) {-- try { db.exec(`ALTER TABLE operator_alerts ADD COLUMN ${col}`); } catch { /* already there */ }-- }--}++function ensureTables(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }--function operatorUserIds(db, env = process.env) {++async function operatorUserIds(db, env = process.env) {const names = new Set([(env.OWNER_USERNAME || 'goosely').toLowerCase()]);for (const n of String(env.OPERATOR_ALERT_USERNAMES || '').split(',')) if (n.trim()) names.add(n.trim().toLowerCase());const q = db.prepare('SELECT id FROM users WHERE lower(username) = ?');-- return [...names].map((n) => q.get(n)).filter(Boolean).map((u) => u.id);++ return (await Promise.all([...names].map(async (n) => await q.get(n)))).filter(Boolean).map((u) => u.id);}function message(a) {@@@ -68,11 +47,11 @@ const ago = (ms) => (ms < 3600e3 ? `${Math.max(1, Math.round(ms / 60e3))} min` :* notify(userId, notification) creates one notification (the notification service's create(); `silent` skips* web push); revise(userId, notificationId, fields) edits one in place (title, message, icon, is_read).*/--function receive(db, body, { notify, revise = null, now = Date.now(), env = process.env } = {}) {++async function receive(db, body, { notify, revise = null, now = Date.now(), env = process.env } = {}) {ensureTables(db);const source = body.source;const current = new Map(body.alerts.map((a) => [a.fingerprint, a]));-- const rows = db.prepare('SELECT * FROM operator_alerts WHERE source = ?').all(source);++ const rows = await db.prepare('SELECT * FROM operator_alerts WHERE source = ?').all(source);const byFp = new Map(rows.map((r) => [r.fingerprint, r]));const work = [];const out = { ok: true, firing: current.size, opened: 0, reminded: 0, resolved: 0, notified: 0 };@@@ -85,7 +64,7 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = procopened_at = CASE WHEN operator_alerts.state = 'resolved' AND @reopen = 0 THEN @now ELSE operator_alerts.opened_at END`);const resolve = db.prepare("UPDATE operator_alerts SET state = 'resolved', resolved_at = ? WHERE source = ? AND fingerprint = ?");-- db.transaction(() => {++ await db.tx(async () => {for (const a of current.values()) {const prev = byFp.get(a.fingerprint);let kind = null;@@@ -97,23 +76,23 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = procout.reminded += 1;}const paged = kind === 'opened' || kind === 'reminded';-- upsert.run({ source, fingerprint: a.fingerprint, name: a.name, severity: a.severity, summary: a.summary, description: a.description || null,++ await upsert.run({ source, fingerprint: a.fingerprint, name: a.name, severity: a.severity, summary: a.summary, description: a.description || null,service: a.service || null, started_at: a.started_at, now, reopen: kind === 'reopened' ? 1 : 0,notified_at: paged ? now : (prev && prev.notified_at) || null });if (kind) work.push({ kind, a, prev });}for (const r of rows) {if (r.state !== 'firing' || current.has(r.fingerprint)) continue;-- resolve.run(now, source, r.fingerprint);++ await resolve.run(now, source, r.fingerprint);out.resolved += 1;work.push({ kind: 'resolved', a: r, prev: r });}-- db.prepare("DELETE FROM operator_alerts WHERE state = 'resolved' AND resolved_at < ?").run(now - KEEP_RESOLVED_MS);-- })();++ await db.prepare("DELETE FROM operator_alerts WHERE state = 'resolved' AND resolved_at < ?").run(now - KEEP_RESOLVED_MS);++ });const notices = (row) => { try { return JSON.parse((row && row.notices) || '[]'); } catch { return []; } };const saveNotices = db.prepare('UPDATE operator_alerts SET notices = ? WHERE source = ? AND fingerprint = ?');-- const users = work.length ? operatorUserIds(db, env) : [];++ const users = work.length ? await operatorUserIds(db, env) : [];for (const { kind, a, prev } of work) {if (a.severity === 'info') continue;const critical = a.severity === 'critical';@@@ -133,7 +112,7 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = procif (created && created.id) kept.push([uid, created.id]);} catch (e) { console.warn('[operator-alerts] notify failed:', e.message); }}-- saveNotices.run(JSON.stringify(kept), source, a.fingerprint);++ await saveNotices.run(JSON.stringify(kept), source, a.fingerprint);continue;}if (typeof revise !== 'function') continue;@@@ -148,9 +127,9 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc}/** Firing and recently resolved alerts, newest first (the admin view and /status). */--function list(db, { limit = 100 } = {}) {++async function list(db, { limit = 100 } = {}) {ensureTables(db);-- return db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);++ return await db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);}module.exports = { ensureTables, receive, list, operatorUserIds, REMIND_MS, FLAP_MS };@@@ -90,11 +90,11 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,}/** The one place an avatar changes. `origin` names who asked ('network', 'live'), so we never echo a push back. */-- function apply(userId, username, url, origin) {-- const before = (getUrl.get(userId) || {}).avatar_url || null;++ async function apply(userId, username, url, origin) {++ const before = (await getUrl.get(userId) || {}).avatar_url || null;if (before === url) return false;-- setUrl.run(url, userId);-- try { db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(userId, 'avatar_change', JSON.stringify({ from: before, to: url, origin }), null); } catch { /* audit is best effort */ }++ await setUrl.run(url, userId);++ try { await db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(userId, 'avatar_change', JSON.stringify({ from: before, to: url, origin }), null); } catch { /* audit is best effort */ }if (origin !== 'live') pushToSites(userId, username, url);return true;}@@@ -108,15 +108,15 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,if (!got.ok) return res.status(got.status || 422).json({ error: got.error });n.url = got.url; n.paste = got.slug;} else if (n.url) { const v = await verifyImage(n.url); if (!v.ok) return res.status(422).json({ error: v.error }); }-- apply(req.user.id, req.user.username, n.url, 'network');++ await apply(req.user.id, req.user.username, n.url, 'network');res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });});-- api.delete('/', requireAuth, (req, res) => { apply(req.user.id, req.user.username, null, 'network'); res.json({ ok: true, avatar_url: null }); });++ api.delete('/', requireAuth, async (req, res) => { await apply(req.user.id, req.user.username, null, 'network'); res.json({ ok: true, avatar_url: null }); });const pub = express.Router();-- pub.get('/:username', (req, res) => {++ pub.get('/:username', async (req, res) => {const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);-- const row = byName.get(name);++ const row = await byName.get(name);res.set({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400', 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });const ok = row && row.avatar_url && !!normalizeAvatar(row.avatar_url).url;if (ok) return res.redirect(302, row.avatar_url);@@@ -124,11 +124,11 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,});/** Live changed someone's picture: adopt it (same rules, no verification round trip for our own media host). */-- function fromSite(body) {++ async function fromSite(body) {const id = parseInt(body && body.user_id, 10); if (!id) return { status: 400, error: 'user_id required' };const n = normalizeAvatar(body.avatar_url); if (n.error || n.ingest) return { status: 422, error: n.error || 'Sites report openvibe.media pictures only' };-- const u = db.prepare('SELECT username FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };-- return { status: 200, changed: apply(id, u.username, n.url, String(body.origin || 'live')) };++ const u = await db.prepare('SELECT username FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };++ return { status: 200, changed: await apply(id, u.username, n.url, String(body.origin || 'live')) };}return { api, pub, fromSite, apply, log };@@@ -41,13 +41,13 @@ function publicFromPrivate(privateKey) {return ecdh.getPublicKey().toString('base64url');}--function initVapid(db) {++async function initVapid(db) {_db = db;if (!webpush) return;-- const upsert = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');++ const upsert = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');// Both are environment-first (VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY, server/secrets.js), else site_settings.-- let publicKey = db.getSetting('vapid_public_key');-- let privateKey = db.getSetting('vapid_private_key');++ let publicKey = await db.getSetting('vapid_public_key');++ let privateKey = await db.getSetting('vapid_private_key');// The public key is derived from the private one, so a private key alone (or a stale public key// beside it) still gives a matching pair; a new pair is made only when there is no private key.@@@ -64,13 +64,13 @@ function initVapid(db) {const keys = webpush.generateVAPIDKeys();publicKey = keys.publicKey;privateKey = keys.privateKey;-- upsert.run('vapid_public_key', publicKey, 'secret');-- upsert.run('vapid_private_key', privateKey, 'secret');++ await upsert.run('vapid_public_key', publicKey, 'secret');++ await upsert.run('vapid_private_key', privateKey, 'secret');_publicKey = publicKey;console.log('[push] Generated new VAPID keypair');}-- const contactEmail = db.getSetting('vapid_contact_email') || 'mailto:[email protected]';++ const contactEmail = await db.getSetting('vapid_contact_email') || 'mailto:[email protected]';webpush.setVapidDetails(contactEmail, publicKey, privateKey);console.log('[push] VAPID initialized');}@@@ -78,40 +78,41 @@ function initVapid(db) {/*** Get the public VAPID key for client subscription.*/--function getPublicKey() {-- return _publicKey || _db?.getSetting('vapid_public_key') || null;++async function getPublicKey() {++ return _publicKey || await _db?.getSetting('vapid_public_key') || null;}/*** Save a push subscription for a user.*/--function subscribe(userId, subscription) {++async function subscribe(userId, subscription) {if (!_db) throw new Error('Push service not initialized');if (!subscription?.endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {throw new Error('Invalid push subscription');}if (String(subscription.endpoint).length > 1000 || !endpointAllowed(subscription.endpoint)) throw new Error('Invalid push subscription endpoint');const stmt = _db.prepare(`-- INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth, user_agent, created_at)-- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)++ INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth, user_agent, created_at)++ VALUES (?, ?, ?, ?, ?, ov_now())++ ON CONFLICT (endpoint) DO UPDATE SET user_id = excluded.user_id, keys_p256dh = excluded.keys_p256dh, keys_auth = excluded.keys_auth, user_agent = excluded.user_agent, created_at = excluded.created_at`);-- stmt.run(userId, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, subscription.userAgent || null);++ await stmt.run(userId, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, subscription.userAgent || null);}/*** Remove a push subscription.*/--function unsubscribe(userId, endpoint) {++async function unsubscribe(userId, endpoint) {if (!_db) return;-- _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(userId, endpoint);++ await _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(userId, endpoint);}/*** Remove all subscriptions for a user.*/--function unsubscribeAll(userId) {++async function unsubscribeAll(userId) {if (!_db) return;-- _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);++ await _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);}/**@@@ -122,7 +123,7 @@ function unsubscribeAll(userId) {async function sendPush(userId, payload) {if (!webpush || !_db) return;-- const subs = _db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);++ const subs = await _db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);if (!subs.length) return;const pushPayload = JSON.stringify({@@@ -151,7 +152,7 @@ async function sendPush(userId, payload) {// Cleanup stale subscriptionsif (stale.length) {const placeholders = stale.map(() => '?').join(',');-- _db.prepare(`DELETE FROM push_subscriptions WHERE id IN (${placeholders})`).run(...stale);++ await _db.prepare(`DELETE FROM push_subscriptions WHERE id IN (${placeholders})`).run(...stale);}}@@@ -161,7 +162,7 @@ async function sendPush(userId, payload) {async function sendPushBulk(userIds, payload) {if (!webpush || !userIds.length) return;// Fire all in parallel, don't block-- await Promise.allSettled(userIds.map(uid => sendPush(uid, payload)));++ await Promise.allSettled(userIds.map(async uid => await sendPush(uid, payload)));}module.exports = { initVapid, getPublicKey, subscribe, unsubscribe, unsubscribeAll, sendPush, sendPushBulk, endpointAllowed };@@@ -7,18 +7,18 @@ const pushService = require('./push-service');// All routes require auth (middleware applied by parent)/** GET /api/push/vapid-key — return the public VAPID key for client subscription */--router.get('/vapid-key', (req, res) => {-- const key = pushService.getPublicKey();++router.get('/vapid-key', async (req, res) => {++ const key = await pushService.getPublicKey();if (!key) return res.status(503).json({ error: 'Push notifications not configured' });res.json({ publicKey: key });});/** POST /api/push/subscribe — save a PushSubscription */--router.post('/subscribe', (req, res) => {++router.post('/subscribe', async (req, res) => {try {const { subscription } = req.body;if (!subscription) return res.status(400).json({ error: 'Missing subscription' });-- pushService.subscribe(req.user.id, subscription);++ await pushService.subscribe(req.user.id, subscription);res.json({ success: true });} catch (err) {res.status(400).json({ error: err.message });@@@ -26,17 +26,17 @@ router.post('/subscribe', (req, res) => {});/** POST /api/push/unsubscribe — remove a PushSubscription */--router.post('/unsubscribe', (req, res) => {++router.post('/unsubscribe', async (req, res) => {const { endpoint } = req.body;if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });-- pushService.unsubscribe(req.user.id, endpoint);++ await pushService.unsubscribe(req.user.id, endpoint);res.json({ success: true });});/** GET /api/push/status — check subscription + live notification preferences */--router.get('/status', (req, res) => {++router.get('/status', async (req, res) => {const db = req.app.locals.db;-- const subs = db.prepare('SELECT id, endpoint, created_at FROM push_subscriptions WHERE user_id = ?').all(req.user.id);++ const subs = await db.prepare('SELECT id, endpoint, created_at FROM push_subscriptions WHERE user_id = ?').all(req.user.id);const notifService = req.app.locals.notificationService;const streamPref = notifService? notifService._getPrefByCategory.get(req.user.id, 'stream') || { enabled: 1, sound: 1, toasts: 1 }@@@ -57,13 +57,13 @@ router.get('/status', (req, res) => {});/** PUT /api/push/live-preferences — toggle live notification mode */--router.put('/live-preferences', (req, res) => {++router.put('/live-preferences', async (req, res) => {const { stream_live_all } = req.body;const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });if (stream_live_all !== undefined) {-- notifService.setPreference(req.user.id, 'stream_live_all', {++ await notifService.setPreference(req.user.id, 'stream_live_all', {enabled: !!stream_live_all,sound: 1,toasts: 1,@@@ -9,42 +9,32 @@const express = require('express');const ensured = new WeakSet();--function ensureSchema(db) {-- if (ensured.has(db)) return;-- ensured.add(db);-- db.exec(`CREATE TABLE IF NOT EXISTS platform_nodes (-- id TEXT PRIMARY KEY,-- source TEXT NOT NULL,-- doc TEXT NOT NULL,-- status TEXT NOT NULL,-- reported_at TEXT NOT NULL-- )`);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }/** Apply a report: upsert every node; mark this source's other nodes down. → { nodes, marked_down } */--function report(db, { source, nodes }, now = new Date().toISOString()) {++async function report(db, { source, nodes }, now = new Date().toISOString()) {ensureSchema(db);const upsert = db.prepare(`INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES (?, ?, ?, ?, ?)ON CONFLICT(id) DO UPDATE SET source = excluded.source, doc = excluded.doc, status = excluded.status, reported_at = excluded.reported_at`);let marked = 0;-- db.transaction(() => {-- for (const n of nodes) upsert.run(n.id, source, JSON.stringify(n), n.health.status, now);++ await db.tx(async () => {++ for (const n of nodes) await upsert.run(n.id, source, JSON.stringify(n), n.health.status, now);const ids = new Set(nodes.map((n) => n.id));-- for (const row of db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {++ for (const row of await db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {if (ids.has(row.id)) continue;const doc = JSON.parse(row.doc);doc.health = { status: 'down', checked_at: now };doc.updated_at = now;-- db.prepare('UPDATE platform_nodes SET doc = ?, status = ? WHERE id = ?').run(JSON.stringify(doc), 'down', row.id);++ await db.prepare('UPDATE platform_nodes SET doc = ?, status = ? WHERE id = ?').run(JSON.stringify(doc), 'down', row.id);marked++;}-- })();++ });return { nodes: nodes.length, marked_down: marked };}--function list(db, { role = null, region = null } = {}) {++async function list(db, { role = null, region = null } = {}) {ensureSchema(db);-- return db.prepare('SELECT doc FROM platform_nodes ORDER BY id').all().map((r) => JSON.parse(r.doc))++ return (await db.prepare('SELECT doc FROM platform_nodes ORDER BY id').all()).map((r) => JSON.parse(r.doc)).filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));}@@@ -52,22 +42,22 @@ function routers({ guard }) {const pub = express.Router();const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store').set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');-- pub.get('/', (req, res) => {++ pub.get('/', async (req, res) => {const role = typeof req.query.role === 'string' ? req.query.role : null;const region = typeof req.query.region === 'string' ? req.query.region : null;-- open(res, 60).json({ nodes: list(req.app.locals.db, { role, region }), generated_at: new Date().toISOString() });++ open(res, 60).json({ nodes: await list(req.app.locals.db, { role, region }), generated_at: new Date().toISOString() });});// A beacon a browser times to estimate its distance from a node (openvibe-sdk/geo). Answered by the node that// serves it; a report names another URL for nodes whose traffic does not come through here.pub.get('/:id/beacon', (req, res) => open(res, 0).status(204).end());const internal = express.Router();-- internal.post('/report', guard, express.json({ limit: '256kb' }), (req, res) => {++ internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {const { validate } = require('openvibe-contracts');const v = validate('network.node-report-request@1', req.body);if (!v.valid) return res.status(400).json({ error: 'The body does not match network.node-report-request@1', details: (v.errors || []).slice(0, 5) });-- const out = report(req.app.locals.db, req.body);++ const out = await report(req.app.locals.db, req.body);res.set('X-Nodes-Marked-Down', String(out.marked_down))-- .json({ nodes: list(req.app.locals.db), generated_at: new Date().toISOString() });++ .json({ nodes: await list(req.app.locals.db), generated_at: new Date().toISOString() });});return { pub, internal };}@@@ -1,56 +1,39 @@'use strict';----const path = require('path');++// ═══════════════════════════════════════════════════════════════++// reset-db — development/owner convenience: throw away the local database and let the next boot recreate++// the schema. Under PostgreSQL there is no file to remove: in production (DATABASE_URL set) this refuses++// and tells the operator what to run instead; without it (the embedded PGlite database of a dev/test++// process) the PGlite directory is moved aside, so the next start migrates a fresh one.++// ═══════════════════════════════════════════════════════════════const fs = require('fs');--const dotenv = require('dotenv');----function loadEnv() {-- const envPath = path.resolve(process.cwd(), '.env');-- if (fs.existsSync(envPath)) {-- const parsed = dotenv.config({ path: envPath });-- if (parsed.error) {-- throw parsed.error;-- }-- return parsed.parsed || {};-- }-- return {};--}++const path = require('path');++const config = require('./config');--function resolveDatabasePath(env) {-- if (env.DB_PATH) {-- return path.resolve(process.cwd(), env.DB_PATH);++async function main({ log = console, cfg = config } = {}) {++ if (cfg.db.url) {++ log.error('reset-db: the database is PostgreSQL (DATABASE_URL is set); there is no file to remove.');++ log.error("Drop and recreate the schema as the owner instead, e.g. psql \"$DATABASE_DIRECT_URL\" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' — or point DATABASE_URL at a scratch database and re-run scripts/migrate-to-postgres.js.");++ return 1;}-- return path.resolve(__dirname, '..', 'data', 'network.db');--}----function main() {-- const env = loadEnv();-- const dbPath = resolveDatabasePath(env);-- if (!fs.existsSync(dbPath)) {-- console.log(`No database file found at ${dbPath}. Nothing to reset.`);-- process.exit(0);++ const dir = path.resolve(process.cwd(), cfg.db.pgliteDir);++ if (!fs.existsSync(dir)) {++ log.log(`No embedded database at ${dir}. Nothing to reset.`);++ return 0;}-- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');-- const backupPath = `${dbPath}.backup.${timestamp}`;--++ const backup = `${dir}.backup.${new Date().toISOString().replace(/[:.]/g, '-')}`;try {-- fs.copyFileSync(dbPath, backupPath);-- fs.unlinkSync(dbPath);-- for (const suffix of ['-wal', '-shm']) {-- const journal = `${dbPath}${suffix}`;-- if (fs.existsSync(journal)) {-- fs.unlinkSync(journal);-- }-- }-- console.log(`Backed up existing database to ${backupPath}`);-- console.log(`Database reset complete. Restart the openvibe-network service to recreate the schema.`);-- process.exit(0);++ fs.renameSync(dir, backup);++ log.log(`Moved the embedded database to ${backup}`);++ log.log('Reset complete. Restart the openvibe-network service to recreate the schema (migrations run at boot).');++ return 0;} catch (err) {-- console.error(`Failed to reset database: ${err.message}`);-- process.exit(1);++ log.error(`Failed to reset database: ${err.message}`);++ return 1;}}--main();++if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`Failed to reset database: ${err.message}`); process.exit(1); });++++module.exports = { main };@@@ -52,27 +52,27 @@ const isManaged = (key) => BY_KEY.has(key);const isSecret = (key) => SECRETS.some(s => s.key === key);const envName = (key) => (BY_KEY.get(key) || {}).env || null;--function dbValue(db, key) {-- try { const r = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key); return r && r.value ? String(r.value) : ''; } catch { return ''; }++async function dbValue(db, key) {++ try { const r = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key); return r && r.value ? String(r.value) : ''; } catch { return ''; }}/** 'env' | 'database' | 'unset' for a managed key. */--function source(db, key, env = process.env) {++async function source(db, key, env = process.env) {if (fromEnv(key, env) !== null) return 'env';-- return dbValue(db, key) ? 'database' : 'unset';++ return (await dbValue(db, key)) ? 'database' : 'unset';}/** Where each secret comes from, by name only: [{ key, env, source, database_copy, use }]. Never values. */--function report(db, env = process.env) {-- return [...SECRETS, ...COMPANIONS].map(s => ({-- key: s.key, env: s.env, secret: isSecret(s.key), source: source(db, s.key, env),-- database_copy: !!dbValue(db, s.key), use: s.use,-- }));++async function report(db, env = process.env) {++ return await Promise.all([...SECRETS, ...COMPANIONS].map(async s => ({++ key: s.key, env: s.env, secret: isSecret(s.key), source: await source(db, s.key, env),++ database_copy: !!(await dbValue(db, s.key)), use: s.use,++ })));}/** One boot line: "resend_api_key=env discord_bot_token=database ..." (names and sources only). */--function summary(db, env = process.env) {-- return report(db, env).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');++async function summary(db, env = process.env) {++ return (await report(db, env)).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');}module.exports = { SECRETS, COMPANIONS, UNUSED, fromEnv, isManaged, isSecret, envName, source, report, summary };@@@ -26,8 +26,8 @@ function redactResolvedRegistry(resolved) {function createSetupRoutes(db, config) {const router = express.Router();-- function hasAdminUser() {-- return !!db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();++ async function hasAdminUser() {++ return !!await db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();}function isValidSetupToken(req) {@@@ -35,8 +35,8 @@ function createSetupRoutes(db, config) {return config.setupToken && token && token === config.setupToken;}-- function requireSetupMode(req, res, next) {-- if (!hasAdminUser()) return next();++ async function requireSetupMode(req, res, next) {++ if (!await hasAdminUser()) return next();if (isValidSetupToken(req)) return next();return res.status(403).json({ ok: false, error: 'Setup locked. Provide a valid setup token or log in as an admin.' });}@@@ -72,12 +72,12 @@ function createSetupRoutes(db, config) {return required.filter(key => !resolvedRegistry[key]?.value).map(key => `${key} is missing or invalid.`);}-- function buildSetupStatus() {-- const resolvedRegistry = urlRegistry.getResolvedRegistry(db, process.env);++ async function buildSetupStatus() {++ const resolvedRegistry = await urlRegistry.getResolvedRegistry(db, process.env);return {-- adminExists: hasAdminUser(),++ adminExists: await hasAdminUser(),setupTokenConfigured: Boolean(config.setupToken),-- registrySeeded: urlRegistry.isRegistrySeeded(db),++ registrySeeded: await urlRegistry.isRegistrySeeded(db),bootstrapProfile: config.bootstrapProfile,warnings: getSetupWarnings(resolvedRegistry),issues: getSetupIssues(resolvedRegistry),@@@ -85,39 +85,39 @@ function createSetupRoutes(db, config) {};}-- router.get('/status', (req, res) => {++ router.get('/status', async (req, res) => {try {-- res.json({ ok: true, status: buildSetupStatus() });++ res.json({ ok: true, status: await buildSetupStatus() });} catch (err) {res.status(500).json({ ok: false, error: err.message });}});-- router.post('/bootstrap', requireSetupMode, (req, res) => {++ router.post('/bootstrap', requireSetupMode, async (req, res) => {try {const profile = String(req.body.profile || config.bootstrapProfile || 'local-dev');const validProfiles = ['local-dev', 'single-node-prod'];if (!validProfiles.includes(profile)) {return res.status(400).json({ ok: false, error: 'Invalid bootstrap profile' });}-- urlRegistry.seedBootstrapRegistry(db, process.env, profile);-- const status = buildSetupStatus();++ await urlRegistry.seedBootstrapRegistry(db, process.env, profile);++ const status = await buildSetupStatus();return res.json({ ok: true, message: 'Bootstrap completed', status });} catch (err) {res.status(500).json({ ok: false, error: err.message });}});-- router.post('/admin', requireSetupMode, (req, res) => {++ router.post('/admin', requireSetupMode, async (req, res) => {try {const { username, password } = req.body;if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password are required' });-- if (hasAdminUser() && !isValidSetupToken(req)) {++ if (await hasAdminUser() && !isValidSetupToken(req)) {return res.status(403).json({ ok: false, error: 'Admin account already exists' });}const normalizedUsername = String(username).trim().toLowerCase();const passwordHash = bcrypt.hashSync(String(password), 10);-- db.prepare(`++ await db.prepare(`INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)ON CONFLICT(username) DO UPDATE SET@@@ -146,7 +146,7 @@ function createSetupRoutes(db, config) {tools_subdomain_base: 'TOOLS_SUBDOMAIN_BASE',extra_origins: 'ALLOWED_EXTRA_ORIGINS',};-- router.post('/identity', requireSetupMode, (req, res) => {++ router.post('/identity', requireSetupMode, async (req, res) => {try {const updated = {};for (const [bodyKey, registryKey] of Object.entries(IDENTITY_FIELD_MAP)) {@@@ -174,7 +174,7 @@ function createSetupRoutes(db, config) {if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {continue;}-- const entry = urlRegistry.setRegistryEntry(db, registryKey, value, null);++ const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, null);updated[registryKey] = entry.value;}return res.json({ ok: true, updated, message: 'Identity config saved' });@@@ -187,7 +187,7 @@ function createSetupRoutes(db, config) {// POST /api/setup/urls — configure network URLs during first-time setup.// Writes the provided values into the url_registry with source='admin'.// Only keys that exist in URL_DEFINITIONS are accepted.-- router.post('/urls', requireSetupMode, (req, res) => {++ router.post('/urls', requireSetupMode, async (req, res) => {try {const allowedKeys = new Set(Object.keys(URL_DEFINITIONS));const updated = {};@@@ -198,13 +198,13 @@ function createSetupRoutes(db, config) {continue;}try {-- const entry = urlRegistry.setRegistryEntry(db, key, value, null);++ const entry = await urlRegistry.setRegistryEntry(db, key, value, null);updated[key] = entry.value;} catch (err) {rejected[key] = err.message;}}-- const status = buildSetupStatus();++ const status = await buildSetupStatus();return res.json({ ok: true, updated, rejected, status });} catch (err) {res.status(500).json({ ok: false, error: err.message });@@@ -233,7 +233,7 @@ function createSetupRoutes(db, config) {service_map: 'DEPLOY_SERVICE_MAP',};-- router.post('/deploy', requireSetupMode, (req, res) => {++ router.post('/deploy', requireSetupMode, async (req, res) => {try {const updated = {};const errors = {};@@@ -280,7 +280,7 @@ function createSetupRoutes(db, config) {if (value === undefined || value === null) continue;try {-- const entry = urlRegistry.setRegistryEntry(db, registryKey, value, null);++ const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, null);updated[registryKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;} catch (err) {errors[bodyKey] = err.message;@@@ -303,9 +303,9 @@ function createSetupRoutes(db, config) {});// GET /api/setup/deploy-status — check deploy prerequisites and current config-- router.get('/deploy-status', (req, res) => {++ router.get('/deploy-status', async (req, res) => {try {-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);++ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);const prereqs = certManager.checkPrerequisites();res.json({@@@ -3,19 +3,18 @@/*** Provision or rotate a service principal's client credentials (roadmap Wave 1, ADR-003).*-- * sudo node server/setup/service-principal.js create <id> --env-file /etc/openvibe/<id>.env [--db data/network.db]++ * sudo node server/setup/service-principal.js create <id> --env-file /etc/openvibe/<id>.env* sudo node server/setup/service-principal.js rotate <id> --env-file /etc/openvibe/<id>.env* node server/setup/service-principal.js list** A service principal is an OAuth client with no redirect URIs (it can only use client_credentials).* The secret is written straight into the service's env file as OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET* (file mode 0600) and is never printed. Grants are separate (principal_grants); this only makes the-- * identity exist.++ * identity exist. The database is DATABASE_URL (PostgreSQL); there is no SQLite path (plan T2).*/const fs = require('fs');--const path = require('path');const crypto = require('crypto');--const Database = require('better-sqlite3');++const { initDb } = require('../db/database');const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;@@@ -29,14 +28,13 @@ function upsertEnv(file, pairs) {fs.chmodSync(file, 0o600);}--function main(argv) {++async function main(argv) {const [cmd, id] = argv;const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };-- const dbPath = path.resolve(opt('db') || process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'network.db'));-- const db = new Database(dbPath, { fileMustExist: true });-- try {++ const db = await initDb();++ {if (cmd === 'list') {-- for (const r of db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {++ for (const r of await db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {const kind = JSON.parse(r.redirect_uris || '[]').length ? 'site' : 'service';console.log(`${r.client_id}\t${kind}\t${r.name}`);}@@@ -49,20 +47,20 @@ function main(argv) {const envFile = opt('env-file');if (!envFile) { console.error('--env-file is required (the secret is written there, never printed)'); return 2; }const secret = crypto.randomBytes(32).toString('base64url');-- const existing = db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);++ const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);if (cmd === 'create') {if (existing) { console.error(`${id} already exists; use rotate`); return 1; }-- db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)")++ await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)").run(id, secret, `OpenVibe.${id} (service)`);} else {if (!existing) { console.error(`${id} does not exist; use create`); return 1; }-- db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, id);++ await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, id);}upsertEnv(envFile, { OV_OAUTH_CLIENT_ID: id, OV_OAUTH_CLIENT_SECRET: secret });console.log(`${cmd === 'create' ? 'created' : 'rotated'} service principal ${id}; credentials written to ${envFile} (0600). Restart the service to pick them up.`);return 0;-- } finally { db.close(); }++ }}--if (require.main === module) process.exitCode = main(process.argv.slice(2));++if (require.main === module) main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; });module.exports = { main, upsertEnv };@@@ -26,51 +26,29 @@ class IncidentError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}--function ensureSchema(db) {-- db.exec(`CREATE TABLE IF NOT EXISTS status_incidents (-- id TEXT PRIMARY KEY,-- kind TEXT NOT NULL CHECK (kind IN ('incident', 'maintenance')),-- title TEXT NOT NULL,-- severity TEXT CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),-- state TEXT NOT NULL,-- services TEXT NOT NULL DEFAULT '[]',-- starts_at TEXT NOT NULL,-- ends_at TEXT,-- created_by TEXT NOT NULL,-- created_at TEXT NOT NULL,-- updated_at TEXT NOT NULL-- );-- CREATE TABLE IF NOT EXISTS status_incident_updates (-- incident_id TEXT NOT NULL REFERENCES status_incidents(id),-- at TEXT NOT NULL,-- state TEXT NOT NULL,-- message TEXT NOT NULL,-- author TEXT NOT NULL-- );-- CREATE INDEX IF NOT EXISTS idx_status_incident_updates ON status_incident_updates(incident_id, at);`);--}++function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }const statesOf = (kind) => (kind === 'maintenance' ? MAINTENANCE_STATES : INCIDENT_STATES);--function present(db, row) {++async function present(db, row) {if (!row) return null;const out = {id: row.id, kind: row.kind, title: row.title, ...(row.severity ? { severity: row.severity } : {}), state: row.state,services: JSON.parse(row.services || '[]'), starts_at: row.starts_at, ends_at: row.ends_at || null,-- updates: db.prepare('SELECT at, state, message FROM status_incident_updates WHERE incident_id = ? ORDER BY at, rowid').all(row.id),++ updates: await db.prepare('SELECT at, state, message FROM status_incident_updates WHERE incident_id = ? ORDER BY at, seq').all(row.id),created_at: row.created_at, updated_at: row.updated_at,};return out;}--function get(db, id) { ensureSchema(db); return present(db, db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id))); }++async function get(db, id) { ensureSchema(db); return await present(db, await db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id))); }/** Active: open incidents, and maintenance windows not completed. Recent: closed within 30 days. */--function list(db, { now = Date.now() } = {}) {++async function list(db, { now = Date.now() } = {}) {ensureSchema(db);const since = new Date(now - RECENT_DAYS * 86400000).toISOString();-- const active = db.prepare("SELECT * FROM status_incidents WHERE state NOT IN ('resolved', 'completed') ORDER BY starts_at DESC").all().map((r) => present(db, r));-- const recent = db.prepare("SELECT * FROM status_incidents WHERE state IN ('resolved', 'completed') AND updated_at >= ? ORDER BY updated_at DESC LIMIT 50").all(since).map((r) => present(db, r));++ const active = (await Promise.all((await db.prepare("SELECT * FROM status_incidents WHERE state NOT IN ('resolved', 'completed') ORDER BY starts_at DESC").all()).map(async (r) => await present(db, r))));++ const recent = (await Promise.all((await db.prepare("SELECT * FROM status_incidents WHERE state IN ('resolved', 'completed') AND updated_at >= ? ORDER BY updated_at DESC LIMIT 50").all(since)).map(async (r) => await present(db, r))));return { active, recent };}@@@ -80,7 +58,7 @@ function check(schema, body) {}/** Open an incident or maintenance window (body: network.status-incident-request@1, the open form). */--function open(db, body, author, { now = new Date().toISOString() } = {}) {++async function open(db, body, author, { now = new Date().toISOString() } = {}) {ensureSchema(db);check('network.status-incident-request@1', body);if (!body.kind) throw new IncidentError(400, 'status.invalid_request', 'kind, title, services and message open an incident');@@@ -92,30 +70,30 @@ function open(db, body, author, { now = new Date().toISOString() } = {}) {const startsAt = body.starts_at ? new Date(body.starts_at).toISOString() : now;const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : null;if (endsAt && endsAt < startsAt) throw new IncidentError(400, 'status.invalid_window', 'ends_at is before starts_at');-- db.transaction(() => {-- db.prepare(`INSERT INTO status_incidents (id, kind, title, severity, state, services, starts_at, ends_at, created_by, created_at, updated_at)++ await db.tx(async () => {++ await db.prepare(`INSERT INTO status_incidents (id, kind, title, severity, state, services, starts_at, ends_at, created_by, created_at, updated_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, kind, body.title.trim(), kind === 'incident' ? (body.severity || 'minor') : null, state,JSON.stringify([...new Set(body.services)]), startsAt, endsAt, author, now, now);-- db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(id, now, state, body.message.trim(), author);-- })();-- return get(db, id);++ await db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(id, now, state, body.message.trim(), author);++ });++ return await get(db, id);}/** Add an update (body: the update form): a new state and message; ends_at for a window. Closed ones refuse. */--function update(db, id, body, author, { now = new Date().toISOString() } = {}) {++async function update(db, id, body, author, { now = new Date().toISOString() } = {}) {ensureSchema(db);check('network.status-incident-request@1', body);if (!body.state || body.kind) throw new IncidentError(400, 'status.invalid_request', 'an update is { state, message, ends_at? }');-- const row = db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id));++ const row = await db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id));if (!row) throw new IncidentError(404, 'status.incident_not_found', 'no such incident');if (CLOSED.has(row.state)) throw new IncidentError(409, 'status.incident_closed', `this ${row.kind} is ${row.state}`);if (!statesOf(row.kind).includes(body.state)) throw new IncidentError(400, 'status.invalid_state', `a ${row.kind} is one of ${statesOf(row.kind).join(', ')}`);const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : (CLOSED.has(body.state) ? now : row.ends_at);-- db.transaction(() => {-- db.prepare('UPDATE status_incidents SET state = ?, ends_at = ?, updated_at = ? WHERE id = ?').run(body.state, endsAt, now, row.id);-- db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(row.id, now, body.state, body.message.trim(), author);-- })();-- return get(db, row.id);++ await db.tx(async () => {++ await db.prepare('UPDATE status_incidents SET state = ?, ends_at = ?, updated_at = ? WHERE id = ?').run(body.state, endsAt, now, row.id);++ await db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(row.id, now, body.state, body.message.trim(), author);++ });++ return await get(db, row.id);}/**@@@ -125,15 +103,15 @@ function update(db, id, body, author, { now = new Date().toISOString() } = {}) {function router({ requireAuth, incidentGuard }) {const r = express.Router();r.use(http.middleware());-- r.get('/', (req, res) => {-- res.set('Cache-Control', 'public, max-age=30').json(list(req.app.locals.db));++ r.get('/', async (req, res) => {++ res.set('Cache-Control', 'public, max-age=30').json(await list(req.app.locals.db));});-- const writer = (req, res, next) => {++ const writer = async (req, res, next) => {const h = String(req.headers.authorization || '');const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);let user = null;if (token) {-- try { const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config }); user = out && out.user; } catch { user = null; }++ try { const out = await require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config }); user = out && out.user; } catch { user = null; }}if (user) {if (user.role !== 'admin') return http.sendProblem(res, 403, 'status.staff_only', { detail: 'only staff admins post incidents', ctx: req.ov });@@@ -142,16 +120,16 @@ function router({ requireAuth, incidentGuard }) {}return incidentGuard(req, res, () => { req.statusAuthor = req.principal ? String(req.principal.sub) : 'service'; next(); });};-- const send = (res, req, fn, status = 200) => {++ const send = async (res, req, fn, status = 200) => {res.set('Cache-Control', 'no-store');-- try { res.status(status).json(fn()); } catch (err) {++ try { res.status(status).json(await fn()); } catch (err) {if (err instanceof IncidentError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });console.error('[Status incidents]', err.message);return http.sendProblem(res, 500, 'status.failed', { detail: 'the incident could not be saved', ctx: req.ov });}};-- r.post('/', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => open(req.app.locals.db, req.body || {}, req.statusAuthor), 201));-- r.post('/:id/updates', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => update(req.app.locals.db, req.params.id, req.body || {}, req.statusAuthor)));++ r.post('/', express.json({ limit: '16kb' }), writer, async (req, res) => await send(res, req, async () => await open(req.app.locals.db, req.body || {}, req.statusAuthor), 201));++ r.post('/:id/updates', express.json({ limit: '16kb' }), writer, async (req, res) => await send(res, req, async () => await update(req.app.locals.db, req.params.id, req.body || {}, req.statusAuthor)));return r;}@@@ -259,11 +259,11 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {r.get('/api/v1/status/slo', (_req, res) => {res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(loadSlo());});-- r.get('/status', (_req, res) => {++ r.get('/status', async (_req, res) => {const slo = { ...loadSlo(), pollSeconds: ecosystem.pollMs / 1000 };res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-cache, max-age=0').set('X-Robots-Tag', 'noindex, nofollow');let incidents = null;-- try { incidents = require('./incidents').list(_req.app.locals.db); } catch { incidents = null; }++ try { incidents = await require('./incidents').list(_req.app.locals.db); } catch { incidents = null; }res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null, incidents));});return r;@@@ -16,13 +16,7 @@ const router = express.Router();const EXPORT_FORMAT = 'openvibe-theme@1';const MAX_PENDING = 5;let reviewReady = false;--function ensureReview(db) {-- if (reviewReady) return;-- for (const col of ["review_status TEXT NOT NULL DEFAULT 'approved'", 'reviewed_by INTEGER', 'reviewed_at DATETIME', 'review_note TEXT']) {-- try { db.exec(`ALTER TABLE themes ADD COLUMN ${col}`); } catch { /* already there */ }-- }-- reviewReady = true;--}++function ensureReview(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }const parseTheme = (t) => {if (!t) return t;try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }@@@ -35,14 +29,14 @@ const visibleTo = (t, userId) => t && ((t.is_public === 1 && t.review_status ===function getDb(req) { return req.app.locals.db; }function getConfig(req) { return req.app.locals.config; }--function optionalAuth(req, res, next) {++async function optionalAuth(req, res, next) {// Sliding sessions (server/auth/session.js): renewable tokens count, and get renewed.const authHeader = req.headers.authorization;const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;if (!token) return next();try {const { verifySession, COOKIE } = require('../auth/session');-- const out = verifySession(token, { db: getDb(req), publicKey: req.app.locals.publicKey, config: req.app.locals.config });++ const out = await verifySession(token, { db: await getDb(req), publicKey: req.app.locals.publicKey, config: req.app.locals.config });if (out.error) return next();// Routes here read both req.user.sub (JWT shape) and req.user.id (row shape) — give them both.req.user = { ...out.decoded, ...out.user, sub: out.user.id, id: out.user.id }; req.token = token;@@@ -53,16 +47,16 @@ function optionalAuth(req, res, next) {next();}--function requireAuth(req, res, next) {-- optionalAuth(req, res, () => {++async function requireAuth(req, res, next) {++ await optionalAuth(req, res, () => {if (!req.user) return res.status(401).json({ error: 'Authentication required' });next();});}// ── List Themes ──────────────────────────────────────────────--router.get('/', (req, res) => {-- const db = getDb(req);++router.get('/', async (req, res) => {++ const db = await getDb(req);ensureReview(db);const { mode, search, sort, limit } = req.query;@@@ -83,7 +77,7 @@ router.get('/', (req, res) => {sql += ` LIMIT ?`;params.push(Math.min(parseInt(limit, 10) || 100, 500));-- const themes = db.prepare(sql).all(...params);++ const themes = await db.prepare(sql).all(...params);for (const t of themes) {try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }try { t.preview_colors = JSON.parse(t.preview_colors); } catch { t.preview_colors = null; }@@@ -99,16 +93,16 @@ router.get('/me/active', (req, res, next) => {const hasAuth = /^Bearer\s+\S/.test(req.headers.authorization || '') || /(?:^|;\s*)ov_token=/.test(req.headers.cookie || '');if (!hasAuth) return res.set('Cache-Control', 'no-store').json({ theme_id: null, guest: true });next();--}, requireAuth, (req, res) => {-- const db = getDb(req);++}, requireAuth, async (req, res) => {++ const db = await getDb(req);const userId = req.user.sub || req.user.id;-- const prefs = db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);++ const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });let custom = null;try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}let display = null;-- try { const d = db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }++ try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }res.json({ theme_id: prefs.theme_id, custom_variables: custom, display });});@@@ -120,38 +114,38 @@ function cleanDisplay(d) {for (const k of Object.keys(DISPLAY)) if (DISPLAY[k].includes(String(d[k]))) out[k] = String(d[k]);return Object.keys(out).length ? out : null;}--router.put('/me/display', requireAuth, (req, res) => {-- const db = getDb(req);++router.put('/me/display', requireAuth, async (req, res) => {++ const db = await getDb(req);const userId = req.user.sub || req.user.id;const display = cleanDisplay(req.body) || {};-- db.prepare(`INSERT INTO user_preferences (user_id, display_prefs, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)-- ON CONFLICT(user_id) DO UPDATE SET display_prefs = excluded.display_prefs, updated_at = CURRENT_TIMESTAMP`).run(userId, JSON.stringify(display));++ await db.prepare(`INSERT INTO user_preferences (user_id, display_prefs, updated_at) VALUES (?, ?, ov_now())++ ON CONFLICT(user_id) DO UPDATE SET display_prefs = excluded.display_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(display));res.json({ ok: true, display });});// ── Your submissions, with their review status ────────────────--router.get('/me/submissions', requireAuth, (req, res) => {-- const db = getDb(req);++router.get('/me/submissions', requireAuth, async (req, res) => {++ const db = await getDb(req);ensureReview(db);const userId = req.user.sub || req.user.id;-- const themes = db.prepare('SELECT id, name, slug, description, mode, variables, tags, review_status, review_note, reviewed_at, created_at FROM themes WHERE author_id = ? AND is_builtin = 0 ORDER BY created_at DESC LIMIT 50').all(userId).map(parseTheme);++ const themes = (await db.prepare('SELECT id, name, slug, description, mode, variables, tags, review_status, review_note, reviewed_at, created_at FROM themes WHERE author_id = ? AND is_builtin = 0 ORDER BY created_at DESC LIMIT 50').all(userId)).map(parseTheme);res.set('Cache-Control', 'private, no-store').json({ themes });});// ── Get Theme by ID or Slug (approved themes; your own at any status) ──--router.get('/:idOrSlug', optionalAuth, (req, res) => {-- const db = getDb(req);++router.get('/:idOrSlug', optionalAuth, async (req, res) => {++ const db = await getDb(req);ensureReview(db);-- const theme = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);++ const theme = await db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);if (!visibleTo(theme, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });res.json({ theme: parseTheme(theme) });});// ── Export a theme as a file (import it elsewhere, or submit an edited copy) ──--router.get('/:idOrSlug/export', optionalAuth, (req, res) => {-- const db = getDb(req);++router.get('/:idOrSlug/export', optionalAuth, async (req, res) => {++ const db = await getDb(req);ensureReview(db);-- const t = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);++ const t = await db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);if (!visibleTo(t, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });parseTheme(t);const file = { format: EXPORT_FORMAT, name: t.name, slug: t.slug, description: t.description || '', mode: t.mode, variables: t.variables, tags: t.tags };@@@ -159,14 +153,14 @@ router.get('/:idOrSlug/export', optionalAuth, (req, res) => {});// ── Set User's Active Theme ──────────────────────────────────--router.put('/me', requireAuth, (req, res) => {-- const db = getDb(req);++router.put('/me', requireAuth, async (req, res) => {++ const db = await getDb(req);const userId = req.user.sub || req.user.id;const { theme_id, custom_variables } = req.body;ensureReview(db);if (theme_id) {-- const theme = db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);++ const theme = await db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);if (!visibleTo(theme, userId)) return res.status(404).json({ error: 'Theme not found' });}if (custom_variables) {@@@ -175,13 +169,13 @@ router.put('/me', requireAuth, (req, res) => {req.body.custom_variables = v.variables;}-- db.prepare(`++ await db.prepare(`INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)-- VALUES (?, ?, ?, CURRENT_TIMESTAMP)++ VALUES (?, ?, ?, ov_now())ON CONFLICT(user_id) DO UPDATE SET-- theme_id = COALESCE(?, theme_id),++ theme_id = COALESCE(?, user_preferences.theme_id),custom_theme_variables = ?,-- updated_at = CURRENT_TIMESTAMP++ updated_at = ov_now()`).run(userId,theme_id || 'vibe',@@@ -194,7 +188,7 @@ router.put('/me', requireAuth, (req, res) => {});// ── Submit a community theme (pending review) ─────────────────--function submitTheme(db, userId, body) {++async function submitTheme(db, userId, body) {ensureReview(db);const { name, slug, description, mode, variables, tags } = body || {};if (!name || !slug || !variables) return [400, { error: 'name, slug, and variables required' }];@@@ -204,52 +198,52 @@ function submitTheme(db, userId, body) {const v = cleanVariables(variables);if (!v.ok) return [422, { error: 'Theme rejected', code: 'theme.invalid_variables', errors: v.errors }];const cleanTags = Array.isArray(tags) ? tags.filter((t) => typeof t === 'string' && /^[a-z0-9-]{1,24}$/.test(t)).slice(0, 8) : [];-- if (db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug)) return [409, { error: 'A theme with that slug already exists' }];-- const pending = db.prepare("SELECT COUNT(*) AS n FROM themes WHERE author_id = ? AND review_status = 'pending'").get(userId).n;++ if (await db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug)) return [409, { error: 'A theme with that slug already exists' }];++ const pending = (await db.prepare("SELECT COUNT(*) AS n FROM themes WHERE author_id = ? AND review_status = 'pending'").get(userId)).n;if (pending >= MAX_PENDING) return [429, { error: `You have ${pending} themes waiting for review; wait for those first`, code: 'theme.too_many_pending' }];const id = `community-${slug}`;-- db.prepare(`INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags, review_status)++ await db.prepare(`INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags, review_status)VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, 'pending')`).run(id, name.trim(), slug, userId, String(description || '').slice(0, 300), mode || 'dark', JSON.stringify(v.variables), JSON.stringify(cleanTags));-- return [201, { theme: parseTheme(db.prepare('SELECT * FROM themes WHERE id = ?').get(id)), review: 'pending', note: 'Submitted themes are reviewed before anyone else sees them. You can use yours right away.' }];++ return [201, { theme: parseTheme(await db.prepare('SELECT * FROM themes WHERE id = ?').get(id)), review: 'pending', note: 'Submitted themes are reviewed before anyone else sees them. You can use yours right away.' }];}--router.post('/', requireAuth, (req, res) => {-- const [status, body] = submitTheme(getDb(req), req.user.sub || req.user.id, req.body);++router.post('/', requireAuth, async (req, res) => {++ const [status, body] = await submitTheme(await getDb(req), req.user.sub || req.user.id, req.body);res.status(status).json(body);});// ── Import a theme file (openvibe-theme@1, as /:id/export writes it): a submission like any other ──--router.post('/import', requireAuth, (req, res) => {++router.post('/import', requireAuth, async (req, res) => {const file = req.body || {};if (file.format !== EXPORT_FORMAT) return res.status(400).json({ error: `Not an ${EXPORT_FORMAT} file`, code: 'theme.bad_format' });-- const [status, body] = submitTheme(getDb(req), req.user.sub || req.user.id, file);++ const [status, body] = await submitTheme(await getDb(req), req.user.sub || req.user.id, file);res.status(status).json(body);});// ── Review queue (admins; mounted at /api/admin/themes behind requireAuth + requireAdmin) ──function reviewRouter() {const r = express.Router();-- r.get('/', (req, res) => {-- const db = getDb(req);++ r.get('/', async (req, res) => {++ const db = await getDb(req);ensureReview(db);const status = ['pending', 'approved', 'rejected'].includes(req.query.status) ? req.query.status : 'pending';-- const themes = db.prepare(`SELECT t.id, t.name, t.slug, t.description, t.mode, t.variables, t.tags, t.review_status, t.review_note, t.reviewed_at, t.created_at, u.username AS author-- FROM themes t LEFT JOIN users u ON u.id = t.author_id WHERE t.is_builtin = 0 AND t.review_status = ? ORDER BY t.created_at ASC LIMIT 200`).all(status).map(parseTheme);++ const themes = (await db.prepare(`SELECT t.id, t.name, t.slug, t.description, t.mode, t.variables, t.tags, t.review_status, t.review_note, t.reviewed_at, t.created_at, u.username AS author++ FROM themes t LEFT JOIN users u ON u.id = t.author_id WHERE t.is_builtin = 0 AND t.review_status = ? ORDER BY t.created_at ASC LIMIT 200`).all(status)).map(parseTheme);res.set('Cache-Control', 'private, no-store').json({ status, themes });});-- r.post('/:id/review', (req, res) => {-- const db = getDb(req);++ r.post('/:id/review', async (req, res) => {++ const db = await getDb(req);ensureReview(db);const decision = req.body && req.body.decision;if (!['approve', 'reject'].includes(decision)) return res.status(400).json({ error: 'decision is approve or reject' });const note = String((req.body && req.body.note) || '').slice(0, 300) || null;if (decision === 'reject' && !note) return res.status(400).json({ error: 'Say why it is rejected (note)', code: 'theme.note_required' });-- const t = db.prepare('SELECT id, is_builtin FROM themes WHERE id = ?').get(req.params.id);++ const t = await db.prepare('SELECT id, is_builtin FROM themes WHERE id = ?').get(req.params.id);if (!t || t.is_builtin) return res.status(404).json({ error: 'No such community theme' });-- db.prepare("UPDATE themes SET review_status = ?, is_public = ?, reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP, review_note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")++ await db.prepare("UPDATE themes SET review_status = ?, is_public = ?, reviewed_by = ?, reviewed_at = ov_now(), review_note = ?, updated_at = ov_now() WHERE id = ?").run(decision === 'approve' ? 'approved' : 'rejected', decision === 'approve' ? 1 : 0, req.user.id || req.user.sub, note, t.id);-- res.json({ theme: parseTheme(db.prepare('SELECT * FROM themes WHERE id = ?').get(t.id)) });++ res.json({ theme: parseTheme(await db.prepare('SELECT * FROM themes WHERE id = ?').get(t.id)) });});return r;}@@@ -2,30 +2,14 @@const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('openvibe-shared/url-resolver');--function initializeUrlRegistry(db) {-- db.exec(`-- CREATE TABLE IF NOT EXISTS url_registry (-- key TEXT PRIMARY KEY,-- label TEXT NOT NULL,-- category TEXT NOT NULL,-- service TEXT NOT NULL,-- scope TEXT NOT NULL,-- type TEXT NOT NULL,-- value TEXT,-- description TEXT,-- source TEXT NOT NULL DEFAULT 'admin',-- updated_by INTEGER,-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP-- );-- `);--}++function initializeUrlRegistry(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }--function getRegistryRows(db) {-- return db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry ORDER BY category, service, key').all();++async function getRegistryRows(db) {++ return await db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry ORDER BY category, service, key').all();}--function getRegistryRow(db, key) {-- return db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry WHERE key = ?').get(key);++async function getRegistryRow(db, key) {++ return await db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry WHERE key = ?').get(key);}function encodeRegistryValue(value, type) {@@@ -58,14 +42,14 @@ function decodeRegistryValue(value, type) {}}--function getAllRegistryEntries(db) {-- const rows = db.prepare('SELECT key, value, source, updated_by, updated_at FROM url_registry').all();++async function getAllRegistryEntries(db) {++ const rows = await db.prepare('SELECT key, value, source, updated_by, updated_at FROM url_registry').all();const rowMap = rows.reduce((map, row) => {map[row.key] = row;return map;}, {});-- const warnings = getRegistryWarnings(db);++ const warnings = await getRegistryWarnings(db);return Object.values(URL_DEFINITIONS).map(def => {const row = rowMap[def.key];@@@ -88,9 +72,9 @@ function getAllRegistryEntries(db) {});}--function getRegistryWarnings(db, env = process.env) {++async function getRegistryWarnings(db, env = process.env) {const warnings = {};-- const resolved = getResolvedRegistry(db, env);++ const resolved = await getResolvedRegistry(db, env);try {const liveHost = resolved.OV_LIVE_URL.value ? new URL(resolved.OV_LIVE_URL.value).hostname : null;const whipHost = resolved.WHIP_PUBLIC_URL.value ? new URL(resolved.WHIP_PUBLIC_URL.value).hostname : null;@@@ -104,8 +88,8 @@ function getRegistryWarnings(db, env = process.env) {return warnings;}--function loadRegistryValuesBySource(db, source) {-- const rows = db.prepare("SELECT key, value FROM url_registry WHERE source = ? AND value IS NOT NULL AND value != ''").all(source);++async function loadRegistryValuesBySource(db, source) {++ const rows = await db.prepare("SELECT key, value FROM url_registry WHERE source = ? AND value IS NOT NULL AND value != ''").all(source);return rows.reduce((map, row) => {const def = URL_DEFINITIONS[row.key];map[row.key] = def ? decodeRegistryValue(row.value, def.type) : row.value;@@@ -113,12 +97,12 @@ function loadRegistryValuesBySource(db, source) {}, {});}--function loadOverrides(db) {-- return loadRegistryValuesBySource(db, 'admin');++async function loadOverrides(db) {++ return await loadRegistryValuesBySource(db, 'admin');}--function loadBootstrapValues(db) {-- return loadRegistryValuesBySource(db, 'bootstrap');++async function loadBootstrapValues(db) {++ return await loadRegistryValuesBySource(db, 'bootstrap');}function formatEntry(row) {@@@ -139,13 +123,13 @@ function formatEntry(row) {};}--function getResolvedRegistry(db, env = process.env) {-- const overrides = loadOverrides(db);-- const bootstrap = loadBootstrapValues(db);++async function getResolvedRegistry(db, env = process.env) {++ const overrides = await loadOverrides(db);++ const bootstrap = await loadBootstrapValues(db);return resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);}--function setRegistryEntry(db, key, value, updatedBy = null) {++async function setRegistryEntry(db, key, value, updatedBy = null) {if (!URL_DEFINITIONS[key]) {throw new Error(`Unknown URL registry key: ${key}`);}@@@ -154,28 +138,28 @@ function setRegistryEntry(db, key, value, updatedBy = null) {if (encoded == null) {throw new Error(`Invalid value for ${key}`);}-- db.prepare(`++ await db.prepare(`INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_by, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'admin', ?, CURRENT_TIMESTAMP)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'admin', ?, ov_now())ON CONFLICT(key) DO UPDATE SETvalue = excluded.value,source = 'admin',updated_by = excluded.updated_by,updated_at = excluded.updated_at`).run(entry.key, entry.label, entry.category, entry.service, entry.scope, entry.type, encoded, entry.description, updatedBy);-- return formatEntry(getRegistryRow(db, key));++ return formatEntry(await getRegistryRow(db, key));}--function resetRegistryEntry(db, key) {++async function resetRegistryEntry(db, key) {if (!URL_DEFINITIONS[key]) {throw new Error(`Unknown URL registry key: ${key}`);}-- db.prepare('DELETE FROM url_registry WHERE key = ?').run(key);++ await db.prepare('DELETE FROM url_registry WHERE key = ?').run(key);return URL_DEFINITIONS[key];}--function isRegistrySeeded(db) {-- const rowCount = db.prepare('SELECT COUNT(*) AS cnt FROM url_registry').get().cnt;++async function isRegistrySeeded(db) {++ const rowCount = (await db.prepare('SELECT COUNT(*) AS cnt FROM url_registry').get()).cnt;return rowCount > 0;}@@@ -195,24 +179,22 @@ function buildBootstrapOverrides(env = {}, profile = 'local-dev') {return bootstrap;}--function seedBootstrapRegistry(db, env = process.env, profile = 'local-dev') {++async function seedBootstrapRegistry(db, env = process.env, profile = 'local-dev') {const bootstrap = buildBootstrapOverrides(env, profile);-- const insert = db.prepare(`-- INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bootstrap', CURRENT_TIMESTAMP)-- ON CONFLICT(key) DO NOTHING-- `);-- const exists = db.prepare('SELECT 1 FROM url_registry WHERE key = ? LIMIT 1');---- const tx = db.transaction(() => {++ await db.tx(async (t) => {++ const insert = t.prepare(`++ INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bootstrap', ov_now())++ ON CONFLICT(key) DO NOTHING++ `);++ const exists = t.prepare('SELECT 1 FROM url_registry WHERE key = ? LIMIT 1');for (const [key, def] of Object.entries(URL_DEFINITIONS)) {-- if (exists.get(def.key)) continue;++ if (await exists.get(def.key)) continue;const encoded = encodeRegistryValue(bootstrap[key], def.type);if (encoded == null) continue;-- insert.run(def.key, def.label, def.category, def.service, def.scope, def.type, encoded, def.description || '');++ await insert.run(def.key, def.label, def.category, def.service, def.scope, def.type, encoded, def.description || '');}});-- tx();}module.exports = {@@@ -19,42 +19,43 @@ const cookieParser = require('cookie-parser');const jwt = require('jsonwebtoken');const bcrypt = require('bcryptjs');const { validate, serviceAuth } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const wallet = require('../server/coins/wallet');const accountMerge = require('../server/identity/account-merge');const accountData = require('../server/identity/account-data');const zip = require('../server/utils/zip');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-account-data-'));const exportDir = path.join(dir, 'account-exports');const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;// The five services that keep data about people are registered clients (production has them all).--for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);++for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);const principals = require('../server/identity/principals');--principals.ensureSchema(db);++await principals.ensureSchema(db);require('../server/identity/follows').ensureSchema(db);--accountMerge.ensureSchema(db);--accountData.ensureSchema(db);++await accountMerge.ensureSchema(db);++await accountData.ensureSchema(db);const ISSUER = 'https://openvibe.network';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };process.env.OWNER_USERNAME = 'boss';const hash = bcrypt.hashSync('secret12', 4);--const mk = (name, role = 'user') => {-- const id = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(name, `${name}@example.com`, hash, role).lastInsertRowid;-- subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));-- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++const mk = async (name, role = 'user') => {++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?) RETURNING id').run(name, `${name}@example.com`, hash, role)).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);};const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });// A service token as Network issues it: `cap` holds only what principal_grants gives that service.const svc = (name, cap = ['network.account.export.contribute', 'network.account.deletion.confirm']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'],cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);--const events = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === type);++const events = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === type);const authRoutes = require('../server/auth/routes');const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);@@@ -79,26 +80,26 @@ const server = http.createServer(app);.then((r) => { try { r.body = JSON.parse(r.buf.toString('utf8')); } catch { r.body = {}; } return r; });const settle = () => new Promise((r) => setImmediate(r));try {-- const dana = mk('dana'); const eve = mk('eve'); const x = mk('xavier'); const boss = mk('boss', 'admin');-- db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-dana')").run(dana.id);-- db.prepare("INSERT INTO user_sessions (user_id, session_token, device_name, is_active, expires_at) VALUES (?, 'sess-secret-dana', 'phone', 1, datetime('now', '+1 day'))").run(dana.id);-- db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-secret-dana', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(dana.id);-- wallet.credit(db, { user_id: dana.id, app_id: 'live', amount: 40, reason: 'chat', idempotency_key: 'd-1' });-- require('../server/identity/modules').write(db, dana.subject_id, 'live.profile', { is_streamer: true, followers: 2 }, { writer: { type: 'service', id: 'live' } });++ const dana = await mk('dana'); const eve = await mk('eve'); const x = await mk('xavier'); const boss = await mk('boss', 'admin');++ await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-dana')").run(dana.id);++ await db.prepare("INSERT INTO user_sessions (user_id, session_token, device_name, is_active, expires_at) VALUES (?, 'sess-secret-dana', 'phone', 1, datetime('now', '+1 day'))").run(dana.id);++ await db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-secret-dana', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(dana.id);++ await wallet.credit(db, { user_id: dana.id, app_id: 'live', amount: 40, reason: 'chat', idempotency_key: 'd-1' });++ await require('../server/identity/modules').write(db, dana.subject_id, 'live.profile', { is_streamer: true, followers: 2 }, { writer: { type: 'service', id: 'live' } });const follows = require('../server/identity/follows');-- follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });-- follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });-- db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);++ await follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });++ await follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });++ await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);// ── Export ──-- const expected = accountData.expectedServices(db, 'network.account.export.contribute');++ const expected = await accountData.expectedServices(db, 'network.account.export.contribute');assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');let r = await call('POST', '/api/v1/account/export', tok(dana));assert.strictEqual(r.status, 201, JSON.stringify(r.body));assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));const exp = r.body.export_id;assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), expected.map((s) => `${s}:waiting`));-- const req = events('network.account.export_requested');++ const req = await events('network.account.export_requested');assert.strictEqual(req.length, 1);assert.deepStrictEqual([req[0].payload.export_id, req[0].payload.subject, req[0].visibility], [exp, dana.subject_id, 'internal']);r = await call('POST', '/api/v1/account/export', tok(dana));@@@ -122,13 +123,13 @@ const server = http.createServer(app);r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [{ message: 'hello' }] }]));assert.strictEqual(r.status, 200);await settle();-- assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp).status, 'pending', 'still waiting for three services');++ assert.strictEqual((await db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp)).status, 'pending', 'still waiting for three services');r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));assert.deepStrictEqual([r.status, r.body.error], [409, 'export.pending']);// The deadline passes: the archive is built without the silent services, and says so.-- const deadline = Date.parse(db.prepare('SELECT deadline FROM account_exports WHERE id = ?').get(exp).deadline);-- assert.deepStrictEqual(accountData.sweep(db, { dir: exportDir, now: deadline + 1000, notify: (u, n) => notified.push([u, n.title]) }), { built: 1, expired: 0, deleted: 0 });++ const deadline = Date.parse((await db.prepare('SELECT deadline FROM account_exports WHERE id = ?').get(exp)).deadline);++ assert.deepStrictEqual(await accountData.sweep(db, { dir: exportDir, now: deadline + 1000, notify: (u, n) => notified.push([u, n.title]) }), { built: 1, expired: 0, deleted: 0 });r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));assert.strictEqual(r.body.status, 'partial');assert.ok(validate('network.account-export@1', r.body).valid);@@@ -157,17 +158,17 @@ const server = http.createServer(app);assert.ok(Number(r.headers.get('retry-after')) > 0);// Every expected service answers: ready at once.-- for (const s of ['community', 'games', 'media']) db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);++ for (const s of ['community', 'games', 'media']) await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);r = await call('POST', '/api/v1/account/export', tok(eve));const exp2 = r.body.export_id;await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));await call('POST', `/internal/account-exports/${exp2}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));await settle(); await settle();-- assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp2).status, 'ready');++ assert.strictEqual((await db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp2)).status, 'ready');// After 7 days the archive is gone.-- const expires = Date.parse(db.prepare('SELECT expires_at FROM account_exports WHERE id = ?').get(exp).expires_at);-- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: expires + 1000 }).expired, 2);++ const expires = Date.parse((await db.prepare('SELECT expires_at FROM account_exports WHERE id = ?').get(exp)).expires_at);++ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: expires + 1000 })).expired, 2);assert.ok(!fs.existsSync(path.join(exportDir, `${exp}.zip`)));r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));assert.deepStrictEqual([r.status, r.body.error], [410, 'export.expired']);@@@ -192,37 +193,37 @@ const server = http.createServer(app);r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });const del = r.body.deletion_id;const due = Date.parse(r.body.delete_after);-- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due - 60000 }).deleted, 0, 'nothing before the date');-- assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id).username, 'dana');++ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due - 60000 })).deleted, 0, 'nothing before the date');++ assert.strictEqual((await db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id)).username, 'dana');// dana had folded an older account in (ADR-029): its subject goes with her.-- const old = mk('dana_old');-- accountMerge.merge(db, old, db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });++ const old = await mk('dana_old');++ await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });const oldToken = tok(dana);-- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 1000 }).deleted, 1);-- const gone = db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);++ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);++ const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);assert.match(gone.username, /^deleted-/);assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);-- assert.match(db.prepare('SELECT username FROM users WHERE id = ?').get(old.id).username, /^deleted-/, 'the merged-in account too');++ assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the merged-in account too');for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id']]) {-- assert.strictEqual(db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id).n, 0, `${t} erased`);++ assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);}-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id).n, 0);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id).n, 0, 'follows both ways');-- assert.strictEqual(wallet.getBalance ? wallet.getBalance(db, dana.id) : db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id).balance, 0);-- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id).n, 1, 'one closing entry; the ledger stays');-- assert.ok(mk('dana'), 'the username is released');-- const p = subjects.resolve(db, { subject_id: dana.subject_id });++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id)).n, 0, 'follows both ways');++ assert.strictEqual(wallet.getBalance ? await wallet.getBalance(db, dana.id) : (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id)).balance, 0);++ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id)).n, 1, 'one closing entry; the ledger stays');++ assert.ok(await mk('dana'), 'the username is released');++ const p = await subjects.resolve(db, { subject_id: dana.subject_id });assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);-- assert.strictEqual(subjects.resolve(db, { subject_id: old.subject_id }).deleted, true, 'the alias resolves as deleted too');++ assert.strictEqual((await subjects.resolve(db, { subject_id: old.subject_id })).deleted, true, 'the alias resolves as deleted too');r = await call('GET', '/api/v1/account/deletion', oldToken);assert.strictEqual(r.status, 401, 'old tokens are refused');-- const ev = events('network.account.deleted');++ const ev = await events('network.account.deleted');assert.strictEqual(ev.length, 1);assert.ok(validate('network.account.deleted@1', ev[0].payload).valid);assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);-- assert.ok(events('network.user.token_valid_after').some((e) => e.payload.reason === 'account_deleted'));-- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 5000 }).deleted, 0, 'once');++ assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));++ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');// Services confirm; staff see what is outstanding.const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };@@@ -241,15 +242,31 @@ const server = http.createServer(app);// Staff cancel a scheduled deletion only with a reason.await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });-- const eveDel = db.prepare("SELECT id FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(eve.id).id;++ const eveDel = (await db.prepare("SELECT id FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(eve.id)).id;r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'no' });assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.reason_required']);r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'the person wrote to support and changed their mind' });assert.strictEqual(r.body.status, 'cancelled');-- assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'account_deletion_cancelled'").get());++ assert.ok(await db.prepare("SELECT 1 FROM audit_log WHERE action = 'account_deletion_cancelled'").get());++++ // ── Concurrent double-spend of one wallet: exactly one debit wins (decision 1, plan T2) ──++ // On PostgreSQL under READ COMMITTED the balance check moves into the conditional UPDATE, so two++ // spends of one wallet race safely; on SQLite the single writer hid this.++ const spender = await mk('spender');++ await wallet.credit(db, { user_id: spender.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'sp-1' });++ const spends = await Promise.allSettled([++ wallet.debit(db, { user_id: spender.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'sp-2' }),++ wallet.debit(db, { user_id: spender.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'sp-3' }),++ ]);++ assert.strictEqual(spends.filter((s) => s.status === 'fulfilled').length, 1, 'one of two concurrent spends of one wallet wins');++ assert.strictEqual(await wallet.getBalance(db, spender.id), 0, 'the wallet was spent once, never twice');++ const losers = spends.filter((s) => s.status === 'rejected');++ assert.strictEqual(losers.length, 1);++ assert.strictEqual(losers[0].reason.status, 409, 'the loser is insufficient_funds, not a crash');} finally {server.close();fs.rmSync(dir, { recursive: true, force: true });}console.log('account export and deletion: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -22,33 +22,34 @@ const cookieParser = require('cookie-parser');const jwt = require('jsonwebtoken');const bcrypt = require('bcryptjs');const { validate, ids } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const wallet = require('../server/coins/wallet');const accountMerge = require('../server/identity/account-merge');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-merge-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--require('../server/identity/principals').ensureSchema(db);++await require('../server/identity/principals').ensureSchema(db);require('../server/identity/follows').ensureSchema(db);--accountMerge.ensureSchema(db);++await accountMerge.ensureSchema(db);const ISSUER = 'https://openvibe.network';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };process.env.OWNER_USERNAME = 'boss';const hash = bcrypt.hashSync('secret12', 4);--const mk = (name, role = 'user') => {-- const id = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(name, hash, role).lastInsertRowid;-- subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));-- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++const mk = async (name, role = 'user') => {++ const id = (await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(name, hash, role)).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);};// A session token from 10 s ago (the revocation cutoff has second precision); authAgo: its sign-in time, or null for none.const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });--const events = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === type);++const events = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === type);const authRoutes = require('../server/auth/routes');const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);@@@ -67,29 +68,29 @@ const server = http.createServer(app);const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));try {-- const carol = mk('carol'); const carol2 = mk('carol_old');-- const x = mk('xavier'); const y = mk('yolanda'); const z = mk('zed');++ const carol = await mk('carol'); const carol2 = await mk('carol_old');++ const x = await mk('xavier'); const y = await mk('yolanda'); const z = await mk('zed');// What the folded-in account has.-- db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-carol'), (?, 'google', 'g-carol-old'), (?, 'discord', 'd-carol-old')").run(carol.id, carol2.id, carol2.id);-- db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-old', 1, datetime('now', '+1 day'))").run(carol2.id);-- db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-old', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(carol2.id);-- db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES ('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ?, 'bot', datetime('now'), ?)").run(carol2.subject_id, carol2.subject_id);-- wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });-- wallet.credit(db, { user_id: carol2.id, app_id: 'live', amount: 150, reason: 'chat', idempotency_key: 'c-2' });++ await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-carol'), (?, 'google', 'g-carol-old'), (?, 'discord', 'd-carol-old')").run(carol.id, carol2.id, carol2.id);++ await db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-old', 1, datetime('now', '+1 day'))").run(carol2.id);++ await db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-old', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(carol2.id);++ await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES ('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ?, 'bot', datetime('now'), ?)").run(carol2.subject_id, carol2.subject_id);++ await wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });++ await wallet.credit(db, { user_id: carol2.id, app_id: 'live', amount: 150, reason: 'chat', idempotency_key: 'c-2' });const modules = require('../server/identity/modules');const live = { type: 'service', id: 'live' };-- const w = (s, n, d) => { const o = modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };-- w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });-- w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });-- w(carol2.subject_id, 'live.stats', { streams_30d: 4 });++ const w = async (s, n, d) => { const o = await modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };++ await w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });++ await w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });++ await w(carol2.subject_id, 'live.stats', { streams_30d: 4 });const follows = require('../server/identity/follows');-- follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });-- follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already-- follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });-- follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel-- follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself-- db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);-- db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);++ await follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });++ await follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already++ await follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });++ await follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel++ await follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself++ await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);++ await db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);const oldCarol2 = tok(carol2);const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });@@@ -106,7 +107,7 @@ const server = http.createServer(app);assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired']);r = await call('POST', '/api/v1/account/merge', tok(carol), { intent });assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.same_account']);-- const expired = accountMerge.createIntent(db, carol, { now: Date.now() - 11 * 60 * 1000 });++ const expired = await accountMerge.createIntent(db, carol, { now: Date.now() - 11 * 60 * 1000 });r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: expired.intent });assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired'], 'an intent lives 10 minutes');@@@ -119,39 +120,39 @@ const server = http.createServer(app);assert.strictEqual(Date.parse(r.body.split_until) - Date.parse(r.body.merged_at), 30 * 86400000);// Resolution: either subject, and the old legacy id, answer the survivor.-- let res = subjects.resolve(db, { subject_id: carol2.subject_id });++ let res = await subjects.resolve(db, { subject_id: carol2.subject_id });assert.deepStrictEqual([res.subject.id, res.merged_from, res.username], [carol.subject_id, carol2.subject_id, 'carol']);-- res = subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '70' });++ res = await subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '70' });assert.deepStrictEqual([res.subject.id, res.merged_from], [carol.subject_id, carol2.subject_id], 'the legacy map is not repointed, the alias answers');-- assert.strictEqual(db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get().subject_id, carol2.subject_id);-- assert.strictEqual(subjects.resolve(db, { subject_id: carol.subject_id }).merged_from, undefined);++ assert.strictEqual((await db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get()).subject_id, carol2.subject_id);++ assert.strictEqual((await subjects.resolve(db, { subject_id: carol.subject_id })).merged_from, undefined);// Coins: summed, one ledger entry per side.-- assert.deepStrictEqual([wallet.getBalance(db, carol.id), wallet.getBalance(db, carol2.id)], [200, 0]);-- const ledger = db.prepare("SELECT user_id, delta FROM coin_transactions WHERE reason = 'account_merge' ORDER BY id").all();++ assert.deepStrictEqual([await wallet.getBalance(db, carol.id), await wallet.getBalance(db, carol2.id)], [200, 0]);++ const ledger = await db.prepare("SELECT user_id, delta FROM coin_transactions WHERE reason = 'account_merge' ORDER BY id").all();assert.deepStrictEqual(ledger.map((l) => [l.user_id, l.delta]), [[carol2.id, -150], [carol.id, 150]]);// Modules: the survivor's fields win; the other fills only what it lacks; a record only it had moves.-- assert.deepStrictEqual(modules.read(db, carol.subject_id, 'live.profile').data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });-- assert.deepStrictEqual(modules.read(db, carol.subject_id, 'live.stats').data, { streams_30d: 4 });-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id).n, 0);-- const modEv = events('network.module.updated').filter((e) => e.payload.reason === 'subject_merged');++ assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.profile')).data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });++ assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.stats')).data, { streams_30d: 4 });++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id)).n, 0);++ const modEv = (await events('network.module.updated')).filter((e) => e.payload.reason === 'subject_merged');assert.ok(modEv.some((e) => e.payload.merged_from && e.payload.change === 'updated' && e.payload.keys.includes('channel_url')), 'the filled record is announced');// Follows: moved, the clash and the self-follow dropped, the folded channel's follower now follows the survivor.-- const f = db.prepare('SELECT follower_subject AS a, target_id AS b FROM user_follows ORDER BY a, b').all().map((x2) => `${x2.a === carol.subject_id ? 'carol' : x2.a === z.subject_id ? 'zed' : x2.a}>${x2.b === x.subject_id ? 'x' : x2.b === y.subject_id ? 'y' : x2.b === carol.subject_id ? 'carol' : x2.b}`).sort();++ const f = (await db.prepare('SELECT follower_subject AS a, target_id AS b FROM user_follows ORDER BY a, b').all()).map((x2) => `${x2.a === carol.subject_id ? 'carol' : x2.a === z.subject_id ? 'zed' : x2.a}>${x2.b === x.subject_id ? 'x' : x2.b === y.subject_id ? 'y' : x2.b === carol.subject_id ? 'carol' : x2.b}`).sort();assert.deepStrictEqual(f, ['carol>x', 'carol>y', 'zed>carol']);// Providers: google clashed and stays on the folded-in account (signing in with it lands on the survivor); discord moved.-- assert.deepStrictEqual(db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id).map((l) => l.service), ['discord', 'google']);-- assert.strictEqual(db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get().user_id, carol2.id);-- assert.strictEqual(db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get().user_id, carol.id);-- assert.strictEqual(db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get().owner_subject, carol.subject_id);-- assert.deepStrictEqual(db.prepare('SELECT user_id, language FROM user_preferences ORDER BY user_id').all().map((p) => [p.user_id, p.language]), [[carol.id, 'en']], 'the survivor keeps its preferences');++ assert.deepStrictEqual((await db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id)).map((l) => l.service), ['discord', 'google']);++ assert.strictEqual((await db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get()).user_id, carol2.id);++ assert.strictEqual((await db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get()).user_id, carol.id);++ assert.strictEqual((await db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get()).owner_subject, carol.subject_id);++ assert.deepStrictEqual((await db.prepare('SELECT user_id, language FROM user_preferences ORDER BY user_id').all()).map((p) => [p.user_id, p.language]), [[carol.id, 'en']], 'the survivor keeps its preferences');// Events: network.subject.merged, and the folded-in account's tokens end (account_merged).-- const merged = events('network.subject.merged');++ const merged = await events('network.subject.merged');assert.strictEqual(merged.length, 1);assert.ok(validate('events.event-envelope@1', merged[0]).valid && validate('network.subject.merged@1', merged[0].payload).valid);assert.deepStrictEqual([merged[0].payload.from, merged[0].payload.into, merged[0].payload.initiated_by, merged[0].visibility], [carol2.subject_id, carol.subject_id, 'person', 'internal']);-- const revoked = events('network.user.token_valid_after').filter((e) => e.subject.id === carol2.subject_id);++ const revoked = (await events('network.user.token_valid_after')).filter((e) => e.subject.id === carol2.subject_id);assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');@@@ -165,25 +166,25 @@ const server = http.createServer(app);// (401); the survivor lists the merge instead.r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });assert.strictEqual(r.status, 401);-- const again = accountMerge.merge(db, db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), carol);++ const again = await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), carol);assert.deepStrictEqual([again.replayed, again.merge_id], [true, merged[0].payload.merge_id]);r = await call('GET', '/api/v1/account/merges', tok(carol));assert.deepStrictEqual(r.body.merges.map((m) => m.merge_id), [merged[0].payload.merge_id]);-- assert.strictEqual(wallet.getBalance(db, carol.id), 200);-- assert.strictEqual(events('network.subject.merged').length, 1);-- assert.throws(() => accountMerge.merge(db, db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');++ assert.strictEqual(await wallet.getBalance(db, carol.id), 200);++ assert.strictEqual((await events('network.subject.merged')).length, 1);++ await assert.rejects(async () => await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');// ── Staff (account recovery) ──-- const admin = mk('adminy', 'admin'); const boss = mk('boss', 'admin');-- const d1 = mk('dora'); const d2 = mk('dora_lost');++ const admin = await mk('adminy', 'admin'); const boss = await mk('boss', 'admin');++ const d1 = await mk('dora'); const d2 = await mk('dora_lost');r = await call('POST', '/api/admin/account-merges', tok(admin), { from: 'dora_lost', into: 'dora', reason: 'lost her password, verified by email' });assert.strictEqual(r.status, 403, 'an admin is not the owner');r = await call('POST', '/api/admin/account-merges', tok(boss), { from: 'dora_lost', into: 'dora', reason: 'short' });assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.reason_required']);r = await call('POST', '/api/admin/account-merges', tok(boss), { from: d2.subject_id, into: 'dora', reason: 'lost her password, verified by email' });assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.strictEqual(events('network.subject.merged').pop().payload.initiated_by, 'staff');-- const audit = db.prepare("SELECT details FROM audit_log WHERE action = 'account_merge'").all().map((a) => JSON.parse(a.details));++ assert.strictEqual((await events('network.subject.merged')).pop().payload.initiated_by, 'staff');++ const audit = (await db.prepare("SELECT details FROM audit_log WHERE action = 'account_merge'").all()).map((a) => JSON.parse(a.details));assert.deepStrictEqual([audit.length, audit[0].from, audit[0].reason], [1, d2.subject_id, 'lost her password, verified by email']);r = await call('GET', '/api/admin/account-merges', tok(boss));assert.strictEqual(r.body.merges.length, 2);@@@ -191,9 +192,9 @@ const server = http.createServer(app);assert.ok(d1);// ── 30 days later: only the alias facts remain ──-- assert.strictEqual(accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get().n, 0);-- assert.strictEqual(subjects.resolve(db, { subject_id: carol2.subject_id }).subject.id, carol.subject_id, 'the alias still answers');++ assert.strictEqual(await accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get()).n, 0);++ assert.strictEqual((await subjects.resolve(db, { subject_id: carol2.subject_id })).subject.id, carol.subject_id, 'the alias still answers');// auth_time: a renewal keeps the original sign-in time, a sign-in sets it.const renewed = jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: { auth_time: 1234 } }));@@@ -207,3 +208,4 @@ const server = http.createServer(app);}console.log('account merge: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -58,5 +58,27 @@ console.warn = () => {};} finally {server.close();}++++ // ── A shared store that is down (Valkey unreachable) degrades to this process, never a 500 (decision 5) ──++ let storeHits = 0;++ const downApp = express();++ downApp.use('/api/', createNetworkActorLimits({++ env: { NETWORK_LIMITS_MINUTE: '2' }, publicKey: keys.publicKey, issuer: ISS, now: () => t,++ store: { hit: async () => { storeHits++; throw new Error('valkey unreachable'); } },++ }));++ downApp.all('/api/*', (req, res) => res.json({ ok: true }));++ const downServer = http.createServer(downApp);++ await new Promise((r) => downServer.listen(0, '127.0.0.1', r));++ const downBase = `http://127.0.0.1:${downServer.address().port}`;++ try {++ const dave = session(9, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP9');++ const hit = () => fetch(`${downBase}/api/v1/projects`, { method: 'POST', headers: { authorization: `Bearer ${dave}` } }).then((r) => r.status);++ assert.strictEqual(await hit(), 200, 'a request with the store down is served, not a 500');++ assert.strictEqual(await hit(), 200);++ assert.strictEqual(await hit(), 429, 'the in-process counters still enforce the limit');++ assert.ok(storeHits >= 3, 'the shared store was tried first, every time');++ } finally {++ downServer.close();++ }console.log('actor limits: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });@@@ -10,25 +10,26 @@ const os = require('os');const path = require('path');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-admin-role-events-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;const profile = require('../server/identity/profile-events');--profile.ensureSchema(db);--profile.drain(db);--db.prepare('DELETE FROM network_event_outbox').run();++await profile.ensureSchema(db);++await profile.drain(db);++await db.prepare('DELETE FROM network_event_outbox').run();delete process.env.OWNER_USERNAME; // the default owner, 'goosely'const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const MIA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';--db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'goosely', 'x', ?, 'admin')").run(OWNER);--db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (2, 'mia', 'x', ?, 'user')").run(MIA);--profile.drain(db);--db.prepare('DELETE FROM network_event_outbox').run();--const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'goosely', 'x', ?, 'admin')").run(OWNER);++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (2, 'mia', 'x', ?, 'user')").run(MIA);++await profile.drain(db);++await db.prepare('DELETE FROM network_event_outbox').run();++const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.user.updated');// Any outbound call the admin routes make is recorded; none may go to Live's retired route.@@@ -37,7 +38,7 @@ const realFetch = global.fetch;global.fetch = (url, opts) => { outbound.push(String(url)); return Promise.resolve(new Response('{}', { status: 200 })); };const createAdminRoutes = require('../server/admin/routes');--const owner = db.prepare('SELECT * FROM users WHERE id = 1').get();++const owner = await db.prepare('SELECT * FROM users WHERE id = 1').get();const app = express();app.use(express.json());app.use('/api/admin', createAdminRoutes(db, {}, {}, (req, res, next) => { req.user = owner; next(); }));@@@ -48,9 +49,9 @@ const server = http.createServer(app);const base = `http://127.0.0.1:${server.address().port}/api/admin`;const call = (method, p, body) => realFetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));-- const roleEvent = (n, role) => {-- assert.strictEqual(profile.drain(db), 1, 'one event for the change');-- const ev = events();++ const roleEvent = async (n, role) => {++ assert.strictEqual(await profile.drain(db), 1, 'one event for the change');++ const ev = await events();assert.strictEqual(ev.length, n);const e = ev[n - 1];assert.ok(validate('events.event-envelope@1', e).valid);@@@ -60,13 +61,13 @@ const server = http.createServer(app);try {let r = await call('PUT', '/users/2/role', { role: 'global_mod' });assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- roleEvent(1, 'global_mod');++ await roleEvent(1, 'global_mod');r = await call('PUT', '/users/2/role', { role: 'user' });assert.strictEqual(r.status, 200);-- roleEvent(2, 'user'); // a downgrade too: Live applies it from the event++ await roleEvent(2, 'user'); // a downgrade too: Live applies it from the eventr = await call('POST', '/users/grant-admin', { id: 2 });assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- roleEvent(3, 'admin');++ await roleEvent(3, 'admin');assert.deepStrictEqual(outbound.filter((u) => /user-role/.test(u)), [], 'no push to Live\'s retired /internal/user-role');const src = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');assert.ok(!/pushRoleToStreamer|fetch\([^)]*user-role/.test(src), 'the push is gone from the admin routes');@@@ -74,7 +75,7 @@ const server = http.createServer(app);} finally {global.fetch = realFetch;server.close();-- db.close();fs.rmSync(dir, { recursive: true, force: true });}})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -1,32 +1,28 @@'use strict';--// ADR-021 analytics bounds on OpenVibe.Network (openvibe-shared/analytics wired by--// server/analytics/network.js, scripts/analytics-prune.js):++// ADR-021 analytics bounds on OpenVibe.Network (openvibe-shared/analytics/pg wired by++// server/analytics/network.js; ADR-035): the tracker runs on the service's PostgreSQL handle.// - Network's path options (paramPrefixes, pathRules) template its usernames, anon tokens and slugs;// - real requests through Network's own routers (auth, OAuth, avatars, admin analytics) leave no IP,// user id, city, raw user agent, full referer, query value or username in any analytics table;// the session id is a rotating id, paths are route templates, every row is a valid analytics/event.v1;// - a request with Sec-GPC: 1 or DNT: 1 is not recorded at all;--// - the tracker runs on its own connection to network.db: the identity connection keeps busy_timeout 5000;// - the admin dashboards keep their shapes (bot rows carry ua_class / session_id, never an IP);--// - the analytics-prune job deletes raw rows older than 30 days and leaves rollups alone;--// - the CLI's dry run changes nothing, --apply needs --backup <new file> or --no-backup, the backup is--// owner-only, and a scrub touches analytics rows only.++// - pruneRawEventsPg deletes raw rows older than 30 days and leaves rollups alone.++//++// The SQLite-only checks (the tracker's own connection with busy_timeout/secure_delete, and the++// analytics-prune CLI that backed up and VACUUMed network.db) no longer exist: on PostgreSQL the++// tracker shares the service handle and retention is pruneRawEventsPg (plan T2, ADR-035).// node test/analytics-privacy.test.jsconst assert = require('assert');const crypto = require('crypto');--const fs = require('fs');--const os = require('os');--const path = require('path');const http = require('http');const express = require('express');const cookieParser = require('cookie-parser');const jwt = require('jsonwebtoken');--const Database = require('better-sqlite3');--const { initDb } = require('../server/db/database');--const { privacy, retention, event } = require('openvibe-shared/analytics');++const { getDb } = require('../server/db/database');++const { privacy, event } = require('openvibe-shared/analytics');const { sqlTime } = require('openvibe-shared/analytics/tracker');const networkAnalytics = require('../server/analytics/network');--const cli = require('../scripts/analytics-prune');let failures = 0;async function check(name, fn) {@@@ -34,17 +30,15 @@ async function check(name, fn) {catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }}--const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-analytics-'));--const quietInit = (file) => { const log = console.log; console.log = () => {}; try { return initDb(file); } finally { console.log = log; } };const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';const USERNAME = 'alexsecretname';--/** Every value in every analytics table (network.db also holds accounts, which are not analytics). */--function dumpAnalytics(db) {-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'analytics_%'").pluck().all();++/** Every value in every analytics table (the database also holds accounts, which are not analytics). */++async function dumpAnalytics(db) {++ const tables = await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE 'analytics_%'").pluck().all();assert.ok(tables.includes('analytics_events'));-- return tables.map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');++ return (await Promise.all(tables.map(async (t) => JSON.stringify(await db.prepare(`SELECT * FROM ${t}`).all())))).join('\n');}(async () => {@@@ -66,11 +60,10 @@ function dumpAnalytics(db) {});// ── Real requests through Network's routers ──────────────-- const dbFile = path.join(dir, 'network.db');-- const db = quietInit(dbFile);-- db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (42, ?, 'x', 'admin')").run(USERNAME);++ const db = await getDb();++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (42, ?, 'x', 'admin')").run(USERNAME);let clock = Date.parse('2026-09-23T10:15:00Z');-- const analytics = networkAnalytics.openAnalytics(dbFile, { timers: false, now: () => clock });++ const analytics = networkAnalytics.openAnalytics(db, { timers: false, now: () => clock });const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -122,9 +115,9 @@ function dumpAnalytics(db) {assert.strictEqual(await get('/avatar/gpcsecretname', hdr({ 'sec-gpc': '1', 'x-forwarded-for': '198.51.100.61' })), 200);assert.strictEqual(await get('/internal/users/by-username/dntsecretname', hdr({ dnt: '1', 'x-forwarded-for': '198.51.100.62' })), 403);await new Promise((r) => setTimeout(r, 50));-- analytics.flush();++ await analytics.flush();-- const rows = db.prepare('SELECT * FROM analytics_events ORDER BY id').all();++ const rows = await db.prepare('SELECT * FROM analytics_events ORDER BY id').all();assert.strictEqual(rows.length, 8);for (const r of rows) {assert.strictEqual(r.service, 'openvibe-network');@@@ -147,26 +140,18 @@ function dumpAnalytics(db) {assert.strictEqual(rows[0].session_id, rows[1].session_id, 'same visitor, same session');assert.notStrictEqual(rows[0].session_id, rows[2].session_id);-- const everything = dumpAnalytics(db);++ const everything = await dumpAnalytics(db);for (const needle of ['203.0.113.77', '198.51.100.9', '127.0.0.1', 'Amsterdam', 'supersecret', 'secret-query','/search', 'statesecret', 'openvibe.live%2Fcb', USERNAME, 'bobsecretname', 'carolsecretname', '0123456789abcdefanon', '424242','alex%40example.com', '[email protected]', 'Mozilla/5.0', 'curl/8.5.0', 'Bearer', token.slice(0, 20),'gpcsecretname', 'dntsecretname', '198.51.100.61', '198.51.100.62']) {assert.ok(!everything.includes(needle), `found ${needle} in the analytics tables`);}-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);-- });---- await check('the tracker has its own connection: the identity connection keeps busy_timeout 5000', () => {-- assert.notStrictEqual(analytics.db, db);-- assert.strictEqual(db.pragma('busy_timeout', { simple: true }), 5000);-- assert.strictEqual(analytics.db.pragma('busy_timeout', { simple: true }), 250);-- assert.strictEqual(db.pragma('secure_delete', { simple: true }), 0);});await check('admin dashboards keep their shapes; bot rows carry ua_class / session_id, never an IP', async () => {const auth = { authorization: `Bearer ${token}`, 'user-agent': CHROME };-- analytics.aggregate();++ await analytics.aggregate();const warn = console.warn;console.warn = () => {}; // the remote services are unreachable here on purposetry { await dashboards(auth); } finally { console.warn = warn; }@@@ -186,112 +171,31 @@ function dumpAnalytics(db) {clock = Date.now();await get('/api/auth/me', hdr({ authorization: `Bearer ${token}` }));await new Promise((r) => setTimeout(r, 50));-- analytics.flush();++ await analytics.flush();const rt = await getJson('/api/admin/analytics/realtime', auth);assert.ok(rt.ok && typeof rt.realtime.visitors === 'number' && rt.realtime.visitors >= 1, JSON.stringify(rt));const ov = await getJson('/api/admin/analytics/overview?days=30', auth);assert.ok(ov.ok && ov.overview.services.some((s) => s.name === 'openvibe-network'));-- const hourly = db.prepare("SELECT * FROM analytics_hourly WHERE hour = '2026-09-23 10:00:00'").get();++ const hourly = await db.prepare("SELECT * FROM analytics_hourly WHERE hour = '2026-09-23 10:00:00'").get();assert.ok(hourly && hourly.unique_visitors >= 2 && hourly.unique_users === 1, JSON.stringify(hourly));}server.close();-- await check('analytics-prune job: raw rows older than 30 days go, rollups stay', async () => {++ await check('analytics-prune: raw rows older than 30 days go, rollups stay', async () => {const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, ip, user_id, created_at) VALUES ('openvibe-network', 'pageview', '/@old', '198.51.100.1', 7, ?)`);-- for (let i = 0; i < 5; i++) ins.run(sqlTime(Date.now() - (40 + i) * 86400000));-- ins.run(sqlTime(Date.now() - 29 * 86400000));-- db.prepare("INSERT OR REPLACE INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors) VALUES ('openvibe-network', '2026-07-01', 9, 3, 4)").run();-- const totals = retention.rollupTotals(db);-- const before = db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get();-- const logs = [];-- const job = networkAnalytics.schedulePrune(analytics, { initialDelayMs: 1e9, intervalMs: 1e9, log: (l) => logs.push(l) });-- try {-- const out = await job.run();-- assert.strictEqual(out.deleted, 5);-- assert.ok(/pruned 5 raw events/.test(logs[0]), logs.join('\n'));-- } finally { job.stop(); }-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), before - 5);-- assert.deepStrictEqual(retention.rollupTotals(db), totals);-- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM users').pluck().get(), 1);++ for (let i = 0; i < 5; i++) await ins.run(sqlTime(Date.now() - (40 + i) * 86400000));++ await ins.run(sqlTime(Date.now() - 29 * 86400000));++ await db.prepare("INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors) VALUES ('openvibe-network', '2026-07-01', 9, 3, 4) ON CONFLICT (service, date) DO UPDATE SET pageviews = excluded.pageviews, api_calls = excluded.api_calls, unique_visitors = excluded.unique_visitors").run();++ const daily = JSON.stringify(await db.prepare('SELECT * FROM analytics_daily ORDER BY date').all());++ const before = await db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get();++ const out = await networkAnalytics.schedulePrune(analytics, { days: 30 });++ assert.strictEqual(out.removed, 5);++ assert.strictEqual(await db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), before - 5);++ assert.strictEqual(JSON.stringify(await db.prepare('SELECT * FROM analytics_daily ORDER BY date').all()), daily);++ assert.strictEqual(await db.prepare('SELECT COUNT(*) FROM users').pluck().get(), 1);});analytics.destroy();-- analytics.db.close();-- db.close();---- // ── CLI against a network.db ─────────────────────────────-- const quiet = [];-- const log = (l) => quiet.push(l);-- const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');-- function legacyNetworkDb(name) {-- const file = path.join(dir, name);-- const d = quietInit(file);-- d.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'keepme', 'x')").run();-- const t = networkAnalytics.openAnalytics(file, { timers: false });-- t.destroy();-- t.db.close();-- const ins = d.prepare(`INSERT INTO analytics_events (service, event_type, path, method, status_code, user_id, session_id, ip, city, user_agent, referer, created_at)-- VALUES ('openvibe-network', 'pageview', ?, 'GET', 200, 7, ?, ?, 'Lyon', ?, 'https://t.co/abc?x=1', ?)`);-- const nowMs = Date.now() + 120000;-- for (let i = 0; i < 4; i++) ins.run('/avatar/keepme?s=' + i, 'legacy-session-' + i, '198.51.100.' + i, CHROME, sqlTime(nowMs - (45 + i) * 86400000));-- for (let i = 0; i < 3; i++) ins.run('/api/auth/anon/tok' + i + 'X9zz99', 'abcdef012345678' + i, '198.51.100.2' + i, FIREFOX, sqlTime(nowMs - (2 + i) * 86400000));-- ins.run('/internal/users/by-username/keepme', null, '127.0.0.1', 'node', sqlTime(nowMs - 3 * 86400000));-- d.prepare(`INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, top_paths, top_referers)-- VALUES ('openvibe-network', '2026-07-01', 100, 40, 30, 10, 2, ?, ?)`)-- .run(JSON.stringify([{ path: '/@keepme', cnt: 3 }, { path: '/internal/users/by-username/keepme', cnt: 2 }, { path: '/login', cnt: 1 }]), JSON.stringify([{ referer: 'https://www.google.com/search?q=keepme', cnt: 4 }]));-- d.pragma('wal_checkpoint(TRUNCATE)');-- d.close();-- return file;-- }-- const snapshot = (file) => { const d = new Database(file, { readonly: true }); try { return dumpAnalytics(d); } finally { d.close(); } };---- await check('CLI: default database is $DB_PATH, else data/network.db, from the repo root', () => {-- const root = path.join(__dirname, '..');-- assert.strictEqual(cli.defaultDbPath({}, {}), path.join(root, 'data', 'network.db'));-- assert.strictEqual(cli.defaultDbPath({}, { DB_PATH: './data/other.db' }), path.join(root, 'data', 'other.db'));-- assert.strictEqual(cli.defaultDbPath({ db: '/x/y.db' }, { DB_PATH: './data/other.db' }), '/x/y.db');-- });---- await check('CLI dry run (with and without --scrub) changes nothing', async () => {-- const file = legacyNetworkDb('cli-dry.db');-- const before = { hash: sha(file), dump: snapshot(file) };-- assert.strictEqual(await cli.main(['--db', file], log), 0);-- assert.strictEqual(await cli.main(['--db', file, '--scrub', '--days', '7'], log), 0);-- assert.strictEqual(sha(file), before.hash);-- assert.strictEqual(snapshot(file), before.dump);-- assert.ok(quiet.some((l) => /prune\s+4 rows/.test(l)), quiet.join('\n'));-- });---- await check('CLI --apply refuses without a backup choice, an existing target, or days > 30', async () => {-- const file = legacyNetworkDb('cli-refuse.db');-- const before = snapshot(file);-- assert.strictEqual(await cli.main(['--db', file, '--apply'], log), 2);-- const existing = path.join(dir, 'exists.bak');-- fs.writeFileSync(existing, 'x');-- assert.strictEqual(await cli.main(['--db', file, '--apply', '--backup', existing], log), 2);-- assert.strictEqual(await cli.main(['--db', file, '--apply', '--no-backup', '--days', '31'], log), 2);-- assert.strictEqual(snapshot(file), before);-- });---- await check('CLI --apply --scrub --backup: owner-only verified backup, analytics reduced, accounts untouched', async () => {-- const file = legacyNetworkDb('cli-apply.db');-- const bak = path.join(dir, 'cli-apply.backup.db');-- assert.strictEqual(await cli.main(['--db', file, '--apply', '--scrub', '--backup', bak, '--batch', '2'], log), 0);-- assert.strictEqual(fs.statSync(bak).mode & 0o777, 0o600);-- const b = new Database(bak, { readonly: true });-- assert.strictEqual(b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 8, 'backup has every row');-- b.close();-- const d = new Database(file, { readonly: true });-- assert.strictEqual(d.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 4);-- assert.deepStrictEqual(d.prepare('SELECT DISTINCT path FROM analytics_events ORDER BY path').pluck().all(), ['/api/auth/anon/:param', '/internal/users/:param/:username']);-- assert.deepStrictEqual(JSON.parse(d.prepare('SELECT top_paths FROM analytics_daily').pluck().get()),-- [{ path: '/@:user', cnt: 3 }, { path: '/internal/users/:param/:username', cnt: 2 }, { path: '/login', cnt: 1 }]);-- assert.strictEqual(d.prepare("SELECT username FROM users WHERE id = 7").pluck().get(), 'keepme');-- d.close();-- const all = snapshot(file);-- for (const needle of ['198.51.100.', '127.0.0.1', 'Lyon', 'legacy-session', 'keepme', 'X9zz99', 'Mozilla/5.0']) assert.ok(!all.includes(needle), needle);-- });-- fs.rmSync(dir, { recursive: true, force: true });if (failures) { console.log(`\n${failures} failed`); process.exit(1); }console.log('\nanalytics privacy: all passed');process.exit(0);@@@ -1,6 +1,7 @@'use strict';const assert = require('assert');--const express = require('express'); const Database = require('better-sqlite3');++const express = require('express');++const { getDb } = require('../server/db/database');const { createAvatarService, normalizeAvatar, verifyImage } = require('../server/profile/avatar');// What counts as an avatar@@@ -21,20 +22,20 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:assert.equal((await verifyImage(`${M}/x.png`, img)).ok, true);assert.equal((await verifyImage(`${M}/x.png`, html)).ok, false, 'a page is not a picture');-- const db = new Database(':memory:');-- db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT, avatar_url TEXT); CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT); INSERT INTO users VALUES (1,'Goosely',NULL),(2,'legacy','https://evil.example/old.png')");++ const db = getDb();++ await db.prepare("INSERT INTO users (id, username, password_hash, avatar_url) VALUES (1, 'Goosely', 'x', NULL), (2, 'legacy', 'x', 'https://evil.example/old.png')").run();const pushes = []; const pushHeaders = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); pushHeaders.push(o.headers); return { ok: true }; };// Network's own service token for the push (plan T2): live.avatar.write, audience openvibe.live.const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: 'https://openvibe.network' });const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });-- assert.equal(svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' }).changed, true);++ assert.equal((await svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' })).changed, true);assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');-- svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');++ await svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');const bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });assert.ok(v.ok && v.claims.sub === 'svc:network' && v.claims.cap.includes('live.avatar.write'), `the push carries Network's token for live.avatar.write: ${v.reason || ''}`);-- assert.equal(svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' }).status, 422);++ assert.equal((await svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' })).status, 422);global.fetch = realFetch;const app = express(); app.use('/avatar', svc.pub);@@@ -13,29 +13,30 @@ const path = require('path');const http = require('http');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-blocks-'));const dbFile = path.join(dir, 'network.db');const log = console.log; console.log = () => {};--const db = initDb(dbFile);++const db = getDb();console.log = log;// chat and community are registered on the host, not seeded: copies of tools, then their grants seeded again.for (const c of ['chat', 'community']) {-- const cols = db.prepare('PRAGMA table_info(oauth_clients)').all().map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');-- db.prepare(`INSERT OR IGNORE INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools'`).run(c);++ const cols = (await db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?").all('oauth_clients')).map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');++ await db.prepare(`INSERT INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools' ON CONFLICT DO NOTHING`).run(c);}--require('../server/identity/principals').ensureSchema(db);--for (const c of ['chat', 'community', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++await require('../server/identity/principals').ensureSchema(db);++for (const c of ['chat', 'community', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';const MOD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';--db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES(1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob <b>', 'x', ?, 'user'), (3, 'cat', NULL, 'x', ?, 'user'), (4, 'mod', 'Mod', 'x', ?, 'global_mod')`).run(ANN, BOB, CAT, MOD);--db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();--const GUEST_SUBJECT = db.prepare('SELECT subject_id FROM users WHERE id = 5').get().subject_id;++await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();++const GUEST_SUBJECT = (await db.prepare('SELECT subject_id FROM users WHERE id = 5').get()).subject_id;const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };@@@ -53,7 +54,7 @@ app.get('/internal/blocks', principals.guard('network.blocks.read', { legacy: faapp.use('/internal', require('../server/internal/routes'));const server = http.createServer(app);--const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.block.changed');++const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map(r => r.envelope).filter(e => e.event_type === 'network.block.changed');const valid = (e) => {const v = validate('events.event-envelope@1', e);assert.ok(v.valid, JSON.stringify(v.errors));@@@ -65,66 +66,66 @@ const valid = (e) => {(async () => {await new Promise(r => server.listen(0, '127.0.0.1', r));const base = `http://127.0.0.1:${server.address().port}`;-- const tok = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);++ const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined }).then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));-- const as = (id) => ({ authorization: `Bearer ${tok(id)}` });++ const as = async (id) => ({ authorization: `Bearer ${await tok(id)}` });const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json())).access_token;// ── A person blocks and unblocks ──let r = await call('GET', '/api/v1/me/blocks');assert.strictEqual(r.status, 401, 'signed in only');-- r = await call('GET', '/api/v1/me/blocks', { headers: as(1) });++ r = await call('GET', '/api/v1/me/blocks', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.subject, r.body.blocks], [200, ANN, []]);assert.ok(r.cache.includes('no-store'));-- r = await call('PUT', '/api/v1/me/blocks/BOB', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/blocks/BOB', { headers: await as(1) });assert.strictEqual(r.status, 201, JSON.stringify(r.body));assert.deepStrictEqual([r.body.changed, r.body.block.subject, r.body.block.username, r.body.block.active, r.body.block.revision], [true, BOB, 'bob', true, 1]);-- r = await call('PUT', `/api/v1/me/blocks/${BOB}`, { headers: as(1) });++ r = await call('PUT', `/api/v1/me/blocks/${BOB}`, { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.changed, r.body.block.revision], [200, false, 1], 'blocking again changes nothing');-- assert.strictEqual(events().length, 1, 'and announces nothing');-- r = await call('PUT', '/api/v1/me/blocks/mod', { headers: as(1) });++ assert.strictEqual((await events()).length, 1, 'and announces nothing');++ r = await call('PUT', '/api/v1/me/blocks/mod', { headers: await as(1) });assert.strictEqual(r.status, 201, 'staff can be blocked');-- r = await call('GET', '/api/v1/me/blocks', { headers: as(1) });++ r = await call('GET', '/api/v1/me/blocks', { headers: await as(1) });assert.deepStrictEqual(r.body.blocks.map(b => b.username).sort(), ['bob', 'mod']);const bob = r.body.blocks.find(b => b.username === 'bob');assert.deepStrictEqual([bob.subject, bob.display_name], [BOB, 'Bob <b>'], 'names are data; the page renders them as text');assert.ok(bob.blocked_at);// Refusals: yourself, a guest (by subject or name), nobody, not signed in as a person.-- r = await call('PUT', '/api/v1/me/blocks/ann', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/blocks/ann', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);-- r = await call('PUT', `/api/v1/me/blocks/${ANN}`, { headers: as(1) });++ r = await call('PUT', `/api/v1/me/blocks/${ANN}`, { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);-- r = await call('PUT', '/api/v1/me/blocks/anon1234', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/blocks/anon1234', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);-- r = await call('PUT', '/api/v1/me/blocks/gst_01JAB2C3D4E5F6G7H8J9K0MNPQ', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/blocks/gst_01JAB2C3D4E5F6G7H8J9K0MNPQ', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);if (/^usr_/.test(GUEST_SUBJECT || '')) {-- r = await call('PUT', `/api/v1/me/blocks/${GUEST_SUBJECT}`, { headers: as(1) });++ r = await call('PUT', `/api/v1/me/blocks/${GUEST_SUBJECT}`, { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest'], 'a guest users row, by its subject');}-- r = await call('PUT', '/api/v1/me/blocks/nobody_here', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/blocks/nobody_here', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.unknown_person']);-- r = await call('PUT', '/api/v1/me/blocks/bob', { headers: as(5) });++ r = await call('PUT', '/api/v1/me/blocks/bob', { headers: await as(5) });assert.deepStrictEqual([r.status, r.body.code], [403, 'blocks.guest'], 'a guest cannot block');-- assert.throws(() => blocks.setBlock(db, ANN, ANN, true), /yourself/);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_blocks').get().n, 2, 'refusals wrote nothing');++ await assert.rejects(async () => await blocks.setBlock(db, ANN, ANN, true), /yourself/);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_blocks').get()).n, 2, 'refusals wrote nothing');// Unblock, block again: the pair's revision grows every time.-- r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });++ r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.changed, r.body.block.active, r.body.block.revision], [200, true, false, 2]);-- r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });++ r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.not_blocked']);-- r = await call('PUT', '/api/v1/me/blocks/bob', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/blocks/bob', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.block.revision], [201, 3]);-- r = await call('PUT', '/api/v1/me/blocks/ann', { headers: as(3) });++ r = await call('PUT', '/api/v1/me/blocks/ann', { headers: await as(3) });assert.strictEqual(r.status, 201, 'cat blocks ann');// ── Events: one per change, valid, in order, revisions growing per pair ──-- const evs = events();++ const evs = await events();evs.forEach(valid);const annBob = evs.filter(e => e.payload.blocker === ANN && e.payload.blocked === BOB).map(e => [e.payload.active, e.payload.revision]);assert.deepStrictEqual(annBob, [[true, 1], [false, 2], [true, 3]]);@@@ -132,14 +133,14 @@ const valid = (e) => {assert.strictEqual(new Set(evs.map(e => e.event_id)).size, evs.length);// In the same transaction: a change that fails after the row is written leaves neither row nor event.-- const before = events().length;++ const before = (await events()).length;const enqueue = require('../server/developer/event-relay').writerFor(db);const real = enqueue.enqueue;enqueue.enqueue = () => { throw new Error('outbox down'); };-- assert.throws(() => blocks.setBlock(db, CAT, BOB, true), /outbox down/);++ await assert.rejects(async () => await blocks.setBlock(db, CAT, BOB, true), /outbox down/);enqueue.enqueue = real;-- assert.strictEqual(blocks.isBlocked(db, CAT, BOB), false, 'rolled back with its event');-- assert.strictEqual(events().length, before);++ assert.strictEqual(await blocks.isBlocked(db, CAT, BOB), false, 'rolled back with its event');++ assert.strictEqual((await events()).length, before);// ── /internal/blocks for services ──const chat = await svc('chat');@@@ -158,24 +159,24 @@ const valid = (e) => {assert.ok([401, 403].includes(r.status), 'never the shared key');r = await call('GET', `/internal/blocks?subject=${ANN}`);assert.ok([401, 403].includes(r.status), 'no credential');-- for (const c of ['chat', 'community']) assert.ok(principals.grantsFor(db, c, 'openvibe.network').some(g => g.capability === 'network.blocks.read'), `${c} holds network.blocks.read`);++ for (const c of ['chat', 'community']) assert.ok((await principals.grantsFor(db, c, 'openvibe.network')).some(g => g.capability === 'network.blocks.read'), `${c} holds network.blocks.read`);// ── Network's own notifications ──const { NotificationService } = require('../server/notifications/notification-service');const notes = new NotificationService(db);-- const count = (uid) => db.prepare('SELECT COUNT(*) AS n FROM notifications WHERE user_id = ?').get(uid).n;-- assert.strictEqual(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, sender_name: 'bob', message: 'bob followed you' }), null, 'ann blocked bob: nothing from him');-- assert.strictEqual(notes.create({ user_id: 1, type: 'MENTION', actor_subject: BOB, message: 'bob mentioned you' }), null, 'nor when the actor is named by subject');-- assert.strictEqual(count(1), 0);-- assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 3, sender_name: 'cat' }), 'cat is not blocked by ann (only the other way round)');-- assert.ok(notes.create({ user_id: 3, type: 'FOLLOW', sender_id: 2, sender_name: 'bob' }), 'cat did not block bob');-- assert.ok(notes.create({ user_id: 1, type: 'WARNING', sender_id: 4, sender_name: 'mod', message: 'a warning' }), 'a staff action reaches ann although she blocked mod');-- assert.strictEqual(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 4 }), null, 'but mod following her does not');-- assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, actor_subject: null }), 'actor_subject null: no known person (a Live id in sender_id)');-- assert.ok(notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');-- assert.strictEqual(notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' }).length, 1, 'bulk: only cat hears bob went live');-- await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });-- assert.ok(notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');++ const count = async (uid) => (await db.prepare('SELECT COUNT(*) AS n FROM notifications WHERE user_id = ?').get(uid)).n;++ assert.strictEqual(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, sender_name: 'bob', message: 'bob followed you' }), null, 'ann blocked bob: nothing from him');++ assert.strictEqual(await notes.create({ user_id: 1, type: 'MENTION', actor_subject: BOB, message: 'bob mentioned you' }), null, 'nor when the actor is named by subject');++ assert.strictEqual(await count(1), 0);++ assert.ok(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 3, sender_name: 'cat' }), 'cat is not blocked by ann (only the other way round)');++ assert.ok(await notes.create({ user_id: 3, type: 'FOLLOW', sender_id: 2, sender_name: 'bob' }), 'cat did not block bob');++ assert.ok(await notes.create({ user_id: 1, type: 'WARNING', sender_id: 4, sender_name: 'mod', message: 'a warning' }), 'a staff action reaches ann although she blocked mod');++ assert.strictEqual(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 4 }), null, 'but mod following her does not');++ assert.ok(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, actor_subject: null }), 'actor_subject null: no known person (a Live id in sender_id)');++ assert.ok(await notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');++ assert.strictEqual((await notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' })).length, 1, 'bulk: only cat hears bob went live');++ await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });++ assert.ok(await notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');// ── Import of Chat's exported dm_blocks ──const importer = require('../scripts/import-blocks');@@@ -189,20 +190,20 @@ const valid = (e) => {{ blocker_subject: '42', blocked_subject: BOB }, // not a subject] }));const out = [];-- let code = await importer.main(['--db', dbFile, '--file', file], (l) => out.push(l));++ let code = await importer.main(['--file', file], (l) => out.push(l));assert.strictEqual(code, 0);assert.match(out.join('\n'), /pairs {6}6; to import 1; skipped: duplicate-in-file 1, already-on-network 1, self 1, unknown-account 1, not-a-subject 1/);-- assert.ok(!blocks.isBlocked(db, BOB, CAT), 'dry run changed nothing');-- const n = events().length;-- code = await importer.main(['--db', dbFile, '--file', file, '--apply'], () => {});++ assert.ok(!await blocks.isBlocked(db, BOB, CAT), 'dry run changed nothing');++ const n = (await events()).length;++ code = await importer.main(['--file', file, '--apply'], () => {});assert.strictEqual(code, 0);-- assert.ok(blocks.isBlocked(db, BOB, CAT), 'imported');-- assert.ok(!blocks.isBlocked(db, ANN, BOB), 'an unblock on Network wins over the old Chat block');-- assert.strictEqual(events().length, n + 1);-- const imported = events().at(-1); valid(imported);++ assert.ok(await blocks.isBlocked(db, BOB, CAT), 'imported');++ assert.ok(!await blocks.isBlocked(db, ANN, BOB), 'an unblock on Network wins over the old Chat block');++ assert.strictEqual((await events()).length, n + 1);++ const imported = (await events()).at(-1); valid(imported);assert.deepStrictEqual([imported.payload.blocker, imported.payload.blocked, imported.payload.active, imported.payload.revision], [BOB, CAT, true, 1]);out.length = 0;-- await importer.main(['--db', dbFile, '--file', file], (l) => out.push(l));++ await importer.main(['--file', file], (l) => out.push(l));assert.match(out.join('\n'), /to import 0/, 'a second run imports nothing');// The account page lists blocks as text (DOM nodes), with unblock buttons.@@@ -215,3 +216,4 @@ const valid = (e) => {fs.rmSync(dir, { recursive: true, force: true });console.log('blocks: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -12,25 +12,26 @@ const http = require('http');const express = require('express');const { ids, validate } = require('openvibe-contracts');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { NotificationService } = require('../server/notifications/notification-service');const { createEventsConsumer } = require('../server/notifications/events-consumer');const creators = require('../server/analytics/creators');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-creator-analytics-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--require('../server/identity/principals').ensureSchema(db);--for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++await require('../server/identity/principals').ensureSchema(db);++for (const c of ['live', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);const CAROL = ids.newId('user'), DAVE = ids.newId('user');--db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?)').run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user');++await db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?)').run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user');const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };const { signToken } = require('../server/auth/routes');const SECRET = 's'.repeat(40);--const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, log: { log() {}, warn() {}, error() {} } });++const consumer = await createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, log: { log() {}, warn() {}, error() {} } });const app = express();Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });app.use(require('cookie-parser')());@@@ -61,7 +62,7 @@ const ended = (streamId, startedAt, minutes, stats) => ({assert.strictEqual((await deliver(ended(502, day(2), 30, { peak_viewers: 20, avg_viewers: 10, unique_chatters: 9, messages: 200, watch_minutes: 250 }))).outcome, 'analytics:recorded');assert.strictEqual((await deliver(ended(503, day(1), 15))).outcome, 'analytics:recorded', 'an ended stream without stats still counts');assert.strictEqual((await deliver({ ...ended(501, day(30), 60, { peak_viewers: 12, avg_viewers: 6.5, unique_chatters: 4, messages: 120, watch_minutes: 300 }) })).outcome, 'analytics:recorded', 'a new event for the same stream replaces it');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM creator_streams').get().n, 3);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM creator_streams').get()).n, 3);assert.strictEqual((await deliver({ ...ended(504, day(1), 5), source: 'media' })).outcome, 'ignored:source');// Public: streams, minutes, peak. No audience figures.@@@ -74,12 +75,12 @@ const ended = (streamId, startedAt, minutes, stats) => ({assert.strictEqual(r.body.daily.reduce((n, d) => n + d.streams, 0), 3);// The creator, and Live's service token: full figures. Another person: public only. A service without the grant: refused.-- const creatorTok = { authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = 20').get(), keys.privateKey, config)}` };++ const creatorTok = { authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = 20').get(), keys.privateKey, config)}` };r = await call(`/api/v1/creators/${CAROL}/analytics`, creatorTok);assert.deepStrictEqual([r.body.full, r.body.totals.messages, r.body.totals.unique_chatters, r.body.totals.watch_minutes], [true, 320, 13, 550]);assert.strictEqual(r.body.totals.avg_viewers, Math.round(((6.5 * 3600 + 10 * 1800) / 6300) * 10) / 10, 'duration-weighted average');assert.match(r.cache, /private/);-- r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = 21').get(), keys.privateKey, config)}` });++ r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = 21').get(), keys.privateKey, config)}` });assert.strictEqual(r.body.full, false);r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${await svc('live')}` });assert.strictEqual(r.body.full, true);@@@ -87,7 +88,7 @@ const ended = (streamId, startedAt, minutes, stats) => ({assert.strictEqual((await call('/api/v1/creators/nobody/analytics')).status, 404);// Privacy (ADR-021): the table holds counts and the creator's subject only.-- const cols = db.prepare('PRAGMA table_info(creator_streams)').all().map((c) => c.name);++ const cols = (await db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?").all('creator_streams')).map((c) => c.name);assert.deepStrictEqual(cols, creators.COLUMNS);assert.ok(!cols.some((c) => /ip|viewer_id|chatter_id|user_id|follower/.test(c)), 'no viewer, chatter or IP column');@@@ -100,3 +101,4 @@ const ended = (streamId, startedAt, minutes, stats) => ({}console.log('creator analytics: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -1,37 +1,24 @@'use strict';--// ADR-007 data ownership: Network never opens another service's database. Booting with a--// Live database on disk (and OPENVIBELIVE_DB_PATH pointing at it) must not copy roles from it--// (compatibility register C-58, removed).++// ADR-007 data ownership: Network never opens another service's database. A second boot must not copy a++// role out of Live's database (compatibility register C-58, removed), and no server code names it.// node test/data-ownership.test.jsconst assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const Database = require('better-sqlite3');++const { getDb, initDb } = require('../server/db/database');const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-data-ownership-'));++process.env.OPENVIBELIVE_DB_PATH = path.join(dir, 'live.db');--// A Live database in which the linked account is an admin.--const livePath = path.join(dir, 'live.db');--const live = new Database(livePath);--live.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, role TEXT)");--live.prepare("INSERT INTO users (id, username, role) VALUES (7, 'migrated', 'admin')").run();--live.close();--process.env.OPENVIBELIVE_DB_PATH = livePath;----const { initDb } = require('../server/db/database');--const netPath = path.join(dir, 'network.db');--const log = console.log; console.log = () => {};--let db = initDb(netPath);--db.prepare("INSERT INTO users (username, password_hash, role, legacy_source, legacy_id) VALUES ('migrated', 'x', 'user', 'live', 7)").run();--db.close();++(async () => {++const db = await getDb();++await db.prepare("INSERT INTO users (username, password_hash, role, legacy_source, legacy_id) VALUES ('migrated', 'x', 'user', 'live', 7)").run();// The removed sync ran on every boot, so a second boot is where it would have promoted the user.--db = initDb(netPath);--console.log = log;++await initDb();--assert.strictEqual(db.prepare("SELECT role FROM users WHERE username = 'migrated'").get().role, 'user',++assert.strictEqual((await db.prepare("SELECT role FROM users WHERE username = 'migrated'").get()).role, 'user','a boot must not copy a role out of Live\'s database');--db.close();// No server code names Live's database or the variable that pointed at it.function walk(d, out = []) {@@@ -47,3 +34,4 @@ assert.deepStrictEqual(offenders, [], 'server code must not reference Live\'s dafs.rmSync(dir, { recursive: true, force: true });console.log('data-ownership: ok');++})().catch(err => { console.error(err); process.exit(1); });@@@ -16,15 +16,16 @@ const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');const contracts = require('openvibe-contracts');++(async () => {const { serviceAuth, validate } = contracts;--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const policy = require('../server/developer/policy');const relay = require('../server/developer/event-relay');const quiet = { log() {}, warn() {}, error() {} };const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devdefaults-'));--const db = initDb(path.join(dir, 'network.db'));--db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();++const db = getDb();++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -91,7 +92,7 @@ async function withoutCatalog(hidden, fn) {// contracts define them as public and active; until then they are left out like any unknown id.assert.ok(['media.object.list', 'media.object.delete'].every(id => policy.DEFAULT_SANDBOX_ALLOWANCE.includes(id)));const MEDIA_VERBS = ['media.object.list', 'media.object.delete'].map(id => ({ id, version: '1.0', owner: 'media', status: 'active', visibility: 'public', description: 'test', permissions: [], resourceConstraints: ['namespace'], events: [], implementedBy: [] }));-- await withoutCatalog(MEDIA_VERBS.map(c => c.id), () => withCatalog(MEDIA_VERBS, () => {++ await withoutCatalog(MEDIA_VERBS.map(c => c.id), async () => await withCatalog(MEDIA_VERBS, () => {const media = policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.'));assert.deepStrictEqual(media, ['media.object.delete', 'media.object.list', 'media.object.read', 'media.object.upload'], 'with the verbs in the catalog, sandbox apps may hold them');}));@@@ -212,10 +213,10 @@ async function withoutCatalog(hidden, fn) {});// ── Relay off: nothing is sent ──-- assert.strictEqual(relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);++ assert.strictEqual(await relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);assert.strictEqual(relay.outboxFor(db), null);// (The table itself exists from boot: user modules write their events into it whether or not a relay runs.)-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n, 0, 'nothing queued while the relay is off');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n, 0, 'nothing queued while the relay is off');// ── Relay on: a fake Events receives every envelope once, in order, with a Network service token ──const received = [];@@@ -240,18 +241,18 @@ async function withoutCatalog(hidden, fn) {});await new Promise(res => events.listen(0, '127.0.0.1', res));const eventsUrl = `http://127.0.0.1:${events.address().port}`;-- const before = db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all().map(x => JSON.parse(x.event).event_id);++ const before = (await db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all()).map(x => JSON.parse(x.event).event_id);assert.ok(before.length >= 5, 'the walkthrough produced events');-- let outbox = relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });++ let outbox = await relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });assert.ok(outbox);-- assert.strictEqual(outbox.pending(), before.length, 'events written while the relay was off are backfilled');++ assert.strictEqual(await outbox.pending(), before.length, 'events written while the relay was off are backfilled');// A new event now goes into the outbox in the same transaction as its audit row.r = await api('owner', 'POST', `/${P}/apps`, { name: 'later', environment: 'sandbox', type: 'confidential' });assert.strictEqual(r.status, 201);-- assert.strictEqual(outbox.pending(), before.length + 1);++ assert.strictEqual(await outbox.pending(), before.length + 1);let flushed = await outbox.flush();assert.strictEqual(flushed.sent, before.length + 1, JSON.stringify(flushed));-- const all = db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all().map(x => JSON.parse(x.event));++ const all = (await db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all()).map(x => JSON.parse(x.event));assert.deepStrictEqual(received.map(e => e.event_id), all.map(e => e.event_id), 'published in dev_audit id order');for (const e of received) {assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(e));@@@ -267,27 +268,28 @@ async function withoutCatalog(hidden, fn) {assert.ok(validate('identity.service-token-claims@1', tv.claims).valid);}// Duplicates are harmless: a republished row is answered as a duplicate and stored once.-- db.prepare(`UPDATE ${relay.TABLE} SET sent_at = NULL WHERE id = (SELECT MAX(id) FROM ${relay.TABLE})`).run();++ await db.prepare(`UPDATE ${relay.TABLE} SET sent_at = NULL, next_attempt_at = 0 WHERE id = (SELECT MAX(id) FROM ${relay.TABLE})`).run();flushed = await outbox.flush();assert.strictEqual(flushed.sent, 1);assert.strictEqual(stored.size, all.length, 'Events stored each event once');// A restart backfills nothing new and sends nothing twice.await relay.stopRelay(db);-- outbox = relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });-- assert.strictEqual(outbox.pending(), 0);++ outbox = await relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });++ assert.strictEqual(await outbox.pending(), 0);const n = received.length;await outbox.flush();assert.strictEqual(received.length, n);// The audit and the outbox row are one transaction: a failing enqueue leaves no audit row.-- const auditCount = db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get().n;++ const auditCount = (await db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get()).n;const origEnqueue = outbox.enqueue;outbox.enqueue = () => { throw new Error('boom'); };r = await api('owner', 'POST', `/${P}/apps`, { name: 'atomic', environment: 'sandbox', type: 'confidential' });assert.strictEqual(r.status, 500);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get().n, auditCount, 'no audit row without its outbox row');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get()).n, auditCount, 'no audit row without its outbox row');outbox.enqueue = origEnqueue;await relay.stopRelay(db);server.close(); events.close();console.log('developer defaults and events relay: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -13,21 +13,22 @@ const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');const { serviceAuth, validate, capabilities } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const tokens = require('../server/developer/tokens');++(async () => {const logged = [];const orig = { log: console.log, warn: console.warn, error: console.error };for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };const out = (...a) => orig.log(...a);const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-exporttokens-'));--const db = initDb(path.join(dir, 'network.db'));--db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++const db = getDb();++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'),(14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();--const sid = (id) => subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));++const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -59,9 +60,9 @@ const minted = [];const text = await r.text();return { status: r.status, cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };};-- const mint = (who, project, body) => api(who, 'POST', `/${project}/export-tokens`, body);++ const mint = async (who, project, body) => await api(who, 'POST', `/${project}/export-tokens`, body);const verify = (t, audience, o = {}) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience, acceptSandbox: true, ...o });-- const exportRows = () => db.prepare("SELECT * FROM dev_audit WHERE action = 'project.export_token_issued' ORDER BY id").all();++ const exportRows = async () => await db.prepare("SELECT * FROM dev_audit WHERE action = 'project.export_token_issued' ORDER BY id").all();// A project with one member of each role, and a project the owner is not in.let r = await api('owner', 'POST', '', { name: 'Exported' });@@@ -95,7 +96,7 @@ const minted = [];assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project\'s namespaces, both environments');assert.strictEqual(v.claims.project_id, P);assert.strictEqual(v.claims.env, 'production');-- assert.strictEqual(v.claims.on_behalf_of, sid(10), 'the person exporting');++ assert.strictEqual(v.claims.on_behalf_of, await sid(10), 'the person exporting');assert.strictEqual(v.claims.purpose, 'export');assert.strictEqual(v.claims.exp - v.claims.iat, 300, 'five minutes');assert.ok(Math.abs(v.claims.iat - Date.now() / 1000) < 5);@@@ -121,12 +122,12 @@ const minted = [];assert.ok(v.ok, v.reason);assert.deepStrictEqual(v.claims.cap, ['events.app.read']);assert.strictEqual(v.claims.env, 'sandbox');-- assert.strictEqual(v.claims.on_behalf_of, sid(11));++ assert.strictEqual(v.claims.on_behalf_of, await sid(11));assert.strictEqual(verify(r.body.access_token, 'openvibe.events', { acceptSandbox: false }).code, 'token.sandbox_refused','a receiver that did not opt in to sandbox tokens still refuses it');// ── Everyone else is refused, and nothing is minted or audited for them ──-- const before = exportRows().length;++ const before = (await exportRows()).length;r = await mint('dev', P, { audience: 'openvibe.media', env: 'sandbox' });assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'project.forbidden', 'a developer may not export');r = await mint('viewer', P, { audience: 'openvibe.media', env: 'sandbox' });@@@ -154,7 +155,7 @@ const minted = [];assert.strictEqual(r.status, 422, JSON.stringify(body));assert.strictEqual(r.body.code, code, JSON.stringify(body));}-- assert.strictEqual(exportRows().length, before, 'refusals write no export audit row');++ assert.strictEqual((await exportRows()).length, before, 'refusals write no export audit row');// ── The export principal opens nothing else ──r = await api(minted[0], 'GET', `/${P}`);@@@ -166,10 +167,10 @@ const minted = [];assert.deepStrictEqual(r.body.apps.map(a => a.id), [realApp], 'it is not listed as an app');// ── The audit: one row per token, with who, what and until when; never the token ──-- let rows = exportRows();++ let rows = await exportRows();assert.strictEqual(rows.length, 2);assert.deepStrictEqual(rows.map(x => [x.project_id, x.actor, x.target]),-- [[P, `user:${sid(10)}`, tokens.exportSubject(P)], [P, `user:${sid(11)}`, tokens.exportSubject(P)]]);++ [[P, `user:${await sid(10)}`, tokens.exportSubject(P)], [P, `user:${await sid(11)}`, tokens.exportSubject(P)]]);const d0 = JSON.parse(rows[0].detail);const c0 = verify(minted[0], 'openvibe.media').claims;assert.deepStrictEqual(d0, { audience: 'openvibe.media', env: 'production', cap: ['media.object.list', 'media.object.read'], jti: c0.jti,@@@ -184,16 +185,17 @@ const minted = [];r = await mint('owner', P, { audience: 'openvibe.events', env: 'production' });assert.strictEqual(r.status, 201, r.text);minted.push(r.body.access_token);-- assert.strictEqual(exportRows().length, 3);++ assert.strictEqual((await exportRows()).length, 3);// ── Tokens are never stored or logged ──-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(x => x.name);++ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map(x => x.name);for (const t of minted) {const sig = t.split('.')[2];-- for (const name of tables) assert.ok(!JSON.stringify(db.prepare(`SELECT * FROM "${name}"`).all()).includes(sig), `a token in ${name}`);++ for (const name of tables) assert.ok(!JSON.stringify(await db.prepare(`SELECT * FROM "${name}"`).all()).includes(sig), `a token in ${name}`);assert.ok(!logged.some(l => l.includes(sig)), 'a token in the logs');}out('export tokens: all checks passed');server.close();})().catch(err => { orig.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -11,21 +11,22 @@ const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');const { serviceAuth, validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const policy = require('../server/developer/policy');// Everything the server logs is captured, so the test can prove no secret was ever logged.++(async () => {const logged = [];const orig = { log: console.log, warn: console.warn, error: console.error };for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };const out = (...a) => orig.log(...a);const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devprojects-'));--const db = initDb(path.join(dir, 'network.db'));--db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++const db = getDb();++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();--const sid = (id) => subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));++const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -83,7 +84,7 @@ const secretsSeen = [];const P = r.body.id;assert.match(P, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);assert.strictEqual(r.body.role, 'owner');-- assert.deepStrictEqual(r.body.owner, { type: 'user', id: sid(10) });++ assert.deepStrictEqual(r.body.owner, { type: 'user', id: await sid(10) });assert.deepStrictEqual(r.body.environments, ['sandbox'], 'new projects are sandbox-only');assert.deepStrictEqual(r.body.allowance, []);@@@ -102,7 +103,7 @@ const secretsSeen = [];// ── Members ──r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });assert.strictEqual(r.status, 201, r.text);-- r = await api('owner', 'POST', `/${P}/members`, { subject_id: sid(12), role: 'viewer' });++ r = await api('owner', 'POST', `/${P}/members`, { subject_id: await sid(12), role: 'viewer' });assert.strictEqual(r.status, 201, r.text);r = await api('owner', 'POST', `/${P}/members`, { username: 'staff', role: 'owner' });assert.strictEqual(r.status, 403, 'ownership is not assignable');@@@ -200,7 +201,7 @@ const secretsSeen = [];const now = Math.floor(Date.now() / 1000);const forged = (env) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `app:${A}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['identity.subject.resolve'],project_id: P, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);-- const resolve = (tok) => fetch(`${base}/internal/identity/resolve?subject_id=${sid(10)}`, { headers: { authorization: `Bearer ${tok}` } }).then(async x => ({ status: x.status, body: await x.json() }));++ const resolve = async (tok) => fetch(`${base}/internal/identity/resolve?subject_id=${await sid(10)}`, { headers: { authorization: `Bearer ${tok}` } }).then(async x => ({ status: x.status, body: await x.json() }));r = await resolve(forged('sandbox'));assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.sandbox_refused', 'Network (a production audience) refuses sandbox tokens');r = await resolve(forged('production'));@@@ -282,7 +283,7 @@ const secretsSeen = [];assert.strictEqual(t.status, 200, JSON.stringify(t.body));v = verify(t.body.access_token);assert.strictEqual(v.claims.sub, `app:${B}`);-- assert.strictEqual(v.claims.on_behalf_of, sid(11));++ assert.strictEqual(v.claims.on_behalf_of, await sid(11));assert.deepStrictEqual(v.claims.cap, ['media.object.upload']);t = await exchange(code, { code_verifier: verifier });assert.strictEqual(t.status, 400, 'codes are single-use');@@@ -339,13 +340,13 @@ const secretsSeen = [];assert.strictEqual(r.status, 200);const types = new Set(r.body.entries.map(e => e.event_type).filter(Boolean));for (const e of ['network.app.created', 'network.app.revoked', 'network.credential.rotated', 'network.credential.revoked', 'network.grant.changed']) assert.ok(types.has(e), `event ${e}`);-- for (const row of db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL').all()) {++ for (const row of await db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL').all()) {const env = JSON.parse(row.event);assert.ok(validate('events.event-envelope@1', env).valid, JSON.stringify(env));assert.strictEqual(env.source, 'network');}-- assert.throws(() => db.prepare('UPDATE dev_audit SET action = ?').run('x'), /append-only/);-- assert.throws(() => db.prepare('DELETE FROM dev_audit').run(), /append-only/);++ await assert.rejects(async () => await db.prepare('UPDATE dev_audit SET action = ?').run('x'), /append-only/);++ await assert.rejects(async () => await db.prepare('DELETE FROM dev_audit').run(), /append-only/);// ── Archive revokes every app ──r = await api('dev', 'POST', `/${P}/archive`);@@@ -358,10 +359,10 @@ const secretsSeen = [];assert.strictEqual(r.status, 409);// ── Secrets: never stored in plaintext, never logged ──-- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(x => x.name);++ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map(x => x.name);for (const s of secretsSeen) {for (const name of tables) {-- const dump = JSON.stringify(db.prepare(`SELECT * FROM "${name}"`).all());++ const dump = JSON.stringify(await db.prepare(`SELECT * FROM "${name}"`).all());assert.ok(!dump.includes(s), `secret stored in plaintext in ${name}`);}assert.ok(!logged.some(l => l.includes(s)), 'secret appeared in logs');@@@ -370,3 +371,4 @@ const secretsSeen = [];out('developer projects: all checks passed');server.close();})().catch(err => { orig.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -1,26 +1,56 @@--const Database=require('better-sqlite3');const express=require('express');const assert=require('assert');--const db=new Database(':memory:');db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO users VALUES(1,'alex'),(2,'mod')");--process.env.OWNER_USERNAME=process.env.OWNER_USERNAME||'alex';--const {createDomainRoutes}=require('../server/domains/routes');--const {ownerName}=require('../server/auth/owner-guard');--const owner=ownerName();--const auth=(req,res,next)=>{const w=req.headers['x-as'];if(!w)return res.status(401).json({error:'auth'});req.user=w==='owner'?{id:1,username:owner,role:'admin'}:{id:2,username:'mod',role:'admin'};next();};--const cat={getCatalog:async()=>({source:'live',catalog:{tools:[{id:'yt',hosts:{canonical:'youtube-downloader.openvibe.tools',short:'yt.openvibe.tools',aliases:[]}}],families:[]}})};--const r=createDomainRoutes(db,auth,{catalog:cat,checkDomain:async()=>({ok:true})});--const app=express();app.use(express.json());app.use('/api/domains',r.publicRouter);app.use('/api/admin/domains',r.adminRouter);--const s=app.listen(0,async()=>{const b='http://127.0.0.1:'+s.address().port;const j=async(m,p,as,body)=>{const x=await fetch(b+p,{method:m,headers:{'content-type':'application/json',...(as?{'x-as':as}:{})},body:body?JSON.stringify(body):undefined});return{s:x.status,j:await x.json().catch(()=>null)}};--let x=await j('GET','/api/admin/domains');assert.equal(x.s,401);--x=await j('GET','/api/admin/domains','admin');assert.equal(x.s,403,'admin non-owner refused');--x=await j('POST','/api/admin/domains','admin',{tool_id:'yt',host:'evil.com',role:'canonical'});assert.equal(x.s,403);--x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloadOnline.com',role:'canonical'});assert.equal(x.s<300,true,JSON.stringify(x.j));--x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);--x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);--x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);--x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'youtubedownloader.openvibe.tools',role:'mirror'});assert.ok(x.s<300,'mirror role accepted: '+JSON.stringify(x.j));--x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'ytmirror.example.com',role:'mirror'});assert.ok(x.s<300,'a tool may have several mirrors');--x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));--assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);--assert.equal(x.j.domains.filter(d=>d.role==='mirror').length,2);--// an old table (CHECK without 'mirror') is rebuilt in place, rows kept--{const D=require('better-sqlite3');const old=new D(':memory:');old.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY,user_id INT,action TEXT,details TEXT,ip TEXT);CREATE TABLE tool_domains (id INTEGER PRIMARY KEY AUTOINCREMENT, tool_id TEXT NOT NULL, host TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')), enabled INTEGER NOT NULL DEFAULT 1, note TEXT, created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','keep.example.com','alias')");require('../server/domains/routes').ensureSchema(old);old.prepare("INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','m.example.com','mirror')").run();assert.equal(old.prepare('SELECT COUNT(*) c FROM tool_domains').get().c,2,'rows survive the rebuild');}--console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});++'use strict';++// Tool domains (server/domains/routes.js): who may add a host, what is refused, one canonical and one++// short host per tool, many mirrors and aliases, and the public list. The database is the process one++// (test/helpers/pg-preload.mjs); the old "rebuild a legacy table in place" drill is gone with SQLite++// (the schema is migrations/NNNN_*.sql now).++const express = require('express');++const assert = require('assert');++const { getDb } = require('../server/db/database');++++process.env.OWNER_USERNAME = process.env.OWNER_USERNAME || 'alex';++++(async () => {++const db = await getDb();++await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'alex', 'x', 'admin'), (2, 'mod', 'x', 'admin') ON CONFLICT (id) DO NOTHING").run();++++const { createDomainRoutes } = require('../server/domains/routes');++const { ownerName } = require('../server/auth/owner-guard');++const owner = ownerName();++const auth = (req, res, next) => {++ const w = req.headers['x-as'];++ if (!w) return res.status(401).json({ error: 'auth' });++ req.user = w === 'owner' ? { id: 1, username: owner, role: 'admin' } : { id: 2, username: 'mod', role: 'admin' };++ next();++};++const cat = { getCatalog: async () => ({ source: 'live', catalog: { tools: [{ id: 'yt', hosts: { canonical: 'youtube-downloader.openvibe.tools', short: 'yt.openvibe.tools', aliases: [] } }], families: [] } }) };++const r = await createDomainRoutes(db, auth, { catalog: cat, checkDomain: async () => ({ ok: true }) });++const app = express();++app.use(express.json());++app.use('/api/domains', r.publicRouter);++app.use('/api/admin/domains', r.adminRouter);++await new Promise((resolve) => {++ const s = app.listen(0, async () => {++ const b = 'http://127.0.0.1:' + s.address().port;++ const j = async (m, p, as, body) => {++ const x = await fetch(b + p, { method: m, headers: { 'content-type': 'application/json', ...(as ? { 'x-as': as } : {}) }, body: body ? JSON.stringify(body) : undefined });++ return { s: x.status, j: await x.json().catch(() => null) };++ };++ let x = await j('GET', '/api/admin/domains'); assert.equal(x.s, 401);++ x = await j('GET', '/api/admin/domains', 'admin'); assert.equal(x.s, 403, 'admin non-owner refused');++ x = await j('POST', '/api/admin/domains', 'admin', { tool_id: 'yt', host: 'evil.com', role: 'canonical' }); assert.equal(x.s, 403);++ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'YoutubeDownloadOnline.com', role: 'canonical' }); assert.equal(x.s < 300, true, JSON.stringify(x.j));++ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'other.com', role: 'canonical' }); assert.ok(x.s < 300);++ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'nope', host: 'a.com', role: 'alias' }); assert.equal(x.s, 400);++ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'bad host/../', role: 'alias' }); assert.equal(x.s, 400);++ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'youtubedownloader.openvibe.tools', role: 'mirror' }); assert.ok(x.s < 300, 'mirror role accepted: ' + JSON.stringify(x.j));++ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'ytmirror.example.com', role: 'mirror' }); assert.ok(x.s < 300, 'a tool may have several mirrors');++ x = await j('GET', '/api/domains');++ assert.equal(x.j.domains.filter(d => d.role === 'canonical').length, 1);++ assert.equal(x.j.domains.filter(d => d.role === 'mirror').length, 2);++ console.log('audit rows', (await db.prepare('select count(*) c from audit_log').get()).c);++ console.log('domains: all checks passed');++ s.close();++ resolve();++ });++});++})().catch(err => { console.error(err); process.exit(1); });@@@ -13,20 +13,21 @@ const express = require('express');const crypto = require('crypto');const { ids, serviceAuth } = require('openvibe-contracts');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { NotificationService } = require('../server/notifications/notification-service');const { createEventsConsumer, TOPICS } = require('../server/notifications/events-consumer');const { createLiveFollowers } = require('../server/notifications/live-followers');const streamLive = require('../server/notifications/stream-live');const { topicsFrom } = require('../scripts/subscribe-events');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-events-consumer-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;const ALICE = ids.newId('user'), BOB = ids.newId('user'), NOBODY = ids.newId('user');--db.prepare("INSERT INTO users (id, username, password_hash, subject_id, email, email_verified) VALUES (7, 'alice', 'x', ?, '[email protected]', 1), (8, 'bob', 'x', ?, NULL, 0)").run(ALICE, BOB);++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, email, email_verified) VALUES (7, 'alice', 'x', ?, '[email protected]', 1), (8, 'bob', 'x', ?, NULL, 0)").run(ALICE, BOB);const notifications = new NotificationService(db);const SECRET = 'a'.repeat(40), NEXT = 'b'.repeat(40);@@@ -54,7 +55,7 @@ const tradeEvent = (over = {}) => ({},...over,});--const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at, rowid').all(userId);++const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at, seq').all(userId);(async () => {// The subscriptions this consumer is meant for, and nothing else.@@@ -65,7 +66,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);assert.throws(() => topicsFrom(['--topic', 'live.*']));-- let consumer = createEventsConsumer({ db, notifications, secrets: '' });++ let consumer = await createEventsConsumer({ db, notifications, secrets: '' });const app = express();app.use('/internal/events', (req, res, next) => consumer.router(req, res, next));app.use(express.json()); // as in server/index.js: the consumer runs before the JSON parser@@@ -84,9 +85,9 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =let r = await post(dealsEvent());assert.strictEqual(r.status, 503);assert.strictEqual(r.body.code, 'network.webhook_disabled');-- assert.strictEqual(rows(7).length, 0);++ assert.strictEqual((await rows(7)).length, 0);-- consumer = createEventsConsumer({ db, notifications, secrets: `${SECRET},${NEXT}` });++ consumer = await createEventsConsumer({ db, notifications, secrets: `${SECRET},${NEXT}` });assert.strictEqual(consumer.enabled, true);// A signed v2 Deals watch match becomes one notification for the watcher.@@@ -94,7 +95,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =r = await post(e1);assert.strictEqual(r.status, 200);assert.deepStrictEqual(r.body, { event_id: e1.event_id, duplicate: false, outcome: 'notified' });-- let mine = rows(7);++ let mine = await rows(7);assert.strictEqual(mine.length, 1);assert.strictEqual(mine[0].type, 'DEAL_WATCH_MATCH');assert.strictEqual(mine[0].category, 'service');@@@ -109,7 +110,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =assert.strictEqual(r.status, 200);assert.strictEqual(r.body.duplicate, true);assert.strictEqual(r.body.outcome, null);-- assert.strictEqual(rows(7).length, 1);++ assert.strictEqual((await rows(7)).length, 1);// v1-only (the v2 headers stripped), stale v2, wrong secret, unsigned: refused, nothing stored.for (const [label, opts] of [@@@ -122,14 +123,14 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =r = await post(e, opts);assert.strictEqual(r.status, 401, label);assert.strictEqual(r.body.code, 'network.bad_signature', label);-- assert.ok(!db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(e.event_id), `${label}: not even recorded`);++ assert.ok(!await db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(e.event_id), `${label}: not even recorded`);}-- assert.strictEqual(rows(7).length, 1);++ assert.strictEqual((await rows(7)).length, 1);// The rotated-in secret verifies too.r = await post(dealsEvent({ payload: { ...dealsEvent().payload, kind: 'keyword', query: 'headphones', offer_id: 'dof_2' } }), { secret: NEXT });assert.strictEqual(r.body.outcome, 'notified');-- assert.match(rows(7)[1].message, /\(matches "headphones"\)$/);++ assert.match((await rows(7))[1].message, /\(matches "headphones"\)$/);// Unknown types are acknowledged and ignored (and recorded, so a redelivery is a duplicate).const other = dealsEvent({ event_type: 'deals.offer.created' });@@@ -139,7 +140,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =assert.strictEqual(r.body.duplicate, true);r = await post(dealsEvent({ event_type: 'constructor' }));assert.strictEqual(r.body.outcome, 'ignored:type', 'no prototype lookups');-- assert.strictEqual(rows(7).length, 2);++ assert.strictEqual((await rows(7)).length, 2);// A publisher may not speak for another service; an unknown person gets nothing.r = await post(dealsEvent({ source: 'trade' }));@@@ -152,7 +153,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =// A Trade alert reaches the rule's owner.r = await post(tradeEvent());assert.strictEqual(r.body.outcome, 'notified');-- let bobs = rows(8);++ let bobs = await rows(8);assert.strictEqual(bobs.length, 1);assert.strictEqual(bobs[0].type, 'TRADE_ALERT');assert.strictEqual(bobs[0].title, 'Trade alert: AAPL');@@@ -161,31 +162,31 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =assert.strictEqual(bobs[0].url, null);r = await post(tradeEvent({ payload: { ...tradeEvent().payload, trigger: { kind: 'document', id: 'doc_1', form_type: '8-K', title: 'Current report' } } }));assert.strictEqual(r.body.outcome, 'notified');-- assert.match(rows(8)[1].message, /New 8-K: Current report\./);++ assert.match((await rows(8))[1].message, /New 8-K: Current report\./);r = await post(tradeEvent({ subject: { type: 'watch', id: 'x' } }));assert.strictEqual(r.body.outcome, 'ignored:payload');// A link to a site whose domain serves the service is kept; a foreign host never is.r = await post(tradeEvent({ payload: { ...tradeEvent().payload, instrument: { symbol: 'X', url: 'https://openvibe.live/x' } } }));-- assert.strictEqual(rows(8).pop().url, 'https://openvibe.live/x');++ assert.strictEqual((await rows(8)).pop().url, 'https://openvibe.live/x');r = await post(tradeEvent({ payload: { ...tradeEvent().payload, instrument: { symbol: 'Y', url: 'https://evil.example/y' } } }));-- const last = rows(8).pop();++ const last = (await rows(8)).pop();assert.strictEqual(last.url, null);assert.strictEqual(JSON.parse(last.rich_content).context.planned_url, null);// Preferences: with the 'service' category turned off, nothing is created (and it stays handled).-- const before = rows(8).length;-- notifications.setPreference(8, 'service', { enabled: false });++ const before = (await rows(8)).length;++ await notifications.setPreference(8, 'service', { enabled: false });const muted = tradeEvent();r = await post(muted);assert.strictEqual(r.body.outcome, 'suppressed:preference');-- assert.strictEqual(rows(8).length, before);++ assert.strictEqual((await rows(8)).length, before);assert.strictEqual((await post(muted)).body.duplicate, true);// Email follows the same per-category choice: none by default, yes once opted in (verified address).-- const n = { ...rows(7)[0], email_verified: 1, email_bounced_at: null };-- assert.strictEqual(notifications.shouldEmail(n), false);-- notifications.setPreference(7, 'service', { email: true });-- assert.strictEqual(notifications.shouldEmail(n), true);++ const n = { ...(await rows(7))[0], email_verified: 1, email_bounced_at: null };++ assert.strictEqual(await notifications.shouldEmail(n), false);++ await notifications.setPreference(7, 'service', { email: true });++ assert.strictEqual(await notifications.shouldEmail(n), true);// Malformed envelopes are refused.r = await post({ event_id: 'nope', event_type: 'deals.watch.matched' });@@@ -194,11 +195,11 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =// ── live.stream.started: every follower of the channel, once ──────────────────────────────const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });const CAROL = ids.newId('user'), DAVE = ids.newId('user'), FRANK = ids.newId('user'), GINA = ids.newId('user'), HANK = ids.newId('user'), STRANGER = ids.newId('user');-- db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, avatar_url) VALUES++ await db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, avatar_url) VALUES(20, 'carol', 'x', ?, 'https://openvibe.media/avatar/carol'), (21, 'dave', 'x', ?, NULL), (22, 'erin', 'x', NULL, NULL),(23, 'frank', 'x', ?, NULL), (24, 'gina', 'x', ?, NULL), (25, 'hank', 'x', ?, NULL)`).run(CAROL, DAVE, FRANK, GINA, HANK);-- notifications.setPreference(23, 'stream', { enabled: false }); // Frank muted go-lives-- notifications.setPreference(24, 'stream_live_all', { enabled: true }); // Gina wants every go-live++ await notifications.setPreference(23, 'stream', { enabled: false }); // Frank muted go-lives++ await notifications.setPreference(24, 'stream_live_all', { enabled: true }); // Gina wants every go-live// Live's GET /internal/followers, as the Live-side change specifies. Streams: 501 carol, 502 hank.let liveMode = 'ok'; const liveCalls = []; const liveAuth = [];const FOLLOWERS = {@@@ -224,7 +225,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =await new Promise(r => live.listen(0, '127.0.0.1', r));const discordCalls = [];let clock = Date.now();-- consumer = createEventsConsumer({++ consumer = await createEventsConsumer({db, notifications, secrets: SECRET, now: () => clock,liveFollowers: createLiveFollowers({ privateKey, issuer: 'https://openvibe.network', liveUrl: `http://127.0.0.1:${live.address().port}` }),discord: () => ({ sendLiveAlert: async (streamer, stream) => { discordCalls.push([streamer, stream]); return { sent: true }; } }),@@@ -239,7 +240,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =},...over,});-- const goLives = (uid) => db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'STREAM_LIVE' ORDER BY rowid").all(uid);++ const goLives = async (uid) => await db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'STREAM_LIVE' ORDER BY seq").all(uid);// One delivery: Dave (by subject), Erin (by network id, no subject yet) and Gina (all go-lives) are// notified once each; Frank muted the category, Carol is the streamer, the stranger has no account.@@@ -248,10 +249,10 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual(r.body.outcome, 'notified');assert.deepStrictEqual(r.body.detail, { followers: 5, unresolved: 1, targets: 4, notified: 3 });-- for (const uid of [21, 22, 24]) assert.strictEqual(goLives(uid).length, 1, `user ${uid} is told once`);-- assert.strictEqual(goLives(23).length, 0, 'the stream category is muted: preferences apply');-- assert.strictEqual(goLives(20).length, 0, 'the streamer is not told about themself');-- const n1 = goLives(21)[0];++ for (const uid of [21, 22, 24]) assert.strictEqual((await goLives(uid)).length, 1, `user ${uid} is told once`);++ assert.strictEqual((await goLives(23)).length, 0, 'the stream category is muted: preferences apply');++ assert.strictEqual((await goLives(20)).length, 0, 'the streamer is not told about themself');++ const n1 = (await goLives(21))[0];assert.strictEqual(n1.title, 'Carol 3 is live!');assert.strictEqual(n1.message, 'Building a b robot /b', 'no markup reaches the inbox');assert.strictEqual(n1.category, 'stream');@@@ -279,41 +280,41 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =r = await post(s1);assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, true, null]);assert.strictEqual(liveCalls.length, 0);-- for (const uid of [21, 22, 24]) assert.strictEqual(goLives(uid).length, 1);++ for (const uid of [21, 22, 24]) assert.strictEqual((await goLives(uid)).length, 1);assert.strictEqual(discordCalls.length, 1);// A new stream row from the same channel within the hour (a reconnect): the announcement window holds.r = await post(liveEvent({}, { stream_id: 501 }));assert.strictEqual(r.body.outcome, 'skipped:cooldown');-- assert.strictEqual(goLives(21).length, 1);++ assert.strictEqual((await goLives(21)).length, 1);// ...and it is the same window Live's direct POST /internal/events/stream-live claims (keyed by the Network id),// so during the switch the two paths never both announce.-- assert.strictEqual(streamLive.claimAnnouncement(db, { streamerKey: 20, streamId: 501 }).reason, 'cooldown');++ assert.strictEqual((await streamLive.claimAnnouncement(db, { streamerKey: 20, streamId: 501 })).reason, 'cooldown');// v1-only (and any unverifiable) live delivery: refused, not recorded, Live never asked.liveCalls.length = 0;const v1 = liveEvent({ subject: { type: 'stream', id: '502', revision: 1 }, actor: { type: 'user', id: HANK } }, { stream_id: 502, channel: { username: 'hank', subject: { type: 'user', id: HANK } } });r = await post(v1, { strip: ['X-OpenVibe-Signature-V2', 'X-OpenVibe-Timestamp'] });assert.deepStrictEqual([r.status, r.body.code], [401, 'network.bad_signature']);-- assert.ok(!db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));++ assert.ok(!await db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));assert.strictEqual(liveCalls.length, 0);// Live down: 503, nothing recorded, so Events' retry does the work once Live answers.liveMode = 'down';r = await post(v1);assert.deepStrictEqual([r.status, r.body.code], [503, 'network.dependency_unavailable']);-- assert.ok(!db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));-- assert.strictEqual(goLives(21).length, 1);++ assert.ok(!await db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));++ assert.strictEqual((await goLives(21)).length, 1);liveMode = 'ok';r = await post(v1);assert.strictEqual(r.body.outcome, 'notified');-- assert.strictEqual(goLives(21).length, 2, 'Dave follows Hank too');-- assert.strictEqual(goLives(24).length, 2, 'Gina gets every go-live');++ assert.strictEqual((await goLives(21)).length, 2, 'Dave follows Hank too');++ assert.strictEqual((await goLives(24)).length, 2, 'Gina gets every go-live');// Refused or ignored without notifying anyone.liveCalls.length = 0;-- const count = () => db.prepare("SELECT COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE'").get().c;-- const total = count();++ const count = async () => (await db.prepare("SELECT COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE'").get()).c;++ const total = await count();r = await post(liveEvent({ source: 'tips' }));assert.strictEqual(r.body.outcome, 'ignored:source', 'only Live speaks for Live channels');// The signatures are made at the consumer's clock (it moves forward below).@@@ -330,10 +331,11 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =assert.strictEqual(r.body.outcome, 'ignored:channel-mismatch', 'stream 502 is Hank\'s, not Carol\'s');r = await post(liveEvent({}, { stream_id: 999 }), { now: clock });assert.strictEqual(r.body.outcome, 'ignored:stream');-- assert.strictEqual(count(), total);++ assert.strictEqual(await count(), total);assert.strictEqual(discordCalls.length, 2);live.close();srv.close();console.log('events consumer: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -8,15 +8,16 @@ const os = require('os');const path = require('path');const crypto = require('crypto');const express = require('express');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { createEventsOps } = require('../server/admin/events-ops');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-eventsops-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });--db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'goosely', 'x', 'admin'), (2, 'adminb', 'x', 'admin')").run();++await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'goosely', 'x', 'admin'), (2, 'adminb', 'x', 'admin')").run();const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JRTKS';(async () => {@@@ -30,7 +31,7 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JRlet as = 1;const app = express();-- app.use((req, res, next) => { req.user = db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); });++ app.use(async (req, res, next) => { req.user = await db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); });app.use('/api/admin/events', createEventsOps({ db, eventsUrl: `http://127.0.0.1:${ev.address().port}`, privateKey, issuer: 'https://openvibe.network' }));const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });const base = `http://127.0.0.1:${srv.address().port}`;@@@ -51,7 +52,7 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JRr = await call('POST', '/api/admin/events/replay', { subscription_id: SUB, event_ids: [EVT] });assert.deepStrictEqual(r.body, { ok: true, queued: 1 });assert.deepStrictEqual(seen.at(-1).body, { subscription_id: SUB, event_ids: [EVT] });-- const audit = db.prepare("SELECT user_id, details FROM audit_log WHERE action = 'events_replay'").all();++ const audit = await db.prepare("SELECT user_id, details FROM audit_log WHERE action = 'events_replay'").all();assert.strictEqual(audit.length, 1); assert.strictEqual(audit[0].user_id, 1);assert.deepStrictEqual(JSON.parse(audit[0].details).event_ids, [EVT]);@@@ -64,3 +65,4 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JRfs.rmSync(dir, { recursive: true, force: true });console.log('events ops: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -8,11 +8,12 @@ const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-notify-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = await getDb();console.log = log;require('../server/identity/blocks').ensureSchema(db);@@@ -20,7 +21,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';--db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, avatar_url) VALUES++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, avatar_url) VALUES(1, 'ann', 'Ann', 'x', ?, 'https://openvibe.media/avatar/ann'), (2, 'bob', 'Bob', 'x', ?, NULL),(3, 'cat', NULL, 'x', ?, NULL), (4, 'dan', 'Dan', 'x', ?, NULL)`).run(ANN, BOB, CAT, DAN);@@@ -30,13 +31,13 @@ const follows = require('../server/identity/follows');follows.ensureSchema(db);follows.setNotifier(require('../server/notifications/follow-notify').followNotifier(notifications));--const followsOf = (userId) => db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'FOLLOW' ORDER BY created_at").all(userId);--const announced = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))++const followsOf = async (userId) => await db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'FOLLOW' ORDER BY created_at").all(userId);++const announced = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.notification.created');// A new follow: one FOLLOW for the followed person, from the follower, linking to the follower's channel.--follows.setFollow(db, ANN, 'channel', BOB, true);--let n = followsOf(2);++await follows.setFollow(db, ANN, 'channel', BOB, true);++let n = await followsOf(2);assert.strictEqual(n.length, 1);assert.strictEqual(n[0].message, 'Ann followed you');assert.strictEqual(n[0].title, 'New Follower');@@@ -45,50 +46,54 @@ assert.strictEqual(n[0].service, 'live');assert.strictEqual(n[0].category, 'social');assert.strictEqual(n[0].sender_id, 1);assert.strictEqual(n[0].sender_avatar, 'https://openvibe.media/avatar/ann');--assert.deepStrictEqual(announced().map((e) => [e.subject.id, e.payload.type]), [[BOB, 'FOLLOW']], 'announced to the followed person');--assert.strictEqual(followsOf(1).length, 0, 'the follower is not notified');++assert.deepStrictEqual((await announced()).map((e) => [e.subject.id, e.payload.type]), [[BOB, 'FOLLOW']], 'announced to the followed person');++assert.strictEqual((await followsOf(1)).length, 0, 'the follower is not notified');// Same follow again, a flag change, an unfollow, and following again within the hour: nothing new.--follows.setFollow(db, ANN, 'channel', BOB, true);--follows.setFollow(db, ANN, 'channel', BOB, true, { notifyEmail: false });--follows.setFollow(db, ANN, 'channel', BOB, false);--follows.setFollow(db, ANN, 'channel', BOB, true);--assert.strictEqual(followsOf(2).length, 1, 'one an hour per follower');--assert.strictEqual(announced().length, 1);++await follows.setFollow(db, ANN, 'channel', BOB, true);++await follows.setFollow(db, ANN, 'channel', BOB, true, { notifyEmail: false });++await follows.setFollow(db, ANN, 'channel', BOB, false);++await follows.setFollow(db, ANN, 'channel', BOB, true);++assert.strictEqual((await followsOf(2)).length, 1, 'one an hour per follower');++assert.strictEqual((await announced()).length, 1);// Another follower is another notification; a name falls back to the username.--follows.setFollow(db, CAT, 'channel', BOB, true);--n = followsOf(2);++await follows.setFollow(db, CAT, 'channel', BOB, true);++n = await followsOf(2);assert.strictEqual(n.length, 2);assert.strictEqual(n[1].message, 'cat followed you');// An import (Live's backfill) announces nothing and notifies nobody.--follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);--assert.ok(follows.status(db, 'channel', ANN, DAN).following, 'imported');--assert.strictEqual(followsOf(1).length, 0);++await follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);++assert.ok((await follows.status(db, 'channel', ANN, DAN)).following, 'imported');++assert.strictEqual((await followsOf(1)).length, 0);// The followed person's social preference off: the follow is made, no notification.--notifications.setPreference(4, 'social', { enabled: false });--follows.setFollow(db, ANN, 'channel', DAN, true);--assert.ok(follows.status(db, 'channel', DAN, ANN).following);--assert.strictEqual(followsOf(4).length, 0);++await notifications.setPreference(4, 'social', { enabled: false });++await follows.setFollow(db, ANN, 'channel', DAN, true);++assert.ok((await follows.status(db, 'channel', DAN, ANN)).following);++assert.strictEqual((await followsOf(4)).length, 0);// A follower the followed person blocked: no notification.--db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject) VALUES (?, ?)').run(ANN, CAT);--follows.setFollow(db, CAT, 'channel', ANN, true);--assert.strictEqual(followsOf(1).length, 0, 'blocked follower');--follows.setFollow(db, BOB, 'channel', ANN, true);--assert.strictEqual(followsOf(1).length, 1, 'anyone else still notifies');++await db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject) VALUES (?, ?)').run(ANN, CAT);++await follows.setFollow(db, CAT, 'channel', ANN, true);++assert.strictEqual((await followsOf(1)).length, 0, 'blocked follower');++await follows.setFollow(db, BOB, 'channel', ANN, true);++assert.strictEqual((await followsOf(1)).length, 1, 'anyone else still notifies');// A notifier that throws never fails the follow, and the follow and its event still commit.follows.setNotifier(() => { throw new Error('boom'); });const warn = console.warn; console.warn = () => {};--const out = follows.setFollow(db, DAN, 'channel', CAT, true);++const out = await follows.setFollow(db, DAN, 'channel', CAT, true);console.warn = warn;assert.ok(out.changed && out.active);--assert.ok(follows.status(db, 'channel', CAT, DAN).following);--assert.ok(db.prepare("SELECT 1 FROM network_event_outbox WHERE envelope LIKE '%network.follow.created%' AND envelope LIKE ?").get(`%"follower":"${DAN}","target_type":"channel","target_id":"${CAT}"%`));++assert.ok((await follows.status(db, 'channel', CAT, DAN)).following);++{++ const created = await db.prepare("SELECT envelope FROM network_event_outbox WHERE envelope->>'event_type' = 'network.follow.created'").all();++ assert.ok(created.some((r) => r.envelope.payload.follower === DAN && r.envelope.payload.target_type === 'channel' && r.envelope.payload.target_id === CAT), 'the follow and its event still commit');++}follows.setNotifier(null);fs.rmSync(dir, { recursive: true, force: true });console.log('follow notifications: ok');++})().catch(err => { console.error(err); process.exit(1); });@@@ -12,18 +12,19 @@ const http = require('http');const express = require('express');const { ids } = require('openvibe-contracts');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { NotificationService } = require('../server/notifications/notification-service');const { createEventsConsumer } = require('../server/notifications/events-consumer');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-auth-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--require('../server/identity/principals').ensureSchema(db);--for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++await require('../server/identity/principals').ensureSchema(db);++for (const c of ['live', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);const CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');--db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?), (22, ?, ?, ?, ?)')++await db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?), (22, ?, ?, ?, ?)').run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user', 'erin', 'x', ERIN, 'user');const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });@@@ -34,7 +35,7 @@ const follows = require('../server/identity/follows');const principals = require('../server/identity/principals');const SECRET = 's'.repeat(40);const notifications = new NotificationService(db);--const consumer = createEventsConsumer({ db, notifications, secrets: SECRET, followsAuthority: 'network', log: { log() {}, warn() {}, error() {} } });++const consumer = await createEventsConsumer({ db, notifications, secrets: SECRET, followsAuthority: 'network', log: { log() {}, warn() {}, error() {} } });const app = express();Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });app.use(express.urlencoded({ extended: true }));@@@ -59,13 +60,13 @@ const server = http.createServer(app);assert.deepStrictEqual([r.status, r.body.followers, r.body.following, r.body.notify_push], [201, 1, true, false]);r = await call('PUT', `/internal/follows/channel/${CAROL}`, { body: { follower: ERIN }, token: live });assert.strictEqual(r.body.followers, 2);-- assert.strictEqual(db.prepare('SELECT source FROM user_follows WHERE follower_subject = ?').get(ERIN).source, 'live', 'the writing service is recorded');++ assert.strictEqual((await db.prepare('SELECT source FROM user_follows WHERE follower_subject = ?').get(ERIN)).source, 'live', 'the writing service is recorded');assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ' }, token: live })).status, 404, 'the follower must be a Network account');assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, extra: 1 }, token: live })).status, 400);r = await call('DELETE', `/internal/follows/channel/carol?follower=${ERIN}`, { token: live });assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);await call('PUT', '/internal/follows/channel/carol', { body: { follower: ERIN }, token: live });-- const evs = db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope)).filter((e) => e.event_type.startsWith('network.follow.'));++ const evs = (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((x) => x.envelope).filter((e) => e.event_type.startsWith('network.follow.'));assert.deepStrictEqual(evs.map((e) => e.event_type), ['network.follow.created', 'network.follow.created', 'network.follow.deleted', 'network.follow.created']);// ── Go-live with FOLLOWS_AUTHORITY=network: Network's followers, no Live call ──@@@ -80,7 +81,7 @@ const server = http.createServer(app);const body = await res.json();assert.strictEqual(res.status, 200, JSON.stringify(body));assert.strictEqual(body.outcome, 'notified', JSON.stringify(body));-- const told = db.prepare("SELECT user_id FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 ORDER BY user_id").all().map((x) => x.user_id);++ const told = (await db.prepare("SELECT user_id FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 ORDER BY user_id").all()).map((x) => x.user_id);assert.deepStrictEqual(told, [21, 22], 'both followers, from Network\'s graph');} finally {server.close();@@@ -88,3 +89,4 @@ const server = http.createServer(app);}console.log('follows authority: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -12,22 +12,23 @@ const path = require('path');const http = require('http');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follows-'));const dbFile = path.join(dir, 'network.db');const log = console.log; console.log = () => {};--const db = initDb(dbFile);++const db = getDb();console.log = log;--require('../server/identity/principals').ensureSchema(db);--for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++await require('../server/identity/principals').ensureSchema(db);++for (const c of ['live', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';--db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES(1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob', 'x', ?, 'streamer'), (3, 'cat', NULL, 'x', ?, 'user')`).run(ANN, BOB, CAT);--db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();++await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };@@@ -45,7 +46,7 @@ app.use('/api/v1/me/follows', routers.me);app.use('/api/v1/follows', routers.pub);const server = http.createServer(app);--const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type.startsWith('network.follow.'));++const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type.startsWith('network.follow.'));const valid = (e) => {assert.ok(validate('events.event-envelope@1', e).valid);const pv = validate(`${e.event_type}@1`, e.payload);@@@ -56,46 +57,46 @@ const valid = (e) => {(async () => {await new Promise((r) => server.listen(0, '127.0.0.1', r));const base = `http://127.0.0.1:${server.address().port}`;-- const tok = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);++ const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));-- const as = (id) => ({ authorization: `Bearer ${tok(id)}` });++ const as = async (id) => ({ authorization: `Bearer ${await tok(id)}` });const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;try {// ── Follow, idempotently; events ──assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob')).status, 401, 'signed in only');-- let r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(1) });++ let r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(1) });assert.strictEqual(r.status, 201);assert.ok(validate('network.follow-status-result@1', r.body).valid, JSON.stringify(r.body));assert.deepStrictEqual([r.body.target_id, r.body.followers, r.body.following, r.body.notify_email, r.body.notify_push], [BOB, 1, true, true, true]);-- r = await call('PUT', '/api/v1/me/follows/channel/Bob', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/follows/channel/Bob', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.followers], [200, 1], 'following again changes nothing (and names are case-insensitive)');-- assert.strictEqual(events().length, 1, 'and announces nothing');-- r = await call('PUT', `/api/v1/me/follows/channel/${BOB}`, { headers: as(1), body: { notify_email: false } });++ assert.strictEqual((await events()).length, 1, 'and announces nothing');++ r = await call('PUT', `/api/v1/me/follows/channel/${BOB}`, { headers: await as(1), body: { notify_email: false } });assert.deepStrictEqual([r.status, r.body.notify_email, r.body.notify_push], [200, false, true], 'new flags: a change');-- let ev = events();++ let ev = await events();assert.deepStrictEqual(ev.map((e) => [e.event_type, e.payload.revision, e.payload.notify_email]), [['network.follow.created', 1, true], ['network.follow.created', 2, false]]);ev.forEach(valid);-- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/ann', { headers: as(1) })).status, 400, 'never oneself');-- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/anon1234', { headers: as(1) })).status, 404, 'a guest is not a channel');-- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/nobody', { headers: as(1) })).status, 404);-- assert.strictEqual((await call('PUT', '/api/v1/me/follows/space/bob', { headers: as(1) })).status, 404, 'unknown target type');-- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(5) })).status, 403, 'guests do not follow');-- await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(3) });++ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/ann', { headers: await as(1) })).status, 400, 'never oneself');++ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/anon1234', { headers: await as(1) })).status, 404, 'a guest is not a channel');++ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/nobody', { headers: await as(1) })).status, 404);++ assert.strictEqual((await call('PUT', '/api/v1/me/follows/space/bob', { headers: await as(1) })).status, 404, 'unknown target type');++ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(5) })).status, 403, 'guests do not follow');++ await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(3) });// ── Public count; the viewer's own state; lists are not public ──r = await call('GET', '/api/v1/follows/channel/bob');assert.deepStrictEqual([r.status, r.body], [200, { target_type: 'channel', target_id: BOB, followers: 2 }]);assert.match(r.cache, /public/);-- r = await call('GET', '/api/v1/follows/channel/bob', { headers: as(1) });++ r = await call('GET', '/api/v1/follows/channel/bob', { headers: await as(1) });assert.deepStrictEqual([r.body.following, r.body.notify_email], [true, false]);assert.match(r.cache, /private/);-- r = await call('GET', '/api/v1/me/follows', { headers: as(1) });++ r = await call('GET', '/api/v1/me/follows', { headers: await as(1) });assert.ok(validate('network.follow-list-result@1', r.body).valid);assert.deepStrictEqual(r.body.items.map((i) => i.target_id), [BOB]);assert.ok([401, 403].includes((await call('GET', '/api/v1/follows/channel/bob/followers')).status), 'no token: not the owner, not a service');-- assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: as(1) })).status, 403, 'a follower is not the owner');-- r = await call('GET', '/api/v1/follows/channel/bob/followers', { headers: as(2) });++ assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: await as(1) })).status, 403, 'a follower is not the owner');++ r = await call('GET', '/api/v1/follows/channel/bob/followers', { headers: await as(2) });assert.deepStrictEqual([r.status, r.body.items.map((i) => i.follower).sort()], [200, [ANN, CAT].sort()], 'the owner sees who follows');r = await call('GET', '/api/v1/follows/channel/bob/followers?limit=1', { headers: { authorization: `Bearer ${await svc('live')}` } });assert.deepStrictEqual([r.status, r.body.items.length, typeof r.body.next_cursor], [200, 1, 'string'], 'a service with network.follows.read, paged');@@@ -106,20 +107,20 @@ const valid = (e) => {assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: { authorization: `Bearer ${await svc('tools')}` } })).status, 403, 'a service without the capability');// ── Unfollow, idempotently ──-- r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: as(1) });++ r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: await as(1) });assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);-- r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: as(1) });++ r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: await as(1) });assert.strictEqual(r.status, 200);-- ev = events().filter((e) => e.event_type === 'network.follow.deleted');++ ev = (await events()).filter((e) => e.event_type === 'network.follow.deleted');assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.reason]), [[3, 'unfollowed']], 'one delete, revision 3');ev.forEach(valid);-- r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(1) });++ r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(1) });assert.strictEqual(r.status, 200, 'a follow again (the row exists; revision 4)');-- assert.strictEqual(events().pop().payload.revision, 4);++ assert.strictEqual((await events()).pop().payload.revision, 4);// ── Account removal ──-- assert.strictEqual(db.transaction(() => follows.onSubjectRemoved(db, CAT))(), 1);-- assert.deepStrictEqual(events().pop().payload.reason, 'account_removed');++ assert.strictEqual(await db.tx(async () => await follows.onSubjectRemoved(db, CAT)), 1);++ assert.deepStrictEqual((await events()).pop().payload.reason, 'account_removed');// The Live backfill and its reconciliation used to be exercised here through the one-time// scripts/follows-backfill.js (retired in plan T2); importFollows() itself is covered by@@@ -130,3 +131,4 @@ const valid = (e) => {}console.log('follows: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -2,13 +2,13 @@const assert = require('assert');const crypto = require('crypto');const http = require('http');--const Database = require('better-sqlite3');++const { getDb } = require('../server/db/database');const { createFrameService, BANNED } = require('../server/frame/service');const { siteForHost } = require('../server/frame/sites');--const db = new Database(':memory:');--db.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");--const svc = createFrameService(db, { internalKey: 'change-me-in-production', services: {} }, null);++(async () => {++const db = getDb();++const svc = await createFrameService(db, { internalKey: 'change-me-in-production', services: {} }, null);assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');assert.equal(siteForHost('evil.example'), null);@@@ -20,11 +20,13 @@ assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'aassert.ok(p.soon.length >= 9, 'every planned site still listed as soon (chat launched 2026-09-24)');// Real use reorders: community gets the history, so it rises above the cold-start order.++// user_history's foreign key needs its users (PostgreSQL enforces the constraint SQLite only declared).++for (let i = 0; i < 9; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `h${i}`, 'x');const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");--for (let i = 0; i < 40; i++) ins.run(i % 9, 'community', null);--for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');++for (let i = 0; i < 40; i++) await ins.run(i % 9, 'community', null);++for (let i = 0; i < 6; i++) await ins.run(1, 'tools', 'dns');(async () => {-- db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (date('now'), 'openvibe.games', 900), (date('now'), 'dns.openvibe.tools', 50), (date('now'), 'openvibe.network', 1000)").run();++ await db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (substring(ov_now(), 1, 10), 'openvibe.games', 900), (substring(ov_now(), 1, 10), 'dns.openvibe.tools', 50), (substring(ov_now(), 1, 10), 'openvibe.network', 1000)").run();const realFetch = global.fetch; global.fetch = async () => ({ ok: false });await svc.refreshRank(); global.fetch = realFetch;p = svc.payloadFor('openvibe.network');@@@ -49,10 +51,10 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });const cfg = { internalKey: 'a-real-internal-key', services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } };const aiUrl = `http://127.0.0.1:${ai.address().port}`;-- const db2 = new Database(':memory:');-- const copySvc = (d) => createFrameService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });++ const db2 = db; // the process's database (test/helpers/pg-preload.mjs); the copy cache lives here++ const copySvc = async (d) => await createFrameService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });-- let s2 = copySvc(db2);++ let s2 = await copySvc(db2);aiMode = 'down';await s2.refreshCopy();p = s2.payloadFor('openvibe.games');@@@ -66,19 +68,18 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');p = s2.payloadFor('openvibe.games');assert.equal(p.footer.ai, true);assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.');-- assert.equal(JSON.parse(db2.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get().value).model, 'test-model', 'the model is read from run.provenance.model (ai.run@1)');++ assert.equal(JSON.parse((await db2.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get()).value).model, 'test-model', 'the model is read from run.provenance.model (ai.run@1)');aiMode = 'down';await s2.refreshCopy();p = s2.payloadFor('openvibe.games');assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'AI failure keeps the last good copy');-- s2 = copySvc(db2);++ s2 = await copySvc(db2);assert.equal(s2.payloadFor('openvibe.games').footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'and it survives a restart');// Without a signing key there is no AI client, and still no Live call.-- await createFrameService(new Database(':memory:'), cfg, null).refreshCopy();++ await (await createFrameService(db, cfg, null)).refreshCopy();assert.equal(liveHits, 0, 'Live is never asked for copy');-- live.close(); ai.close();// The page-view beacon (navigator.sendBeacon, a no-cors request) is readable cross-origin, so no// site's console reports it as blocked.{@@@ -91,17 +92,7 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');assert.equal(r.headers.get('access-control-allow-origin'), '*');srv.close();}-- {-- // Renamed from chrome_cache/chrome_hits: an existing database keeps its rows.-- const old = new Database(':memory:');-- old.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");-- old.exec("CREATE TABLE chrome_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP); INSERT INTO chrome_cache (key, value) VALUES ('copy', '{\"x\":1}')");-- old.exec("CREATE TABLE chrome_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host)); INSERT INTO chrome_hits VALUES ('2026-09-23', 'openvibe.live', 7)");-- createFrameService(old, { internalKey: 'k', services: {} }, null);-- assert.equal(old.prepare("SELECT hits FROM frame_hits WHERE host = 'openvibe.live'").get().hits, 7);-- assert.equal(old.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get().value, '{"x":1}');-- assert.equal(old.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name IN ('chrome_cache', 'chrome_hits')").get().n, 0);-- createFrameService(old, { internalKey: 'k', services: {} }, null); // idempotent-- }++ live.close(); ai.close();console.log('frame service: all checks passed');})().catch(e => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -9,19 +9,20 @@ const os = require('os');const path = require('path');const http = require('http');const express = require('express');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const github = require('../server/integrations/github');const principals = require('../server/identity/principals');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-github-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;delete process.env.GITHUB_TOKEN;process.env.OWNER_USERNAME = 'goosely';--db.prepare("UPDATE oauth_clients SET client_secret = 'blog-secret' WHERE client_id = 'blog'").run();--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'goosely', 'x'), (2, 'anadmin', 'x')").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'blog-secret' WHERE client_id = 'blog'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'goosely', 'x'), (2, 'anadmin', 'x')").run();const TOKEN = 'github_pat_' + 'A1b2C3d4E5'.repeat(6);const seen = [];@@@ -39,7 +40,7 @@ app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibapp.locals.privateKey = keys.privateKey;app.locals.publicKey = keys.publicKey;app.use('/oauth', require('../server/auth/oauth-routes'));--app.use((req, _res, next) => { const u = req.headers['x-user']; req.user = u ? db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });++app.use(async (req, _res, next) => { const u = req.headers['x-user']; req.user = u ? await db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });app.use('/api/admin/integrations/github', github.adminRouter(db, { fetchImpl: fakeGithub }));app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read', { legacy: false }), github.internalHandler(db));const server = http.createServer(app);@@@ -65,8 +66,8 @@ const server = http.createServer(app);assert.strictEqual(r.status, 200, r.text);assert.ok(!r.text.includes(TOKEN), 'the token is never sent back');assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'database', TOKEN.slice(-4)]);-- assert.strictEqual(github.tokenOf(db), TOKEN);-- const audit = db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();++ assert.strictEqual(await github.tokenOf(db), TOKEN);++ const audit = await db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();assert.strictEqual(audit.length, 1);assert.ok(!audit[0].details.includes(TOKEN), 'the audit row keeps the last four only');assert.ok(!(await call('GET', A, { user: 'goosely' })).text.includes(TOKEN));@@@ -104,12 +105,12 @@ const server = http.createServer(app);assert.deepStrictEqual([r.body.github.source, r.body.github.last4], ['env', 'ZZZZ']);assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409);assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);-- assert.strictEqual(github.tokenOf(db), process.env.GITHUB_TOKEN);++ assert.strictEqual(await github.tokenOf(db), process.env.GITHUB_TOKEN);delete process.env.GITHUB_TOKEN;// The registry's library tags read the token at call time.const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');-- assert.ok(/token: \(\) => require\('\.\/integrations\/github'\)\.tokenOf\(db\)/.test(src), 'library tags get the token lazily');++ assert.ok(/token: (async )?\(\) => (await )?require\('\.\/integrations\/github'\)\.tokenOf\(db\)/.test(src), 'library tags get the token lazily');assert.ok(src.indexOf("app.get('/internal/integrations/github-token'") < src.indexOf("app.use('/internal', require('./internal/routes'))"), 'mounted before the /internal router');} finally {server.close();@@@ -117,3 +118,4 @@ const server = http.createServer(app);}console.log('github integration: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -11,20 +11,21 @@ const path = require('path');const http = require('http');const express = require('express');const { validate, capabilities } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-grants-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;const principals = require('../server/identity/principals');const grants = require('../server/identity/grants-admin');--for (const c of ['live', 'media']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);--db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0)").run();--principals.ensureSchema(db);++for (const c of ['live', 'media']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0) ON CONFLICT DO NOTHING").run();++await principals.ensureSchema(db);const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';--const events = () => db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.principal_grant.changed');--const has = (client, cap, aud = 'openvibe.network') => principals.grantsFor(db, client, aud).some((g) => g.capability === cap);++const events = async () => (await db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.principal_grant.changed');++const has = async (client, cap, aud = 'openvibe.network') => (await principals.grantsFor(db, client, aud)).some((g) => g.capability === cap);const ok = (e) => { assert.ok(validate('events.event-envelope@1', e).valid); const v = validate('network.principal_grant.changed@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); };// Every default grant names its capability owner's audience, so any of them can be revoked here.@@@ -33,56 +34,56 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);}--assert.strictEqual(events().length, 0, 'the seed is not announced');++assert.strictEqual((await events()).length, 0, 'the seed is not announced');// Grant, update, revoke.--assert.ok(!has('live', 'network.staff.read'));--let out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'route reports to moderators', expires_at: '2099-01-01T00:00:00Z' }, OWNER);++assert.ok(!await has('live', 'network.staff.read'));++let out = await grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'route reports to moderators', expires_at: '2099-01-01T00:00:00Z' }, OWNER);assert.deepStrictEqual([out.change, out.audience], ['granted', 'openvibe.network'], 'the audience is the capability owner');--assert.ok(has('live', 'network.staff.read'));--let ev = events().at(-1); ok(ev);++assert.ok(await has('live', 'network.staff.read'));++let ev = (await events()).at(-1); ok(ev);assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });assert.deepStrictEqual(ev.subject, { type: 'grant', id: 'live:[email protected]' });assert.strictEqual(ev.payload.expires_at, '2099-01-01T00:00:00.000Z');--out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'no expiry after all' }, OWNER);++out = await grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'no expiry after all' }, OWNER);assert.strictEqual(out.change, 'updated');--grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'not needed' }, OWNER);--assert.ok(!has('live', 'network.staff.read'), 'the next token no longer carries it');--assert.deepStrictEqual(events().map((e) => e.payload.change), ['granted', 'updated', 'revoked']);--events().forEach(ok);--assert.throws(() => grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'again' }, OWNER), /no active grant/);--assert.deepStrictEqual(grants.changes(db, { client: 'live' }).map((c) => c.change), ['revoked', 'updated', 'granted']);--assert.strictEqual(grants.list(db, { client: 'live' }).find((g) => g.capability === 'network.staff.read').state, 'revoked');++await grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'not needed' }, OWNER);++assert.ok(!await has('live', 'network.staff.read'), 'the next token no longer carries it');++assert.deepStrictEqual((await events()).map((e) => e.payload.change), ['granted', 'updated', 'revoked']);++(await events()).forEach(ok);++await assert.rejects(async () => await grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'again' }, OWNER), /no active grant/);++assert.deepStrictEqual((await grants.changes(db, { client: 'live' })).map((c) => c.change), ['revoked', 'updated', 'granted']);++assert.strictEqual((await grants.list(db, { client: 'live' })).find((g) => g.capability === 'network.staff.read').state, 'revoked');// Refusals.--const refused = (body, re) => assert.throws(() => grants.grant(db, { reason: 'because', ...body }, OWNER), re);--refused({ client_id: 'nobody', capability: 'network.staff.read' }, /not a service principal/);--refused({ client_id: 'thirdparty', capability: 'network.staff.read' }, /not a service principal/);--refused({ client_id: 'live', capability: 'network.nope.read' }, /not in openvibe-contracts/);--refused({ client_id: 'live', capability: 'Network.Staff' }, /malformed/);--refused({ client_id: 'live', capability: 'network.staff.read', audience: 'openvibe.media' }, /enforced by network/);--refused({ client_id: 'live', capability: 'network.staff.read', expires_at: '2001-01-01' }, /in the past/);--assert.throws(() => grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: '' }, OWNER), /reason is required/);--refused({ client_id: 'live', capability: 'network.staff.read', namespaces: ['Bad Namespace'] }, /namespace is malformed/);--const before = events().length;--assert.strictEqual(events().length, before, 'a refused change announces nothing');++const refused = (body, re) => assert.rejects(async () => await grants.grant(db, { reason: 'because', ...body }, OWNER), re);++await refused({ client_id: 'nobody', capability: 'network.staff.read' }, /not a service principal/);++await refused({ client_id: 'thirdparty', capability: 'network.staff.read' }, /not a service principal/);++await refused({ client_id: 'live', capability: 'network.nope.read' }, /not in openvibe-contracts/);++await refused({ client_id: 'live', capability: 'Network.Staff' }, /malformed/);++await refused({ client_id: 'live', capability: 'network.staff.read', audience: 'openvibe.media' }, /enforced by network/);++await refused({ client_id: 'live', capability: 'network.staff.read', expires_at: '2001-01-01' }, /in the past/);++await assert.rejects(async () => await grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: '' }, OWNER), /reason is required/);++await refused({ client_id: 'live', capability: 'network.staff.read', namespaces: ['Bad Namespace'] }, /namespace is malformed/);++const before = (await events()).length;++assert.strictEqual((await events()).length, before, 'a refused change announces nothing');// Expiry: stops counting at once, recorded once.--grants.grant(db, { client_id: 'media', capability: 'network.staff.read', reason: 'a week', expires_at: '2099-01-01' }, OWNER);--db.prepare("UPDATE principal_grants SET expires_at = datetime('now', '-1 minute') WHERE client_id = 'media' AND capability = 'network.staff.read'").run();--assert.ok(!has('media', 'network.staff.read'), 'expired: no longer in tokens, before the job runs');--assert.strictEqual(grants.list(db, { client: 'media' }).find((g) => g.capability === 'network.staff.read').state, 'expired');--assert.strictEqual(grants.expireDue(db), 1);--assert.strictEqual(grants.expireDue(db), 0, 'recorded once');--ev = events().at(-1); ok(ev);++await grants.grant(db, { client_id: 'media', capability: 'network.staff.read', reason: 'a week', expires_at: '2099-01-01' }, OWNER);++await db.prepare("UPDATE principal_grants SET expires_at = datetime('now', '-1 minute') WHERE client_id = 'media' AND capability = 'network.staff.read'").run();++assert.ok(!await has('media', 'network.staff.read'), 'expired: no longer in tokens, before the job runs');++assert.strictEqual((await grants.list(db, { client: 'media' })).find((g) => g.capability === 'network.staff.read').state, 'expired');++assert.strictEqual(await grants.expireDue(db), 1);++assert.strictEqual(await grants.expireDue(db), 0, 'recorded once');++ev = (await events()).at(-1); ok(ev);assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.type], ['expired', null, 'system']);// Routes: owner only.(async () => {const { createStaffApi } = require('../server/admin/staff-api');-- db.prepare("INSERT INTO users (id, username, password_hash, role, subject_id) VALUES (1, 'goosely', 'x', 'admin', ?), (2, 'mod', 'x', 'global_mod', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR'), (3, 'someone', 'x', 'user', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS')").run(OWNER);++ await db.prepare("INSERT INTO users (id, username, password_hash, role, subject_id) VALUES (1, 'goosely', 'x', 'admin', ?), (2, 'mod', 'x', 'global_mod', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR'), (3, 'someone', 'x', 'user', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS')").run(OWNER);let as = 1;-- const requireAuth = (req, res, next) => { req.user = db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); };++ const requireAuth = async (req, res, next) => { req.user = await db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); };const app = express();app.use(express.json());app.use('/api/admin/grants', requireAuth, grants.router(db));@@@ -93,7 +94,7 @@ assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.tylet r = await call('POST', '/api/admin/grants', { client_id: 'live', capability: 'network.staff.read', reason: 'owner via API' });assert.strictEqual(r.status, 200); assert.strictEqual(r.body.grant.change, 'granted');-- assert.strictEqual(events().at(-1).payload.actor_subject, OWNER);++ assert.strictEqual((await events()).at(-1).payload.actor_subject, OWNER);assert.strictEqual((await call('GET', '/api/admin/grants?client=live')).body.grants.find((g) => g.capability === 'network.staff.read').state, 'active');assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read' })).status, 400, 'a reason is required');as = 2;@@@ -115,3 +116,4 @@ assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.tyfs.rmSync(dir, { recursive: true, force: true });console.log('grants admin and staff APIs: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -1,26 +1,21 @@'use strict';--// Boots the real server (server/index.js) in a child process for route-level tests: a scratch--// database and data directory, loopback only, a free port, no .env (the child runs in the temp dir),--// ephemeral signing keys, and no traffic off the machine (fetch to anything but loopback is refused--// by a preload). stop() kills it; logs() returns what it printed.++// Boots the real server (server/index.js) for route-level tests. Two modes:++// bootServer() IN THIS PROCESS (the Media pattern, ADR-035): it uses the same process++// database the preload opened (test/helpers/pg-preload.mjs), so a test can++// seed rows and call the routes against one database. A route dump is++// written by a preload that patches express's listen; a net guard keeps++// every client on loopback. Callers end with process.exit(0).++// bootServer({ child: true }) a CHILD process with its own PGlite directory (PGLITE_DIR), for a++// standalone server whose database nobody else needs (a second, bare boot).const fs = require('fs');const os = require('os');const net = require('net');const path = require('path');const { spawn } = require('child_process');++const { routeDumpEnv } = require('../security-crawl');const ROOT = path.join(__dirname, '..', '..');--const NO_EGRESS = `'use strict';--const real = globalThis.fetch;--globalThis.fetch = (input, init) => {-- let host = '';-- try { host = new URL(typeof input === 'string' ? input : input.url).hostname; } catch { /* */ }-- if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return real(input, init);-- return Promise.reject(new Error('test: no egress to ' + host));--};--`;--function freePort() {return new Promise((resolve, reject) => {const s = net.createServer();@@@ -29,45 +24,71 @@ function freePort() {});}--async function bootServer({ env = {}, timeoutMs = 20000 } = {}) {++async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-boot-'));-- const guard = path.join(dir, 'no-egress.js');-- fs.writeFileSync(guard, NO_EGRESS);const port = await freePort();-- const childEnv = {-- PATH: process.env.PATH, HOME: dir, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1',-- DB_PATH: path.join(dir, 'network.db'), AVATAR_PATH: path.join(dir, 'avatars'),++ const dump = routeDumpEnv(dir);++ const baseEnv = {++ NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, 'pglite'),++ AVATAR_PATH: path.join(dir, 'avatars'),JWT_PRIVATE_KEY: path.join(dir, 'none.pem'), JWT_PUBLIC_KEY: path.join(dir, 'none.pub.pem'),BOOTSTRAP_PROFILE: 'local-dev', INTERNAL_API_KEY: 'k'.repeat(40),OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9',OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',-- ...env,};-- const child = spawn(process.execPath, ['-r', guard, path.join(ROOT, 'server', 'index.js')], { cwd: dir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });++++ if (child) {++ const childEnv = { PATH: process.env.PATH, HOME: dir, ...baseEnv, ...dump, ...env };++ const proc = spawn(process.execPath, ['-r', dump.NODE_OPTIONS.replace('--require ', ''), path.join(ROOT, 'server', 'index.js')], { cwd: dir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });++ let out = '';++ proc.stdout.on('data', (d) => { out += d; });++ proc.stderr.on('data', (d) => { out += d; });++ let exited = null;++ proc.on('exit', (code, sig) => { exited = { code, sig }; });++ const base = `http://127.0.0.1:${port}`;++ const t0 = Date.now();++ for (;;) {++ if (exited) throw new Error(`server exited early (${JSON.stringify(exited)}):\n${out}`);++ try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not listening yet */ }++ if (Date.now() - t0 > timeoutMs) { proc.kill('SIGKILL'); throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`); }++ await new Promise(r => setTimeout(r, 100));++ }++ return {++ base, port, dir, logs: () => out,++ stop: () => new Promise((resolve) => {++ if (exited) return resolve();++ proc.once('exit', () => resolve());++ proc.kill('SIGTERM');++ setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* */ } }, 3000).unref();++ }),++ };++ }++++ // In-process: the route-dump + no-egress preload, then env, then the server module.++ require(dump.NODE_OPTIONS.replace('--require ', ''));++ Object.assign(process.env, baseEnv, dump, env);++ // The preload (test/helpers/pg-preload.mjs) loads server/config.js before a test's environment exists.++ // Refresh that same object in place — every module holds this reference — so the booted server reads++ // the test's env (ports, keys, ADMIN_USERNAME), not the runner's.++ const configPath = require.resolve(path.join(ROOT, 'server', 'config.js'));++ const config = require(configPath);++ delete require.cache[configPath];++ Object.assign(config, require(configPath));let out = '';-- child.stdout.on('data', (d) => { out += d; });-- child.stderr.on('data', (d) => { out += d; });-- let exited = null;-- child.on('exit', (code, sig) => { exited = { code, sig }; });-- const base = `http://127.0.0.1:${port}`;-- const t0 = Date.now();-- for (;;) {-- if (exited) throw new Error(`server exited early (${JSON.stringify(exited)}):\n${out}`);-- try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not listening yet */ }-- if (Date.now() - t0 > timeoutMs) { child.kill('SIGKILL'); throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`); }-- await new Promise(r => setTimeout(r, 100));++ for (const stream of [process.stdout, process.stderr]) {++ const write = stream.write.bind(stream);++ stream.write = (chunk, ...rest) => { out += String(chunk); return write(chunk, ...rest); };}++ const t0 = Date.now();++ const { app, server } = await require(path.join(ROOT, 'server', 'index.js')).ready;++ if (!server.listening) await new Promise((r) => server.once('listening', r));++ if (Date.now() - t0 > timeoutMs) throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`);return {-- base, port, dir,++ base: `http://127.0.0.1:${server.address().port}`, port: server.address().port, dir, app, server,logs: () => out,-- stop: () => new Promise((resolve) => {-- if (exited) return resolve();-- child.once('exit', () => resolve());-- child.kill('SIGTERM');-- setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, 3000).unref();-- }),++ stop: () => new Promise((resolve) => { server.close(() => resolve()); }),};}@@@ -19,3 +19,14 @@ for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(ta}globalThis.__ovNetworkTestDb = t.db;globalThis.__ovNetworkTestDbClose = t.close;++// Owner-only DDL (a CREATE TRIGGER, say) for tests that must fail a write at the database, not in a mock: on the++// containers the runtime role may not create in the schema, so this runs on the owner connection. PGlite has one role.++globalThis.__ovNetworkDdl = async (sql) => {++ if (!t.directUrl) return t.db.exec(sql);++ const { createDb } = require('openvibe-sdk/db');++ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });++ try { return await owner.exec(sql); } finally { await owner.close(); }++};++// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site++// settings, built-in themes) once, exactly as the server does at boot. Every test file then reads it with getDb().++await require(path.join(root, 'server', 'db', 'database.js')).initDb();@@@ -4,11 +4,12 @@const assert = require('assert');const http = require('http');const express = require('express');--const Database = require('better-sqlite3');++const { getDb } = require('../server/db/database');const { createHistoryRoutes, allowedUrl, serviceFromUrl } = require('../server/history/routes');const { ssoTargets, safeNext } = require('../server/auth/sso-targets');// ── pure helpers ─────────────────────────────────────────────++(async () => {assert.strictEqual(allowedUrl('https://json.openvibe.tools/?x=1&token=abc#frag'), 'https://json.openvibe.tools/?x=1', 'tokens + fragments stripped');assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network URLs are dropped');assert.strictEqual(allowedUrl('javascript:alert(1)'), null);@@@ -25,15 +26,14 @@ assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fallassert.strictEqual(safeNext('//evil'), '/');assert.strictEqual(safeNext('/my#linked'), '/my#linked');--// ── API against an in-memory database ────────────────────────--const db = new Database(':memory:');--db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, is_anon INTEGER DEFAULT 0);-- INSERT INTO users (id, username) VALUES (1, 'alex'), (2, 'other');`);++// ── API against the process database ─────────────────────────++const db = getDb();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'alex', 'x'), (2, 'other', 'x')").run();const users = { a: { id: 1, username: 'alex' }, b: { id: 2, username: 'other' }, anon: { id: 3, username: 'anon', is_anon: true } };const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]; if (!u) return res.status(401).json({ error: 'no' }); req.user = u; next(); };const app = express();app.use(express.json());--app.use('/api/history', createHistoryRoutes(db, requireAuth));++app.use('/api/history', await createHistoryRoutes(db, requireAuth));const server = http.createServer(app);(async () => {@@@ -86,3 +86,4 @@ const server = http.createServer(app);server.close();console.log('history + sso targets: all checks passed');})().catch((err) => { console.error(err); server.close(); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -9,27 +9,27 @@ const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');const contracts = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subjects-'));const dbPath = path.join(dir, 'network.db');--let db = initDb(dbPath);++let db = getDb();const quiet = console.log; console.log = () => {};// ── An existing database: rows without subject ids, a Live-migrated account ──--db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();--db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();--db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();--db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (2, 'live', '321'), (1, 'tools', 'network:1')").run();--db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DROP TABLE identity_legacy_map;");--db.close();--db = initDb(dbPath); // boot again: backfill + seed++await db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();++await db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();++await db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();++await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (2, 'live', '321'), (1, 'tools', 'network:1')").run();++await db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DELETE FROM identity_legacy_map");++await subjects.ensureSchema(db); // the boot's backfill + seed (server/db/database.js seedDb)console.log = quiet;--const alex = db.prepare('SELECT * FROM users WHERE id = 1').get();--const beth = db.prepare('SELECT * FROM users WHERE id = 2').get();--const guest = db.prepare('SELECT * FROM anon_users WHERE id = 5').get();++const alex = await db.prepare('SELECT * FROM users WHERE id = 1').get();++const beth = await db.prepare('SELECT * FROM users WHERE id = 2').get();++const guest = await db.prepare('SELECT * FROM anon_users WHERE id = 5').get();assert.ok(contracts.validate('identity.subject-ref', { type: 'user', id: alex.subject_id }).valid, 'backfilled user subject is a valid SubjectRef');assert.ok(contracts.validate('identity.subject-ref', { type: 'guest', id: guest.subject_id }).valid, 'backfilled guest subject is gst_');assert.ok(alex.subject_id < beth.subject_id, 'backfilled ids sort by account age');@@@ -37,30 +37,30 @@ assert.strictEqual(alex.subject_id.slice(4, 14), contracts.ids.ulid(Date.parse('// Rebooting changes nothing.const before = alex.subject_id;--console.log = () => {}; db.close(); db = initDb(dbPath); console.log = quiet;--assert.strictEqual(db.prepare('SELECT subject_id FROM users WHERE id = 1').get().subject_id, before, 'subject ids are stable across boots');++console.log = () => {}; await subjects.ensureSchema(db); console.log = quiet; // boot again: nothing changes++assert.strictEqual((await db.prepare('SELECT subject_id FROM users WHERE id = 1').get()).subject_id, before, 'subject ids are stable across boots');// Seeded map: network self ids and the Live legacy id.--assert.strictEqual(subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 }).subject.id, before);--const viaLive = subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '77' });++assert.strictEqual((await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 })).subject.id, before);++const viaLive = await subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '77' });assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves by its Live id');assert.strictEqual(viaLive.username, 'alex');assert.ok(!('email' in viaLive) && !('password_hash' in viaLive), 'projection carries no private fields');--assert.strictEqual(subjects.resolve(db, { subject_id: guest.subject_id }).subject.type, 'guest');--assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '321' }).subject.id, beth.subject_id, 'seeded from a reported Live link');--assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get().n, 0, "OAuth 'network:<id>' links say nothing about site ids");--assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);++assert.strictEqual((await subjects.resolve(db, { subject_id: guest.subject_id })).subject.type, 'guest');++assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '321' })).subject.id, beth.subject_id, 'seeded from a reported Live link');++assert.strictEqual((await db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get()).n, 0, "OAuth 'network:<id>' links say nothing about site ids");++assert.strictEqual(await subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);// A row inserted by a path that forgot subject_id is fixed lazily.--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();--db.prepare('UPDATE users SET subject_id = NULL WHERE id = 3').run();--const cat = db.prepare('SELECT * FROM users WHERE id = 3').get();--const catSid = subjects.ensureUserSubject(db, cat);++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();++await db.prepare('UPDATE users SET subject_id = NULL WHERE id = 3').run();++const cat = await db.prepare('SELECT * FROM users WHERE id = 3').get();++const catSid = await subjects.ensureUserSubject(db, cat);assert.ok(/^usr_/.test(catSid) && cat.subject_id === catSid);--assert.strictEqual(subjects.ensureUserSubject(db, cat), catSid, 'idempotent');++assert.strictEqual(await subjects.ensureUserSubject(db, cat), catSid, 'idempotent');// ── Legacy map writes ──--let r = subjects.upsertLegacy(db, [++let r = await subjects.upsertLegacy(db, [{ network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88, verified: true },{ network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88 }, // repeat{ network_user_id: 1, source_system: 'live', source_type: 'user', source_id: 88 }, // someone else's id@@@ -73,7 +73,7 @@ assert.strictEqual(r.inserted, 1);assert.strictEqual(r.unchanged, 1);assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never repointed');assert.deepStrictEqual(r.rejected.map(c => c.index), [3, 4, 5, 6]);--assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88' }).subject.id, beth.subject_id);++assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '88' })).subject.id, beth.subject_id);// ── HTTP: /internal/identity behind a service token (X-Internal-Key was retired in plan T2) ──const app = express();@@@ -124,7 +124,7 @@ const { signToken } = require('../server/auth/routes');assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { authorization: `Bearer ${LIVE_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });assert.strictEqual(h.status, 200);-- assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '555' }).network_user_id, 3, '/internal/link-account also writes the legacy map');++ assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '555' })).network_user_id, 3, '/internal/link-account also writes the legacy map');h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });assert.strictEqual(h.status, 400);h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));@@@ -134,14 +134,14 @@ const { signToken } = require('../server/auth/routes');assert.strictEqual(h.body.code, 'token.missing');if (signToken) {-- const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });++ const token = signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });const claims = jwt.decode(token);assert.strictEqual(claims.sub, 1, 'sub stays the integer id');assert.strictEqual(claims.subject_id, before, 'token carries the subject id');}server.close();-- db.close();fs.rmSync(dir, { recursive: true, force: true });console.log('identity subjects: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -8,29 +8,30 @@ const fs = require('fs');const os = require('os');const path = require('path');const { modules } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-loyalty-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = await getDb();console.log = log;const principals = require('../server/identity/principals');--db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('live', 'x', 'live', '[]', 1)").run();++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('live', 'x', 'live', '[]', 1) ON CONFLICT DO NOTHING").run();const aud = principals.SELF_AUDIENCE;--const grant = (cap) => principals.grantsFor(db, 'live', aud).find((g) => g.capability === cap);++const grant = async (cap) => (await principals.grantsFor(db, 'live', aud)).find((g) => g.capability === cap);// A database as an older release left it: Live's module grants without live.loyalty, and a transfer grant.--principals.ensureSchema(db);--db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability IN ('network.modules.read', 'network.modules.write')").run(JSON.stringify(['live.profile', 'live.stats']));--db.prepare("INSERT OR REPLACE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.coins.transfer', ?, '[\"live\"]', 'default')").run(aud);--assert.ok(grant('network.coins.transfer'), 'the old transfer grant is there before the boot');++await principals.ensureSchema(db);++await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability IN ('network.modules.read', 'network.modules.write')").run(JSON.stringify(['live.profile', 'live.stats']));++await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.coins.transfer', ?, '[\"live\"]', 'default') ON CONFLICT (client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by").run(aud);++assert.ok(await grant('network.coins.transfer'), 'the old transfer grant is there before the boot');--principals.ensureSchema(db); // the next boot--assert.strictEqual(grant('network.coins.transfer'), undefined, 'the transfer grant is revoked at boot');++await principals.ensureSchema(db); // the next boot++assert.strictEqual(await grant('network.coins.transfer'), undefined, 'the transfer grant is revoked at boot');assert.ok(principals.REVOKED_GRANTS.some(([c, cap]) => c === 'live' && cap === 'network.coins.transfer'));assert.ok(!principals.DEFAULT_GRANTS.some(([, cap]) => cap === 'network.coins.transfer'), 'no default hands out the transfer grant');for (const cap of ['network.modules.read', 'network.modules.write']) {-- assert.deepStrictEqual(grant(cap).namespaces, ['live.profile', 'live.stats', 'live.loyalty'], `${cap} gained live.loyalty`);++ assert.deepStrictEqual((await grant(cap)).namespaces, ['live.profile', 'live.stats', 'live.loyalty'], `${cap} gained live.loyalty`);}// live.loyalty is private: nothing of it appears in a public view.@@@ -44,3 +45,4 @@ assert.ok(!modules.validateData('live.loyalty', { ...record, cashout_usd: 12 }).fs.rmSync(dir, { recursive: true, force: true });console.log('loyalty policy: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });@@@ -15,29 +15,30 @@ const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');const { validate, serviceAuth } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mod-principals-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--for (const c of ['games']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);++for (const c of ['games']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);const principals = require('../server/identity/principals');--principals.ensureSchema(db);++await principals.ensureSchema(db);const modPrincipals = require('../server/identity/mod-principals');const ISSUER = 'https://openvibe.network';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };const svc = (name, cap = ['mods.grant.manage']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'], cap,iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);--const mk = (name, role) => {-- const id = db.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)").run(name, role).lastInsertRowid;-- subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));-- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++const mk = async (name, role) => {++ const id = (await db.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?) RETURNING id").run(name, role)).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);};const tok = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role, iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });--const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.mod.grants_changed');++const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.mod.grants_changed');const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/fixtures/mods.mod-manifest/valid/market-stall-1.1.json'), 'utf8'));const authRoutes = require('../server/auth/routes');@@@ -56,7 +57,7 @@ const server = http.createServer(app);const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined }).then(async (x) => ({ status: x.status, body: await x.json().catch(() => ({})) }));try {-- assert.ok(principals.grantsFor(db, 'games', 'openvibe.network').some((g) => g.capability === 'mods.grant.manage'), 'Games holds mods.grant.manage');++ assert.ok((await principals.grantsFor(db, 'games', 'openvibe.network')).some((g) => g.capability === 'mods.grant.manage'), 'Games holds mods.grant.manage');const games = svc('games');const [a, b] = manifest.permissions.capabilities;@@@ -93,14 +94,14 @@ const server = http.createServer(app);assert.strictEqual(x.body.revision, 3);// ── Staff ──-- const boss = mk('boss', 'admin'); const eve = mk('eve', 'user');++ const boss = await mk('boss', 'admin'); const eve = await mk('eve', 'user');x = await call('GET', '/api/admin/mods', tok(eve));assert.strictEqual(x.status, 403);x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'no' });assert.deepStrictEqual([x.status, x.body.error], [400, 'mod.reason_required']);x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'placed props in the spawn area' });assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [], 4]);-- assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'mod_grant_change'").get(), 'audited');++ assert.ok(await db.prepare("SELECT 1 FROM audit_log WHERE action = 'mod_grant_change'").get(), 'audited');// ── Revoke the install ──x = await call('POST', `/internal/mods/${manifest.id}/revoke`, games, { actor: 'games', reason: 'the install ended' });@@@ -109,7 +110,7 @@ const server = http.createServer(app);assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.revoked'], 'never granted again');// Every change announced once, with the complete approved set.-- const ev = events();++ const ev = await events();assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.change.action, e.payload.by, e.payload.approved.length]),[[1, 'register', 'runtime', 1], [2, 'approve', 'runtime', 2], [3, 'revoke', 'runtime', 1], [4, 'revoke', 'staff', 0], [5, 'revoke_all', 'runtime', 0]]);for (const e of ev) assert.ok(validate('network.mod.grants_changed@1', e.payload).valid && e.visibility === 'internal' && e.subject.type === 'mod');@@@ -120,3 +121,4 @@ const server = http.createServer(app);}console.log('mod principals: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -9,21 +9,22 @@ const path = require('path');const http = require('http');const express = require('express');const { ids } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { NotificationService } = require('../server/notifications/notification-service');const { createEventsConsumer } = require('../server/notifications/events-consumer');const { createModerationAudit, rowOf } = require('../server/admin/moderation-audit');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modaudit-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;const MOD = ids.newId('user'), TARGET = ids.newId('user');const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: type.split('.')[0], actor, occurred_at: new Date().toISOString(), payload });(async () => {const audit = createModerationAudit(db);-- const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: 'a'.repeat(40), moderationAudit: audit });++ const consumer = await createEventsConsumer({ db, notifications: new NotificationService(db), secrets: 'a'.repeat(40), moderationAudit: audit });const chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });@@@ -31,17 +32,17 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: idconst live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });-- for (const e of [chat, community, tips, billing, live]) assert.strictEqual(consumer.apply(e).outcome, 'recorded', e.event_type);-- assert.strictEqual(consumer.apply(chat).duplicate, true, 'a redelivery records nothing twice');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM notifications').get().n, 0, 'audit events notify nobody');++ for (const e of [chat, community, tips, billing, live]) assert.strictEqual((await consumer.apply(e)).outcome, 'recorded', e.event_type);++ assert.strictEqual((await consumer.apply(chat)).duplicate, true, 'a redelivery records nothing twice');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'audit events notify nobody');-- const all = audit.list({});++ const all = await audit.list({});assert.strictEqual(all.items.length, 5);const liveRow = all.items.find((r) => r.service === 'live');assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);-- assert.strictEqual(audit.list({ action: 'site_ban' }).items.length, 1);-- assert.strictEqual(audit.list({ since: '2000-01-01', until: '2001-01-01' }).items.length, 0);-- assert.strictEqual(audit.list({ since: '2000-01-01' }).items.length, 5);++ assert.strictEqual((await audit.list({ action: 'site_ban' })).items.length, 1);++ assert.strictEqual((await audit.list({ since: '2000-01-01', until: '2001-01-01' })).items.length, 0);++ assert.strictEqual((await audit.list({ since: '2000-01-01' })).items.length, 5);const csv = require('../server/admin/moderation-audit').toCsv(all.items);assert.ok(csv.startsWith('id,occurred_at,service,action,'));assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');@@@ -51,12 +52,12 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: idassert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);assert.strictEqual(byService.tips.action, 'interaction.hidden');assert.strictEqual(byService.billing.actor_subject, MOD);-- assert.strictEqual(audit.list({ service: 'community' }).items.length, 1);-- assert.strictEqual(audit.list({ actor: MOD }).items.length, 4, 'tips carries no moderator identity');-- assert.strictEqual(audit.list({ target: TARGET }).items.length, 4);-- const page = audit.list({ limit: 2 });++ assert.strictEqual((await audit.list({ service: 'community' })).items.length, 1);++ assert.strictEqual((await audit.list({ actor: MOD })).items.length, 4, 'tips carries no moderator identity');++ assert.strictEqual((await audit.list({ target: TARGET })).items.length, 4);++ const page = await audit.list({ limit: 2 });assert.strictEqual(page.items.length, 2);-- assert.strictEqual(audit.list({ limit: 2, before: page.next }).items.length, 2, 'paged by id');++ assert.strictEqual((await audit.list({ limit: 2, before: page.next })).items.length, 2, 'paged by id');assert.strictEqual(rowOf(ev('chat.message.deleted', {})), null);// The ten services on common.moderation-action@1 (Contracts 0.53.0): subscribed, one row each, the service from the prefix.@@@ -68,16 +69,16 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: idassert.ok(TOPICS.includes(type), `${type} is one of the consumer's subscriptions`);const payload = { action: 'item.hidden', target: { type: `${svc}_item`, id: `${svc}-1`, owner_subject: TARGET }, actor_subject: MOD, reason: `${svc} reason`, details: { previous: 'visible' } };assert.ok(contracts.validate(`${type}@1`, payload).valid, `${type}: a valid payload`);-- assert.strictEqual(consumer.apply(ev(type, payload)).outcome, 'recorded', type);++ assert.strictEqual((await consumer.apply(ev(type, payload))).outcome, 'recorded', type);}for (const svc of COMMON) {-- const rows = audit.list({ service: svc }).items;++ const rows = (await audit.list({ service: svc })).items;assert.strictEqual(rows.length, 1, svc);const r = rows[0];assert.deepStrictEqual([r.action, r.actor_subject, r.target_type, r.target_id, r.target_subject, r.scope, r.reason, r.details],['item.hidden', MOD, `${svc}_item`, `${svc}-1`, TARGET, null, `${svc} reason`, { previous: 'visible' }], svc);}-- assert.strictEqual(audit.list({ action: 'item.hidden' }).items.length, COMMON.length);++ assert.strictEqual((await audit.list({ action: 'item.hidden' })).items.length, COMMON.length);// A service acting without a person names no actor; an unknown owner is no target subject.const bySvc = rowOf(ev('coupons.moderation.action', { action: 'merchant.disabled', target: { type: 'merchant', id: 'mer_1', owner_subject: null }, actor_subject: null, details: {} }, { type: 'service', id: 'coupons' }));assert.deepStrictEqual([bySvc.service, bySvc.action, bySvc.actor_subject, bySvc.target_subject, bySvc.reason], ['coupons', 'merchant.disabled', null, null, null]);@@@ -99,3 +100,4 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id} finally { srv.close(); }console.log('moderation audit: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -16,27 +16,28 @@ const path = require('path');const http = require('http');const express = require('express');const contracts = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-module-events-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);++for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);// Chat's principal is provisioned by hand in production (no seeded client); the grants follow at boot.--db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'chat-secret', 'OpenVibe.Chat', '[]', 1)").run();++await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'chat-secret', 'OpenVibe.Chat', '[]', 1)").run();// A Live write grant as an older boot seeded it (with chat.preferences), to see it narrow.--db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability = 'network.modules.write'").run(JSON.stringify(['chat.preferences', 'chat.tts_defaults', 'live.profile']));++await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability = 'network.modules.write'").run(JSON.stringify(['chat.preferences', 'chat.tts_defaults', 'live.profile']));// Chat's grants as a boot before contracts 0.41.0 seeded them (chat.preferences only), to see them widen.--for (const cap of ['network.modules.read', 'network.modules.write']) db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('chat', ?, 'openvibe.network', '[\"chat.preferences\"]', 'default')").run(cap);--require('../server/identity/principals').ensureSchema(db);++for (const cap of ['network.modules.read', 'network.modules.write']) await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('chat', ?, 'openvibe.network', '[\"chat.preferences\"]', 'default')").run(cap);++await require('../server/identity/principals').ensureSchema(db);const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';const GUEST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPG';--db.prepare(`INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', '${ANN}'), (2, 'bob', 'x', '${BOB}'), (3, 'cat', 'x', '${CAT}')`).run();--db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id) VALUES (50, 7, 'anon-token-7', '${GUEST}')`).run();++await db.prepare(`INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', '${ANN}'), (2, 'bob', 'x', '${BOB}'), (3, 'cat', 'x', '${CAT}')`).run();++await db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id) VALUES (50, 7, 'anon-token-7', '${GUEST}')`).run();const modulesLib = require('../server/identity/modules');const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });@@@ -54,9 +55,9 @@ app.use('/internal', require('../server/internal/routes'));const server = http.createServer(app);/** Module events in the outbox, oldest first (after `afterId`). */--const events = (afterId = 0) => db.prepare("SELECT id, envelope FROM network_event_outbox WHERE id > ? ORDER BY id").all(afterId)-- .map(r => ({ id: r.id, ...JSON.parse(r.envelope) })).filter(e => e.event_type === 'network.module.updated');--const lastId = () => (db.prepare('SELECT MAX(id) AS id FROM network_event_outbox').get() || {}).id || 0;++const events = async (afterId = 0) => (await db.prepare("SELECT id, envelope FROM network_event_outbox WHERE id > ? ORDER BY id").all(afterId))++ .map(r => ({ id: r.id, ...r.envelope })).filter(e => e.event_type === 'network.module.updated');++const lastId = async () => (await db.prepare('SELECT MAX(id) AS id FROM network_event_outbox').get() || {}).id || 0;const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e.id ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'id')) : e); assert.ok(v.valid, JSON.stringify(v.errors)); };(async () => {@@@ -64,16 +65,16 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', econst base = `http://127.0.0.1:${server.address().port}`;const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined }).then(async r => ({ status: r.status, body: r.status === 204 ? null : await r.json().catch(() => null) }));-- const tokenOf = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });++ const tokenOf = async (id) => ({ authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json()));// ── Grants: Chat reads and writes the chat.* namespaces; Live's grants lost them (contracts 0.41.0) ──const CHAT_NS = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];-- const grant = (c, cap) => JSON.parse(db.prepare('SELECT namespaces FROM principal_grants WHERE client_id = ? AND capability = ?').get(c, cap).namespaces);-- assert.deepStrictEqual(grant('chat', 'network.modules.read'), CHAT_NS);-- assert.deepStrictEqual(grant('chat', 'network.modules.write'), CHAT_NS);-- assert.deepStrictEqual(grant('live', 'network.modules.write'), ['live.profile', 'live.stats', 'live.loyalty'], 'the old defaults move to the new one at boot');-- assert.deepStrictEqual(grant('live', 'network.modules.read'), ['live.profile', 'live.stats', 'live.loyalty']);++ const grant = async (c, cap) => JSON.parse((await db.prepare('SELECT namespaces FROM principal_grants WHERE client_id = ? AND capability = ?').get(c, cap)).namespaces);++ assert.deepStrictEqual(await grant('chat', 'network.modules.read'), CHAT_NS);++ assert.deepStrictEqual(await grant('chat', 'network.modules.write'), CHAT_NS);++ assert.deepStrictEqual(await grant('live', 'network.modules.write'), ['live.profile', 'live.stats', 'live.loyalty'], 'the old defaults move to the new one at boot');++ assert.deepStrictEqual(await grant('live', 'network.modules.read'), ['live.profile', 'live.stats', 'live.loyalty']);// Chat manages its own Events subscriptions (live.release.deployed, network.module.updated).const chatEvents = await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: 'chat', client_secret: 'chat-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' }) }).then(r => r.json());assert.strictEqual(chatEvents.scope, 'events.subscription.manage', JSON.stringify(chatEvents));@@@ -90,19 +91,19 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', eassert.strictEqual(r.status, 403, 'Live no longer writes chat.preferences');// ── Events on write: created, then updated with the changed keys only, no values ──-- let mark = lastId();++ let mark = await lastId();r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '0' }, body: { data: { timestamps: true, compact: false } } });assert.strictEqual(r.status, 201, JSON.stringify(r.body)); assert.strictEqual(r.body.updated_by, 'svc:chat'); assert.strictEqual(r.body.revision, 1);-- let ev = events(mark);++ let ev = await events(mark);assert.strictEqual(ev.length, 1); valid(ev[0]);assert.deepStrictEqual(ev[0].actor, { type: 'service', id: 'chat' });assert.strictEqual(ev[0].visibility, 'internal'); assert.strictEqual(ev[0].source, 'network');assert.deepStrictEqual(ev[0].subject, { type: 'user_module', id: `${ANN}:chat.preferences`, revision: 1 });assert.deepStrictEqual(ev[0].payload, { owner: { type: 'user', id: ANN }, namespace: 'chat.preferences', namespace_owner: 'chat', schema_version: 1, revision: 1, change: 'created', reason: 'write', keys: ['compact', 'timestamps'] });-- mark = lastId();-- r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...tokenOf(1), 'if-match': '1' }, body: { data: { timestamps: true, font_scale: 1.2 } } });++ mark = await lastId();++ r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...await tokenOf(1), 'if-match': '1' }, body: { data: { timestamps: true, font_scale: 1.2 } } });assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);-- ev = events(mark);++ ev = await events(mark);assert.strictEqual(ev.length, 1);assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ANN });assert.deepStrictEqual(ev[0].payload.keys, ['compact', 'font_scale'], 'timestamps did not change');@@@ -110,36 +111,36 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', eassert.ok(!JSON.stringify(ev[0].payload).includes('1.2') && !('public' in ev[0].payload), 'a private namespace never carries a value');r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '1' }, body: { data: { compact: true } } });assert.strictEqual(r.status, 412, 'a stale write emits nothing');-- assert.strictEqual(events(mark).length, 1);++ assert.strictEqual((await events(mark)).length, 1);// ── Public fields: the changed public values travel, private ones never ──-- mark = lastId();++ mark = await lastId();r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 42, is_streamer: true, stream_minutes_30d: 900 } } });assert.strictEqual(r.status, 201);r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 43, is_streamer: true, stream_minutes_30d: 950 } } });-- ev = events(mark);++ ev = await events(mark);assert.deepStrictEqual(ev[0].payload.public, { followers: 42, is_streamer: true });assert.deepStrictEqual(ev[1].payload.keys, ['followers', 'stream_minutes_30d']);assert.deepStrictEqual(ev[1].payload.public, { followers: 43 }, 'stream_minutes_30d is not public');// ── Deletes: user and owner service; revisions never go back ──-- mark = lastId();-- r = await call('DELETE', '/api/modules/chat.preferences', { headers: tokenOf(1) });++ mark = await lastId();++ r = await call('DELETE', '/api/modules/chat.preferences', { headers: await tokenOf(1) });assert.strictEqual(r.status, 204);-- ev = events(mark);++ ev = await events(mark);assert.strictEqual(ev.length, 1); valid(ev[0]);assert.deepStrictEqual([ev[0].payload.change, ev[0].payload.reason, ev[0].payload.revision, ev[0].subject.revision], ['deleted', 'delete', 3, 3]);assert.deepStrictEqual(ev[0].payload.keys, ['font_scale', 'timestamps'], 'the keys that went');-- r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...tokenOf(1), 'if-match': '0' }, body: { data: { compact: true } } });++ r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...await tokenOf(1), 'if-match': '0' }, body: { data: { compact: true } } });assert.strictEqual(r.status, 201); assert.strictEqual(r.body.revision, 4, 'a new record continues after the delete');r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: live });assert.strictEqual(r.status, 403, 'only the owner service deletes');r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '3' } });assert.strictEqual(r.status, 412, JSON.stringify(r.body));-- mark = lastId();++ mark = await lastId();r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '4' } });assert.strictEqual(r.status, 204);-- ev = events(mark);++ ev = await events(mark);assert.deepStrictEqual([ev[0].payload.reason, ev[0].payload.revision, ev[0].actor.id], ['owner_delete', 5, 'chat']);r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: chat });assert.strictEqual(r.status, 404);@@@ -147,77 +148,77 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', eassert.strictEqual(r.status, 401, 'never the retired key'); assert.strictEqual(r.body.code, 'token.missing');r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key', ...live } });assert.strictEqual(r.status, 403, 'and a bearer is still judged on the token alone');-- assert.strictEqual(events(mark).length, 1, 'a refused delete emits no event');++ assert.strictEqual((await events(mark)).length, 1, 'a refused delete emits no event');// ── Account removal: rows and revisions go, one event each; the trigger guards the account row ──-- modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });-- modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });-- assert.throws(() => db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');-- assert.throws(() => db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);-- mark = lastId();-- db.transaction(() => {-- assert.strictEqual(modulesLib.onSubjectRemoved(db, BOB), 2);-- db.prepare('DELETE FROM users WHERE id = 2').run();-- })();-- ev = events(mark);++ await modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });++ await modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });++ await assert.rejects(async () => await db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');++ await assert.rejects(async () => await db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);++ mark = await lastId();++ await db.tx(async () => {++ assert.strictEqual(await modulesLib.onSubjectRemoved(db, BOB), 2);++ await db.prepare('DELETE FROM users WHERE id = 2').run();++ });++ ev = await events(mark);assert.deepStrictEqual(ev.map(e => [e.payload.namespace, e.payload.change, e.payload.reason, e.actor.type]),[['chat.preferences', 'deleted', 'subject_removed', 'system'], ['live.profile', 'deleted', 'subject_removed', 'system']]);ev.forEach(valid);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(BOB).n, 0);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB).n, 0);-- assert.throws(() => modulesLib.onSubjectRemoved(db, 2), /subject id/);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(BOB)).n, 0);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB)).n, 0);++ await assert.rejects(async () => await modulesLib.onSubjectRemoved(db, 2), /subject id/);// ── Merge: CAT folds into ANN. Moved where ANN had none, dropped where ANN has one ──-- modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });-- modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });-- mark = lastId();-- const merged = modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });++ await modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });++ await modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });++ mark = await lastId();++ const merged = await modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });assert.deepStrictEqual(merged, { moved: 1, filled: 0, dropped: 1 }, 'ANN already has followers: nothing to fill (ADR-029 fills only missing fields; test/account-merge.test.js)');-- const annPrefs = modulesLib.read(db, ANN, 'chat.preferences');++ const annPrefs = await modulesLib.read(db, ANN, 'chat.preferences');assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');assert.strictEqual(annPrefs.updated_by, 'svc:network');-- assert.strictEqual(modulesLib.read(db, ANN, 'live.profile').data.followers, 43, 'the survivor\'s record wins');-- ev = events(mark);++ assert.strictEqual((await modulesLib.read(db, ANN, 'live.profile')).data.followers, 43, 'the survivor\'s record wins');++ ev = await events(mark);assert.deepStrictEqual(ev.map(e => [e.payload.owner.id, e.payload.namespace, e.payload.change, (e.payload.merged_into || e.payload.merged_from || {}).id]), [[ANN, 'chat.preferences', 'created', CAT], [CAT, 'chat.preferences', 'deleted', ANN], [CAT, 'live.profile', 'deleted', ANN],]);ev.forEach(valid);assert.ok(!('public' in ev[2].payload), 'a delete carries no values');-- db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go-- assert.throws(() => modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);++ await db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go++ await assert.rejects(async () => await modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──-- modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });-- modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });-- assert.throws(() => db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);-- r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: tokenOf(1) });++ await modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });++ await modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });++ await assert.rejects(async () => await db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);++ r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: await tokenOf(1) });assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.tts_defaults').data, { volume: 15 }, 'moved');-- assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.preferences').data, { hide_emotes: true, compact: true }, 'the account kept its own values and gained only the field it lacked (ADR-029)');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST).n, 0);++ assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.tts_defaults')).data, { volume: 15 }, 'moved');++ assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.preferences')).data, { hide_emotes: true, compact: true }, 'the account kept its own values and gained only the field it lacked (ADR-029)');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST)).n, 0);// ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──-- modulesLib.write(db, ANN, 'tools.usage', { recent: [{ tool: 'yt' }] }, { writer: { type: 'service', id: 'tools' } });++ await modulesLib.write(db, ANN, 'tools.usage', { recent: [{ tool: 'yt' }] }, { writer: { type: 'service', id: 'tools' } });const realGet = contracts.services.get;contracts.services.get = (id) => (id === 'tools' || id === 'chat' ? { ...realGet(id), status: 'retired' } : realGet(id));try {-- const w = modulesLib.write(db, ANN, 'chat.preferences', { compact: true }, { writer: { type: 'user' }, expectedRevision: 6 });++ const w = await modulesLib.write(db, ANN, 'chat.preferences', { compact: true }, { writer: { type: 'user' }, expectedRevision: 6 });assert.strictEqual(w.status, 409); assert.strictEqual(w.code, 'modules.namespace_retired');-- r = await call('GET', '/api/modules', { headers: tokenOf(1) });++ r = await call('GET', '/api/modules', { headers: await tokenOf(1) });assert.strictEqual(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable, false);const t0 = Date.now();-- assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 }), 0, 'retention starts when Network first sees the retirement');-- assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 + 89 * 86400000 }), 0);-- mark = lastId();-- assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 + 91 * 86400000 }), 1, 'tools.usage: 90 days');-- assert.deepStrictEqual(events(mark).map(e => [e.payload.namespace, e.payload.reason]), [['tools.usage', 'owner_delete']]);-- assert.ok(modulesLib.read(db, ANN, 'chat.preferences'), 'retain-readonly keeps records');-- r = await call('DELETE', '/api/modules/chat.preferences', { headers: tokenOf(1) });++ assert.strictEqual(await modulesLib.sweepRetired(db, { now: t0 }), 0, 'retention starts when Network first sees the retirement');++ assert.strictEqual(await modulesLib.sweepRetired(db, { now: t0 + 89 * 86400000 }), 0);++ mark = await lastId();++ assert.strictEqual(await modulesLib.sweepRetired(db, { now: t0 + 91 * 86400000 }), 1, 'tools.usage: 90 days');++ assert.deepStrictEqual((await events(mark)).map(e => [e.payload.namespace, e.payload.reason]), [['tools.usage', 'owner_delete']]);++ assert.ok(await modulesLib.read(db, ANN, 'chat.preferences'), 'retain-readonly keeps records');++ r = await call('DELETE', '/api/modules/chat.preferences', { headers: await tokenOf(1) });assert.strictEqual(r.status, 204, 'people can always delete their own');} finally { contracts.services.get = realGet; }-- modulesLib.sweepRetired(db);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_module_retirements').get().n, 0, 'un-retired again');++ await modulesLib.sweepRetired(db);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_retirements').get()).n, 0, 'un-retired again');// ── Relay: rows written while it was off are published, envelopes valid ──const published = [];@@@ -229,14 +230,14 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', ereturn new Response(JSON.stringify(body.events ? { results } : results[0]), { status: 200, headers: { 'content-type': 'application/json' } });};const relay = require('../server/developer/event-relay');-- const outbox = relay.startRelay(db, { eventsUrl: 'http://events.test', privateKey: keys.privateKey, issuer: config.jwt.issuer, fetch: fakeEvents, autoStart: false, log: { log() {}, warn() {} } });++ const outbox = await relay.startRelay(db, { eventsUrl: 'http://events.test', privateKey: keys.privateKey, issuer: config.jwt.issuer, fetch: fakeEvents, autoStart: false, log: { log() {}, warn() {} } });await outbox.flush();const mod = published.filter(e => e.event_type === 'network.module.updated');-- assert.strictEqual(mod.length, events().length, 'every module event went out');++ assert.strictEqual(mod.length, (await events()).length, 'every module event went out');mod.forEach(valid);-- assert.strictEqual(outbox.pending(), 0);++ assert.strictEqual(await outbox.pending(), 0);// With the relay on, a write lands in the same outbox and goes on the next flush.-- modulesLib.write(db, ANN, 'live.profile', { followers: 44 }, { writer: { type: 'service', id: 'live' } });++ await modulesLib.write(db, ANN, 'live.profile', { followers: 44 }, { writer: { type: 'service', id: 'live' } });await outbox.flush();assert.strictEqual(published.at(-1).payload.revision, 3); assert.strictEqual(published.at(-1).subject.id, `${ANN}:live.profile`);await relay.stopRelay(db);@@@ -245,3 +246,4 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', efs.rmSync(dir, { recursive: true, force: true });console.log('module events and lifecycle: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -10,20 +10,21 @@ const path = require('path');const http = require('http');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;// chat is registered on the host, not seeded (ai is seeded since its console, WS-O task 4): add a missing one as a copy of tools, then seed the grants again.for (const c of ['chat', 'ai']) {-- const cols = db.prepare('PRAGMA table_info(oauth_clients)').all().map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');-- db.prepare(`INSERT OR IGNORE INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools'`).run(c);++ const cols = (await db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?").all('oauth_clients')).map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');++ await db.prepare(`INSERT INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools' ON CONFLICT DO NOTHING`).run(c);}--require('../server/identity/principals').ensureSchema(db);--for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret'], ['chat', 'chat-secret'], ['ai', 'ai-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);--db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();++await require('../server/identity/principals').ensureSchema(db);++for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret'], ['chat', 'chat-secret'], ['ai', 'ai-secret']]) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });@@@ -42,7 +43,7 @@ const server = http.createServer(app);(async () => {await new Promise(r => server.listen(0, '127.0.0.1', r));const base = `http://127.0.0.1:${server.address().port}`;-- const annTok = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);++ const annTok = signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined }).then(async r => ({ status: r.status, etag: r.headers.get('etag'), cache: r.headers.get('cache-control'), body: r.status === 204 ? null : await r.json().catch(() => null) }));const me = { authorization: `Bearer ${annTok}` };@@@ -94,7 +95,7 @@ const server = http.createServer(app);assert.deepStrictEqual(r.body.data, { style: 'casual', history: false }, 'ai is a listed reader of ai.preferences');r = await call('PUT', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { streams_30d: 3, peak_viewers_30d: 12, new_followers_30d: 7 } } });assert.strictEqual(r.status, 201, JSON.stringify(r.body));-- db.prepare("UPDATE principal_grants SET namespaces = '[\"tools.usage\",\"live.stats\"]' WHERE client_id = 'tools' AND capability = 'network.modules.read'").run();++ await db.prepare("UPDATE principal_grants SET namespaces = '[\"tools.usage\",\"live.stats\"]' WHERE client_id = 'tools' AND capability = 'network.modules.read'").run();const tools2 = await svc('tools');r = await call('GET', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${tools2}` } });assert.deepStrictEqual(r.body.data, { streams_30d: 3, peak_viewers_30d: 12 }, 'a granted service that is not a listed reader sees public fields only');@@@ -102,13 +103,13 @@ const server = http.createServer(app);assert.strictEqual(r.body.data.new_followers_30d, 7, 'the owner reads the whole record');// ── Version migration: a stored v1 record reads as the current version ──-- db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES (?, 'tools.usage', 1, 1, ?)").run(ANN, JSON.stringify({ recent: [{ tool: 'img' }] }));++ await db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES (?, 'tools.usage', 1, 1, ?)").run(ANN, JSON.stringify({ recent: [{ tool: 'img' }] }));r = await call('GET', '/api/modules/tools.usage', { headers: me });assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, { recent: [{ tool: 'img' }] });r = await call('PUT', '/api/modules/tools.usage', { headers: { ...me, 'if-match': '1' }, body: { data: { ...r.body.data, favorites: ['img'] } } });assert.strictEqual(r.status, 200, 'the person may star tools (tools.usage v2)');-- assert.strictEqual(db.prepare("SELECT version FROM user_modules WHERE subject_id = ? AND namespace = 'tools.usage'").get(ANN).version, 2, 'the next write stores the current version');-- db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'chat.tts_defaults', 1, 1, ?)").run(JSON.stringify({ voice: 'en-1', rate: 1.2, muted: true }));++ assert.strictEqual((await db.prepare("SELECT version FROM user_modules WHERE subject_id = ? AND namespace = 'tools.usage'").get(ANN)).version, 2, 'the next write stores the current version');++ await db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'chat.tts_defaults', 1, 1, ?)").run(JSON.stringify({ voice: 'en-1', rate: 1.2, muted: true }));r = await call('GET', '/internal/modules/chat.tts_defaults/usr_01JAB2C3D4E5F6G7H8J9K0MNPB', { headers: { authorization: `Bearer ${chat}` } });assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, {}, 'v1 tts fields are dropped by the migration');r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });@@@ -117,7 +118,7 @@ const server = http.createServer(app);assert.strictEqual(r.status, 401, 'and a bearer is still judged on the token alone'); assert.strictEqual(r.body.code, 'token.malformed');r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${tools}` }, body: { data: { followers: 1 } } });assert.strictEqual(r.status, 403, 'a service without the network.modules.write grant is refused');-- assert.ok(db.prepare("SELECT 1 FROM principal_usage WHERE auth = 'service-token' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused service token is audited');++ assert.ok(await db.prepare("SELECT 1 FROM principal_usage WHERE auth = 'service-token' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused service token is audited');r = await call('PUT', `/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 1 } } });assert.strictEqual(r.status, 404, 'unknown subject');r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });@@@ -146,3 +147,4 @@ const server = http.createServer(app);fs.rmSync(dir, { recursive: true, force: true });console.log('modules: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -11,16 +11,17 @@ const path = require('path');const http = require('http');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const principals = require('../server/identity/principals');const nodes = require('../server/registry/nodes');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-nodes-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const app = express();@@@ -80,3 +81,4 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatiofs.rmSync(dir, { recursive: true, force: true });}})().catch((err) => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -18,9 +18,10 @@ const crypto = require('crypto');const express = require('express');const jwt = require('jsonwebtoken');const contracts = require('openvibe-contracts');++(async () => {const { ids, serviceAuth } = contracts;const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { NotificationService, NOTIFICATION_EVENT } = require('../server/notifications/notification-service');const { createEventsConsumer } = require('../server/notifications/events-consumer');const { createLiveFollowers } = require('../server/notifications/live-followers');@@@ -34,14 +35,14 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLenconst dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-notification-events-'));const DB_PATH = path.join(dir, 'network.db');const quietly = (f) => { const l = console.log; console.log = () => {}; try { return f(); } finally { console.log = l; } };--let db = quietly(() => initDb(DB_PATH));++let db = getDb();const ALICE = ids.newId('user'), BOB = ids.newId('user'), CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');--db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, is_anon) VALUES++await db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, is_anon) VALUES(1, 'alice', 'x', ?, 0), (2, 'bob', 'x', ?, 0), (3, 'guest_1', 'x', NULL, 1), (4, 'nosubject', 'x', NULL, 0),(20, 'carol', 'x', ?, 0), (21, 'dave', 'x', ?, 0), (22, 'erin', 'x', ?, 0)`).run(ALICE, BOB, CAROL, DAVE, ERIN);--const outbox = (d = db) => d.prepare(`SELECT envelope FROM ${eventRelay.TABLE} ORDER BY id`).all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === NOTIFICATION_EVENT);++const outbox = async (d = db) => (await d.prepare(`SELECT envelope FROM ${eventRelay.TABLE} ORDER BY id`).all()).map((r) => r.envelope).filter((e) => e.event_type === NOTIFICATION_EVENT);const PAYLOAD_KEYS = ['category', 'created_at', 'notification_id', 'priority', 'service', 'type', 'unread_count'];const checkEnvelope = (env, subject) => {const v = contracts.validate('events.event-envelope@1', env);@@@ -57,48 +58,49 @@ const checkEnvelope = (env, subject) => {(async () => {// ── 1. network.notification.created, in the notification's transaction ────────────────────────let notifications = new NotificationService(db);-- const n1 = notifications.create({ user_id: 1, type: 'CONTENT_REPLY', category: 'social', title: 'New reply to your comment', message: 'bob replied: secret words', sender_id: 2, sender_name: 'bob', url: 'https://openvibe.live/vod/5#comments', service: 'live' });++ const n1 = await notifications.create({ user_id: 1, type: 'CONTENT_REPLY', category: 'social', title: 'New reply to your comment', message: 'bob replied: secret words', sender_id: 2, sender_name: 'bob', url: 'https://openvibe.live/vod/5#comments', service: 'live' });assert.ok(n1);-- let evs = outbox();++ let evs = await outbox();assert.strictEqual(evs.length, 1);checkEnvelope(evs[0], ALICE);assert.deepStrictEqual({ ...evs[0].payload, created_at: null }, { notification_id: n1.id, type: 'CONTENT_REPLY', category: 'social', priority: 'normal', service: 'live', created_at: null, unread_count: 1 });assert.ok(!JSON.stringify(evs[0]).includes('secret words') && !JSON.stringify(evs[0]).includes('bob') && !JSON.stringify(evs[0]).includes(BOB), 'no text, no sender, anywhere in the envelope');-- notifications.create({ user_id: 1, type: 'STREAM_LIVE', title: 'carol is live!', sender_id: 20, service: 'live' });-- evs = outbox();++ await notifications.create({ user_id: 1, type: 'STREAM_LIVE', title: 'carol is live!', sender_id: 20, service: 'live' });++ evs = await outbox();assert.strictEqual(evs.length, 2);assert.strictEqual(evs[1].payload.unread_count, 2, 'the unread count after the insert');assert.strictEqual(evs[1].payload.category, 'stream');assert.notStrictEqual(evs[0].event_id, evs[1].event_id);// Nothing stored, nothing announced: a muted category; a blocked sender is the same rule (create() returns null).-- notifications.setPreference(2, 'social', { enabled: false });-- assert.strictEqual(notifications.create({ user_id: 2, type: 'FOLLOW', category: 'social', title: 'x' }), null);++ await notifications.setPreference(2, 'social', { enabled: false });++ assert.strictEqual(await notifications.create({ user_id: 2, type: 'FOLLOW', category: 'social', title: 'x' }), null);// Stored but never announced: a guest, an account without a usr_ subject.-- assert.ok(notifications.create({ user_id: 3, type: 'WELCOME', title: 'hi' }));-- assert.ok(notifications.create({ user_id: 4, type: 'WELCOME', title: 'hi' }));-- assert.strictEqual(outbox().length, 2);++ assert.ok(await notifications.create({ user_id: 3, type: 'WELCOME', title: 'hi' }));++ assert.ok(await notifications.create({ user_id: 4, type: 'WELCOME', title: 'hi' }));++ assert.strictEqual((await outbox()).length, 2);// Odd stored shapes are normalised in the event, never dropped with the notification.-- notifications.create({ user_id: 2, type: 'arena_hot', category: 'Game Stuff', priority: 'urgent', title: 'x', service: 'Live!' });-- evs = outbox();++ await notifications.create({ user_id: 2, type: 'arena_hot', category: 'Game Stuff', priority: 'urgent', title: 'x', service: 'Live!' });++ evs = await outbox();assert.strictEqual(evs.length, 3);checkEnvelope(evs[2], BOB);assert.deepStrictEqual([evs[2].payload.type, evs[2].payload.category, evs[2].payload.priority, evs[2].payload.service], ['GENERIC', 'system', 'normal', null]);// Both or neither: when the event cannot be queued, the notification is not stored either.-- db.exec(`CREATE TRIGGER outbox_down BEFORE INSERT ON ${eventRelay.TABLE} BEGIN SELECT RAISE(ABORT, 'outbox down'); END`);-- const before = db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get().c;-- assert.throws(() => notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' }), /outbox down/);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get().c, before, 'no notification without its event');-- db.exec('DROP TRIGGER outbox_down');++ await globalThis.__ovNetworkDdl(`CREATE OR REPLACE FUNCTION ov_test_outbox_down() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'outbox down'; END $$;++ CREATE TRIGGER outbox_down BEFORE INSERT ON ${eventRelay.TABLE} FOR EACH ROW EXECUTE FUNCTION ov_test_outbox_down()`);++ const before = (await db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get()).c;++ await assert.rejects(async () => await notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' }), /outbox down/);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get()).c, before, 'no notification without its event');++ await globalThis.__ovNetworkDdl(`DROP TRIGGER IF EXISTS outbox_down ON ${eventRelay.TABLE}; DROP FUNCTION IF EXISTS ov_test_outbox_down()`);// A bulk fan-out is one transaction with one event per recipient.-- const bulk = notifications.createBulk([1, 2, 21], { type: 'SERVICE_ANNOUNCEMENT', category: 'admin', title: 'Maintenance tonight' });++ const bulk = await notifications.createBulk([1, 2, 21], { type: 'SERVICE_ANNOUNCEMENT', category: 'admin', title: 'Maintenance tonight' });assert.strictEqual(bulk.length, 3);-- assert.deepStrictEqual(outbox().slice(-3).map((e) => e.subject.id), [ALICE, BOB, DAVE]);++ assert.deepStrictEqual((await outbox()).slice(-3).map((e) => e.subject.id), [ALICE, BOB, DAVE]);// The relay publishes it to Events like every other Network event.const published = [];-- const relay = eventRelay.startRelay(db, {++ const relay = await eventRelay.startRelay(db, {eventsUrl: 'http://events.test', privateKey, issuer: ISSUER, autoStart: false, log: { log() {}, warn() {} },fetch: async (url, init) => {const body = JSON.parse(init.body);@@@ -108,7 +110,7 @@ const checkEnvelope = (env, subject) => {},});notifications = new NotificationService(db);-- notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' });++ await notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' });await relay.flush();assert.ok(published.some((e) => e.event_type === NOTIFICATION_EVENT && e.subject.id === ALICE), 'relayed to Events');await eventRelay.stopRelay(db);@@@ -127,7 +129,7 @@ const checkEnvelope = (env, subject) => {const r = await fetch(`${base}/api/v1/realtime/ticket`, { method: 'POST', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers } });return { status: r.status, body: await r.json().catch(() => null), headers: r.headers };};-- const aliceSession = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, config);++ const aliceSession = signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, config);let r = await mint(aliceSession);assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual(r.headers.get('cache-control'), 'no-store');@@@ -145,13 +147,13 @@ const checkEnvelope = (env, subject) => {assert.notStrictEqual(jwt.decode(again.body.ticket).jti, claims.jti, 'every ticket is its own');// A ticket is never a session: not here, not with the session issuer anyone checks.-- assert.ok(session.verifySession(r.body.ticket, { db, publicKey, config }).error, 'Network\'s session guard refuses it');++ assert.ok((await session.verifySession(r.body.ticket, { db, publicKey, config })).error, 'Network\'s session guard refuses it');assert.strictEqual((await mint(r.body.ticket)).status, 401, 'no ticket for a ticket');assert.throws(() => jwt.verify(r.body.ticket, publicKey, { algorithms: ['RS256'], issuer: ISSUER }), /issuer/, 'services checking the session issuer refuse it');assert.throws(() => jwt.verify(r.body.ticket, publicKey, { algorithms: ['RS256'], issuer: `${ISSUER}/realtime`, audience: 'openvibe.live' }), /audience/);// Refused: signed out, a guest, a service token, and everyone while the operator has it off.assert.strictEqual((await mint(null)).status, 401);-- r = await mint(signToken(db.prepare('SELECT * FROM users WHERE id = 3').get(), privateKey, config));++ r = await mint(signToken(await db.prepare('SELECT * FROM users WHERE id = 3').get(), privateKey, config));assert.deepStrictEqual([r.status, r.body.code], [403, 'realtime.guest']);const svc = serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: [], iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: 'tok_x' }, privateKey);assert.strictEqual((await mint(svc)).status, 401, 'services get no ticket');@@@ -160,10 +162,10 @@ const checkEnvelope = (env, subject) => {assert.deepStrictEqual([r.status, r.body.code], [503, 'realtime.disabled']);enabled = true;// An account that has no subject yet is given one.-- r = await mint(signToken(db.prepare('SELECT * FROM users WHERE id = 4').get(), privateKey, config));++ r = await mint(signToken(await db.prepare('SELECT * FROM users WHERE id = 4').get(), privateKey, config));assert.strictEqual(r.status, 200);assert.match(r.body.subject, /^usr_/);-- assert.strictEqual(db.prepare('SELECT subject_id FROM users WHERE id = 4').get().subject_id, r.body.subject);++ assert.strictEqual((await db.prepare('SELECT subject_id FROM users WHERE id = 4').get()).subject_id, r.body.subject);// The stream URL: the default, an operator's origin, or the default for anything odd.assert.strictEqual(ticketMod.streamUrlFrom(''), 'https://events.openvibe.network/realtime/stream');assert.strictEqual(ticketMod.streamUrlFrom('https://events.example.test/'), 'https://events.example.test/realtime/stream');@@@ -186,11 +188,11 @@ const checkEnvelope = (env, subject) => {await new Promise((r2) => live.listen(0, '127.0.0.1', r2));const SECRET = 's'.repeat(40);const logged = []; const discord = [];-- function boot() {++ async function boot() {// A fresh process: a new connection to the same file, new services, nothing in memory.-- const d = quietly(() => initDb(DB_PATH));++ const d = getDb();const n = new NotificationService(d);-- const c = createEventsConsumer({++ const c = await createEventsConsumer({db: d, notifications: n, secrets: SECRET,liveFollowers: createLiveFollowers({ privateKey, issuer: ISSUER, liveUrl: `http://127.0.0.1:${live.address().port}` }),discord: () => ({ sendLiveAlert: async (s) => { discord.push(s.username); return { sent: true }; } }),@@@ -206,16 +208,17 @@ const checkEnvelope = (env, subject) => {const res = await fetch(proc.url, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });return { status: res.status, body: await res.json() };};-- const stop = (proc) => new Promise((r2) => { proc.server.close(() => { proc.db.close(); r2(); }); });++ // The database handle is the process's shared one (test/helpers/pg-preload.mjs): a "restart" closes the++ // server, not the database.++ const stop = (proc) => new Promise((r2) => proc.server.close(r2));const started = {event_id: ids.newId('event'), event_type: 'live.stream.started', version: 1, source: 'live',actor: { type: 'user', id: CAROL }, subject: { type: 'stream', id: '701', revision: 1 }, visibility: 'public', priority: 'important',occurred_at: new Date().toISOString(),payload: { stream_id: 701, channel: { username: 'carol', display_name: 'Carol', url: 'https://openvibe.live/@carol', subject: { type: 'user', id: CAROL } }, title: 'Go', category: null, protocol: 'whip', is_nsfw: false, started_at: new Date().toISOString() },};-- db.close();-- const goLives = (d) => d.prepare("SELECT user_id, COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 AND user_id IN (21, 22) GROUP BY user_id ORDER BY user_id").all().map((x) => [x.user_id, x.c]);-- const goLiveEvents = (d) => outbox(d).filter((e) => [DAVE, ERIN].includes(e.subject.id) && e.payload.type === 'STREAM_LIVE').length;++ const goLives = async (d) => (await d.prepare("SELECT user_id, COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 AND user_id IN (21, 22) GROUP BY user_id ORDER BY user_id").all()).map((x) => [x.user_id, x.c]);++ const goLiveEvents = async (d) => (await outbox(d)).filter((e) => [DAVE, ERIN].includes(e.subject.id) && e.payload.type === 'STREAM_LIVE').length;// The process dies in the middle of the fan-out (Dave's notification written, Erin's not): nothing// of it survives, not the notification, its event, the announcement window or the inbox claim.@@@ -225,11 +228,11 @@ const checkEnvelope = (env, subject) => {proc.notifications.create = (data) => { if (++calls === 2) throw new Error('SIGKILL'); return create(data); };let r3 = await deliver(proc, started);assert.deepStrictEqual([r3.status, r3.body.code], [500, 'network.event_failed']);-- assert.deepStrictEqual(goLives(proc.db), [], 'the half-done fan-out rolled back');-- assert.strictEqual(goLiveEvents(proc.db), 0);-- assert.ok(!proc.db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(started.event_id));-- const claimed = proc.db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'stream_live_announcements'").get()-- ? proc.db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE stream_id = '701'").get().c : 0;++ assert.deepStrictEqual(await goLives(proc.db), [], 'the half-done fan-out rolled back');++ assert.strictEqual(await goLiveEvents(proc.db), 0);++ assert.ok(!await proc.db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(started.event_id));++ const claimed = await proc.db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'stream_live_announcements'").get()++ ? (await proc.db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE stream_id = '701'").get()).c : 0;assert.strictEqual(claimed, 0, 'the announcement window is not spent');await stop(proc);@@@ -238,8 +241,8 @@ const checkEnvelope = (env, subject) => {r3 = await deliver(proc, started);assert.strictEqual(r3.status, 200, JSON.stringify(r3.body));assert.deepStrictEqual([r3.body.duplicate, r3.body.outcome], [false, 'notified']);-- assert.deepStrictEqual(goLives(proc.db), [[21, 1], [22, 1]]);-- assert.strictEqual(goLiveEvents(proc.db), 2, 'one network.notification.created per follower');++ assert.deepStrictEqual(await goLives(proc.db), [[21, 1], [22, 1]]);++ assert.strictEqual(await goLiveEvents(proc.db), 2, 'one network.notification.created per follower');await new Promise((r2) => setImmediate(r2));assert.deepStrictEqual(discord, ['carol'], 'the Discord alert once, after the commit');assert.ok(logged.some((m) => m.includes(`live.stream.started ${started.event_id}: notified`)), 'the outcome is in the log (C-85 evidence)');@@@ -250,14 +253,15 @@ const checkEnvelope = (env, subject) => {liveCalls.length = 0;r3 = await deliver(proc, started);assert.deepStrictEqual([r3.status, r3.body.duplicate, r3.body.outcome], [200, true, null]);-- assert.deepStrictEqual(goLives(proc.db), [[21, 1], [22, 1]], 'nobody is told twice');-- assert.strictEqual(goLiveEvents(proc.db), 2);++ assert.deepStrictEqual(await goLives(proc.db), [[21, 1], [22, 1]], 'nobody is told twice');++ assert.strictEqual(await goLiveEvents(proc.db), 2);assert.strictEqual(liveCalls.length, 0, 'Live is not asked again');await new Promise((r2) => setImmediate(r2));assert.deepStrictEqual(discord, ['carol']);await stop(proc);-- live.close();++ live.close();fs.rmSync(dir, { recursive: true, force: true });console.log('notification events: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -9,14 +9,15 @@ const path = require('path');const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pkce-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -96,3 +97,4 @@ const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKeconsole.log('oauth pkce: all checks passed');server.close();})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -11,14 +11,15 @@ const path = require('path');const http = require('http');const express = require('express');const metrics = require('openvibe-shared/metrics');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const observability = require('../server/observability');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-observability-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -138,17 +139,15 @@ const server = http.createServer(app);assert.strictEqual(body.ready, true, 'outside production the dev key only degrades');assert.deepStrictEqual(body.degraded, ['signing_key']);-- // The database gone: not ready.-- console.log = () => {};-- const db2 = initDb(path.join(dir, 'other.db'));-- console.log = log;++ // The database gone: not ready. A handle whose query rejects stands in for a PostgreSQL server++ // that answers nothing (there is no second, unopenable database file any more — plan T2).++ const db2 = { prepare: () => ({ get: async () => { throw new Error('connection refused'); } }) };const ready3 = observability.createNetworkReadiness({ db: db2, release: 'x', production: true, getKeys: () => ({ privateKey: keys.privateKey, publicKey: keys.publicKey }) });-- db2.close();body = await ready3.run();assert.strictEqual(body.ready, false);assert.deepStrictEqual(body.failed, ['db']);server.close();-- db.close();console.log('observability: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -14,14 +14,23 @@ const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');const { bootServer } = require('./helpers/boot-server');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'];(async () => {// ── The real server: discovery at the issuer's root ──const ISSUER = 'https://openvibe.network';-- const srv = await bootServer({ env: { OV_NETWORK_URL: ISSUER, BASE_URL: ISSUER } });++ // A real RSA key pair on disk: the server loads JWT_PRIVATE_KEY/JWT_PUBLIC_KEY from these paths and must++ // publish exactly this key's kid and modulus at /api/.well-known/jwks (decision 2, plan T2: the key++ // stays a file/env key, never in the database).++ const jwksKeys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-jwks-'));++ const privPath = path.join(keyDir, 'private.pem');++ const pubPath = path.join(keyDir, 'public.pem');++ fs.writeFileSync(privPath, jwksKeys.privateKey, { mode: 0o600 });++ fs.writeFileSync(pubPath, jwksKeys.publicKey);++ const srv = await bootServer({ env: { OV_NETWORK_URL: ISSUER, BASE_URL: ISSUER, JWT_PRIVATE_KEY: privPath, JWT_PUBLIC_KEY: pubPath } });try {const get = (p) => fetch(srv.base + p).then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json().catch(() => null) }));const root = await get('/.well-known/openid-configuration');@@@ -51,17 +60,28 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_urconst r = await fetch(srv.base + new URL(root.body[k]).pathname, { redirect: 'manual' });assert.notStrictEqual(r.status, 404, `${k} is served (got ${r.status})`);}++ // The published JWKS is the configured key: the same kid the id_token header carries, and the same++ // modulus and exponent as JWT_PUBLIC_KEY (decision 2, plan T2).++ const jwks = await get('/api/.well-known/jwks');++ assert.strictEqual(jwks.status, 200);++ assert.strictEqual(jwks.body.algorithm, 'RS256', 'a real key is served, never the HS256 fallback secret');++ const jwk = (jwks.body.keys || []).find((k) => k.kid === 'ov-network-1');++ assert.ok(jwk, 'the JWKS publishes kid ov-network-1');++ const pub = crypto.createPublicKey(jwksKeys.publicKey).export({ format: 'jwk' });++ assert.strictEqual(jwk.kty, 'RSA');++ assert.strictEqual(jwk.n, pub.n, "the served modulus is the configured key's modulus");++ assert.strictEqual(jwk.e, pub.e, 'and the exponent');// The platform descriptor points at the standard location.assert.strictEqual((await get('/.well-known/openvibe')).body.openid_configuration, `${ISSUER}/.well-known/openid-configuration`);-- } catch (err) { console.error(srv.logs()); throw err; } finally { await srv.stop(); }++ } catch (err) { console.error(srv.logs()); throw err; } finally { await srv.stop(); fs.rmSync(keyDir, { recursive: true, force: true }); }// ── What it advertises is true: id_token and userinfo ──const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-oidc-'));const log = console.log; console.log = () => {};-- const db = initDb(path.join(dir, 'network.db'));++ const db = await getDb();console.log = log;-- db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();-- db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (7, 'viewer', 'x', 'Viewer')").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++ await db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (7, 'viewer', 'x', 'Viewer')").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const app = express();app.use(express.json());@@@ -134,4 +154,4 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_urserver.close();console.log('oidc discovery: all checks passed');--})().catch((err) => { console.error(err); process.exit(1); });++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -11,18 +11,19 @@ const path = require('path');const http = require('http');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const alerts = require('../server/operator/alerts');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;process.env.OWNER_USERNAME = 'Boss';process.env.OPERATOR_ALERT_USERNAMES = 'deputy, nobody-here';--db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'boss', 'x'), (2, 'deputy', 'x'), (3, 'someone', 'x')").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'boss', 'x'), (2, 'deputy', 'x'), (3, 'someone', 'x')").run();const sent = [];const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });@@@ -102,17 +103,17 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').// A day later, a critical still firing: one reminder; then quiet again.sent.length = 0;const later = Date.now() + alerts.REMIND_MS + 1000;-- let out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: `r${sent.length}` }; }, now: later });++ let out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: `r${sent.length}` }; }, now: later });assert.deepStrictEqual(out, { ok: true, firing: 1, opened: 0, reminded: 1, resolved: 0, notified: 2 });assert.strictEqual(sent[0].title, 'Still firing: OpenVibeBackupMissed');-- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });++ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });assert.strictEqual(out.reminded, 0);// A warning is never reminded.-- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 120000 });-- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 2 * alerts.REMIND_MS });++ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 120000 });++ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 2 * alerts.REMIND_MS });assert.strictEqual(out.reminded, 1, 'only the critical one');-- alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: later + 2 * alerts.REMIND_MS + 1 });++ await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: later + 2 * alerts.REMIND_MS + 1 });// Fires again within 12 h of resolving: the same notifications are revised; nothing is created or pushed.sent.length = 0; revised.length = 0;@@@ -129,21 +130,21 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').sent.length = 0;r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('JustInfo', { severity: 'info' })] }, auth);assert.deepStrictEqual([r.body.opened, r.body.notified, sent.length], [1, 0, 0]);-- assert.ok(alerts.list(db).some((x) => x.name === 'JustInfo' && x.state === 'firing'));++ assert.ok((await alerts.list(db)).some((x) => x.name === 'JustInfo' && x.state === 'firing'));// After 12 h resolved, firing again is a new episode with new notifications.-- alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null });++ await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null });const much = Date.now() + alerts.FLAP_MS + 60000;sent.length = 0;-- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, { notify: (u, n) => { sent.push(n); return { id: `x${sent.length}` }; }, now: much });++ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, { notify: (u, n) => { sent.push(n); return { id: `x${sent.length}` }; }, now: much });assert.deepStrictEqual([out.opened, out.notified], [1, 2]);assert.ok(sent.every((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed'));-- alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: much + 1000 });++ await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: much + 1000 });// An empty set resolves everything from that source; the list keeps them.r = await post({ source: 'prometheus', alerts: [] }, auth);assert.deepStrictEqual([r.body.firing, r.body.resolved], [0, 1]);-- const rows = alerts.list(db);++ const rows = await alerts.list(db);assert.ok(rows.every((x) => x.state === 'resolved'), JSON.stringify(rows.map((x) => [x.name, x.state])));assert.ok(rows.every((x) => !('description' in x) || x.description === undefined));@@@ -153,8 +154,8 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);} finally {server.close();-- db.close();fs.rmSync(dir, { recursive: true, force: true });}console.log('operator alerts: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -10,19 +10,20 @@ const path = require('path');const http = require('http');const express = require('express');const { serviceAuth, validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();--db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();// A database seeded before media.analyze (Live's AI grants as the old default) is moved at the next boot.--db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));--require('../server/identity/principals').ensureSchema(db);++await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));++await require('../server/identity/principals').ensureSchema(db);const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -72,11 +73,11 @@ const server = http.createServer(app);const creditOnly = t.body.access_token;t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit network.coins.mint' });assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'asking for an ungranted capability fails');-- db.prepare("UPDATE oauth_clients SET client_secret = 'openre-secret' WHERE client_id = 'openre'").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'openre-secret' WHERE client_id = 'openre'").run();t = await token({ client_id: 'openre', client_secret: 'openre-secret', audience: 'openvibe.network' });assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'a client with no grants gets no token');// The canonical channel/owner resolver on Live (D20-R1): OpenRe, Media and Community hold it, nobody else.-- db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();for (const [client, secret] of [['openre', 'openre-secret'], ['media', 'media-secret'], ['community', 'community-secret']]) {t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.live' });assert.strictEqual(t.status, 200, `${client}: ${JSON.stringify(t.body)}`);@@@ -86,7 +87,7 @@ const server = http.createServer(app);}t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.live', scope: 'live.lineage.resolve' });assert.strictEqual(t.status, 400, 'Live does not grant itself its own resolver');-- const holders = db.prepare("SELECT client_id FROM principal_grants WHERE capability = 'live.lineage.resolve' AND audience = 'openvibe.live' AND revoked_at IS NULL ORDER BY client_id").all().map(x => x.client_id);++ const holders = (await db.prepare("SELECT client_id FROM principal_grants WHERE capability = 'live.lineage.resolve' AND audience = 'openvibe.live' AND revoked_at IS NULL ORDER BY client_id").all()).map(x => x.client_id);assert.deepStrictEqual(holders, ['community', 'media', 'openre']);t = await token({ client_id: 'media', client_secret: 'media-secret', audience: 'openvibe.network' });assert.strictEqual(t.status, 200, JSON.stringify(t.body));@@@ -137,7 +138,7 @@ const server = http.createServer(app);assert.strictEqual(r, 404, 'the key-only internal routes are gone');// Community resolves authors with its own token; Live's token lacks that grant.-- db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });assert.strictEqual(com.status, 200, JSON.stringify(com.body));assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');@@@ -146,7 +147,7 @@ const server = http.createServer(app);r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });assert.strictEqual(r.status, 403, 'a token narrowed to coins cannot resolve identities');// Media's owner_subject backfill: Live user ids -> subjects, an unknown id answers null.-- db.prepare("INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id) SELECT 'live', 'user', '42', subject_id FROM users WHERE id = 7").run();++ await db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) SELECT 'live', 'user', '42', subject_id FROM users WHERE id = 7 ON CONFLICT DO NOTHING").run();r = await post('/internal/identity/resolve-batch', { system: 'live', type: 'user', ids: ['42', '43'] }, { authorization: `Bearer ${mediaResolve}` });assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.match(r.body.results['42'].subject.id, /^usr_/); assert.strictEqual(r.body.results['43'], null);@@@ -158,7 +159,7 @@ const server = http.createServer(app);assert.deepStrictEqual(cm.body.scope.split(' '), ['community.comment.moderate', 'community.comment.write', 'community.paste.create', 'community.paste.moderate', 'community.paste.write', 'community.pulse.write']);const tl = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.tools' });assert.deepStrictEqual(tl.body.scope.split(' '), ['tools.job.read', 'tools.tool.run'], 'Live runs Tools on the service tier, never probes');-- db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');@@@ -189,7 +190,7 @@ const server = http.createServer(app);assert.strictEqual(r.status, 403);r = await post('/internal/link-account', { user_id: 7, service: 'live', service_user_id: '4242' }, LIVE);assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.strictEqual(db.prepare("SELECT service_user_id FROM linked_accounts WHERE user_id = 7 AND service = 'live'").get().service_user_id, '4242');++ assert.strictEqual((await db.prepare("SELECT service_user_id FROM linked_accounts WHERE user_id = 7 AND service = 'live'").get()).service_user_id, '4242');r = await post('/internal/link-account', { user_id: 7, service: 'games', service_user_id: '4242' }, LIVE);assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token links accounts only for its own service');// The key is refused on all five with token.missing: the gate asks for a Bearer and nothing else.@@@ -207,11 +208,11 @@ const server = http.createServer(app);}// What the key never had to answer for is now a matter of ownership: a Games token maps its own system's// ids, and Live's token still may not (the token-based equivalent of the key's no-ownership-rule note).-- db.prepare("UPDATE oauth_clients SET client_secret = 'games-secret' WHERE client_id = 'games'").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'games-secret' WHERE client_id = 'games'").run();const GAMES = { authorization: `Bearer ${(await token({ client_id: 'games', client_secret: 'games-secret', audience: 'openvibe.network' })).body.access_token}` };assert.strictEqual((await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }, GAMES)).status, 200, 'a Games token maps its own ids');assert.strictEqual((await post('/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }, GAMES)).status, 200, 'and links its own accounts');-- const decided = db.prepare("SELECT route FROM principal_usage WHERE principal = 'svc:live' AND allowed = 1").all().map(x => x.route);++ const decided = (await db.prepare("SELECT route FROM principal_usage WHERE principal = 'svc:live' AND allowed = 1").all()).map(x => x.route);for (const route of ['GET /internal/url-registry/resolved', 'GET /internal/coins/stats', 'POST /internal/resolve-anon', 'POST /internal/identity/legacy-map', 'POST /internal/link-account']) {assert.ok(decided.includes(route), `token use of ${route} is recorded`);}@@@ -229,15 +230,15 @@ const server = http.createServer(app);assert.strictEqual(r.body.code, 'token.bad_signature');// The retired key leaves no trace in the usage audit: only service principals are recorded there now.await new Promise((resolve) => setTimeout(resolve, 20));-- assert.deepStrictEqual(db.prepare("SELECT DISTINCT principal FROM principal_usage WHERE principal NOT LIKE 'svc:%' AND principal <> 'unknown'").all(), [], 'no principal outside the svc: namespace is audited (a refused stranger is "unknown")');++ assert.deepStrictEqual(await db.prepare("SELECT DISTINCT principal FROM principal_usage WHERE principal NOT LIKE 'svc:%' AND principal <> 'unknown'").all(), [], 'no principal outside the svc: namespace is audited (a refused stranger is "unknown")');// ── Revocation: new tokens stop carrying a revoked grant ──-- db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();++ await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });assert.ok(!t.body.scope.split(' ').includes('network.coins.debit'));// ── Usage audit ──-- const usage = db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();++ const usage = await db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();assert.ok(usage.some(u => u.principal === 'svc:live' && u.route === 'POST /internal/coins/credit' && u.allowed === 1 && u.count === 1));assert.ok(usage.some(u => u.auth === 'service-token' && u.allowed === 1), 'every allow is a service token');assert.ok(!usage.some(u => u.auth === 'internal-key' || u.principal === 'legacy-key'), 'the retired key is not an auth path any more');@@@ -249,3 +250,4 @@ const server = http.createServer(app);fs.rmSync(dir, { recursive: true, force: true });console.log('principals: all checks passed');})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -8,53 +8,54 @@ const fs = require('fs');const os = require('os');const path = require('path');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-profile-events-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = await getDb();console.log = log;--db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (5, 'early', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE')").run();++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (5, 'early', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE')").run();const profile = require('../server/identity/profile-events');--profile.ensureSchema(db);--assert.strictEqual(profile.drain(db), 1, 'the first boot announces every existing account once');--profile.ensureSchema(db);--assert.strictEqual(profile.drain(db), 0, 'and only the first');--db.prepare('DELETE FROM network_event_outbox').run();--const events = () => db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.user.updated');++await profile.ensureSchema(db);++assert.strictEqual(await profile.drain(db), 1, 'the first boot announces every existing account once');++await profile.ensureSchema(db);++assert.strictEqual(await profile.drain(db), 0, 'and only the first');++await db.prepare('DELETE FROM network_event_outbox').run();++const events = async () => (await db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.user.updated');const ALEX = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';--db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role, display_name) VALUES (7, 'alex', 'x', ?, 'user', 'Alex')").run(ALEX);--db.prepare("INSERT INTO users (id, username, password_hash, is_anon, anon_number) VALUES (8, 'anon-1', 'x', 1, 1)").run();--assert.strictEqual(profile.drain(db), 1, 'the new account; never the guest');--let ev = events();++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role, display_name) VALUES (7, 'alex', 'x', ?, 'user', 'Alex')").run(ALEX);++await db.prepare("INSERT INTO users (id, username, password_hash, is_anon, anon_number) VALUES (8, 'anon-1', 'x', 1, 1)").run();++assert.strictEqual(await profile.drain(db), 1, 'the new account; never the guest');++let ev = await events();assert.strictEqual(ev.length, 1);assert.ok(validate('events.event-envelope@1', ev[0]).valid);assert.ok(validate('network.user.updated@1', ev[0].payload).valid, JSON.stringify(validate('network.user.updated@1', ev[0].payload).errors));assert.deepStrictEqual([ev[0].payload.revision, ev[0].payload.changed, ev[0].subject], [1, ['created'], { type: 'user', id: ALEX, revision: 1 }]);// Changes from anywhere (here plain SQL, as an admin route or an import would) are recorded.--db.prepare("UPDATE users SET role = 'global_mod' WHERE id = 7").run();--db.prepare("UPDATE users SET avatar_url = 'https://openvibe.media/avatar/alex', display_name = 'Alex!' WHERE id = 7").run();--db.prepare("UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = 7").run();--assert.strictEqual(profile.drain(db), 1, 'several quick changes become one event');--ev = events();++await db.prepare("UPDATE users SET role = 'global_mod' WHERE id = 7").run();++await db.prepare("UPDATE users SET avatar_url = 'https://openvibe.media/avatar/alex', display_name = 'Alex!' WHERE id = 7").run();++await db.prepare("UPDATE users SET last_seen = ov_now() WHERE id = 7").run();++assert.strictEqual(await profile.drain(db), 1, 'several quick changes become one event');++ev = await events();assert.strictEqual(ev[1].payload.revision, 2);assert.deepStrictEqual(ev[1].payload.changed.sort(), ['avatar_url', 'display_name', 'role']);assert.strictEqual(ev[1].payload.role, 'global_mod'); assert.strictEqual(ev[1].payload.display_name, 'Alex!');--assert.strictEqual(profile.drain(db), 0, 'last_seen is not a profile change');++assert.strictEqual(await profile.drain(db), 0, 'last_seen is not a profile change');--db.prepare("UPDATE users SET is_banned = 1, username = 'alex2' WHERE id = 7").run();--profile.drain(db);--ev = events();++await db.prepare("UPDATE users SET is_banned = 1, username = 'alex2' WHERE id = 7").run();++await profile.drain(db);++ev = await events();assert.deepStrictEqual([ev[2].payload.revision, ev[2].payload.banned, ev[2].payload.username], [3, true, 'alex2']);assert.deepStrictEqual(ev[2].payload.changed.sort(), ['banned', 'username']);// A new account without a subject yet waits for one.--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (9, 'nosub', 'x')").run();--assert.strictEqual(profile.drain(db), 0, 'no subject yet: waits');--db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB' WHERE id = 9").run();--assert.strictEqual(profile.drain(db), 1, 'announced once it has one');--db.close();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (9, 'nosub', 'x')").run();++assert.strictEqual(await profile.drain(db), 0, 'no subject yet: waits');++await db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB' WHERE id = 9").run();++assert.strictEqual(await profile.drain(db), 1, 'announced once it has one');fs.rmSync(dir, { recursive: true, force: true });console.log('profile events: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });@@@ -16,20 +16,21 @@ const express = require('express');const jwt = require('jsonwebtoken');const { ids, validate } = require('openvibe-contracts');const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const { NotificationService } = require('../server/notifications/notification-service');const { createEventsConsumer, TOPICS } = require('../server/notifications/events-consumer');const { createProjectUsage, parseQuery } = require('../server/developer/usage');const { topicsFrom } = require('../scripts/subscribe-events');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-project-usage-'));const quiet = { log: console.log, warn: console.warn };console.log = () => {}; console.warn = () => {};--const db = initDb(path.join(dir, 'network.db'));--db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++const db = getDb();++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'), (14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();--const sid = (id) => subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));++const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -40,7 +41,7 @@ app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry:app.locals.privateKey = keys.privateKey;app.locals.publicKey = keys.publicKey;const projectUsage = createProjectUsage(db, { log: { warn() {} } });--const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, projectUsage });++const consumer = await createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, projectUsage });app.use('/internal/events', consumer.router);app.use('/api/v1/projects', require('../server/developer/routes').router());const server = http.createServer(app);@@@ -96,31 +97,31 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';const raw = JSON.stringify({ event: jobs, seq: 1 });const res = await fetch(`${base}/internal/events`, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });assert.deepStrictEqual([res.status, (await res.json()).outcome], [200, 'recorded']);-- assert.strictEqual(consumer.apply(jobs).duplicate, true, 'a redelivery records nothing twice');++ assert.strictEqual((await consumer.apply(jobs)).duplicate, true, 'a redelivery records nothing twice');// The same hour re-sent with new totals replaces it; an older revision never does.const resent = rollup('tools', { ...jobs.payload, quantity: 42, errors: 3, error_codes: { 'tools.job.failed': 2, 'tools.job.timeout': 1 }, revision: 2 });-- assert.strictEqual(consumer.apply(resent).outcome, 'recorded');-- assert.strictEqual(consumer.apply(rollup('tools', { ...jobs.payload, quantity: 1, revision: 1 })).outcome, 'ignored:stale');-- assert.strictEqual(consumer.apply(rollup('tools', hour(yesterday, { capability: 'tools.tool.run', dimension: 'image-resize', quantity: 5, errors: 0 }))).outcome, 'recorded');-- assert.strictEqual(consumer.apply(rollup('tools', hour(thisHour - HOUR, { capability: 'tools.job.create', dimension: 'img.process', env: 'sandbox', quantity: 7, errors: 0 }))).outcome, 'recorded');++ assert.strictEqual((await consumer.apply(resent)).outcome, 'recorded');++ assert.strictEqual((await consumer.apply(rollup('tools', { ...jobs.payload, quantity: 1, revision: 1 }))).outcome, 'ignored:stale');++ assert.strictEqual((await consumer.apply(rollup('tools', hour(yesterday, { capability: 'tools.tool.run', dimension: 'image-resize', quantity: 5, errors: 0 })))).outcome, 'recorded');++ assert.strictEqual((await consumer.apply(rollup('tools', hour(thisHour - HOUR, { capability: 'tools.job.create', dimension: 'img.process', env: 'sandbox', quantity: 7, errors: 0 })))).outcome, 'recorded');const published = rollup('events', hour(thisHour, {capability: 'events.app.publish', unit: 'events', env: 'sandbox', quantity: 1800, errors: 61, error_codes: { 'events.quota_exceeded': 60, 'events.type_not_allowed': 1 },samples: [{ at: new Date(thisHour + 120000).toISOString(), code: 'events.quota_exceeded', status: 429, trace_id: TRACE }],}));-- assert.strictEqual(consumer.apply(published).outcome, 'recorded');++ assert.strictEqual((await consumer.apply(published)).outcome, 'recorded');// What is not a project's rollup is ignored.const other = await api('stranger', 'POST', '', { name: 'Someone else' });const ignored = [[rollup('tools', { ...jobs.payload, project_id: `prj_${ids.ulid()}` }), 'ignored:project'],[rollup('events', jobs.payload, { source: 'tools' }), 'ignored:source'],-- [rollup('tools', { ...jobs.payload, owner: { type: 'user', id: sid(10) } }), 'ignored:payload'],++ [rollup('tools', { ...jobs.payload, owner: { type: 'user', id: await sid(10) } }), 'ignored:payload'],[rollup('tools', { ...jobs.payload, window_end: new Date(thisHour + 2 * HOUR).toISOString() }), 'ignored:window'],[rollup('tools', jobs.payload, { subject: { type: 'project', id: other.body.id } }), 'ignored:subject'],[rollup('tools', { ...jobs.payload, window_start: new Date(thisHour + 3 * HOUR).toISOString(), window_end: new Date(thisHour + 4 * HOUR).toISOString() }), 'ignored:future'],];-- for (const [e, outcome] of ignored) assert.strictEqual(consumer.apply(e).outcome, outcome, outcome);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM notifications').get().n, 0, 'usage notifies nobody');++ for (const [e, outcome] of ignored) assert.strictEqual((await consumer.apply(e)).outcome, outcome, outcome);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'usage notifies nobody');// ── The dashboard's answer ──r = await api('owner', 'GET', `/${P}/usage?days=7`);@@@ -151,7 +152,7 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';['events', 'events.quota_exceeded', 429, TRACE, null],['tools', 'tools.job.timeout', 504, TRACE, JOB],], 'newest first; the re-sent hour did not duplicate its sample');-- for (const who of [10, 11, 12]) assert.ok(!r.text.includes(sid(who)), 'no subject id in the answer');++ for (const who of [10, 11, 12]) assert.ok(!r.text.includes(await sid(who)), 'no subject id in the answer');// Filters.r = await api('owner', 'GET', `/${P}/usage?env=sandbox`);@@@ -172,10 +173,10 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';// ── Retention: windows and samples of the past go; the daily numbers stay ──const later = createProjectUsage(db, { now: () => Date.now() + 40 * 24 * HOUR });-- later.prune();-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_usage_windows').get().n, 0);-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_usage_errors').get().n, 0);-- assert.ok(db.prepare('SELECT COUNT(*) AS n FROM dev_usage_daily').get().n > 0);++ await later.prune();++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dev_usage_windows').get()).n, 0);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dev_usage_errors').get()).n, 0);++ assert.ok((await db.prepare('SELECT COUNT(*) AS n FROM dev_usage_daily').get()).n > 0);} finally {server.close();fs.rmSync(dir, { recursive: true, force: true });@@@ -183,3 +184,4 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';}console.log('project usage: all checks passed');})().catch((e) => { console.log = quiet.log; console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -11,7 +11,7 @@ const os = require('os');const path = require('path');const http = require('http');const express = require('express');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const secrets = require('../server/secrets');const ENV_NAMES = ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY'];@@@ -23,48 +23,48 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secrets-'));const dbPath = path.join(dir, 'network.db');-- const db = await quiet(() => initDb(dbPath));-- const set = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');-- const dbVal = (k) => (db.prepare('SELECT value FROM site_settings WHERE key = ?').get(k) || {}).value;-- set.run('resend_api_key', 're_db_key_111', 'string');-- set.run('resend_webhook_secret', 'whsec_db_222', 'string');-- set.run('discord_bot_token', 'db.bot.token.333', 'secret');-- set.run('discord_oauth_client_secret', 'db-oauth-secret-444', 'secret');-- set.run('net.ipinfo_token', 'ipinfo-555', 'secret');-- set.run('ses_secret_access_key', 'ses-666', 'string');-- set.run('some_new_api_key', 'unknown-777', 'string');++ const db = await getDb();++ const set = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');++ const dbVal = async (k) => (await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(k) || {}).value;++ await set.run('resend_api_key', 're_db_key_111', 'string');++ await set.run('resend_webhook_secret', 'whsec_db_222', 'string');++ await set.run('discord_bot_token', 'db.bot.token.333', 'secret');++ await set.run('discord_oauth_client_secret', 'db-oauth-secret-444', 'secret');++ await set.run('net.ipinfo_token', 'ipinfo-555', 'secret');++ await set.run('ses_secret_access_key', 'ses-666', 'string');++ await set.run('some_new_api_key', 'unknown-777', 'string');// ── Environment first, database fallback ──-- assert.strictEqual(db.getSetting('resend_api_key'), 're_db_key_111', 'no variable: the database value');-- assert.strictEqual(secrets.source(db, 'resend_api_key'), 'database');++ assert.strictEqual(await db.getSetting('resend_api_key'), 're_db_key_111', 'no variable: the database value');++ assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'database');process.env.RESEND_API_KEY = 're_env_key_999';-- assert.strictEqual(db.getSetting('resend_api_key'), 're_env_key_999', 'the variable wins');-- assert.strictEqual(secrets.source(db, 'resend_api_key'), 'env');++ assert.strictEqual(await db.getSetting('resend_api_key'), 're_env_key_999', 'the variable wins');++ assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'env');process.env.RESEND_API_KEY = ' ';-- assert.strictEqual(db.getSetting('resend_api_key'), 're_db_key_111', 'a blank variable is unset');++ assert.strictEqual(await db.getSetting('resend_api_key'), 're_db_key_111', 'a blank variable is unset');process.env.RESEND_API_KEY = 're_env_key_999';-- assert.strictEqual(secrets.source(db, 'resend_webhook_secret'), 'database');-- assert.strictEqual(db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');-- const rep = secrets.report(db);++ assert.strictEqual(await secrets.source(db, 'resend_webhook_secret'), 'database');++ assert.strictEqual(await db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');++ const rep = await secrets.report(db);assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'github_token']);assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');-- assert.match(secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);++ assert.match(await secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);// The email service reads it through db.getSetting, and says where it came from.const { EmailService } = require('../server/notifications/email-service');const email = await quiet(() => new EmailService(db));-- assert.strictEqual(email.getStatus().api_key_source, 'env');-- assert.strictEqual(email.getStatus().api_key_env, 'RESEND_API_KEY');-- assert.ok(email.getStatus().api_key.startsWith('re_env'), 'the masked key shown is the one in use');++ assert.strictEqual((await email.getStatus()).api_key_source, 'env');++ assert.strictEqual((await email.getStatus()).api_key_env, 'RESEND_API_KEY');++ assert.ok((await email.getStatus()).api_key.startsWith('re_env'), 'the masked key shown is the one in use');// ── Web push: the public key follows the private key from the environment ──const webpush = require('web-push');const pair = webpush.generateVAPIDKeys();process.env.VAPID_PRIVATE_KEY = pair.privateKey;const push = require('../server/push/push-service');-- await quiet(() => push.initVapid(db));-- assert.strictEqual(push.getPublicKey(), pair.publicKey, 'the public key is derived from VAPID_PRIVATE_KEY');-- assert.notStrictEqual(dbVal('vapid_private_key'), pair.privateKey, 'the environment key is not copied into the database');++ await quiet(async () => await push.initVapid(db));++ assert.strictEqual(await push.getPublicKey(), pair.publicKey, 'the public key is derived from VAPID_PRIVATE_KEY');++ assert.notStrictEqual(await dbVal('vapid_private_key'), pair.privateKey, 'the environment key is not copied into the database');// ── Admin API: sources shown, env-provided secrets never saved ──const createAdminRoutes = require('../server/admin/routes');@@@ -76,7 +76,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lconst requireAuth = (req, _res, next) => { req.user = who; next(); };const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());const discordService = { getStatus: () => ({ connected: false }), reinit: async () => {} };-- db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin')").run();++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin')").run();app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));const server = http.createServer(app);@@@ -97,7 +97,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lr = await call('PUT', '/api/admin/email', { api_key: 're_typed_in_ui', from_name: 'OpenVibe' });assert.strictEqual(r.status, 200);assert.deepStrictEqual(r.body.skipped, ['resend_api_key']);-- assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111', 'the database copy is not overwritten');++ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the database copy is not overwritten');// Generic settings: an env-provided secret shows no value and cannot be saved; a database one can.process.env.RESEND_WEBHOOK_SECRET = 'whsec_env_888';@@@ -107,12 +107,12 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lr = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });assert.strictEqual(r.body.skipped, true);assert.strictEqual(r.body.env, 'RESEND_WEBHOOK_SECRET');-- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved while the environment provides it');++ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved while the environment provides it');delete process.env.RESEND_WEBHOOK_SECRET;r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });assert.ok(!r.body.skipped);-- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_from_ui', 'saved when the environment does not provide it');-- set.run('resend_webhook_secret', 'whsec_db_222', 'string');++ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_from_ui', 'saved when the environment does not provide it');++ await set.run('resend_webhook_secret', 'whsec_db_222', 'string');// Discord: the same rule.process.env.DISCORD_BOT_TOKEN = 'env.bot.token';@@@ -121,8 +121,8 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lassert.strictEqual(r.body.sources.discord_oauth_client_secret.source, 'database');r = await call('PUT', '/api/admin/discord', { settings: { discord_bot_token: 'typed.token', discord_guild_id: '42' } });assert.deepStrictEqual(r.body.skipped, ['discord_bot_token']);-- assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333');-- assert.strictEqual(dbVal('discord_guild_id'), '42', 'other Discord settings still save');++ assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333');++ assert.strictEqual(await dbVal('discord_guild_id'), '42', 'other Discord settings still save');server.close();// ── scripts/secrets-out-of-db.js ──@@@ -132,7 +132,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lfs.writeFileSync(envFile, ['NODE_ENV=production', 'DISCORD_OAUTH_CLIENT_SECRET=rotated-secret', 'RESEND_WEBHOOK_SECRET=', ''].join('\n'), { mode: 0o600 });const out = [];const running = { vars: { RESEND_API_KEY: 're_db_key_111', DISCORD_BOT_TOKEN: 'db.bot.token.333', DISCORD_OAUTH_CLIENT_SECRET: 'rotated-secret' }, pid: 4242 };-- const run = (argv, service = running) => script.main(['--db', dbPath, '--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service });++ const run = (argv, service = running) => script.main(['--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service, db });const values = ['re_db_key_111', 'whsec_db_222', 'db.bot.token.333', 'db-oauth-secret-444', 'ipinfo-555', 'ses-666', 'unknown-777', 'rotated-secret'];const noValues = () => { const text = out.join('\n'); for (const v of values) assert.ok(!text.includes(v), `the script never prints a value (${v.slice(0, 3)}…)`); };@@@ -167,43 +167,43 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lassert.match(text, /discord_oauth_client_secret\n.*DIFFERENT value.*\n\s+--apply: keep: the env file value differs/);assert.match(text, /net\.ipinfo_token\n.*\n\s+--apply: BLANK \(Network never reads it/);assert.match(text, /some_new_api_key\n.*\n\s+--apply: keep: not classified/);-- assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111', 'the dry run changes nothing');++ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the dry run changes nothing');out.length = 0;assert.strictEqual(await run(['--apply']), 2, '--apply refuses without --backup');// A service that was not restarted after the env file changed keeps its database copy.out.length = 0;-- const backup = path.join(dir, 'pre-secrets.db');++ const backup = path.join(dir, 'pre-secrets.json');assert.strictEqual(await run(['--apply', '--backup', backup], { vars: { RESEND_API_KEY: 're_db_key_111' }, pid: 1 }), 0);-- assert.strictEqual(dbVal('resend_api_key'), '', 'blanked: env file and running service have it');-- assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');-- assert.strictEqual(dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');-- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: the running service does not have it');-- assert.strictEqual(dbVal('net.ipinfo_token'), '', 'unused secret blanked');-- assert.strictEqual(dbVal('ses_secret_access_key'), '', 'unused secret blanked');-- assert.strictEqual(dbVal('some_new_api_key'), 'unknown-777', 'unclassified left alone');++ assert.strictEqual(await dbVal('resend_api_key'), '', 'blanked: env file and running service have it');++ assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');++ assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');++ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: the running service does not have it');++ assert.strictEqual(await dbVal('net.ipinfo_token'), '', 'unused secret blanked');++ assert.strictEqual(await dbVal('ses_secret_access_key'), '', 'unused secret blanked');++ assert.strictEqual(await dbVal('some_new_api_key'), 'unknown-777', 'unclassified left alone');assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600, 'the backup is owner-only');noValues();// After the restart, and with --allow-different for the rotated one.out.length = 0;-- assert.strictEqual(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.db'), '--allow-different']), 0);-- assert.strictEqual(dbVal('discord_bot_token'), '');-- assert.strictEqual(dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');-- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'still kept until the service has it');++ assert.strictEqual(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.json'), '--allow-different']), 0);++ assert.strictEqual(await dbVal('discord_bot_token'), '');++ assert.strictEqual(await dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');++ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'still kept until the service has it');noValues();// An unreadable env file refuses --apply.out.length = 0;-- assert.strictEqual(await script.main(['--db', dbPath, '--env-file', path.join(dir, 'missing.env'), '--apply', '--backup', path.join(dir, 'x.db')], (m) => out.push(m), { readService: () => running }), 2);++ assert.strictEqual(await script.main(['--env-file', path.join(dir, 'missing.env'), '--apply', '--backup', path.join(dir, 'x.json')], (m) => out.push(m), { readService: () => running, db }), 2);// Rollback from the first backup: every blanked value comes back; nothing printed.out.length = 0;-- assert.strictEqual(await script.main(['--db', dbPath, '--restore-from', backup], (m) => out.push(m)), 0);-- assert.strictEqual(dbVal('discord_bot_token'), '', 'the rollback dry run changes nothing');-- assert.strictEqual(await script.main(['--db', dbPath, '--restore-from', backup, '--apply'], (m) => out.push(m)), 0);-- assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111');-- assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333');-- assert.strictEqual(dbVal('net.ipinfo_token'), 'ipinfo-555');-- assert.strictEqual(dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'restored from the backup taken before it was blanked');++ assert.strictEqual(await script.main(['--restore-from', backup], (m) => out.push(m), { db }), 0);++ assert.strictEqual(await dbVal('discord_bot_token'), '', 'the rollback dry run changes nothing');++ assert.strictEqual(await script.main(['--restore-from', backup, '--apply'], (m) => out.push(m), { db }), 0);++ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111');++ assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333');++ assert.strictEqual(await dbVal('net.ipinfo_token'), 'ipinfo-555');++ assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'restored from the backup taken before it was blanked');noValues();for (const n of ENV_NAMES) delete process.env[n];@@@ -6,43 +6,30 @@// node test/refresh-tokens.test.jsconst assert = require('assert');const crypto = require('crypto');--const fs = require('fs');--const os = require('os');--const path = require('path');const http = require('http');const express = require('express');const jwt = require('jsonwebtoken');--const Database = require('better-sqlite3');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const refreshTokens = require('../server/auth/refresh-tokens');const quiet = (fn) => { const log = console.log; console.log = () => {}; try { return fn(); } finally { console.log = log; } };(async () => {-- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-refresh-'));-- const dbPath = path.join(dir, 'network.db');---- // ── A database as the old code left it: raw refresh tokens, no family or generation ──-- {-- const old = new Database(dbPath);-- old.exec(`CREATE TABLE oauth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, client_id TEXT NOT NULL,-- user_id INTEGER NOT NULL, scope TEXT DEFAULT 'profile theme', expires_at DATETIME NOT NULL, revoked INTEGER DEFAULT 0,-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);-- old.close();-- }++ // ── Rows as the old code left them: raw refresh tokens, no family or generation ──const RAW_LIVE = crypto.randomBytes(48).toString('hex');const RAW_ROTATED = crypto.randomBytes(48).toString('hex');++ const db = getDb();++ // The token rows' foreign key needs their user: PostgreSQL enforces the constraint SQLite only declared.++ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();{-- const old = new Database(dbPath);-- const ins = old.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at, revoked) VALUES (?, ?, ?, ?, ?)');-- ins.run(RAW_LIVE, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 0);-- ins.run(RAW_ROTATED, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 1);-- old.close();++ const ins = db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at, revoked) VALUES (?, ?, ?, ?, ?)');++ await ins.run(RAW_LIVE, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 0);++ await ins.run(RAW_ROTATED, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 1);}-- // Boot: initDb only adds the columns; the raw rows are still raw (and still work, below).-- const db = quiet(() => initDb(dbPath));-- let rows = db.prepare('SELECT * FROM oauth_tokens ORDER BY id').all();++ // The columns family_id, generation and revoked_reason are part of the migration; the raw rows are++ // still raw (and still work, below).++ let rows = await db.prepare('SELECT * FROM oauth_tokens ORDER BY id').all();assert.strictEqual(rows.length, 2);assert.ok('family_id' in rows[0] && 'generation' in rows[0] && 'revoked_reason' in rows[0], 'boot adds the columns');assert.strictEqual(rows[0].token, RAW_LIVE, 'boot changes no data');@@@ -51,9 +38,8 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r// the server hashes one on its first use, below.assert.strictEqual(rows[1].token, RAW_ROTATED, 'a raw row stays raw until it is used');-- db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();-- db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();-- db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++ await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -77,67 +63,67 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { rassert.strictEqual(t.status, 200, JSON.stringify(t.body));return t.body.refresh_token;};-- const rowOf = (token) => db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshTokens.hash(token));-- const age = (token, seconds) => db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now', ?) WHERE token = ?").run(`-${seconds} seconds`, refreshTokens.hash(token));++ const rowOf = async (token) => await db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshTokens.hash(token));++ const age = async (token, seconds) => await db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now', ?) WHERE token = ?").run(`-${seconds} seconds`, refreshTokens.hash(token));// ── A migrated (pre-change) token still refreshes, once ──let r = await refresh(RAW_LIVE);assert.strictEqual(r.status, 200, `a token written before the change still works: ${JSON.stringify(r.body)}`);-- const legacyFamily = rowOf(RAW_LIVE).family_id;-- assert.strictEqual(rowOf(r.body.refresh_token).family_id, legacyFamily, 'its successor continues the legacy family');-- assert.strictEqual(rowOf(r.body.refresh_token).generation, 1);++ const legacyFamily = (await rowOf(RAW_LIVE)).family_id;++ assert.strictEqual((await rowOf(r.body.refresh_token)).family_id, legacyFamily, 'its successor continues the legacy family');++ assert.strictEqual((await rowOf(r.body.refresh_token)).generation, 1);r = await refresh(RAW_ROTATED);assert.strictEqual(r.status, 400, 'a token the old code had already rotated stays dead');// ── New sign-ins: hash only, family, generations ──const t0 = await signIn();assert.match(t0, /^[0-9a-f]{96}$/);-- const row0 = rowOf(t0);++ const row0 = await rowOf(t0);assert.ok(row0, 'found by its hash');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(t0).n, 0, 'the token itself is not stored');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(t0)).n, 0, 'the token itself is not stored');assert.strictEqual(row0.generation, 0);assert.match(row0.family_id, /^fam_[0-9a-f]{24}$/);r = await refresh(t0);assert.strictEqual(r.status, 200);const t1 = r.body.refresh_token;-- assert.strictEqual(rowOf(t1).family_id, row0.family_id, 'rotation stays in the family');-- assert.strictEqual(rowOf(t1).generation, 1, 'generation counts rotations');-- assert.strictEqual(rowOf(t0).revoked_reason, 'rotated');++ assert.strictEqual((await rowOf(t1)).family_id, row0.family_id, 'rotation stays in the family');++ assert.strictEqual((await rowOf(t1)).generation, 1, 'generation counts rotations');++ assert.strictEqual((await rowOf(t0)).revoked_reason, 'rotated');r = await refresh(t1);const t2 = r.body.refresh_token;-- assert.strictEqual(rowOf(t2).generation, 2);++ assert.strictEqual((await rowOf(t2)).generation, 2);// The stored hash is not a token, and a malformed token is refused without a lookup.-- r = await refresh(rowOf(t2).token);++ r = await refresh((await rowOf(t2)).token);assert.strictEqual(r.status, 400, 'presenting the stored hash does not work');-- r = await refresh(rowOf(t2).token.slice('sha256:'.length));++ r = await refresh((await rowOf(t2)).token.slice('sha256:'.length));assert.strictEqual(r.status, 400, 'nor its hex');// Another client cannot use (or burn) the family.r = await refresh(t2, 'tools', 'tools-secret');assert.strictEqual(r.status, 400);-- assert.strictEqual(rowOf(t2).revoked, 0, 'a client mismatch revokes nothing');++ assert.strictEqual((await rowOf(t2)).revoked, 0, 'a client mismatch revokes nothing');// ── Reuse within the grace window (two tabs at once): refused, family intact ──r = await refresh(t1);assert.strictEqual(r.status, 400, 'a just-rotated token is single-use');-- assert.strictEqual(rowOf(t2).revoked, 0, 'a concurrent double refresh does not revoke the family');++ assert.strictEqual((await rowOf(t2)).revoked, 0, 'a concurrent double refresh does not revoke the family');// ── Reuse after the grace window: the whole family is revoked ──-- age(t1, refreshTokens.REUSE_GRACE_S + 5);++ await age(t1, refreshTokens.REUSE_GRACE_S + 5);r = await refresh(t1);assert.strictEqual(r.status, 400, 'a replayed rotated token is refused');-- assert.strictEqual(rowOf(t2).revoked, 1, 'and its family is revoked: the latest generation dies too');-- assert.strictEqual(rowOf(t2).revoked_reason, 'reuse');++ assert.strictEqual((await rowOf(t2)).revoked, 1, 'and its family is revoked: the latest generation dies too');++ assert.strictEqual((await rowOf(t2)).revoked_reason, 'reuse');r = await refresh(t2);assert.strictEqual(r.status, 400, 'the latest token no longer refreshes');-- const auditRow = db.prepare("SELECT * FROM audit_log WHERE action = 'oauth_refresh_reuse' ORDER BY id DESC").get();++ const auditRow = await db.prepare("SELECT * FROM audit_log WHERE action = 'oauth_refresh_reuse' ORDER BY id DESC").get();assert.ok(auditRow, 'reuse is audited');const details = JSON.parse(auditRow.details);assert.strictEqual(details.family_id, row0.family_id);assert.strictEqual(details.generation_presented, 1);assert.strictEqual(details.generation_current, 2);-- assert.ok(!auditRow.details.includes(t1) && !auditRow.details.includes(rowOf(t1).token), 'the audit carries no token or hash');++ assert.ok(!auditRow.details.includes(t1) && !auditRow.details.includes((await rowOf(t1)).token), 'the audit carries no token or hash');// Other families of the same account are untouched.const other = await signIn();r = await refresh(other);@@@ -152,15 +138,15 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r// ── A raw row that appears after boot (defensive path) is hashed on first use ──const late = crypto.randomBytes(48).toString('hex');-- db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)').run(late, 'live', 7, new Date(Date.now() + 86400e3).toISOString());++ await db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)').run(late, 'live', 7, new Date(Date.now() + 86400e3).toISOString());r = await refresh(late);assert.strictEqual(r.status, 200, 'a raw row still works');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(late).n, 0, 'and is hashed on use');-- assert.strictEqual(rowOf(late).revoked_reason, 'rotated');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(late)).n, 0, 'and is hashed on use');++ assert.strictEqual((await rowOf(late)).revoked_reason, 'rotated');// Expired tokens are refused.const exp = await signIn();-- db.prepare('UPDATE oauth_tokens SET expires_at = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), refreshTokens.hash(exp));++ await db.prepare('UPDATE oauth_tokens SET expires_at = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), refreshTokens.hash(exp));assert.strictEqual((await refresh(exp)).status, 400, 'an expired refresh token is refused');server.close();@@@ -1,11 +1,15 @@++'use strict';++// The URL registry seeds its bootstrap values on first boot and resolves them; the process database++// already migrated (test/helpers/pg-preload.mjs). Only the bootstrap rows are added here.const assert = require('assert');--const Database = require('better-sqlite3');const urlRegistry = require('../server/url-registry');++const { getDb } = require('../server/db/database');--const db = new Database(':memory:');++(async () => {++const db = await getDb();urlRegistry.initializeUrlRegistry(db);--urlRegistry.seedBootstrapRegistry(db, { OV_LIVE_URL: 'https://bootstrap.example.com', OV_NETWORK_URL: 'https://openvibe.network' }, 'local-dev');--const resolved = urlRegistry.getResolvedRegistry(db, {});++await urlRegistry.seedBootstrapRegistry(db, { OV_LIVE_URL: 'https://bootstrap.example.com', OV_NETWORK_URL: 'https://openvibe.network' }, 'local-dev');++const resolved = await urlRegistry.getResolvedRegistry(db, {});assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://bootstrap.example.com');assert.strictEqual(resolved.OV_LIVE_URL.source, 'bootstrap');assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://openvibe.network');@@@ -17,21 +21,22 @@ assert.strictEqual(resolved.OV_MEDIA_INTERNAL_URL.value, 'http://127.0.0.1:4100'assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.value, 'http://127.0.0.1:3000');assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.source, 'bootstrap');--const storedExtra = db.prepare('SELECT key, value, type FROM url_registry WHERE key = ?').get('ALLOWED_EXTRA_ORIGINS');++const storedExtra = await db.prepare('SELECT key, value, type FROM url_registry WHERE key = ?').get('ALLOWED_EXTRA_ORIGINS');assert.strictEqual(storedExtra.type, 'json_array');assert.strictEqual(storedExtra.value, '[]');--assert.deepStrictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, []);++assert.deepStrictEqual((await urlRegistry.getAllRegistryEntries(db)).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, []);--const updated = urlRegistry.setRegistryEntry(db, 'ALLOWED_EXTRA_ORIGINS', ['https://cdn.example.com'], null);++const updated = await urlRegistry.setRegistryEntry(db, 'ALLOWED_EXTRA_ORIGINS', ['https://cdn.example.com'], null);assert.deepStrictEqual(updated.value, ['https://cdn.example.com']);--assert.deepStrictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, ['https://cdn.example.com']);++assert.deepStrictEqual((await urlRegistry.getAllRegistryEntries(db)).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, ['https://cdn.example.com']);--const scalarUpdated = urlRegistry.setRegistryEntry(db, 'NETWORK_NAME', 'CoolTools', null);++const scalarUpdated = await urlRegistry.setRegistryEntry(db, 'NETWORK_NAME', 'CoolTools', null);assert.strictEqual(scalarUpdated.value, 'CoolTools');--assert.strictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'NETWORK_NAME').value, 'CoolTools');++assert.strictEqual((await urlRegistry.getAllRegistryEntries(db)).find(e => e.key === 'NETWORK_NAME').value, 'CoolTools');--const overrides = urlRegistry.loadOverrides(db);++const overrides = await urlRegistry.loadOverrides(db);assert.deepStrictEqual(overrides.ALLOWED_EXTRA_ORIGINS, ['https://cdn.example.com']);assert.strictEqual(overrides.NETWORK_NAME, 'CoolTools');console.log('✅ openvibe-network registry bootstrap test passed');++})().catch(err => { console.error(err); process.exit(1); });@@@ -163,7 +163,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcdassert.strictEqual(code, 1, '--strict fails on a warning');assert.ok(!lines.some(l => l.startsWith('::warning')), 'annotations only under GitHub Actions');lines.length = 0;-- code = await drift.main(['--registry', base], { fetchImpl: (u, o) => (String(u).startsWith(base) ? fetch(u, o) : fakeGithub(u, o)), env: {}, out });++ code = await drift.main(['--registry', base], { fetchImpl: async (u, o) => (String(u).startsWith(base) ? fetch(u, o) : await fakeGithub(u, o)), env: {}, out });assert.strictEqual(code, 0);assert.ok(lines.some(l => l.startsWith(`WARN openvibe-contracts live: pins v0.28.0, latest is ${LATEST}`)), lines.join('\n'));assert.ok(lines.some(l => l.includes(`network: pins ${LATEST} (latest)`)), lines.join('\n'));@@@ -13,28 +13,29 @@ const cookieParser = require('cookie-parser');const jwt = require('jsonwebtoken');const bcrypt = require('bcryptjs');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const subjects = require('../server/identity/subjects');const revocation = require('../server/auth/revocation');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-revoke-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;const ISSUER = 'https://openvibe.network';const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };const hash = bcrypt.hashSync('oldpass1', 4);--const mk = (name, withSubject = true) => {-- const id = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(name, hash).lastInsertRowid;-- if (withSubject) subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));-- else db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);-- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++const mk = async (name, withSubject = true) => {++ const id = (await db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id').run(name, hash)).lastInsertRowid;++ if (withSubject) await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ else await db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);};// A session token issued 10 seconds ago (the cutoff has second precision; a token from the same second survives).const oldToken = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id || undefined, username: u.username, role: u.role || 'user', iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });--const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.user.token_valid_after');++const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map(r => r.envelope).filter(e => e.event_type === 'network.user.token_valid_after');const app = express();app.locals.db = db; app.locals.config = config; app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;@@@ -49,53 +50,53 @@ const server = http.createServer(app);.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));try {revocation.revokeTokens.length; // module loads-- assert.throws(() => revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);++ await assert.rejects(async () => await revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);// Sign out everywhere, this device too.-- const ann = mk('ann'); const annTok = oldToken(ann);++ const ann = await mk('ann'); const annTok = oldToken(ann);assert.strictEqual((await call('GET', '/me', annTok)).status, 200);-- db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);++ await db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);let r = await call('POST', '/sign-out-everywhere', annTok);assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual((await call('GET', '/me', annTok)).status, 401, 'the old token is refused');-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id).n, 0);-- let ev = events();++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id)).n, 0);++ let ev = await events();assert.strictEqual(ev.length, 1);assert.deepStrictEqual([ev[0].source, ev[0].subject, ev[0].payload.subject.id, ev[0].payload.reason, ev[0].payload.valid_after], ['network', { type: 'user', id: ann.subject_id }, ann.subject_id, 'signed_out_everywhere', r.body.valid_after]);assert.ok(validate('events.event-envelope@1', ev[0]).valid && validate('network.user.token_valid_after@1', ev[0].payload).valid);assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ann.subject_id }, 'the person did it');// Sign out other devices: the old token dies, this browser gets a fresh one.-- const bob = mk('bob'); const bobTok = oldToken(bob);++ const bob = await mk('bob'); const bobTok = oldToken(bob);r = await call('DELETE', '/sessions', bobTok);assert.strictEqual(r.status, 200);assert.ok(r.body.token);assert.strictEqual((await call('GET', '/me', bobTok)).status, 401);assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200, 'the fresh token works');-- assert.strictEqual(events().at(-1).payload.reason, 'signed_out_everywhere');++ assert.strictEqual((await events()).at(-1).payload.reason, 'signed_out_everywhere');// Password change: every older token ends; the reply's token works.-- const cat = mk('cat'); const catTok = oldToken(cat);++ const cat = await mk('cat'); const catTok = oldToken(cat);r = await call('POST', '/change-password', catTok, { current_password: 'oldpass1', new_password: 'newpass2' });assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual((await call('GET', '/me', catTok)).status, 401);assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200);-- assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).payload.subject.id], ['password_changed', cat.subject_id]);++ assert.deepStrictEqual([(await events()).at(-1).payload.reason, (await events()).at(-1).payload.subject.id], ['password_changed', cat.subject_id]);// A ban (staff actor) and an account without a subject (no event, the cutoff still moves).-- const dan = mk('dan');-- revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });-- assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).actor.id], ['banned', ann.subject_id]);-- const eve = mk('eve', false);-- const n = events().length;-- const out = revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });++ const dan = await mk('dan');++ await revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });++ assert.deepStrictEqual([(await events()).at(-1).payload.reason, (await events()).at(-1).actor.id], ['banned', ann.subject_id]);++ const eve = await mk('eve', false);++ const n = (await events()).length;++ const out = await revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });assert.strictEqual(out.event, null);-- assert.strictEqual(events().length, n);-- assert.ok(db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id).token_valid_after);++ assert.strictEqual((await events()).length, n);++ assert.ok((await db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id)).token_valid_after);// Wiring: reset and ban call revokeTokens; the admin can end someone's sessions.const authSrc = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');-- assert.ok(/revokeTokens\(db, reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));++ assert.ok(/revokeTokens\((?:db|t), reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));const adminSrc = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');assert.ok(/reason: 'banned'/.test(adminSrc) && /router\.post\('\/users\/:id\/sign-out'/.test(adminSrc) && /reason: 'staff_revoked'/.test(adminSrc));assert.ok(!/token_valid_after = CURRENT_TIMESTAMP/.test(authSrc), 'every cutoff goes through revokeTokens (so it is announced)');@@@ -105,3 +106,4 @@ const server = http.createServer(app);}console.log('revocation: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -26,14 +26,14 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');const { users, dev, db } = w;const PA = dev.PA.id; const AA = dev.PA.app; const PB = dev.PB.id; const AB = dev.PB.app;const DEV_TABLES = ['dev_projects', 'dev_project_members', 'dev_apps', 'dev_credentials', 'dev_grants', 'dev_quotas'];-- const snap = (tables, where = {}) => JSON.stringify(tables.map((t) => db.prepare(`SELECT * FROM "${t}" ${where[t] || ''}`).all()));++ const snap = async (tables, where = {}) => JSON.stringify((await Promise.all(tables.map(async (t) => await db.prepare(`SELECT * FROM "${t}" ${where[t] || ''}`).all()))));const secretNeedles = { ...w.secrets };const refused = [];const expectRefused = async (who, method, p, body, { tables, where, codes = [401, 403, 404] } = {}) => {-- const before = tables ? snap(tables, where) : null;++ const before = tables ? await snap(tables, where) : null;const r = await w.call(who, method, p, body);if (!codes.includes(r.status)) refused.push(`${method} ${p} as ${typeof who === 'string' ? who : 'token'} → ${r.status} ${r.text.slice(0, 160)}`);-- if (tables && snap(tables, where) !== before) refused.push(`${method} ${p}: the database changed on a refusal`);++ if (tables && await snap(tables, where) !== before) refused.push(`${method} ${p}: the database changed on a refusal`);for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${method} ${p}: the refusal carries ${l.label}`);if (/"(client_secret|access_token|refresh_token)"/.test(r.text)) refused.push(`${method} ${p}: the refusal carries a token field`);return r;@@@ -106,13 +106,13 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');await ok('alice', 'POST', `/api/v1/projects/${PA}/apps/${AA}/credentials/${dev.PA.credential}/revoke`, {});await ok('bob', 'POST', `/api/v1/projects/${PB}/apps/${AB}/credentials/rotate`, {}, 201);await ok('alice', 'DELETE', `/api/v1/projects/${PA}/members/${users.carol.subject}`);-- assert.strictEqual(db.prepare('SELECT name FROM dev_projects WHERE id = ?').get(PA).name, 'alice-private-project-renamed');-- assert.ok(db.prepare('SELECT revoked_at FROM dev_credentials WHERE id = ?').get(dev.PA.credential).revoked_at, 'alice revoked her own credential');++ assert.strictEqual((await db.prepare('SELECT name FROM dev_projects WHERE id = ?').get(PA)).name, 'alice-private-project-renamed');++ assert.ok((await db.prepare('SELECT revoked_at FROM dev_credentials WHERE id = ?').get(dev.PA.credential)).revoked_at, 'alice revoked her own credential');// ── Personal data: bob against alice's ids ───────────────────────-- const notif = db.prepare('SELECT id FROM notifications WHERE user_id = ? ORDER BY created_at DESC').get(users.alice.id);-- const session = db.prepare('SELECT id FROM user_sessions WHERE user_id = ? AND is_active = 1').get(users.alice.id);-- const hist = db.prepare('SELECT id FROM user_history WHERE user_id = ?').get(users.alice.id);++ const notif = await db.prepare('SELECT id FROM notifications WHERE user_id = ? ORDER BY created_at DESC').get(users.alice.id);++ const session = await db.prepare('SELECT id FROM user_sessions WHERE user_id = ? AND is_active = 1').get(users.alice.id);++ const hist = await db.prepare('SELECT id FROM user_history WHERE user_id = ?').get(users.alice.id);assert.ok(notif && session && hist, 'alice has a notification, a session and a history entry');const ALICE = {tables: ['notifications', 'user_sessions', 'user_history', 'users', 'user_modules', 'user_follows', 'user_blocks', 'push_subscriptions', 'linked_accounts'],@@@ -123,8 +123,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');},};// Column names differ between modules; fall back to the whole table when a guess is wrong.-- for (const t of ALICE.tables) { try { db.prepare(`SELECT * FROM "${t}" ${ALICE.where[t]}`).all(); } catch { ALICE.where[t] = ''; } }-- const aliceBefore = snap(ALICE.tables, ALICE.where);++ for (const t of ALICE.tables) { try { await db.prepare(`SELECT * FROM "${t}" ${ALICE.where[t]}`).all(); } catch { ALICE.where[t] = ''; } }++ const aliceBefore = await snap(ALICE.tables, ALICE.where);const personal = [['POST', `/api/notifications/${notif.id}/read`, {}],['POST', `/api/notifications/${notif.id}/dismiss`, {}],@@@ -143,16 +143,16 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');for (const [m, p, body] of personal) {const r = await w.call('bob', m, p, body, m === 'PUT' && p.startsWith('/api/modules') ? { 'if-match': '0' } : {});for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${m} ${p}: carries ${l.label}`);-- if (snap(ALICE.tables, ALICE.where) !== aliceBefore) { refused.push(`${m} ${p} as bob changed alice's data (${r.status})`); break; }++ if (await snap(ALICE.tables, ALICE.where) !== aliceBefore) { refused.push(`${m} ${p} as bob changed alice's data (${r.status})`); break; }}// Positive controls: alice can do each of these to her own data.await ok('alice', 'POST', `/api/notifications/${notif.id}/read`, {});-- assert.strictEqual(db.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id).is_read, 1);++ assert.strictEqual((await db.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id)).is_read, 1);await ok('alice', 'DELETE', `/api/history/${hist.id}`);-- assert.ok(!db.prepare('SELECT id FROM user_history WHERE id = ?').get(hist.id));++ assert.ok(!await db.prepare('SELECT id FROM user_history WHERE id = ?').get(hist.id));await ok('alice', 'DELETE', `/api/auth/sessions/${session.id}`);-- assert.strictEqual(db.prepare('SELECT is_active FROM user_sessions WHERE id = ?').get(session.id).is_active, 0);-- assert.strictEqual(db.prepare('SELECT bio FROM users WHERE id = ?').get(users.bob.id).bio, 'bob-was-here', 'the ids in the body were ignored: bob edited his own profile');++ assert.strictEqual((await db.prepare('SELECT is_active FROM user_sessions WHERE id = ?').get(session.id)).is_active, 0);++ assert.strictEqual((await db.prepare('SELECT bio FROM users WHERE id = ?').get(users.bob.id)).bio, 'bob-was-here', 'the ids in the body were ignored: bob edited his own profile');// ── Service principals outside their own app ───────────────────const live = { authorization: `Bearer ${w.liveToken}` };@@@ -179,4 +179,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');} finally {await w.stop();}--})().catch((err) => { console.error(err); process.exit(1); });++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -27,8 +27,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });assert.strictEqual(closed.status, 200, closed.text);const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();-- db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);-- db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);++ await db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);++ await db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);const alice = {'alice email': users.alice.email,@@@ -158,4 +158,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');} finally {await w.stop();}--})().catch((err) => { console.error(err); process.exit(1); });++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -17,6 +17,7 @@// without its key files never publishes its ephemeral HS256 secret as a "public key".// node test/security-secrets.test.jsconst assert = require('assert');++const crypto = require('crypto');const jwt = require('jsonwebtoken');const { buildWorld } = require('./security-world');const { bootServer } = require('./helpers/boot-server');@@@ -111,6 +112,11 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');assert.strictEqual(jwks.status, 200);assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);++ // The configured key (decision 2, plan T2): the kid and modulus JWKS serves are the signing key's.++ const jwk = jwks.body.keys[0];++ assert.strictEqual(jwk.kty, 'RSA');++ assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');++ assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');assert.ok(!/PRIVATE/.test(jwks.text));// ── Error paths: every write route, refused or malformed ─────────@@@ -165,21 +171,22 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');delete all['verification key']; // stored in its own table by design; only its readers are checked aboveconst logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);assert.deepStrictEqual(logHits, [], 'nothing secret was logged');-- const tables = w.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE '%outbox%' OR name LIKE '%audit%' OR name LIKE '%log%' OR name LIKE '%usage%' OR name LIKE '%changes%' OR name LIKE '%alerts%' OR name LIKE '%revisions%' OR name = 'notifications')").all().map((r) => r.name);++ const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));const rowHits = [];for (const t of tables) {-- const text = JSON.stringify(w.db.prepare(`SELECT * FROM "${t}"`).all());++ const text = JSON.stringify(await w.db.prepare(`SELECT * FROM "${t}"`).all());for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);}assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');-- assert.ok(w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n > 0, 'the outbox was written meanwhile');++ assert.ok((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');} finally {await w.stop();}-- // ── Without key files: the ephemeral HS256 secret is never published ───-- const bare = await bootServer();++ // ── Without key files: the ephemeral HS256 secret is never published (a child of its own, so its++ // database and process are not the seeded world's) ───++ const bare = await bootServer({ child: true });try {const r = await fetch(`${bare.base}/api/.well-known/jwks`);const body = await r.json();@@@ -195,4 +202,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');await bare.stop();}out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);--})().catch((err) => { console.error(err); process.exit(1); });++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -13,17 +13,18 @@ const http = require('http');const express = require('express');const cookieParser = require('cookie-parser');const jwt = require('jsonwebtoken');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const { verifySession } = require('../server/auth/session');const { signAssertion } = require('../server/auth/fedcm');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secsess-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();--db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed', 'x')").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed', 'x')").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const ISSUER = 'https://openvibe.network';@@@ -43,7 +44,7 @@ const server = http.createServer(app);const REDIRECT = 'https://openvibe.live/api/auth/callback';const ctx = { db, publicKey: keys.publicKey, privateKey: keys.privateKey, config };--const user7 = db.prepare('SELECT * FROM users WHERE id = 7').get();++const user7 = await db.prepare('SELECT * FROM users WHERE id = 7').get();const assertion = signAssertion(user7, 'https://evil.openvibe.tools', 'n', ctx);const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h', ...opts });@@@ -54,8 +55,8 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys..then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));// ── FedCM assertions are not sessions ────────────────────────────-- assert.ok(!verifySession(sign(7), ctx).error, 'a normal user token is a session');-- assert.ok(verifySession(assertion, ctx).error, 'a FedCM assertion is not a Network session');++ assert.ok(!(await verifySession(sign(7), ctx)).error, 'a normal user token is a session');++ assert.ok((await verifySession(assertion, ctx)).error, 'a FedCM assertion is not a Network session');let r = await post('/oauth/confirm', { token: assertion, client_id: 'live', redirect_uri: REDIRECT });assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });@@@ -86,10 +87,10 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.const t = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });assert.strictEqual(t.status, 200, JSON.stringify(t.body));// backdate what was issued so far, then change the password now-- db.prepare("UPDATE oauth_tokens SET created_at = datetime('now', '-10 minutes') WHERE user_id = 8").run();-- db.prepare("UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = 8").run();++ await db.prepare("UPDATE oauth_tokens SET created_at = datetime('now', '-10 minutes') WHERE user_id = 8").run();++ await db.prepare("UPDATE users SET token_valid_after = ov_now() WHERE id = 8").run();const stale = jwt.sign({ sub: 8, id: 8, username: 'changed', iat: Math.floor(Date.now() / 1000) - 600 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });-- assert.ok(verifySession(stale, ctx).error, 'the session guard refuses the revoked token');++ assert.ok((await verifySession(stale, ctx)).error, 'the session guard refuses the revoked token');r = await post('/oauth/confirm', { token: stale, client_id: 'live', redirect_uri: REDIRECT });assert.strictEqual(r.status, 401, 'a revoked token cannot start an OAuth sign-in');r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'live-secret', refresh_token: t.body.refresh_token });@@@ -111,3 +112,4 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.fs.rmSync(dir, { recursive: true, force: true });console.log('session token confusion and revocation: all checks passed');})().catch((err) => { console.error(err); server.close(); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -76,7 +76,7 @@ net.Socket.prototype.connect = function (...args) {return this;};--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const push = require('../server/push/push-service');const { normalizeAvatar } = require('../server/profile/avatar');const { buildWorld } = require('./security-world');@@@ -192,28 +192,28 @@ function inventory(root) {// ── 1. Web push ──────────────────────────────────────────────────const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ssrf-'));const log = console.log; console.log = () => {};-- const db = initDb(path.join(dir, 'network.db'));-- push.initVapid(db);++ const db = await getDb();++ await push.initVapid(db);console.log = log;-- db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'pushy', 'x')").run();++ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'pushy', 'x')").run();const listener = await counter();const P = listener.port;const keys = { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM', auth: 'tBHItJI5svbpez7KI4CCXg' };const refusedAtSubscribe = [];for (const endpoint of internalUrls(P)) {-- try { push.subscribe(7, { endpoint, keys }); refusedAtSubscribe.push(`accepted ${endpoint}`); } catch (e) { assert.match(e.message, /Invalid push subscription/); }++ try { await push.subscribe(7, { endpoint, keys }); refusedAtSubscribe.push(`accepted ${endpoint}`); } catch (e) { assert.match(e.message, /Invalid push subscription/); }}assert.deepStrictEqual(refusedAtSubscribe, [], 'every internal endpoint is refused at subscribe');-- push.subscribe(7, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys }); // a real push service is fine-- push.subscribe(7, { endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/abc', keys });-- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = 7').get().n, 2);-- db.prepare('DELETE FROM push_subscriptions').run();++ await push.subscribe(7, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys }); // a real push service is fine++ await push.subscribe(7, { endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/abc', keys });++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = 7').get()).n, 2);++ await db.prepare('DELETE FROM push_subscriptions').run();// Rows stored before the rule, and names that resolve inward at send time.const rows = [`https://127.0.0.1:${P}/x`, `https://[::ffff:127.0.0.1]:${P}/x`, `https://2130706433:${P}/x`, `https://localhost:${P}/x`,`https://loop.example.com:${P}/x`, `https://loop.example.com.:${P}/x`, `https://v6loop.example.com:${P}/x`, `https://meta.example.com:${P}/x`, `https://mixed.example.com:${P}/x`,`https://rebind.example.com:${P}/x`, `https://pub.example.com:${P}/x`];const ins = db.prepare('INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) VALUES (7, ?, ?, ?)');-- for (const e of rows) ins.run(e, keys.p256dh, keys.auth);++ for (const e of rows) await ins.run(e, keys.p256dh, keys.auth);connects.length = 0;await push.sendPush(7, { title: 't', message: 'm' });await new Promise((r) => setTimeout(r, 300));@@@ -224,10 +224,9 @@ function inventory(root) {assert.ok(connects.includes(`rebind.example.com→${PUBLIC} ${P}`), 'a rebinding name: the socket connects to the address that was checked');assert.ok(connects.includes(`pub.example.com→${PUBLIC} ${P}`), 'public names are still pushed to (positive control)');assert.deepStrictEqual(connects.filter((c) => !c.endsWith(' denied') && !c.includes(`→${PUBLIC} `)), [], 'no connection to any internal address');-- const left = db.prepare('SELECT endpoint FROM push_subscriptions').all().map((r) => r.endpoint);++ const left = (await db.prepare('SELECT endpoint FROM push_subscriptions').all()).map((r) => r.endpoint);assert.ok(!left.some((e) => /127\.0\.0\.1|\[::ffff|2130706433|localhost/.test(e)), 'rows with an internal address are dropped');await listener.close();-- db.close();// ── 2. Avatars: Network itself fetches openvibe.media only ─────────for (const u of [...internalUrls(8443, '/a.png'), 'https://[email protected]/a.png', 'https://openvibe.media.evil.test/a.png', 'https://127.0.0.1/p/abc/screenshot', 'https://[::1]/p/abc']) {@@@ -267,8 +266,8 @@ function inventory(root) {assert.strictEqual(target.count(), 0, 'nothing connected to the address a person named');assert.deepStrictEqual(made.filter((c) => c.endsWith(` ${T}`)), [], 'no connection to the chosen port at all');const hosts = new Set(made.map((c) => c.split(' ')[0]));-- for (const h of hosts) assert.ok(['127.0.0.1', 'discord.com', 'gateway.discord.gg'].includes(h), `unexpected destination ${h}`);-- assert.match(gh.text, /no egress to api\.github\.com/, 'the GitHub integration test goes to api.github.com only');++ for (const h of hosts) assert.ok(['127.0.0.1', 'discord.com', 'gateway.discord.gg', 'api.github.com'].includes(h), `unexpected destination ${h}`);++ assert.match(gh.text, /no egress to api\.github\.com|could not be reached/, 'the GitHub integration test goes to api.github.com only (the egress guard refuses the socket; undici surfaces it as a fetch failure)');assert.ok(ingested.length >= 10 && ingested.every((x) => x.path === '/internal/avatar-ingest'), `avatar imports went to Media's ingest instead (${ingested.length})`);assert.ok(ingested.some((x) => x.url === `https://127.0.0.1:${T}/a.png`), 'Media was asked to import the URL; Network did not fetch it');} finally {@@@ -278,4 +277,4 @@ function inventory(root) {fs.rmSync(dir, { recursive: true, force: true });}out(`security ssrf: ${Object.keys(inv).length} reviewed call sites, all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);--})().catch((err) => { console.error(err); process.exit(1); });++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -18,7 +18,6 @@ const crypto = require('crypto');const fs = require('fs');const path = require('path');const jwt = require('jsonwebtoken');--const Database = require('better-sqlite3');const { bootServer } = require('./helpers/boot-server');const crawler = require('./security-crawl');@@@ -73,8 +72,8 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {},});const routes = await crawler.readRoutes(dump.OV_ROUTE_DUMP);-- const db = new Database(path.join(srv.dir, 'network.db'));-- db.pragma('busy_timeout = 5000');++ const { getDb } = require('../server/db/database');++ const db = getDb(); // the same handle the in-process server booted on (test/helpers/pg-preload.mjs)// ── Secrets, by label ────────────────────────────────────────────const secrets = {};@@@ -89,22 +88,22 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {// ── People ───────────────────────────────────────────────────────const subjects = require('../server/identity/subjects');const hash = '$2a$10$' + 'x'.repeat(53);-- const addUser = (username, role = 'user', email = null) => {-- db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);-- const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);-- subjects.ensureUserSubject(db, row);-- return db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);++ const addUser = async (username, role = 'user', email = null) => {++ await db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);++ const row = await db.prepare('SELECT * FROM users WHERE username = ?').get(username);++ await subjects.ensureUserSubject(db, row);++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);};const rows = {-- alice: addUser('alice', 'user', '[email protected]'),-- bob: addUser('bob', 'user', '[email protected]'),-- carol: addUser('carol', 'user', '[email protected]'),-- staff: addUser('staffer', 'admin', '[email protected]'),++ alice: await addUser('alice', 'user', '[email protected]'),++ bob: await addUser('bob', 'user', '[email protected]'),++ carol: await addUser('carol', 'user', '[email protected]'),++ staff: await addUser('staffer', 'admin', '[email protected]'),};-- const ownerRow = db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();++ const ownerRow = await db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();if (!ownerRow || ownerRow.role !== 'admin') throw new Error('the server did not create the ADMIN_USERNAME account');-- subjects.ensureUserSubject(db, ownerRow);-- rows.owner = db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);++ await subjects.ensureUserSubject(db, ownerRow);++ rows.owner = await db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);secrets['owner password hash'] = rows.owner.password_hash;const sign = (u) => jwt.sign({ sub: u.id, id: u.id, username: u.username, role: u.role }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '2h' });const users = {};@@@ -112,9 +111,9 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {// First-party OAuth clients get unique secrets (stored in clear in oauth_clients).const clientSecrets = {};-- for (const c of db.prepare('SELECT client_id FROM oauth_clients').all()) {++ for (const c of await db.prepare('SELECT client_id FROM oauth_clients').all()) {clientSecrets[c.client_id] = `${S('client')}-${c.client_id}-${crypto.randomBytes(6).toString('hex')}`;-- db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);++ await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);secrets[`oauth client secret ${c.client_id}`] = clientSecrets[c.client_id];}@@@ -159,7 +158,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {const rot = once('credential rotation', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/apps/${dev.PA.app}/credentials/rotate`, {}), 201, 'rotate'));dev.PA.secret2 = rot.body.credential.client_secret; dev.PA.credential2 = rot.body.credential.id;secrets['dev credential PA rotated'] = dev.PA.secret2;-- for (const c of db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;++ for (const c of await db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;// A grant request (pending) to have a grant row on each app.for (const P of ['PA', 'PB']) await call(dev[P].owner, 'POST', `/api/v1/projects/${dev[P].id}/apps/${dev[P].app}/grants`, { capability: 'media.object.upload' });const exp = once('export token', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }), 201, 'export token'));@@@ -183,28 +182,28 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {secrets['alice push endpoint'] = pushSub.endpoint;secrets['alice push auth'] = pushSub.keys.auth;// A refresh token through the OAuth code flow (client 'live').-- const REDIRECT = (JSON.parse(db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get().redirect_uris) || [])[0];++ const REDIRECT = (JSON.parse((await db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get()).redirect_uris) || [])[0];const conf = await call(null, 'POST', '/oauth/confirm', { token: users.alice.token, client_id: 'live', redirect_uri: REDIRECT });if (conf.status === 200 && conf.body && conf.body.redirect) {const code = new URL(conf.body.redirect).searchParams.get('code');const t = once('oauth code exchange', await call(null, 'POST', '/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: clientSecrets.live, code, redirect_uri: REDIRECT }));if (t.body && t.body.refresh_token) secrets['alice refresh token'] = t.body.refresh_token;}-- for (const r of db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;++ for (const r of await db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;// An anonymous identity seen from another address.const anonToken = `anon-${crypto.randomBytes(12).toString('hex')}`;-- db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');++ await db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');secrets['anon session token'] = anonToken;// Provider-secret copies in the database (the environment wins, but the copies are secrets too).for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'github_token', 'net.ipinfo_token', 'ses_secret_access_key']) {const v = S(`db-${k.replace(/[._]/g, '-')}`);-- db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, 'secret')").run(k, v);++ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES (?, ?, 'secret') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run(k, v);secrets[`db setting ${k}`] = v;}const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;-- db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);++ await db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);// A per-user password hash is never shown to anyone.-- for (const u of ['alice', 'bob', 'staff']) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id).password_hash; }++ for (const u of ['alice', 'bob', 'staff']) { await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = (await db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id)).password_hash; }const callers = {anonymous: {},@@@ -219,7 +218,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,// Every TCP connection the server made so far, as 'host port' lines (the preload logs them).connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },-- stop: async () => { try { db.close(); } catch { /* */ } await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },++ stop: async () => { await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },};}@@@ -1,26 +1,27 @@'use strict';// server/setup/service-principal.js: create/rotate a service client; the secret lands in the env file (0600),--// never on stdout; existing env lines are kept.++// never on stdout; existing env lines are kept. The database is the process one (test/helpers/pg-preload.mjs).const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const sp = require('../server/setup/service-principal');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));--const dbPath = path.join(dir, 'network.db');--const log = console.log; console.log = () => {}; initDb(dbPath).close(); console.log = log;++const db = await getDb();const env = path.join(dir, 'events.env');fs.writeFileSync(env, 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');const out = [];--console.log = (...a) => out.push(a.join(' '));--assert.strictEqual(sp.main(['create', 'events', '--env-file', env, '--db', dbPath]), 0);--assert.strictEqual(sp.main(['create', 'events', '--env-file', env, '--db', dbPath]), 1, 'no double create');++const log = console.log; const err = console.error;++console.log = (...a) => out.push(a.join(' ')); console.error = () => {};++assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 0);++assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 1, 'no double create');const first = fs.readFileSync(env, 'utf8');--assert.strictEqual(sp.main(['rotate', 'events', '--env-file', env, '--db', dbPath]), 0);--console.log = log;++assert.strictEqual(await sp.main(['rotate', 'events', '--env-file', env]), 0);++console.log = log; console.error = err;const second = fs.readFileSync(env, 'utf8');const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];assert.ok(/^PORT=4300$/m.test(second), 'other lines kept');@@@ -28,9 +29,9 @@ assert.strictEqual((second.match(/OV_OAUTH_CLIENT_SECRET=/g) || []).length, 1, 'assert.notStrictEqual(secretOf(first), secretOf(second), 'rotate changes the secret');assert.strictEqual(fs.statSync(env).mode & 0o777, 0o600);assert.ok(!out.join('\n').includes(secretOf(second)) && !out.join('\n').includes(secretOf(first)), 'secrets are never printed');--const Database = require('better-sqlite3');--const row = new Database(dbPath).prepare("SELECT client_secret, redirect_uris FROM oauth_clients WHERE client_id = 'events'").get();++const row = await db.prepare("SELECT client_secret, redirect_uris FROM oauth_clients WHERE client_id = 'events'").get();assert.strictEqual(row.client_secret, secretOf(second));assert.strictEqual(row.redirect_uris, '[]', 'service principals cannot do user sign-in');fs.rmSync(dir, { recursive: true, force: true });console.log('service principal provisioning: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });@@@ -12,19 +12,19 @@ const os = require('os');const path = require('path');const http = require('http');const express = require('express');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');for (const n of ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY']) delete process.env[n];process.env.OWNER_USERNAME = 'owner';const quiet = async (fn) => { const l = console.log, w = console.warn; console.log = () => {}; console.warn = () => {}; try { return await fn(); } finally { console.log = l; console.warn = w; } };(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-netcfg-'));-- const db = await quiet(() => initDb(path.join(dir, 'network.db')));-- const set = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');-- const row = (k) => db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(k);-- set.run('discord_bot_token', 'example-bot-token-not-real', 'secret');-- set.run('migr_pref_email_null', 'true', 'boolean');-- set.run('vapid_private_key', 'example-vapid-private', 'secret');++ const db = getDb();++ const set = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');++ const row = async (k) => await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(k);++ await set.run('discord_bot_token', 'example-bot-token-not-real', 'secret');++ await set.run('migr_pref_email_null', 'true', 'boolean');++ await set.run('vapid_private_key', 'example-vapid-private', 'secret');const createAdminRoutes = require('../server/admin/routes');const createDiscordRoutes = require('../server/discord/routes');@@@ -36,14 +36,14 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.llet who = { id: 1, username: 'owner', role: 'admin', subject_id: 'usr_01KKT9AC60KM7CRTB3WN1Z8P56' };const requireAuth = (req, _res, next) => { req.user = who; next(); };const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());-- db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin'), (2, 'helper', 'x', 'admin')").run();++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin'), (2, 'helper', 'x', 'admin')").run();app.use('/api/admin/discord', createDiscordRoutes(db, { getStatus: () => ({ connected: false }), reinit: async () => {} }, requireAuth, requireAdmin));app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));const server = http.createServer(app);await new Promise((r) => server.listen(0, '127.0.0.1', r));const call = (method, p, body) => fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));-- const cfg = require('../server/admin/site-config').forDb(db);++ const cfg = await require('../server/admin/site-config').forDb(db);// ── Revision 1: configuration only ──await quiet(() => cfg.sync());@@@ -56,26 +56,26 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.llet r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '9', type: 'number', reason: 'fewer alerts' }));assert.strictEqual(r.status, 200, JSON.stringify(r.body));assert.strictEqual(r.body.revision, rev1 + 1);-- assert.deepStrictEqual(row('stream_live_daily_cap'), { value: '9', type: 'number' });-- assert.strictEqual(db.getSetting('stream_live_daily_cap'), 9);++ assert.deepStrictEqual(await row('stream_live_daily_cap'), { value: '9', type: 'number' });++ assert.strictEqual(await db.getSetting('stream_live_daily_cap'), 9);r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'brand_new_flag', value: 'true', type: 'boolean' }));-- assert.deepStrictEqual(row('brand_new_flag'), { value: 'true', type: 'boolean' }, 'a new row takes the given type');++ assert.deepStrictEqual(await row('brand_new_flag'), { value: 'true', type: 'boolean' }, 'a new row takes the given type');// ── Email and Discord changes are revisions too ──r = await quiet(() => call('PUT', '/api/admin/email', { from_name: 'OpenVibe Team' }));assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.strictEqual(row('email_from_name').value, 'OpenVibe Team');++ assert.strictEqual((await row('email_from_name')).value, 'OpenVibe Team');r = await quiet(() => call('PUT', '/api/admin/discord', { settings: { discord_dedupe_minutes: '15', discord_bot_token: 'example-new-bot-token' } }));assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.deepStrictEqual(row('discord_dedupe_minutes'), { value: '15', type: 'number' });-- assert.deepStrictEqual(row('discord_bot_token'), { value: 'example-new-bot-token', type: 'secret' }, 'a secret row stays a secret row');++ assert.deepStrictEqual(await row('discord_dedupe_minutes'), { value: '15', type: 'number' });++ assert.deepStrictEqual(await row('discord_bot_token'), { value: 'example-new-bot-token', type: 'secret' }, 'a secret row stays a secret row');// ── Written around the journal: recorded first, never reverted ──-- set.run('email_daily_cap', '777', 'number');++ await set.run('email_daily_cap', '777', 'number');const before = cfg.store.revision();r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '12', type: 'number' }));assert.strictEqual(cfg.store.revision(), before + 2, 'a sync revision, then the change');-- assert.strictEqual(row('email_daily_cap').value, '777');++ assert.strictEqual((await row('email_daily_cap')).value, '777');// ── History: who, why, secrets as fingerprints ──r = await call('GET', '/api/admin/config/network.site_settings/history?limit=50');@@@ -90,7 +90,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l// ── Rollback undoes the last change only; the secret and the outside row stay ──r = await quiet(() => call('POST', '/api/admin/config/network.site_settings/rollback', { reason: 'too many' }));assert.strictEqual(r.status, 200, JSON.stringify(r.body));-- assert.deepStrictEqual([row('stream_live_daily_cap').value, row('email_daily_cap').value, row('discord_bot_token').value], ['9', '777', 'example-new-bot-token']);++ assert.deepStrictEqual([(await row('stream_live_daily_cap')).value, (await row('email_daily_cap')).value, (await row('discord_bot_token')).value], ['9', '777', 'example-new-bot-token']);assert.strictEqual((await call('POST', '/api/admin/config/other.namespace/rollback', {})).status, 404);// Only the owner reads or rolls back the configuration.@@@ -11,15 +11,16 @@ const path = require('path');const http = require('http');const express = require('express');const { validate } = require('openvibe-contracts');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-incidents-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--require('../server/identity/principals').ensureSchema(db);--for (const c of ['host', 'live']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);--db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'boss', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'admin'), (2, 'pat', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'user')").run();++await require('../server/identity/principals').ensureSchema(db);++for (const c of ['host', 'live']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);++await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'boss', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'admin'), (2, 'pat', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'user')").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };@@@ -41,13 +42,13 @@ const server = http.createServer(app);const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, text: await r.clone().text(), body: await r.json().catch(() => null) }));const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;-- const as = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });++ const as = async (id) => ({ authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });try {const host = { authorization: `Bearer ${await svc('host')}` };// Who may write.const open = { kind: 'incident', title: 'Live streams stall on start', severity: 'major', services: ['live'], message: 'New streams take a minute to start.' };assert.ok([401, 403].includes((await call('POST', '/api/v1/status/incidents', { body: open })).status), 'nobody');-- assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: as(2) })).status, 403, 'a person who is not staff');++ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: await as(2) })).status, 403, 'a person who is not staff');assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: { authorization: `Bearer ${await svc('live')}` } })).status, 403, 'a service without network.status.incident');let r = await call('POST', '/api/v1/status/incidents', { body: open, headers: host });assert.strictEqual(r.status, 201, r.text);@@@ -57,7 +58,7 @@ const server = http.createServer(app);assert.match(inc, /^inc_[0-9A-HJKMNP-TV-Z]{26}$/);// A staff admin updates it through its states; closed refuses more.-- r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'identified', message: 'A restart left the ingest socket unbound.' }, headers: as(1) });++ r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'identified', message: 'A restart left the ingest socket unbound.' }, headers: await as(1) });assert.deepStrictEqual([r.status, r.body.state, r.body.updates.length], [200, 'identified', 2]);assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'completed', message: 'x' }, headers: host })).status, 400, 'a maintenance state on an incident');assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'ok', title: 'renamed' }, headers: host })).status, 400, 'the request contract');@@@ -95,3 +96,4 @@ const server = http.createServer(app);}console.log('status incidents: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });@@@ -11,13 +11,14 @@ const os = require('os');const path = require('path');const http = require('http');const express = require('express');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-themes-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = getDb();console.log = log;--db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'ann', 'x', 'user'), (2, 'bob', 'x', 'user'), (3, 'boss', 'x', 'admin')").run();++await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'ann', 'x', 'user'), (2, 'bob', 'x', 'user'), (3, 'boss', 'x', 'admin')").run();const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };const { signToken } = require('../server/auth/routes');@@@ -30,14 +31,14 @@ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey:app.use('/api/themes', themes);app.use('/api/admin/themes', requireAuth, requireAdmin, themes.reviewRouter());const server = http.createServer(app);--const token = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);++const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);(async () => {await new Promise((r) => server.listen(0, '127.0.0.1', r));const base = `http://127.0.0.1:${server.address().port}`;-- const call = (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => ({ status: r.status, headers: r.headers, body: await r.json().catch(() => null) }));-- db.prepare("INSERT OR IGNORE INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES ('vibe', 'Vibe', 'vibe', 'dark', '{\"--accent\":\"#5b7cfa\"}', 1, 1)").run();++ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES ('vibe', 'Vibe', 'vibe', 'dark', '{\"--accent\":\"#5b7cfa\"}', 1, 1) ON CONFLICT DO NOTHING").run();// ── Values are allow-listed ──const good = { '--accent': '#ff6a3d', '--bg-primary': '#101010', '--accent-rgb': '255, 106, 61', '--shadow': '0 8px 24px rgba(0, 0, 0, .4)' };@@@ -96,3 +97,4 @@ const token = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').gconsole.log('themes review: all checks passed');process.exit(0);})().catch((err) => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++// Single-use tokens consumed twice at once (decision 1, plan T2): on SQLite the single writer hid the++// race; on PostgreSQL under READ COMMITTED two concurrent consumers can both read "unused" before either++// writes, so each claim is a conditional UPDATE inside a transaction and exactly one wins. Covers the two++// consume paths: an email-verification link (auth/email-verify.consumeToken) and a password-reset link++// (POST /api/auth/reset-password).++// node test/token-consume.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const bcrypt = require('bcryptjs');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const emailVerify = require('../server/auth/email-verify');++const resetTokens = require('../server/auth/reset-tokens');++++(async () => {++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-token-'));++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++++const hash = bcrypt.hashSync('oldpass1', 4);++const mk = async (name, email) => {++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, email, hash)).lastInsertRowid;++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);++};++++const app = express();++app.locals.db = db;++app.use(express.json()); app.use(cookieParser());++app.use('/api/auth', require('../server/auth/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}/api/auth`;++ const call = (p, body) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));++ try {++ // ── Email verification: two uses of one link, one success ──++ const user = await mk('emily', '[email protected]');++ const raw = crypto.randomBytes(32).toString('base64url');++ await db.prepare("INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+1 hour'))")++ .run(crypto.createHash('sha256').update(raw).digest('hex'), user.id, user.email);++ const uses = await Promise.all([1, 2].map(() => emailVerify.consumeToken(db, raw).then((r) => r.ok).catch(() => false)));++ assert.strictEqual(uses.filter(Boolean).length, 1, 'one of two concurrent uses of a verification link wins');++ assert.ok((await db.prepare('SELECT email_verified FROM users WHERE id = ?').get(user.id)).email_verified, 'the account is verified once');++ const tokenRow = await db.prepare('SELECT used_at FROM email_verification_tokens WHERE user_id = ?').get(user.id);++ assert.ok(tokenRow.used_at, 'the link is marked used');++++ // ── Password reset: two uses of one link, one success ──++ const frank = await mk('frank', '[email protected]');++ const rawReset = crypto.randomBytes(32).toString('hex');++ await db.prepare("INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, datetime('now', '+1 hour'))")++ .run(frank.id, resetTokens.hashResetToken(rawReset));++ const resets = await Promise.all([1, 2].map(() => call('/reset-password', { token: rawReset, new_password: 'newpass9' })));++ assert.strictEqual(resets.filter((r) => r.status === 200).length, 1, 'one of two concurrent uses of a reset link wins');++ assert.strictEqual(resets.filter((r) => r.status === 400).length, 1, 'the other is refused (already used)');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ? AND used_at IS NULL').get(frank.id)).n, 0, 'the link is marked used');++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('single-use token consume: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });++})().catch((err) => { console.error(err); process.exit(1); });@@@ -62,4 +62,4 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');} finally {await srv.stop();}--})().catch((err) => { console.error(err); process.exit(1); });++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });@@@ -5,46 +5,47 @@ const assert = require('assert');const fs = require('fs');const os = require('os');const path = require('path');--const { initDb } = require('../server/db/database');++const { getDb } = require('../server/db/database');const u = require('../server/identity/usernames');++(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-usernames-'));const log = console.log; console.log = () => {};--const db = initDb(path.join(dir, 'network.db'));++const db = await getDb();console.log = log;--const add = (name, display = null) => db.prepare('INSERT INTO users (username, display_name, password_hash) VALUES (?, ?, ?)').run(name, display, 'x').lastInsertRowid;++const add = async (name, display = null) => (await db.prepare('INSERT INTO users (username, display_name, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, display, 'x')).lastInsertRowid;try {-- const ann = add('ann_old', 'Ann_Old');-- const bob = add('bob', 'Bob the Builder');-- assert.deepStrictEqual(u.rename(db, ann, 'ann_new', { actorId: bob }), { from: 'ann_old', to: 'ann_new' });-- let row = db.prepare('SELECT username, display_name FROM users WHERE id = ?').get(ann);++ const ann = await add('ann_old', 'Ann_Old');++ const bob = await add('bob', 'Bob the Builder');++ assert.deepStrictEqual(await u.rename(db, ann, 'ann_new', { actorId: bob }), { from: 'ann_old', to: 'ann_new' });++ let row = await db.prepare('SELECT username, display_name FROM users WHERE id = ?').get(ann);assert.deepStrictEqual([row.username, row.display_name], ['ann_new', 'ann_new'], 'a display name that was the username follows it');-- assert.strictEqual(u.renamedTo(db, 'ANN_OLD'), 'ann_new', 'case-insensitive');-- assert.strictEqual(u.renamedTo(db, 'ann_new'), null, 'a current name is never "renamed"');-- assert.strictEqual(u.renamedTo(db, 'nobody_here'), null);-- assert.strictEqual(u.renamedTo(db, '../etc'), null);++ assert.strictEqual(await u.renamedTo(db, 'ANN_OLD'), 'ann_new', 'case-insensitive');++ assert.strictEqual(await u.renamedTo(db, 'ann_new'), null, 'a current name is never "renamed"');++ assert.strictEqual(await u.renamedTo(db, 'nobody_here'), null);++ assert.strictEqual(await u.renamedTo(db, '../etc'), null);// Reserved for everyone else, not for the person who held it.-- assert.strictEqual(u.isReserved(db, 'ann_old'), true);-- assert.match(u.problemWith(db, 'ann_old', bob), /belonged to someone else/);-- assert.throws(() => u.rename(db, bob, 'ann_old'), /belonged to someone else/);-- assert.strictEqual(u.problemWith(db, 'ann_old', ann), null, 'Ann may take her old name back');++ assert.strictEqual(await u.isReserved(db, 'ann_old'), true);++ assert.match(await u.problemWith(db, 'ann_old', bob), /belonged to someone else/);++ await assert.rejects(async () => await u.rename(db, bob, 'ann_old'), /belonged to someone else/);++ assert.strictEqual(await u.problemWith(db, 'ann_old', ann), null, 'Ann may take her old name back');// Several renames: every old name points at the current one.-- u.rename(db, ann, 'ann_third');-- assert.strictEqual(u.renamedTo(db, 'ann_old'), 'ann_third');-- assert.strictEqual(u.renamedTo(db, 'ann_new'), 'ann_third');-- assert.deepStrictEqual(u.historyOf(db, ann).map(h => [h.old_username, h.new_username]), [['ann_new', 'ann_third'], ['ann_old', 'ann_new']]);++ await u.rename(db, ann, 'ann_third');++ assert.strictEqual(await u.renamedTo(db, 'ann_old'), 'ann_third');++ assert.strictEqual(await u.renamedTo(db, 'ann_new'), 'ann_third');++ assert.deepStrictEqual((await u.historyOf(db, ann)).map(h => [h.old_username, h.new_username]), [['ann_new', 'ann_third'], ['ann_old', 'ann_new']]);// A display name that is not the username stays; bad and taken names are refused.-- u.rename(db, bob, 'bobby');-- assert.strictEqual(db.prepare('SELECT display_name FROM users WHERE id = ?').get(bob).display_name, 'Bob the Builder');-- assert.throws(() => u.rename(db, bob, 'ann_third'), /taken/);-- assert.throws(() => u.rename(db, bob, 'no spaces'), /3-24 letters/);-- assert.throws(() => u.rename(db, bob, 'anon123'), /anon/);-- assert.throws(() => u.rename(db, bob, 'System'), /reserved by the system/);-- assert.throws(() => u.rename(db, 9999, 'whoever'), /No such user/);++ await u.rename(db, bob, 'bobby');++ assert.strictEqual((await db.prepare('SELECT display_name FROM users WHERE id = ?').get(bob)).display_name, 'Bob the Builder');++ await assert.rejects(async () => await u.rename(db, bob, 'ann_third'), /taken/);++ await assert.rejects(async () => await u.rename(db, bob, 'no spaces'), /3-24 letters/);++ await assert.rejects(async () => await u.rename(db, bob, 'anon123'), /anon/);++ await assert.rejects(async () => await u.rename(db, bob, 'System'), /reserved by the system/);++ await assert.rejects(async () => await u.rename(db, 9999, 'whoever'), /No such user/);// Wiring: registration refuses reserved names, admins rename (owner-protected), the lookup is public.const auth = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');@@@ -53,12 +54,13 @@ try {assert.ok(/router\.put\('\/users\/:id\/username'/.test(admin) && /Only the owner renames the owner/.test(admin));assert.ok(/app\.get\('\/api\/v1\/users\/names\/:name'/.test(fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8')));// The public record: an old name and the current one both lead to the same person.-- assert.deepStrictEqual(u.lookup(db, 'ann_old'), { current: 'ann_third', network_id: ann, renamed: true, previous_names: ['ann_new', 'ann_old'] });-- assert.deepStrictEqual(u.lookup(db, 'ANN_THIRD'), { current: 'ann_third', network_id: ann, renamed: false, previous_names: ['ann_new', 'ann_old'] });-- assert.strictEqual(u.lookup(db, 'nobody_here'), null);-- db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(ann);-- assert.strictEqual(u.lookup(db, 'ann_old'), null, 'banned accounts are not looked up');++ assert.deepStrictEqual(await u.lookup(db, 'ann_old'), { current: 'ann_third', network_id: ann, renamed: true, previous_names: ['ann_new', 'ann_old'] });++ assert.deepStrictEqual(await u.lookup(db, 'ANN_THIRD'), { current: 'ann_third', network_id: ann, renamed: false, previous_names: ['ann_new', 'ann_old'] });++ assert.strictEqual(await u.lookup(db, 'nobody_here'), null);++ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(ann);++ assert.strictEqual(await u.lookup(db, 'ann_old'), null, 'banned accounts are not looked up');} finally {fs.rmSync(dir, { recursive: true, force: true });}console.log('usernames: all checks passed');++})().catch(err => { console.error(err); process.exit(1); });
@@ -49,8 +49,17 @@ ADMIN_PASSWORD=change-me
@@ -49,8 +49,17 @@ ADMIN_PASSWORD=change-me
# Owner account (gates owner-only admin areas: Deploy, SSH, secrets)
# Owner account (gates owner-only admin areas: Deploy, SSH, secrets)
OWNER_USERNAME=
OWNER_USERNAME=
+# PostgreSQL (ADR-035, plan T2), written by OpenVibe.Host roles/data/add-service.sh network: the runtime
+# role through PgBouncer, the owner on a direct connection (migrations), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite (PGLITE_DIR overrides it);
+# production refuses to boot. DB_PATH is read only by the one-time import scripts, never by serving code.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+#VALKEY_PREFIX=ov:network:
+
# Paths (relative to project root)
# Paths (relative to project root)
-DB_PATH=./data/network.db
+DATA_DIR=data
AVATAR_PATH=data/avatars
AVATAR_PATH=data/avatars
JWT_PRIVATE_KEY=data/keys/private.pem
JWT_PRIVATE_KEY=data/keys/private.pem
JWT_PUBLIC_KEY=data/keys/public.pem
JWT_PUBLIC_KEY=data/keys/public.pem
@@ -45,8 +45,8 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -45,8 +45,8 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Depends on
## Depends on
-- `openvibe-contracts` v0.75.0 (registry manifests, capability checks), `openvibe-sdk` v0.12.0 (events,
- per-actor limits), `openvibe-shared` v1.22.0, pinned by release tarball
+- `openvibe-contracts` v0.80.0 (registry manifests, capability checks), `openvibe-sdk` v0.25.1 (PostgreSQL
+ and Valkey adapters, events, per-actor limits), `openvibe-shared` v1.29.2, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
workflow), OpenVibe.Blog (the changelog feed it proxies)
- email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets))
- email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets))
@@ -86,7 +86,7 @@ openvibe.network (port 4000)
@@ -86,7 +86,7 @@ openvibe.network (port 4000)
│ ├── internal/
│ ├── internal/
│ │ └── routes.js # Server-to-server API (verify-token, user sync, notif push)
│ │ └── routes.js # Server-to-server API (verify-token, user sync, notif push)
│ └── db/
│ └── db/
-│ └── database.js # SQLite schema, migrations, seeding
+│ └── database.js # PostgreSQL handle (openvibe-sdk/db), migrations/, boot seeding
├── public/
├── public/
│ ├── index.html # Landing page (hero + service cards)
│ ├── index.html # Landing page (hero + service cards)
│ ├── login.html # Animated login/register page
│ ├── login.html # Animated login/register page
@@ -113,7 +113,10 @@ openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
@@ -113,7 +113,10 @@ openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
# 3. Configure environment
# 3. Configure environment
cp .env.example .env
cp .env.example .env
-# Edit .env — set ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE
+# Edit .env — set ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE.
+# In production also set DATABASE_URL (the runtime role through PgBouncer), DATABASE_DIRECT_URL (the owner
+# role on a direct connection, for migrations) and VALKEY_URL (shared limit counters). Without
+# DATABASE_URL, development uses an embedded PGlite database in data/pglite; production refuses to boot.
# 4. Run
# 4. Run
npm start
npm start
@@ -121,7 +124,7 @@ npm start
@@ -121,7 +124,7 @@ npm start
After the first run the service will seed the URL registry with safe defaults and create an admin account from environment values. Visit `/api/setup/status` for setup health and use `/api/setup/bootstrap` to apply registry profiles manually.
After the first run the service will seed the URL registry with safe defaults and create an admin account from environment values. Visit `/api/setup/status` for setup health and use `/api/setup/bootstrap` to apply registry profiles manually.
```
```
-The server auto-creates the SQLite database and seeds OAuth2 clients on first run. Client secrets are logged to console once on creation — copy them to the consuming services' env files (`/etc/openvibe/<svc>.env`).
+The server applies `migrations/NNNN_*.sql` at boot (as the owner on `DATABASE_DIRECT_URL`) and seeds OAuth2 clients and site settings on first run into PostgreSQL. Client secrets are logged to console once on creation — copy them to the consuming services' env files (`/etc/openvibe/<svc>.env`). The tests run on PGlite (`npm test`) or the PostgreSQL + PgBouncer containers (`npm run test:pg`, after `eval "$(openvibe-sdk scripts/test-services.sh up)"`).
---
---
@@ -224,7 +227,7 @@ sudo apt install certbot python3-certbot-dns-cloudflare
@@ -224,7 +227,7 @@ sudo apt install certbot python3-certbot-dns-cloudflare
### How it works
### How it works
1. **Any service** pushes notifications to openvibe.network via `POST /internal/notifications/push`
1. **Any service** pushes notifications to openvibe.network via `POST /internal/notifications/push`
-2. **openvibe.network** stores them in SQLite with priority, category, and optional rich content
+2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content
3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below)
3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below)
4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).
4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default).
@@ -583,12 +586,12 @@ totals from the event's `stats`: counts only, never who watched or chatted, and
@@ -583,12 +586,12 @@ totals from the event's `stats`: counts only, never who watched or chatted, and
and services with `network.analytics.creator.read` (Live's dashboards), also get average viewers, chatters,
and services with `network.analytics.creator.read` (Live's dashboards), also get average viewers, chatters,
messages and watch minutes.
messages and watch minutes.
-Network records one row per finished request in the `analytics_*` tables of `network.db` and rolls
+Network records one row per finished request in the `analytics_*` tables of its PostgreSQL database and rolls
them up hourly and daily for the admin analytics pages (`/api/admin/analytics`, which also gathers
them up hourly and daily for the admin analytics pages (`/api/admin/analytics`, which also gathers
Live, Tools, Games and Media). What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts
Live, Tools, Games and Media). What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts
-`docs/adr/ADR-021-analytics.md`). The module is `openvibe-shared/analytics` (openvibe-shared v1.4.0;
-the same one Live and Tools use). `server/analytics/network.js` holds only Network's wiring: its own
-connection, its path options (`paramPrefixes`, `pathRules`) and the prune job.
+`docs/adr/ADR-021-analytics.md`). The module is `openvibe-shared/analytics` (the same one Live and Tools
+use), on the service database through its PostgreSQL tracker (`AnalyticsTrackerPg`). `server/analytics/network.js`
+holds only Network's wiring: its path options (`paramPrefixes`, `pathRules`) and the prune job.
- **Stored:** event type, service, **route template** (the matched Express route, else the path
- **Stored:** event type, service, **route template** (the matched Express route, else the path
without its query and with ids, usernames and tokens replaced by `:id` / `:param`), method, status,
without its query and with ids, usernames and tokens replaced by `:id` / `:param`), method, status,
@@ -601,17 +604,14 @@ connection, its path options (`paramPrefixes`, `pathRules`) and the prune job.
@@ -601,17 +604,14 @@ connection, its path options (`paramPrefixes`, `pathRules`) and the prune job.
- **Opt-out:** a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all (no raw row, visitor
- **Opt-out:** a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all (no raw row, visitor
hash, session id or rate counter), so it is also missing from the rollups.
hash, session id or rate counter), so it is also missing from the rollups.
- **Admin panel:** the bot tables list user-agent classes and high-volume session ids, not IPs.
- **Admin panel:** the bot tables list user-agent classes and high-volume session ids, not IPs.
-- **Connection:** the tracker opens its own connection to `network.db`, so its settings
- (`busy_timeout` 250, `secure_delete`) never apply to the identity connection.
+- **Connection:** the tracker buffers and flushes into the service's own PostgreSQL handle (the same
+ database the identity tables live in), so there is no second connection or file of its own.
- **Retention:** raw events older than 30 days are deleted every night in batches of 5000 (the
- **Retention:** raw events older than 30 days are deleted every night in batches of 5000 (the
`analytics-prune` job: first run 5 minutes after boot, then every 24 h). Rollups are kept.
`analytics-prune` job: first run 5 minutes after boot, then every 24 h). Rollups are kept.
-- **Operator CLI:** `scripts/analytics-prune.js` (a wrapper over `openvibe-shared/analytics/prune-cli`
- with Network's database and path rules) runs a dry run by default and changes nothing.
- `--apply` needs `--backup <new file>` (a verified, owner-only copy of all of `network.db`) or an
- explicit `--no-backup`. `--scrub` also rewrites the rows the old tracker wrote and the rollups'
- top-path and referer lists. Rollup totals are compared before and after the run. VACUUM rewrites
- all of `network.db`, so run it with the service stopped, or pass `--no-vacuum` while it is up. Run
- the CLI as the service user.
+- **Operator CLI:** `scripts/analytics-prune.js` (over `openvibe-shared/analytics/pg`'s
+ `pruneRawEventsPg`) runs a dry run by default and changes nothing; `--apply` deletes raw events older
+ than `--days` (1..30, default 30). Run it where Network runs, with `DATABASE_URL` set. The SQLite
+ prune CLI (online backup, VACUUM, one-time scrub) is gone with the file.
## Shared Client Libraries
## Shared Client Libraries
@@ -3,10 +3,10 @@
@@ -3,10 +3,10 @@
"stage": "stable",
"stage": "stable",
"code": true,
"code": true,
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
- "runtime": "node 22, express, better-sqlite3",
+ "runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.75.0",
+ "contracts": "openvibe-contracts v0.80.0",
"capabilities": [
"capabilities": [
"identity.subject.resolve",
"identity.subject.resolve",
"mods.grant.manage",
"mods.grant.manage",
@@ -44,5 +44,5 @@
@@ -44,5 +44,5 @@
"serves /shared/* from its pinned openvibe-shared",
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)"
"per-actor limits (openvibe-sdk/limits)"
],
],
- "updated": "2026-09-28"
+ "updated": "2026-09-29"
}
}
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
[Unit]
[Unit]
Description=OpenVibe.Network — Identity, SSO & OpenCoins (port 4000)
Description=OpenVibe.Network — Identity, SSO & OpenCoins (port 4000)
-After=network.target
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=postgresql.service pgbouncer.service valkey-server.service
[Service]
[Service]
Type=simple
Type=simple
@@ -14,7 +14,7 @@ What exists today: the data model, the `/api/v1/projects` API, app tokens from `
@@ -14,7 +14,7 @@ What exists today: the data model, the `/api/v1/projects` API, app tokens from `
| Credential | `crd_<ULID>` | A client secret (`ovsec_…`, 256 random bits). Only its SHA-256 hash and last four characters are stored. |
| Credential | `crd_<ULID>` | A client secret (`ovsec_…`, 256 random bits). Only its SHA-256 hash and last four characters are stored. |
| Grant | (app, capability) | `requested`, `approved`, `denied` or `revoked`. The audience is `openvibe.<capability owner>`. |
| Grant | (app, capability) | `requested`, `approved`, `denied` or `revoked`. The audience is `openvibe.<capability owner>`. |
| Quota | (project, capability) | A limit, a window (`minute`, `hour`, `day`, `month`, `total`) and a unit (`requests`, `bytes`, `tokens`, …). |
| Quota | (project, capability) | A limit, a window (`minute`, `hour`, `day`, `month`, `total`) and a unit (`requests`, `bytes`, `tokens`, …). |
-| Audit | integer, append-only | SQLite triggers refuse UPDATE and DELETE. |
+| Audit | integer, append-only | PostgreSQL triggers refuse UPDATE and DELETE. |
### Roles
### Roles
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+-- phase: expand
+-- plan T2. Two tables the code created lazily (never at boot, so 0001_initial.sql missed them),
+-- and the insertion-order `seq` identity for the tables whose code ordered by SQLite's implicit rowid.
+-- PostgreSQL has no rowid; `seq` is a per-row identity, monotonic in insertion order, unique.
+
+CREATE TABLE admin_rate_limits (
+ user_id bigint NOT NULL,
+ action text NOT NULL,
+ last_at bigint NOT NULL,
+ PRIMARY KEY (user_id, action)
+);
+
+CREATE TABLE moderation_audit (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ service text NOT NULL,
+ action text NOT NULL,
+ actor_subject text,
+ target_type text,
+ target_id text,
+ target_subject text,
+ scope text,
+ reason text,
+ details text NOT NULL DEFAULT '{}',
+ occurred_at text NOT NULL,
+ recorded_at text NOT NULL DEFAULT ov_now_iso(),
+ seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE
+);
+CREATE INDEX idx_modaudit_when ON moderation_audit(occurred_at);
+CREATE INDEX idx_modaudit_service ON moderation_audit(service, id);
+CREATE INDEX idx_modaudit_actor ON moderation_audit(actor_subject, id);
+
+ALTER TABLE linked_accounts ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE user_sessions ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE oauth_tokens ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE oauth_codes ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE notifications ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE push_subscriptions ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE notification_preferences ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE user_preferences ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE user_effects ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE username_history ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE coin_transactions ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE user_follows ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE user_blocks ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE dev_projects ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE dev_project_members ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
+ALTER TABLE status_incident_updates ADD COLUMN seq bigint GENERATED BY DEFAULT AS IDENTITY UNIQUE;
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- plan T2. Identity columns are GENERATED BY DEFAULT, not ALWAYS: an INSERT may name an id, the way the
+-- SQLite PRIMARY KEYs it replaces allowed (the tests seed explicit ids; the one-time import writes the
+-- source ids). Production never names one, so the sequence still owns new rows. Applied to every identity
+-- column the earlier migrations created, whatever their table.
+
+DO $$
+DECLARE r record;
+BEGIN
+ FOR r IN
+ SELECT c.table_name, c.column_name
+ FROM information_schema.columns c
+ WHERE c.table_schema = current_schema()
+ AND c.is_identity = 'YES'
+ AND c.identity_generation = 'ALWAYS'
+ LOOP
+ EXECUTE format('ALTER TABLE %I ALTER COLUMN %I SET GENERATED BY DEFAULT', r.table_name, r.column_name);
+ END LOOP;
+END $$;
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- plan T2. The community-theme review columns (roadmap WS-E task 2): server/themes/routes.js added them with a
+-- runtime ALTER TABLE on SQLite, so the booted schema 0001_initial.sql never carried them. The review gate (a
+-- theme is visible to everyone only when review_status = 'approved') is unchanged; every existing row is
+-- 'approved', the SQLite column default.
+
+ALTER TABLE themes ADD COLUMN review_status text NOT NULL DEFAULT 'approved';
+ALTER TABLE themes ADD COLUMN reviewed_by bigint;
+ALTER TABLE themes ADD COLUMN reviewed_at text;
+ALTER TABLE themes ADD COLUMN review_note text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+-- phase: expand
+-- plan T2. The revisioned-configuration journal (openvibe-shared/config on PostgreSQL): server/admin/site-config.js
+-- keeps site_settings as what everything reads and records changes to them as revisions of the namespace
+-- network.site_settings. config_snapshots and config_keys are openvibe-shared/config's configSchema(), which inlines
+-- from that module's config-pg.js (a service's migrations own its tables; the runtime role creates none).
+
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL,
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/nodes.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/home-empty-catalog.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
+ "test": "node test/run.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js",
"grant-admin": "node server/grant-admin.js",
"test:pg": "NETWORK_TEST_STORE=pg node test/run.js",
"test:pg": "NETWORK_TEST_STORE=pg node test/run.js",
@@ -1,54 +1,67 @@
@@ -1,54 +1,67 @@
#!/usr/bin/env node
#!/usr/bin/env node
/**
/**
- * Raw analytics retention and one-time scrub for Network's analytics tables (ADR-021). They live in
- * network.db, Network's identity database, next to users and sessions. The command itself is
- * openvibe-shared/analytics/prune-cli; this wrapper passes Network's better-sqlite3, database and path
- * rules (/avatar/<name>, /users/by-username/<name>, /api/auth/anon/<token>; server/analytics/network.js).
+ * ADR-021 raw analytics retention and the one-time scrub of pre-ADR rows, on Network's own PostgreSQL
+ * (plan T2, ADR-035). The analytics tables live in the service database next to users and sessions
+ * (migrations/0002_analytics.sql); the tracker itself schedules the prune at boot, this is the on-demand
+ * operator form.
*
*
- * node scripts/analytics-prune.js # dry run: counts only, changes nothing
- * node scripts/analytics-prune.js --scrub # dry run including what the scrub would rewrite
- * node scripts/analytics-prune.js --apply --backup <file> # online backup to <file>, then prune
- * node scripts/analytics-prune.js --apply --scrub --backup <file>
- * node scripts/analytics-prune.js --apply --no-backup # prune without a backup (explicit)
- * node scripts/analytics-prune.js --help # every option
+ * node scripts/analytics-prune.js # dry run: how many raw events would go, nothing changes
+ * node scripts/analytics-prune.js --apply # delete raw events older than --days
*
*
- * Default database: $DB_PATH, else data/network.db. Relative --db and --backup paths are from the repo
- * root. The backup is a full copy of network.db (accounts, sessions): mode 0600.
+ * --days <n> keep raw events newer than n days, 1..30 (default 30)
+ * --apply actually prune (default: dry run)
*
*
- * VACUUM (after an --apply, unless --no-vacuum) rewrites all of network.db and holds its write lock
- * meanwhile: Network's own writes (sign-ins, sessions) wait 5 s and then fail. Vacuum with the service
- * stopped, or pass --no-vacuum while it is up. Run as the service user so no file changes owner.
+ * On SQLite this was openvibe-shared/analytics/prune-cli: it made an online copy of network.db, VACUUMed
+ * it and scrubbed legacy rows. None of that survives the port — there is no database file to copy or
+ * VACUUM, and openvibe-shared's PostgreSQL path (pruneRawEventsPg) covers retention. Run it where Network
+ * runs, or with DATABASE_URL/DATABASE_DIRECT_URL pointing at a copy; it needs no elevated privileges.
*/
*/
'use strict';
'use strict';
-const path = require('path');
-const Database = require('better-sqlite3');
-const cli = require('openvibe-shared/analytics/prune-cli');
-const networkAnalytics = require('../server/analytics/network');
-
-const ROOT = path.join(__dirname, '..');
-
-const VACUUM_WARNING = `
-On Network, VACUUM rewrites all of network.db and holds its write lock meanwhile: Network's own writes
-(sign-ins, sessions) wait 5 s and then fail. Vacuum with the service stopped, or pass --no-vacuum while
-it is up. Default database: $DB_PATH, else data/network.db; relative paths are from the repo root. The
-backup is a full copy of network.db (mode 0600). Run as the service user so no file changes owner.`;
-
-/** The database the server uses: --db, else $DB_PATH, else data/network.db (relative to the repo root). */
-function defaultDbPath(args, env = process.env) {
- return path.resolve(ROOT, args.db || env.DB_PATH || path.join('data', 'network.db'));
+const { parseArgs } = require('./lib/db-ops');
+const { pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
+
+const USAGE = `Raw analytics retention (ADR-021, PostgreSQL).
+
+ node scripts/analytics-prune.js dry run: counts only, changes nothing
+ node scripts/analytics-prune.js --apply delete raw events older than --days
+
+Options:
+ --days <n> keep raw events newer than n days, 1..30 (default 30)
+ --apply actually prune`;
+
+/** UTC 'YYYY-MM-DD HH:MM:SS', the tracker's own time shape (openvibe-shared/analytics/tracker.sqlTime). */
+function sqlTime(ms) {
+ const d = new Date(ms);
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
}
-function main(argv, log) {
- return cli.main(argv, {
- Database,
- log,
- root: ROOT,
- defaultDb: defaultDbPath,
- ...networkAnalytics.PATH_OPTS,
- usage: cli.USAGE + '\n' + VACUUM_WARNING,
- });
+/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it. */
+async function openDb(injected) {
+ if (injected) return injected;
+ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the analytics tables live in the service database');
+ const { createDb } = require('openvibe-sdk/db');
+ return createDb({ url: process.env.DATABASE_URL, service: 'network-analytics-prune', max: 1 });
+}
+
+async function main(argv = process.argv.slice(2), { db: injected, log = console } = {}) {
+ const args = parseArgs(argv, { flags: ['apply', 'help'], values: ['days'] });
+ if (args.help) { log.log(USAGE); return 0; }
+ const days = Number(args.days || 30);
+ if (!Number.isInteger(days) || days < 1 || days > 30) { log.error('--days must be an integer 1..30'); return 2; }
+
+ const db = await openDb(injected);
+ if (!args.apply) {
+ const n = (await db.prepare('SELECT COUNT(*) AS n FROM analytics_events WHERE created_at < ?').get(sqlTime(Date.now() - days * 86400000))).n;
+ log.log(`dry run: ${n} raw event(s) older than ${days} day(s) would go; rollups stay. Re-run with --apply to prune.`);
+ return 0;
+ }
+
+ const { removed, cutoff } = await pruneRawEventsPg(db, { days });
+ log.log(`pruned ${removed} raw event(s) older than ${cutoff} (${days} day(s)); rollups stay.`);
+ return 0;
}
}
-if (require.main === module) cli.run(main);
+if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
-module.exports = { main, defaultDbPath };
+module.exports = { main, sqlTime, USAGE };
@@ -7,26 +7,42 @@
@@ -7,26 +7,42 @@
*
*
* node scripts/import-blocks.js --file <json> # dry run: what would be imported, nothing changes
* node scripts/import-blocks.js --file <json> # dry run: what would be imported, nothing changes
* node scripts/import-blocks.js --file <json> --apply # insert the blocks and queue their events
* node scripts/import-blocks.js --file <json> --apply # insert the blocks and queue their events
- * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)
*
*
- * A pair is imported only when Network has never seen it: an existing row (blocked, or unblocked since)
- * is the person's later decision and is left alone. Pairs naming an unknown account, a guest or the same
- * person twice are skipped and counted. Each import is a normal block (server/identity/blocks.js setBlock):
- * revision 1 and network.block.changed in network_event_outbox in the same transaction; the running
- * server relays those rows within its next poll. Safe to run twice (the second run imports nothing).
- * Safe while Network runs (busy_timeout 5 s). Run as the database's owner; run as root it becomes
- * that owner before opening the database. Undo a pair with DELETE /api/v1/me/blocks/:subject as the person.
+ * The database is the service's own PostgreSQL (DATABASE_URL, plan T2); run this where Network runs, or
+ * with DATABASE_DIRECT_URL/DATABASE_URL pointing at a copy. A pair is imported only when Network has never
+ * seen it: an existing row (blocked, or unblocked since) is the person's later decision and is left alone.
+ * Pairs naming an unknown account, a guest or the same person twice are skipped and counted. Each import is
+ * a normal block (server/identity/blocks.js setBlock): revision 1 and network.block.changed in
+ * network_event_outbox in the same transaction; the running server relays those rows within its next poll.
+ * Safe to run twice (the second run imports nothing). Undo a pair with DELETE /api/v1/me/blocks/:subject
+ * as the person.
*/
*/
'use strict';
'use strict';
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
-const ops = require('./lib/db-ops');
+const { initDb } = require('../server/db/database');
const blocks = require('../server/identity/blocks');
const blocks = require('../server/identity/blocks');
-const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply] [--db <path>]';
+const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply]';
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+/** --flag / --key value parsing (scripts/lib/db-ops.js keeps the shared one; this stays standalone). */
+function parseArgs(argv, { flags = [], values = [] } = {}) {
+ const out = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ const name = a.replace(/^--/, '');
+ if (flags.includes(name)) out[name] = true;
+ else if (values.includes(name)) {
+ const v = argv[++i];
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
+ out[name] = v;
+ } else if (a === '--help' || a === '-h') out.help = true;
+ else throw new Error(`unknown argument ${a}`);
+ }
+ return out;
+}
+
function readPairs(file) {
function readPairs(file) {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
const list = Array.isArray(data) ? data : data && Array.isArray(data.pairs) ? data.pairs : null;
const list = Array.isArray(data) ? data : data && Array.isArray(data.pairs) ? data.pairs : null;
@@ -35,7 +51,7 @@ function readPairs(file) {
@@ -35,7 +51,7 @@ function readPairs(file) {
}
}
/** Sort each pair into import / skip. → { todo: [{ blocker, blocked }], skipped: { reason: n } } */
/** Sort each pair into import / skip. → { todo: [{ blocker, blocked }], skipped: { reason: n } } */
-function plan(db, pairs) {
+async function plan(db, pairs) {
const person = db.prepare('SELECT is_anon FROM users WHERE subject_id = ?');
const person = db.prepare('SELECT is_anon FROM users WHERE subject_id = ?');
const seen = db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?');
const seen = db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?');
const todo = [];
const todo = [];
@@ -50,11 +66,11 @@ function plan(db, pairs) {
@@ -50,11 +66,11 @@ function plan(db, pairs) {
const key = `${blocker}>${blocked}`;
const key = `${blocker}>${blocked}`;
if (dup.has(key)) { skip('duplicate-in-file'); continue; }
if (dup.has(key)) { skip('duplicate-in-file'); continue; }
dup.add(key);
dup.add(key);
- const a = person.get(blocker);
- const b = person.get(blocked);
+ const a = await person.get(blocker);
+ const b = await person.get(blocked);
if (!a || !b) { skip('unknown-account'); continue; }
if (!a || !b) { skip('unknown-account'); continue; }
if (a.is_anon || b.is_anon) { skip('guest'); continue; }
if (a.is_anon || b.is_anon) { skip('guest'); continue; }
- if (seen.get(blocker, blocked)) { skip('already-on-network'); continue; }
+ if (await seen.get(blocker, blocked)) { skip('already-on-network'); continue; }
todo.push({ blocker, blocked });
todo.push({ blocker, blocked });
}
}
return { todo, skipped };
return { todo, skipped };
@@ -62,34 +78,28 @@ function plan(db, pairs) {
@@ -62,34 +78,28 @@ function plan(db, pairs) {
async function main(argv, log = console.log) {
async function main(argv, log = console.log) {
let args;
let args;
- try { args = ops.parseArgs(argv, { flags: ['apply'], values: ['db', 'file'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
+ try { args = parseArgs(argv, { flags: ['apply'], values: ['file'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
if (args.help) { log(USAGE); return 0; }
if (args.help) { log(USAGE); return 0; }
if (!args.file) { log(USAGE); return 2; }
if (!args.file) { log(USAGE); return 2; }
- const file = ops.dbPath(args);
- if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
let pairs;
let pairs;
try { pairs = readPairs(path.resolve(process.cwd(), args.file)); } catch (e) { log(`error: ${e.message}`); return 2; }
try { pairs = readPairs(path.resolve(process.cwd(), args.file)); } catch (e) { log(`error: ${e.message}`); return 2; }
- ops.dropToOwnerOf(file, log);
- const db = new Database(file);
- db.pragma('busy_timeout = 5000');
- try {
- blocks.ensureSchema(db);
- const { todo, skipped } = plan(db, pairs);
- const why = Object.entries(skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
- log(`database ${file}\npairs ${pairs.length}; to import ${todo.length}; skipped: ${why}`);
- if (!args.apply) { log(todo.length ? 'dry run: nothing changed. Re-run with --apply to import.' : 'nothing to import.'); return 0; }
- let imported = 0;
- let refused = 0;
- for (const p of todo) {
- try { if (blocks.setBlock(db, p.blocker, p.blocked, true).changed) imported++; } catch (e) {
- if (!(e instanceof blocks.BlockError)) throw e;
- refused++;
- log(`refused ${p.blocker} -> ${p.blocked}: ${e.message}`);
- }
+ const db = await initDb();
+ await blocks.ensureSchema(db);
+ const { todo, skipped } = await plan(db, pairs);
+ const why = Object.entries(skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
+ log(`database ${db.store}\npairs ${pairs.length}; to import ${todo.length}; skipped: ${why}`);
+ if (!args.apply) { log(todo.length ? 'dry run: nothing changed. Re-run with --apply to import.' : 'nothing to import.'); return 0; }
+ let imported = 0;
+ let refused = 0;
+ for (const p of todo) {
+ try { if ((await blocks.setBlock(db, p.blocker, p.blocked, true)).changed) imported++; } catch (e) {
+ if (!(e instanceof blocks.BlockError)) throw e;
+ refused++;
+ log(`refused ${p.blocker} -> ${p.blocked}: ${e.message}`);
}
}
- log(`imported ${imported} block(s); ${imported} network.block.changed event(s) queued in network_event_outbox${refused ? `; refused ${refused}` : ''}`);
- return refused ? 1 : 0;
- } finally { db.close(); }
+ }
+ log(`imported ${imported} block(s); ${imported} network.block.changed event(s) queued in network_event_outbox${refused ? `; refused ${refused}` : ''}`);
+ return refused ? 1 : 0;
}
}
if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
@@ -1,51 +1,14 @@
@@ -1,51 +1,14 @@
'use strict';
'use strict';
/**
/**
- * Shared by the operator scripts that change network.db (scripts/secrets-out-of-db.js,
- * scripts/import-blocks.js):
- *
- * dbPath(args) --db, else $DB_PATH, else data/network.db (relative to the repo root)
- * dropToOwnerOf(file) when run as root: become the file's owner (uid/gid) so nothing the script
- * writes (the -wal/-shm files, a backup) ends up owned by root; a no-op otherwise
- * backupTo(db, target) sqlite online backup to a NEW file, owner-only (0600), verified with
- * quick_check; refuses an existing target
+ * Shared by the operator scripts: `parseArgs(argv, { flags, values })`, the `--flag` / `--key value`
+ * parsing they all use. On PostgreSQL (plan T2, ADR-035) the scripts open the service database through
+ * DATABASE_URL themselves (scripts/secrets-out-of-db.js, scripts/import-blocks.js); the old
+ * better-sqlite3 helpers (dbPath, dropToOwnerOf, the sqlite online backup) are gone with the file.
*/
*/
-const fs = require('fs');
const path = require('path');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const ROOT = path.join(__dirname, '..', '..');
-function dbPath(args = {}, env = process.env) {
- return path.resolve(ROOT, args.db || env.DB_PATH || path.join('data', 'network.db'));
-}
-
-function dropToOwnerOf(file, log = console.log) {
- if (typeof process.getuid !== 'function' || process.getuid() !== 0) return false;
- const st = fs.statSync(file);
- if (st.uid === 0) return false;
- process.setgid(st.gid);
- process.setuid(st.uid);
- log(`running as uid ${st.uid} (owner of ${path.basename(file)})`);
- return true;
-}
-
-async function backupTo(db, target, { Database = require('better-sqlite3'), expect = null } = {}) {
- const file = path.resolve(ROOT, target);
- if (fs.existsSync(file)) throw new Error(`backup target ${file} already exists; choose a new path`);
- const umask = process.umask(0o077);
- try { await db.backup(file); } finally { process.umask(umask); }
- fs.chmodSync(file, 0o600);
- const check = new Database(file, { readonly: true });
- try {
- const ok = check.pragma('quick_check', { simple: true });
- if (ok !== 'ok') throw new Error(`backup check failed: quick_check=${ok}`);
- if (expect) {
- const n = check.prepare(`SELECT COUNT(*) AS n FROM ${expect.table}`).get().n;
- if (n < expect.rows) throw new Error(`backup check failed: ${expect.table} has ${n} rows, expected ${expect.rows}`);
- }
- } finally { check.close(); }
- return file;
-}
-
/** --flag / --key value parsing shared by the scripts. */
/** --flag / --key value parsing shared by the scripts. */
function parseArgs(argv, { flags = [], values = [] } = {}) {
function parseArgs(argv, { flags = [], values = [] } = {}) {
const out = { _: [] };
const out = { _: [] };
@@ -63,4 +26,4 @@ function parseArgs(argv, { flags = [], values = [] } = {}) {
@@ -63,4 +26,4 @@ function parseArgs(argv, { flags = [], values = [] } = {}) {
return out;
return out;
}
}
-module.exports = { ROOT, dbPath, dropToOwnerOf, backupTo, parseArgs };
+module.exports = { ROOT, parseArgs };
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* --pglite a dry run: an in-memory PostgreSQL, migrations applied, every table imported with truncate
* --pglite a dry run: an in-memory PostgreSQL, migrations applied, every table imported with truncate
* and verified, and the per-table count + checksum report printed. Nothing to set up, nothing
* and verified, and the per-table count + checksum report printed. Nothing to set up, nothing
* left behind. Use it to prove the schema is importable before touching a real target.
* left behind. Use it to prove the schema is importable before touching a real target.
- * --sqlite the source file (default config.db.path). It is opened read-only; nothing in it changes.
+ * --sqlite the source file (default $DB_PATH or data/network.db). It is opened read-only; nothing in it changes.
* --json the report as JSON.
* --json the report as JSON.
*
*
* Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes
* Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes
@@ -34,9 +34,12 @@ const TABLES = {};
@@ -34,9 +34,12 @@ const TABLES = {};
// (unsent events keep their place in the PostgreSQL outbox).
// (unsent events keep their place in the PostgreSQL outbox).
const SKIP_SOURCE = [];
const SKIP_SOURCE = [];
+// The retired SQLite file: --sqlite, else DB_PATH, else data/network.db (relative to the repo root).
+const DEFAULT_SQLITE = path.resolve(__dirname, '..', process.env.DB_PATH || path.join('data', 'network.db'));
+
if (require.main === module) {
if (require.main === module) {
- runSqliteMigration({ service: 'network', sqlite: config.db.path, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES, skipSource: SKIP_SOURCE })
+ runSqliteMigration({ service: 'network', sqlite: DEFAULT_SQLITE, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES, skipSource: SKIP_SOURCE })
.then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
.then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
}
}
-module.exports = { TABLES, SKIP_SOURCE, MIGRATIONS };
+module.exports = { TABLES, SKIP_SOURCE, MIGRATIONS, DEFAULT_SQLITE };
@@ -15,11 +15,12 @@
@@ -15,11 +15,12 @@
*/
*/
require('dotenv').config();
require('dotenv').config();
const fs = require('fs');
const fs = require('fs');
-const config = require('../server/config');
+const path = require('path');
const args = process.argv.slice(2);
const args = process.argv.slice(2);
const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
const opt = (name, def) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
-const file = opt('--sqlite', config.db.path);
+// The retired SQLite file: --sqlite, else DB_PATH, else data/network.db (relative to the repo root).
+const file = opt('--sqlite', path.resolve(__dirname, '..', process.env.DB_PATH || path.join('data', 'network.db')));
const asJson = args.includes('--json');
const asJson = args.includes('--json');
/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */
/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */
@@ -1,46 +1,47 @@
@@ -1,46 +1,47 @@
#!/usr/bin/env node
#!/usr/bin/env node
/**
/**
- * Provider secrets out of network.db (roadmap §18.2(12); server/secrets.js). Network reads each secret
+ * Provider secrets out of site_settings (roadmap §18.2(12); server/secrets.js). Network reads each secret
* from its environment variable first and from site_settings only as a fallback; this script is how the
* from its environment variable first and from site_settings only as a fallback; this script is how the
* operator empties the database copies once the environment has them. It prints setting and variable
* operator empties the database copies once the environment has them. It prints setting and variable
* NAMES, never a value (values are compared by SHA-256 only).
* NAMES, never a value (values are compared by SHA-256 only).
*
*
- * sudo node scripts/secrets-out-of-db.js
- * dry run: every secret setting by name, whether network.db holds a value, its variable, whether
+ * node scripts/secrets-out-of-db.js
+ * dry run: every secret setting by name, whether the database holds a value, its variable, whether
* the env file sets it (and to the same value), and whether the running service has it
* the env file sets it (and to the same value), and whether the running service has it
- * sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]
+ * node scripts/secrets-out-of-db.js --copy-to-env [--apply]
* for each secret the database holds and the env file does not set, append VAR=value to the env
* for each secret the database holds and the env file does not set, append VAR=value to the env
* file (after copying it to <env file>.bak-<time>, same mode), so the values move without anyone
* file (after copying it to <env file>.bak-<time>, same mode), so the values move without anyone
- * seeing them: the database is read by a child process running as its owner and the values go to
- * the file through a pipe, never to the terminal. Dry run without --apply. Restart the service after.
- * sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
- * takes an online backup (0600, verified), then blanks the database copy of
+ * seeing them: the values go to the file through this process, never to the terminal. Dry run
+ * without --apply. Restart the service after.
+ * node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
+ * writes the secret settings to a new owner-only (0600) JSON backup, then blanks the database copy of
* - each secret whose variable the env file sets to the same value (a different value only with
* - each secret whose variable the env file sets to the same value (a different value only with
* --allow-different) AND the running service already has (restart it after editing the file)
* --allow-different) AND the running service already has (restart it after editing the file)
* - each secret setting Network never reads (Net tools tokens, old SES keys)
* - each secret setting Network never reads (Net tools tokens, old SES keys)
* and leaves every other one as it is, saying why
* and leaves every other one as it is, saying why
- * sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]
+ * node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]
* rollback: puts the backed-up value back into each secret setting that is blank now
* rollback: puts the backed-up value back into each secret setting that is blank now
*
*
+ * On PostgreSQL (plan T2, ADR-035) the settings live in the service database: the script reads and writes
+ * them through DATABASE_URL, exactly as the service does. There is no separate owner process and no
+ * sqlite online backup; --backup writes a JSON file (0600) holding the values being blanked.
+ *
* --env-file <path> default /etc/openvibe/network.env
* --env-file <path> default /etc/openvibe/network.env
- * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)
* --unit <name> systemd unit whose running process must already have the variables
* --unit <name> systemd unit whose running process must already have the variables
* (default openvibe-network); --no-service-check skips that check
* (default openvibe-network); --no-service-check skips that check
*
*
- * Run with sudo: the env file and the service's environment are root-only. The script reads them, then
- * becomes the database's owner before opening network.db, so no file changes owner.
+ * Run with sudo: the env file and the service's environment are root-only.
*/
*/
'use strict';
'use strict';
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const { execFileSync } = require('child_process');
-const Database = require('better-sqlite3');
-const ops = require('./lib/db-ops');
+const { parseArgs } = require('./lib/db-ops');
const secrets = require('../server/secrets');
const secrets = require('../server/secrets');
const { isSensitiveSettingKey } = require('../server/auth/owner-guard');
const { isSensitiveSettingKey } = require('../server/auth/owner-guard');
-const USAGE = `usage: sudo node scripts/secrets-out-of-db.js [--env-file <path>] [--db <path>] [--unit <name> | --no-service-check]
+const USAGE = `usage: sudo node scripts/secrets-out-of-db.js [--env-file <path>] [--unit <name> | --no-service-check]
sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]
sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]
sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;
sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;
@@ -50,6 +51,14 @@ const NOT_SECRET = new Set(['discord_oauth_client_id', 'vapid_public_key', 'vapi
@@ -50,6 +51,14 @@ const NOT_SECRET = new Set(['discord_oauth_client_id', 'vapid_public_key', 'vapi
const digest = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');
const digest = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');
+/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it. */
+async function openDb(injected) {
+ if (injected) return injected;
+ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the settings live in the service database');
+ const { createDb } = require('openvibe-sdk/db');
+ return createDb({ url: process.env.DATABASE_URL, service: 'network-secrets', max: 1 });
+}
+
function parseEnvText(text) {
function parseEnvText(text) {
const util = require('util');
const util = require('util');
if (typeof util.parseEnv === 'function') return util.parseEnv(text);
if (typeof util.parseEnv === 'function') return util.parseEnv(text);
@@ -81,8 +90,8 @@ function serviceEnv(unit) {
@@ -81,8 +90,8 @@ function serviceEnv(unit) {
}
}
/** Every secret-looking setting, classified. */
/** Every secret-looking setting, classified. */
-function classify(db) {
- const rows = db.prepare('SELECT key, value, type FROM site_settings').all();
+async function classify(db) {
+ const rows = await db.prepare('SELECT key, value, type FROM site_settings').all();
const byKey = new Map(rows.map(r => [r.key, r]));
const byKey = new Map(rows.map(r => [r.key, r]));
const out = [];
const out = [];
for (const s of secrets.SECRETS) out.push({ key: s.key, kind: 'provider', env: s.env, value: (byKey.get(s.key) || {}).value || '' });
for (const s of secrets.SECRETS) out.push({ key: s.key, kind: 'provider', env: s.env, value: (byKey.get(s.key) || {}).value || '' });
@@ -97,31 +106,17 @@ function classify(db) {
@@ -97,31 +106,17 @@ function classify(db) {
// A value systemd's EnvironmentFile and util.parseEnv read back unchanged without quoting.
// A value systemd's EnvironmentFile and util.parseEnv read back unchanged without quoting.
const PLAIN_VALUE = /^[A-Za-z0-9._\-+/=:~]+$/;
const PLAIN_VALUE = /^[A-Za-z0-9._\-+/=:~]+$/;
-/** Child side of --copy-to-env: the provider values as JSON on stdout (a pipe to the parent only). */
-function emitValues(file) {
- const db = new Database(file, { readonly: true, fileMustExist: true });
- try {
- const get = db.prepare('SELECT value FROM site_settings WHERE key = ?');
- const out = {};
- for (const s of secrets.SECRETS) { const r = get.get(s.key); if (r && r.value) out[s.env] = String(r.value); }
- process.stdout.write(JSON.stringify(out));
- } finally { db.close(); }
-}
-
-/** The database's provider values, read by a child process running as the database owner. */
-function readValuesAsOwner(file) {
- const st = fs.statSync(file);
- const asOwner = typeof process.getuid === 'function' && process.getuid() === 0 && st.uid !== 0 ? { uid: st.uid, gid: st.gid } : {};
- const res = require('child_process').spawnSync(process.execPath, [__filename, '--db', file], {
- ...asOwner, env: { PATH: process.env.PATH, OV_SECRETS_EMIT: '1' }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000, maxBuffer: 1 << 20,
- });
- if (res.status !== 0) throw new Error(`reading the database failed: ${String(res.stderr || '').trim().split('\n').pop()}`);
- return JSON.parse(String(res.stdout));
+/** The database's provider values: { ENV_NAME: value }. */
+async function readValues(db) {
+ const get = db.prepare('SELECT value FROM site_settings WHERE key = ?');
+ const out = {};
+ for (const s of secrets.SECRETS) { const r = await get.get(s.key); if (r && r.value) out[s.env] = String(r.value); }
+ return out;
}
}
-function copyToEnv(file, envFile, envf, apply, log, now = new Date()) {
+async function copyToEnv(db, envFile, envf, apply, log, now = new Date()) {
if (envf.error) { log(`error: the env file ${envFile} is unreadable (${envf.error}); run with sudo`); return 2; }
if (envf.error) { log(`error: the env file ${envFile} is unreadable (${envf.error}); run with sudo`); return 2; }
- const values = readValuesAsOwner(file);
+ const values = await readValues(db);
const add = [];
const add = [];
for (const s of secrets.SECRETS) {
for (const s of secrets.SECRETS) {
const v = values[s.env];
const v = values[s.env];
@@ -139,52 +134,60 @@ function copyToEnv(file, envFile, envf, apply, log, now = new Date()) {
@@ -139,52 +134,60 @@ function copyToEnv(file, envFile, envf, apply, log, now = new Date()) {
fs.copyFileSync(envFile, bak, fs.constants.COPYFILE_EXCL);
fs.copyFileSync(envFile, bak, fs.constants.COPYFILE_EXCL);
fs.chmodSync(bak, fs.statSync(envFile).mode & 0o777);
fs.chmodSync(bak, fs.statSync(envFile).mode & 0o777);
const text = fs.readFileSync(envFile, 'utf8');
const text = fs.readFileSync(envFile, 'utf8');
- fs.appendFileSync(envFile, `${text.endsWith('\n') || !text ? '' : '\n'}# Provider secrets moved from network.db site_settings (scripts/secrets-out-of-db.js, ${now.toISOString()})\n${add.map(([k, v]) => `${k}=${v}`).join('\n')}\n`);
+ fs.appendFileSync(envFile, `${text.endsWith('\n') || !text ? '' : '\n'}# Provider secrets moved from site_settings (scripts/secrets-out-of-db.js, ${now.toISOString()})\n${add.map(([k, v]) => `${k}=${v}`).join('\n')}\n`);
log(`env file ${envFile}: appended ${add.map(([k]) => k).join(', ')} (previous version: ${bak}). Restart openvibe-network, then run --apply --backup <new file>.`);
log(`env file ${envFile}: appended ${add.map(([k]) => k).join(', ')} (previous version: ${bak}). Restart openvibe-network, then run --apply --backup <new file>.`);
return 0;
return 0;
}
}
-async function main(argv, log = console.log, { readService = serviceEnv } = {}) {
+/** Write the site_settings rows to a NEW owner-only JSON backup; refuses an existing target. */
+function writeBackup(target, rows, log) {
+ const file = path.resolve(target);
+ if (fs.existsSync(file)) throw new Error(`backup target ${file} already exists; choose a new path`);
+ const umask = process.umask(0o077);
+ try { fs.writeFileSync(file, JSON.stringify({ taken_at: new Date().toISOString(), settings: rows }, null, 2)); } finally { process.umask(umask); }
+ fs.chmodSync(file, 0o600);
+ log(`backup ${file} (0600). It holds the site_settings rows (owner-only): delete it once the rollback window is over.`);
+ return file;
+}
+
+async function main(argv, log = console.log, { readService = serviceEnv, db: injected } = {}) {
let args;
let args;
- try { args = ops.parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check', 'copy-to-env'], values: ['db', 'backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
+ try { args = parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check', 'copy-to-env'], values: ['backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
if (args.help) { log(USAGE); return 0; }
if (args.help) { log(USAGE); return 0; }
- const file = ops.dbPath(args);
- if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
const envFile = args['env-file'] || '/etc/openvibe/network.env';
const envFile = args['env-file'] || '/etc/openvibe/network.env';
- if (args['copy-to-env']) {
- if (args.backup || args['restore-from']) { log('--copy-to-env takes only --apply, --db and --env-file'); return 2; }
- log(`database ${file}\nenv file ${envFile}`);
- return copyToEnv(file, envFile, readEnvFile(envFile), !!args.apply, log);
- }
-
- // Root-only reads first, then drop to the database owner.
- const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
- const svc = args['restore-from'] || args['no-service-check'] ? null : readService(args.unit || 'openvibe-network');
- ops.dropToOwnerOf(file, log);
- const db = new Database(file);
- db.pragma('busy_timeout = 5000');
+ let db;
+ try { db = await openDb(injected); } catch (e) { log(`error: ${e.message}`); return 2; }
try {
try {
- log(`database ${file}`);
+ if (args['copy-to-env']) {
+ if (args.backup || args['restore-from']) { log('--copy-to-env takes only --apply and --env-file'); return 2; }
+ log(`env file ${envFile}`);
+ return await copyToEnv(db, envFile, readEnvFile(envFile), !!args.apply, log);
+ }
+
+ const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
+ const svc = args['restore-from'] || args['no-service-check'] ? null : readService(args.unit || 'openvibe-network');
+
+ log('database DATABASE_URL');
if (args['restore-from']) {
if (args['restore-from']) {
- const backup = new Database(path.resolve(ops.ROOT, args['restore-from']), { readonly: true, fileMustExist: true });
- const saved = new Map(backup.prepare('SELECT key, value FROM site_settings').all().map(r => [r.key, r.value]));
- backup.close();
- const todo = classify(db).filter(c => c.kind !== 'unclassified' && !c.value && saved.get(c.key));
+ const backup = JSON.parse(fs.readFileSync(path.resolve(args['restore-from']), 'utf8'));
+ const saved = new Map((backup.settings || []).map(r => [r.key, r.value]));
+ const todo = (await classify(db)).filter(c => c.kind !== 'unclassified' && !c.value && saved.get(c.key));
for (const c of todo) log(` restore ${c.key}`);
for (const c of todo) log(` restore ${c.key}`);
log(`${todo.length} setting(s) to restore from ${args['restore-from']}`);
log(`${todo.length} setting(s) to restore from ${args['restore-from']}`);
if (!args.apply) { log('dry run: nothing changed. Re-run with --apply.'); return 0; }
if (!args.apply) { log('dry run: nothing changed. Re-run with --apply.'); return 0; }
- const up = db.prepare("UPDATE site_settings SET value = ? WHERE key = ? AND (value IS NULL OR value = '')");
- let n = 0;
- db.transaction(() => { for (const c of todo) n += up.run(saved.get(c.key), c.key).changes; })();
- log(`restored ${n} setting(s). Restart openvibe-network if it should read them again (the environment still wins where set).`);
+ await db.tx(async (t) => {
+ let n = 0;
+ for (const c of todo) { n += (await t.prepare("UPDATE site_settings SET value = ? WHERE key = ? AND (value IS NULL OR value = '')").run(saved.get(c.key), c.key)).changes; }
+ log(`restored ${n} setting(s). Restart openvibe-network if it should read them again (the environment still wins where set).`);
+ });
return 0;
return 0;
}
}
log(`env file ${envFile}${envf.error ? ` (unreadable: ${envf.error}; run with sudo)` : ''}`);
log(`env file ${envFile}${envf.error ? ` (unreadable: ${envf.error}; run with sudo)` : ''}`);
if (svc) log(`service ${svc.error ? `unknown (${svc.error})` : `pid ${svc.pid}`}`);
if (svc) log(`service ${svc.error ? `unknown (${svc.error})` : `pid ${svc.pid}`}`);
const plan = [];
const plan = [];
- for (const c of classify(db)) {
+ for (const c of await classify(db)) {
const row = { key: c.key, kind: c.kind, db: c.value ? 'value' : 'empty' };
const row = { key: c.key, kind: c.kind, db: c.value ? 'value' : 'empty' };
if (c.kind === 'provider') {
if (c.kind === 'provider') {
row.env = c.env;
row.env = c.env;
@@ -213,25 +216,22 @@ async function main(argv, log = console.log, { readService = serviceEnv } = {})
@@ -213,25 +216,22 @@ async function main(argv, log = console.log, { readService = serviceEnv } = {})
const blank = plan.filter(p => p.action.startsWith('BLANK'));
const blank = plan.filter(p => p.action.startsWith('BLANK'));
log(`${blank.length} setting(s) would be blanked; ${plan.filter(p => p.action.startsWith('keep')).length} kept.`);
log(`${blank.length} setting(s) would be blanked; ${plan.filter(p => p.action.startsWith('keep')).length} kept.`);
if (!args.apply) { log('dry run: nothing changed. Re-run with --apply --backup <new file>.'); return 0; }
if (!args.apply) { log('dry run: nothing changed. Re-run with --apply --backup <new file>.'); return 0; }
- if (!args.backup) { log('refusing --apply without --backup <new file> (sqlite online backup, 0600)'); return 2; }
+ if (!args.backup) { log('refusing --apply without --backup <new file> (owner-only JSON, 0600)'); return 2; }
if (envf.error) { log(`refusing --apply: the env file ${envFile} is unreadable`); return 2; }
if (envf.error) { log(`refusing --apply: the env file ${envFile} is unreadable`); return 2; }
if (!blank.length) { log('nothing to blank; no backup taken.'); return 0; }
if (!blank.length) { log('nothing to blank; no backup taken.'); return 0; }
- const total = db.prepare('SELECT COUNT(*) AS n FROM site_settings').get().n;
- const target = await ops.backupTo(db, args.backup, { expect: { table: 'site_settings', rows: total } });
- log(`backup ${target} (0600, quick_check ok). It holds the secrets (owner-only, like network.db): delete it once the rollback window is over.`);
- const up = db.prepare("UPDATE site_settings SET value = '' WHERE key = ?");
- let n = 0;
- db.transaction(() => { for (const p of blank) n += up.run(p.key).changes; })();
- log(`blanked ${n} setting(s): ${blank.map(p => p.key).join(', ')}`);
+ const rows = await db.prepare('SELECT key, value FROM site_settings').all();
+ writeBackup(args.backup, rows, log);
+ await db.tx(async (t) => {
+ const up = t.prepare("UPDATE site_settings SET value = '' WHERE key = ?");
+ let n = 0;
+ for (const p of blank) n += (await up.run(p.key)).changes;
+ log(`blanked ${n} setting(s): ${blank.map(p => p.key).join(', ')}`);
+ });
return 0;
return 0;
- } finally { db.close(); }
+ } finally { if (!injected) await db.close().catch(() => {}); }
}
}
if (require.main === module) {
if (require.main === module) {
- // The --copy-to-env child: values go to the parent through a pipe, never to a terminal.
- if (process.env.OV_SECRETS_EMIT === '1' && !process.stdout.isTTY) {
- try { emitValues(ops.dbPath(ops.parseArgs(process.argv.slice(2), { values: ['db'] }))); process.exit(0); } catch (err) { console.error(err.message); process.exit(1); }
- }
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
}
}
@@ -37,10 +37,10 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -37,10 +37,10 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
// Games and Media keep no analytics of their own. The shared navbar's anonymous page-view count
// Games and Media keep no analytics of their own. The shared navbar's anonymous page-view count
// (frame_hits: day + host, nothing else) is the honest number for them; the summary says so.
// (frame_hits: day + host, nothing else) is the honest number for them; the summary says so.
const BEACON_HOSTS = { games: 'openvibe.games', media: 'openvibe.media', tools: 'openvibe.tools' };
const BEACON_HOSTS = { games: 'openvibe.games', media: 'openvibe.media', tools: 'openvibe.tools' };
- function beaconFallback(svc, days) {
+ async function beaconFallback(svc, days) {
try {
try {
const host = BEACON_HOSTS[svc.name]; if (!host) return null;
const host = BEACON_HOSTS[svc.name]; if (!host) return null;
- const row = analytics.db.prepare("SELECT COALESCE(SUM(hits), 0) AS n FROM frame_hits WHERE day >= date('now', ?) AND (host = ? OR host LIKE ?)").get(`-${Math.min(days || 30, 365)} days`, host, '%.' + host);
+ const row = await analytics.db.prepare("SELECT COALESCE(SUM(hits), 0) AS n FROM frame_hits WHERE day >= substring(datetime('now', ?), 1, 10) AND (host = ? OR host LIKE ?)").get(`-${Math.min(days || 30, 365)} days`, host, '%.' + host);
return { ok: true, analytics: { summary: { total_pageviews: row.n, source: 'navbar page-view count (no service analytics)' }, realtime: {} } };
return { ok: true, analytics: { summary: { total_pageviews: row.n, source: 'navbar page-view count (no service analytics)' }, realtime: {} } };
} catch { return null; }
} catch { return null; }
}
}
@@ -52,7 +52,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -52,7 +52,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
const url = `${svc.url}${svc.path}${subPath}?${qs}`;
const url = `${svc.url}${svc.path}${subPath}?${qs}`;
const headers = { 'Content-Type': 'application/json' };
const headers = { 'Content-Type': 'application/json' };
- if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);
+ if (svc.auth === 'beacon') return subPath ? null : await beaconFallback(svc, days);
if (svc.auth === 'internal') {
if (svc.auth === 'internal') {
const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);
const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);
if (!t) return null;
if (!t) return null;
@@ -62,11 +62,11 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -62,11 +62,11 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
}
}
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
- if (!res.ok) return subPath ? null : beaconFallback(svc, days);
+ if (!res.ok) return subPath ? null : await beaconFallback(svc, days);
return await res.json();
return await res.json();
} catch (err) {
} catch (err) {
console.warn(`[Analytics] Failed to fetch from ${svc.name}:`, err.message);
console.warn(`[Analytics] Failed to fetch from ${svc.name}:`, err.message);
- return subPath ? null : beaconFallback(svc, days);
+ return subPath ? null : await beaconFallback(svc, days);
}
}
}
}
@@ -81,7 +81,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -81,7 +81,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
// Fetch from all services in parallel
// Fetch from all services in parallel
const promises = [
const promises = [
- Promise.resolve({ ok: true, analytics: analytics.getStats({ days, hours }), name: 'openvibe-network', label: 'OpenVibe.Network' }),
+ { ok: true, analytics: await analytics.getStats({ days, hours }), name: 'openvibe-network', label: 'OpenVibe.Network' },
...REMOTE_SERVICES.map(svc =>
...REMOTE_SERVICES.map(svc =>
fetchRemoteAnalytics(svc, '', req.token, days, hours).then(result => ({
fetchRemoteAnalytics(svc, '', req.token, days, hours).then(result => ({
...result, name: svc.name, label: svc.label,
...result, name: svc.name, label: svc.label,
@@ -231,7 +231,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -231,7 +231,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
let data = null;
let data = null;
if (name === 'openvibe-network') {
if (name === 'openvibe-network') {
- data = analytics.getStats({ days, hours });
+ data = await analytics.getStats({ days, hours });
} else {
} else {
const svc = REMOTE_SERVICES.find(s => s.name === name);
const svc = REMOTE_SERVICES.find(s => s.name === name);
if (svc) {
if (svc) {
@@ -259,7 +259,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -259,7 +259,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
const days = Math.min(parseInt(req.query.days) || 30, 365);
const days = Math.min(parseInt(req.query.days) || 30, 365);
const promises = [
const promises = [
- Promise.resolve({ ok: true, bots: analytics.getBotAnalysis(days), name: 'openvibe-network' }),
+ { ok: true, bots: await analytics.getBotAnalysis(days), name: 'openvibe-network' },
...REMOTE_SERVICES.map(svc =>
...REMOTE_SERVICES.map(svc =>
fetchRemoteAnalytics(svc, '/bots', req.token, days).then(result => ({
fetchRemoteAnalytics(svc, '/bots', req.token, days).then(result => ({
...result, name: svc.name,
...result, name: svc.name,
@@ -284,13 +284,13 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -284,13 +284,13 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
// Real-time snapshot
// Real-time snapshot
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
- router.get('/realtime', (req, res) => {
+ router.get('/realtime', async (req, res) => {
try {
try {
const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
const fiveMinAgo = new Date(Date.now() - 300000).toISOString().slice(0, 19).replace('T', ' ');
const db = analytics.db;
const db = analytics.db;
// ADR-021: raw events carry no IP; visitors are distinct rotating session ids.
// ADR-021: raw events carry no IP; visitors are distinct rotating session ids.
- const realtime = db.prepare(`
+ const realtime = await db.prepare(`
SELECT
SELECT
COUNT(*) as requests,
COUNT(*) as requests,
COUNT(DISTINCT session_id) as visitors,
COUNT(DISTINCT session_id) as visitors,
@@ -301,7 +301,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -301,7 +301,7 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
WHERE created_at >= ?
WHERE created_at >= ?
`).get(fiveMinAgo);
`).get(fiveMinAgo);
- const byPath = db.prepare(`
+ const byPath = await db.prepare(`
SELECT path, COUNT(*) as hits
SELECT path, COUNT(*) as hits
FROM analytics_events
FROM analytics_events
WHERE created_at >= ? AND is_bot = 0
WHERE created_at >= ? AND is_bot = 0
@@ -71,7 +71,7 @@ function createEventsOps({ db, eventsUrl, privateKey, issuer, fetchImpl = global
@@ -71,7 +71,7 @@ function createEventsOps({ db, eventsUrl, privateKey, issuer, fetchImpl = global
try {
try {
const out = await call('POST', '/api/v1/deliveries/replay', ids ? { subscription_id: b.subscription_id, event_ids: ids } : { subscription_id: b.subscription_id, from_seq: fromSeq });
const out = await call('POST', '/api/v1/deliveries/replay', ids ? { subscription_id: b.subscription_id, event_ids: ids } : { subscription_id: b.subscription_id, from_seq: fromSeq });
if (out.status !== 200) return res.status(out.status === 404 ? 404 : 502).json({ ok: false, error: `Events answered ${out.status}`, detail: out.data && (out.data.detail || out.data.title) });
if (out.status !== 200) return res.status(out.status === 404 ? 404 : 502).json({ ok: false, error: `Events answered ${out.status}`, detail: out.data && (out.data.detail || out.data.title) });
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'events_replay',
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'events_replay',
JSON.stringify({ subscription_id: b.subscription_id, event_ids: ids ? ids.slice(0, 50) : undefined, event_count: ids ? ids.length : undefined, from_seq: fromSeq ?? undefined, queued: out.data.queued }));
JSON.stringify({ subscription_id: b.subscription_id, event_ids: ids ? ids.slice(0, 50) : undefined, event_count: ids ? ids.length : undefined, from_seq: fromSeq ?? undefined, queued: out.data.queued }));
res.json({ ok: true, queued: out.data.queued });
res.json({ ok: true, queued: out.data.queued });
} catch (err) {
} catch (err) {
@@ -31,28 +31,7 @@ const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000
@@ -31,28 +31,7 @@ const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
const json = (v) => { const s = JSON.stringify(obj(v)); return s.length > 4000 ? JSON.stringify({ truncated: true }) : s; };
const json = (v) => { const s = JSON.stringify(obj(v)); return s.length > 4000 ? JSON.stringify({ truncated: true }) : s; };
-function ensure(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS moderation_audit (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- event_id TEXT NOT NULL UNIQUE,
- service TEXT NOT NULL,
- action TEXT NOT NULL,
- actor_subject TEXT,
- target_type TEXT,
- target_id TEXT,
- target_subject TEXT,
- scope TEXT,
- reason TEXT,
- details TEXT NOT NULL DEFAULT '{}',
- occurred_at TEXT NOT NULL,
- recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- );
- CREATE INDEX IF NOT EXISTS idx_modaudit_when ON moderation_audit(occurred_at);
- CREATE INDEX IF NOT EXISTS idx_modaudit_service ON moderation_audit(service, id);
- CREATE INDEX IF NOT EXISTS idx_modaudit_actor ON moderation_audit(actor_subject, id);
- `);
-}
+function ensure(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** One envelope → one audit row (or null when it is not an audit event). No I/O. */
/** One envelope → one audit row (or null when it is not an audit event). No I/O. */
function rowOf(event) {
function rowOf(event) {
@@ -105,17 +84,17 @@ function rowOf(event) {
@@ -105,17 +84,17 @@ function rowOf(event) {
function createModerationAudit(db) {
function createModerationAudit(db) {
ensure(db);
ensure(db);
- const ins = db.prepare(`INSERT OR IGNORE INTO moderation_audit (event_id, service, action, actor_subject, target_type, target_id, target_subject, scope, reason, details, occurred_at)
- VALUES (@event_id, @service, @action, @actor_subject, @target_type, @target_id, @target_subject, @scope, @reason, @details, @occurred_at)`);
+ const ins = db.prepare(`INSERT INTO moderation_audit (event_id, service, action, actor_subject, target_type, target_id, target_subject, scope, reason, details, occurred_at)
+ VALUES (@event_id, @service, @action, @actor_subject, @target_type, @target_id, @target_subject, @scope, @reason, @details, @occurred_at) ON CONFLICT DO NOTHING`);
/** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */
/** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */
- function record(event) {
+ async function record(event) {
const row = rowOf(event);
const row = rowOf(event);
if (!row) return 'ignored:type';
if (!row) return 'ignored:type';
- return ins.run(row).changes ? 'recorded' : 'ignored:duplicate';
+ return (await ins.run(row)).changes ? 'recorded' : 'ignored:duplicate';
}
}
- function list({ service = null, action = null, actor = null, target = null, since = null, until = null, before = null, limit = 50, max = 200 } = {}) {
+ async function list({ service = null, action = null, actor = null, target = null, since = null, until = null, before = null, limit = 50, max = 200 } = {}) {
const where = [];
const where = [];
const args = [];
const args = [];
const day = (v) => { const d = new Date(String(v || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
const day = (v) => { const d = new Date(String(v || '')); return Number.isNaN(d.getTime()) ? null : d.toISOString(); };
@@ -128,7 +107,7 @@ function createModerationAudit(db) {
@@ -128,7 +107,7 @@ function createModerationAudit(db) {
const b = parseInt(before, 10);
const b = parseInt(before, 10);
if (Number.isFinite(b) && b > 0) { where.push('id < ?'); args.push(b); }
if (Number.isFinite(b) && b > 0) { where.push('id < ?'); args.push(b); }
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), max);
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), max);
- const rows = db.prepare(`SELECT * FROM moderation_audit ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, n + 1);
+ const rows = await db.prepare(`SELECT * FROM moderation_audit ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id DESC LIMIT ?`).all(...args, n + 1);
const more = rows.length > n;
const more = rows.length > n;
const items = rows.slice(0, n).map((r) => ({ ...r, details: (() => { try { return JSON.parse(r.details); } catch { return {}; } })() }));
const items = rows.slice(0, n).map((r) => ({ ...r, details: (() => { try { return JSON.parse(r.details); } catch { return {}; } })() }));
return { items, next: more && items.length ? items[items.length - 1].id : null };
return { items, next: more && items.length ? items[items.length - 1].id : null };
@@ -137,16 +116,16 @@ function createModerationAudit(db) {
@@ -137,16 +116,16 @@ function createModerationAudit(db) {
/** Express router for GET /api/v1/staff/moderation-audit (mount behind requireAuth). */
/** Express router for GET /api/v1/staff/moderation-audit (mount behind requireAuth). */
function router() {
function router() {
const r = express.Router();
const r = express.Router();
- r.get('/', (req, res) => {
+ r.get('/', async (req, res) => {
if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
if (!req.user || !staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
const filters = { service: req.query.service, action: req.query.action, actor: req.query.actor, target: req.query.target, since: req.query.since, until: req.query.until };
const filters = { service: req.query.service, action: req.query.action, actor: req.query.actor, target: req.query.target, since: req.query.since, until: req.query.until };
if (req.query.format === 'csv') {
if (req.query.format === 'csv') {
- const { items } = list({ ...filters, limit: CSV_MAX, max: CSV_MAX });
+ const { items } = await list({ ...filters, limit: CSV_MAX, max: CSV_MAX });
res.set('Content-Disposition', `attachment; filename="moderation-audit-${new Date().toISOString().slice(0, 10)}.csv"`);
res.set('Content-Disposition', `attachment; filename="moderation-audit-${new Date().toISOString().slice(0, 10)}.csv"`);
return res.type('text/csv').send(toCsv(items));
return res.type('text/csv').send(toCsv(items));
}
}
- res.json(list({ ...filters, before: req.query.before, limit: req.query.limit }));
+ res.json(await list({ ...filters, before: req.query.before, limit: req.query.limit }));
});
});
return r;
return r;
}
}
@@ -81,7 +81,7 @@ async function refreshService(req, serviceName) {
@@ -81,7 +81,7 @@ async function refreshService(req, serviceName) {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
const config = req.app.locals.config;
const config = req.app.locals.config;
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
req.app.locals.urlRegistry = resolved;
req.app.locals.urlRegistry = resolved;
applyResolvedRegistryToConfig(config, resolved);
applyResolvedRegistryToConfig(config, resolved);
return {
return {
@@ -166,27 +166,27 @@ async function refreshServiceByKey(req, key) {
@@ -166,27 +166,27 @@ async function refreshServiceByKey(req, key) {
if (!serviceName) {
if (!serviceName) {
return { ok: false, service: null, mode: 'unknown', error: `No service defined for key ${key}` };
return { ok: false, service: null, mode: 'unknown', error: `No service defined for key ${key}` };
}
}
- return refreshService(req, serviceName);
+ return await refreshService(req, serviceName);
}
}
function createAdminRoutes(db, notificationService, emailService, requireAuth) {
function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const siteConfig = require('./site-config');
const siteConfig = require('./site-config');
- function getEmailMetrics() {
+ async function getEmailMetrics() {
const summary = {
const summary = {
- total: db.prepare('SELECT COUNT(*) AS count FROM email_delivery_log').get().count,
- sent: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent'").get().count,
- failed: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed'").get().count,
- sent_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent' AND created_at >= datetime('now', '-1 day')").get().count,
- failed_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed' AND created_at >= datetime('now', '-1 day')").get().count,
- password_resets_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'password_reset' AND created_at >= datetime('now', '-1 day')").get().count,
- notifications_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type LIKE 'notification:%' AND created_at >= datetime('now', '-1 day')").get().count,
- tests_24h: db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'test' AND created_at >= datetime('now', '-1 day')").get().count,
- last_sent_at: db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'sent' ORDER BY created_at DESC LIMIT 1").get()?.created_at || null,
- last_failed_at: db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'failed' ORDER BY created_at DESC LIMIT 1").get()?.created_at || null,
+ total: (await db.prepare('SELECT COUNT(*) AS count FROM email_delivery_log').get()).count,
+ sent: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent'").get()).count,
+ failed: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed'").get()).count,
+ sent_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'sent' AND created_at >= datetime('now', '-1 day')").get()).count,
+ failed_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE status = 'failed' AND created_at >= datetime('now', '-1 day')").get()).count,
+ password_resets_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'password_reset' AND created_at >= datetime('now', '-1 day')").get()).count,
+ notifications_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type LIKE 'notification:%' AND created_at >= datetime('now', '-1 day')").get()).count,
+ tests_24h: (await db.prepare("SELECT COUNT(*) AS count FROM email_delivery_log WHERE email_type = 'test' AND created_at >= datetime('now', '-1 day')").get()).count,
+ last_sent_at: (await db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'sent' ORDER BY created_at DESC LIMIT 1").get())?.created_at || null,
+ last_failed_at: (await db.prepare("SELECT created_at FROM email_delivery_log WHERE status = 'failed' ORDER BY created_at DESC LIMIT 1").get())?.created_at || null,
};
};
- const byType = db.prepare(`
+ const byType = await db.prepare(`
SELECT email_type, status, COUNT(*) AS count
SELECT email_type, status, COUNT(*) AS count
FROM email_delivery_log
FROM email_delivery_log
WHERE created_at >= datetime('now', '-30 day')
WHERE created_at >= datetime('now', '-30 day')
@@ -195,7 +195,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -195,7 +195,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
LIMIT 20
LIMIT 20
`).all();
`).all();
- const recent = db.prepare(`
+ const recent = await db.prepare(`
SELECT id, email_type, recipient, subject, status, error_message, created_at
SELECT id, email_type, recipient, subject, status, error_message, created_at
FROM email_delivery_log
FROM email_delivery_log
ORDER BY created_at DESC
ORDER BY created_at DESC
@@ -214,7 +214,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -214,7 +214,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
async function notifyServiceRefresh(req, key) {
async function notifyServiceRefresh(req, key) {
- return refreshServiceByKey(req, key);
+ return await refreshServiceByKey(req, key);
}
}
// A role change reaches Live (and every other consumer) as network.user.updated: the users trigger records
// A role change reaches Live (and every other consumer) as network.user.updated: the users trigger records
@@ -228,10 +228,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -228,10 +228,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
// GET /api/admin/email — get current email config (owner-only: exposes key metadata)
// GET /api/admin/email — get current email config (owner-only: exposes key metadata)
- router.get('/email', requireOwner, (req, res) => {
+ router.get('/email', requireOwner, async (req, res) => {
try {
try {
const status = emailService.getStatus();
const status = emailService.getStatus();
- res.json({ ok: true, email: status, metrics: getEmailMetrics() });
+ res.json({ ok: true, email: status, metrics: await getEmailMetrics() });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
}
}
@@ -247,12 +247,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -247,12 +247,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const setSetting = { run: (k, v, type) => { changes.set[k] = v; changes.types[k] = type; } };
const setSetting = { run: (k, v, type) => { changes.set[k] = v; changes.types[k] = type; } };
const skipped = [];
const skipped = [];
- const collect = () => {
+ const collect = async () => {
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
// Only update API key if it's not a masked placeholder, and never while RESEND_API_KEY
// Only update API key if it's not a masked placeholder, and never while RESEND_API_KEY
// provides it (server/secrets.js): the environment is the source then.
// provides it (server/secrets.js): the environment is the source then.
if (api_key && !/\u2022/.test(api_key)) {
if (api_key && !/\u2022/.test(api_key)) {
- if (secrets.source(db, 'resend_api_key') === 'env') skipped.push('resend_api_key');
+ if (await secrets.source(db, 'resend_api_key') === 'env') skipped.push('resend_api_key');
else setSetting.run('resend_api_key', api_key, 'string');
else setSetting.run('resend_api_key', api_key, 'string');
}
}
if (from_email) setSetting.run('email_from_address', from_email, 'string');
if (from_email) setSetting.run('email_from_address', from_email, 'string');
@@ -262,13 +262,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -262,13 +262,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (from_email_openvibegames !== undefined) setSetting.run('email_from_openvibegames', from_email_openvibegames, 'string');
if (from_email_openvibegames !== undefined) setSetting.run('email_from_openvibegames', from_email_openvibegames, 'string');
if (from_email_openvibenetwork !== undefined) setSetting.run('email_from_openvibenetwork', from_email_openvibenetwork, 'string');
if (from_email_openvibenetwork !== undefined) setSetting.run('email_from_openvibenetwork', from_email_openvibenetwork, 'string');
};
};
- collect();
- if (Object.keys(changes.set).length) await siteConfig.forDb(db).change(changes, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || 'email settings').slice(0, 300) });
+ await collect();
+ if (Object.keys(changes.set).length) await (await siteConfig.forDb(db)).change(changes, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || 'email settings').slice(0, 300) });
emailService.reload();
emailService.reload();
// Audit
// Audit
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'email_config_update', JSON.stringify({ from_email })
req.user.id, 'email_config_update', JSON.stringify({ from_email })
);
);
@@ -297,15 +297,15 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -297,15 +297,15 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
// Where each provider secret comes from (server/secrets.js): names and sources, never values.
// Where each provider secret comes from (server/secrets.js): names and sources, never values.
- router.get('/secrets', requireOwner, (req, res) => {
- res.json({ ok: true, secrets: secrets.report(db) });
+ router.get('/secrets', requireOwner, async (req, res) => {
+ res.json({ ok: true, secrets: await secrets.report(db) });
});
});
// Core site settings are owner-only (off-limits to admins).
// Core site settings are owner-only (off-limits to admins).
- router.get('/settings', requireOwner, (req, res) => {
+ router.get('/settings', requireOwner, async (req, res) => {
res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear here
res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear here
try {
try {
- const rows = db.prepare('SELECT * FROM site_settings').all();
+ const rows = await db.prepare('SELECT * FROM site_settings').all();
const settings = {};
const settings = {};
// Keys managed exclusively by the Email tab — hide from generic settings
// Keys managed exclusively by the Email tab — hide from generic settings
const EMAIL_MANAGED_KEYS = new Set([
const EMAIL_MANAGED_KEYS = new Set([
@@ -321,7 +321,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -321,7 +321,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Provider secrets set in the environment (server/secrets.js): the database copy is not
// Provider secrets set in the environment (server/secrets.js): the database copy is not
// used, and the value is never shown; the UI says which variable provides it.
// used, and the value is never shown; the UI says which variable provides it.
if (secrets.isManaged(r.key)) {
if (secrets.isManaged(r.key)) {
- const src = secrets.source(db, r.key);
+ const src = await secrets.source(db, r.key);
if (src === 'env') { settings[r.key] = { value: '', type: r.type, source: 'env', env: secrets.envName(r.key), redacted: true }; continue; }
if (src === 'env') { settings[r.key] = { value: '', type: r.type, source: 'env', env: secrets.envName(r.key), redacted: true }; continue; }
if (!owner) { settings[r.key] = { value: maskSecret(r.value), type: r.type, source: src, env: secrets.envName(r.key), redacted: true }; continue; }
if (!owner) { settings[r.key] = { value: maskSecret(r.value), type: r.type, source: src, env: secrets.envName(r.key), redacted: true }; continue; }
settings[r.key] = { value: r.value, type: r.type, source: src, env: secrets.envName(r.key), secret: secrets.isSecret(r.key) };
settings[r.key] = { value: r.value, type: r.type, source: src, env: secrets.envName(r.key), secret: secrets.isSecret(r.key) };
@@ -353,16 +353,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -353,16 +353,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
return res.json({ ok: true, skipped: true });
return res.json({ ok: true, skipped: true });
}
}
// A provider secret the environment provides is never saved into the database.
// A provider secret the environment provides is never saved into the database.
- if (secrets.isManaged(key) && secrets.source(db, key) === 'env') {
+ if (secrets.isManaged(key) && await secrets.source(db, key) === 'env') {
return res.json({ ok: true, skipped: true, source: 'env', env: secrets.envName(key), note: `set in the environment (${secrets.envName(key)}); not saved to the database` });
return res.json({ ok: true, skipped: true, source: 'env', env: secrets.envName(key), note: `set in the environment (${secrets.envName(key)}); not saved to the database` });
}
}
let revision = null;
let revision = null;
if (siteConfig.isConfigKey(key)) {
if (siteConfig.isConfigKey(key)) {
- revision = (await siteConfig.forDb(db).change({ set: { [key]: String(value) }, types: { [key]: type || 'string' } }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `setting ${key}`).slice(0, 300) })).revision;
+ revision = (await (await siteConfig.forDb(db)).change({ set: { [key]: String(value) }, types: { [key]: type || 'string' } }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `setting ${key}`).slice(0, 300) })).revision;
} else {
} else {
- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), type || 'string');
+ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(key, String(value), type || 'string');
}
}
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'setting_update', JSON.stringify({ key })
req.user.id, 'setting_update', JSON.stringify({ key })
);
);
res.json({ ok: true, ...(revision ? { revision } : {}) });
res.json({ ok: true, ...(revision ? { revision } : {}) });
@@ -376,18 +376,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -376,18 +376,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// GET /config, /config/:namespace, /config/:namespace/history; POST /config/:namespace/rollback { to?, reason }
// GET /config, /config/:namespace, /config/:namespace/history; POST /config/:namespace/rollback { to?, reason }
// (the rollback first records rows changed outside the journal, so it only undoes configuration changes).
// (the rollback first records rows changed outside the journal, so it only undoes configuration changes).
let configRoutes = null;
let configRoutes = null;
- const configHandlers = () => configRoutes || (configRoutes = require('openvibe-shared/config').adminRoutes([siteConfig.forDb(db).store], {
+ const configHandlers = async () => configRoutes || (configRoutes = require('openvibe-shared/config').adminRoutes([(await siteConfig.forDb(db)).store], {
requireAdmin: (_q, _s, next) => next(), basePath: '/config', actor: (q) => siteConfig.actorOf(q.user),
requireAdmin: (_q, _s, next) => next(), basePath: '/config', actor: (q) => siteConfig.actorOf(q.user),
}));
}));
- router.get('/config', requireOwner, (req, res, next) => configHandlers().list(req, res, next));
- router.get('/config/:namespace', requireOwner, (req, res, next) => configHandlers().get(req, res, next));
- router.get('/config/:namespace/history', requireOwner, (req, res, next) => configHandlers().history(req, res, next));
+ router.get('/config', requireOwner, async (req, res, next) => (await configHandlers()).list(req, res, next));
+ router.get('/config/:namespace', requireOwner, async (req, res, next) => (await configHandlers()).get(req, res, next));
+ router.get('/config/:namespace/history', requireOwner, async (req, res, next) => (await configHandlers()).history(req, res, next));
router.post('/config/:namespace/rollback', requireOwner, async (req, res) => {
router.post('/config/:namespace/rollback', requireOwner, async (req, res) => {
try {
try {
if (req.params.namespace !== 'network.site_settings') return res.status(404).json({ ok: false, error: 'No such configuration namespace' });
if (req.params.namespace !== 'network.site_settings') return res.status(404).json({ ok: false, error: 'No such configuration namespace' });
const to = req.body && req.body.to != null ? Number(req.body.to) : undefined;
const to = req.body && req.body.to != null ? Number(req.body.to) : undefined;
- const snap = await siteConfig.forDb(db).rollback({ actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || 'rollback').slice(0, 300), to });
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'setting_rollback', JSON.stringify({ revision: snap.revision }));
+ const snap = await (await siteConfig.forDb(db)).rollback({ actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || 'rollback').slice(0, 300), to });
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'setting_rollback', JSON.stringify({ revision: snap.revision }));
res.json(snap);
res.json(snap);
} catch (err) {
} catch (err) {
if (err && err.status && err.code) return res.status(err.status).json({ ok: false, error: err.message, code: err.code });
if (err && err.status && err.code) return res.status(err.status).json({ ok: false, error: err.message, code: err.code });
@@ -398,10 +398,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -398,10 +398,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// ═══════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
// URL Registry
// URL Registry
- router.get('/url-registry', (req, res) => {
+ router.get('/url-registry', async (req, res) => {
res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear here
res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear here
try {
try {
- let entries = urlRegistry.getAllRegistryEntries(db);
+ let entries = await urlRegistry.getAllRegistryEntries(db);
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
if (!isOwner(req.user) && Array.isArray(entries)) {
if (!isOwner(req.user) && Array.isArray(entries)) {
entries = entries.map((e) => {
entries = entries.map((e) => {
@@ -432,13 +432,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -432,13 +432,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });
if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });
if (!guardRegistryKey(req, res, key)) return;
if (!guardRegistryKey(req, res, key)) return;
- const entry = urlRegistry.setRegistryEntry(db, key, value, req.user.id);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ const entry = await urlRegistry.setRegistryEntry(db, key, value, req.user.id);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_update', JSON.stringify({ key })
req.user.id, 'url_registry_update', JSON.stringify({ key })
);
);
const refreshResult = await notifyServiceRefresh(req, key);
const refreshResult = await notifyServiceRefresh(req, key);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult)
req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult)
);
);
res.json({ ok: true, entry, refresh: refreshResult });
res.json({ ok: true, entry, refresh: refreshResult });
@@ -454,13 +454,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -454,13 +454,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });
if (value === undefined || value === null) return res.status(400).json({ ok: false, error: 'Value required' });
if (!guardRegistryKey(req, res, key)) return;
if (!guardRegistryKey(req, res, key)) return;
- const entry = urlRegistry.setRegistryEntry(db, key, value, req.user.id);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ const entry = await urlRegistry.setRegistryEntry(db, key, value, req.user.id);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_update', JSON.stringify({ key })
req.user.id, 'url_registry_update', JSON.stringify({ key })
);
);
const refreshResult = await notifyServiceRefresh(req, key);
const refreshResult = await notifyServiceRefresh(req, key);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult)
req.user.id, 'url_registry_refresh_request', JSON.stringify(refreshResult)
);
);
res.json({ ok: true, entry, refresh: refreshResult });
res.json({ ok: true, entry, refresh: refreshResult });
@@ -469,13 +469,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -469,13 +469,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.delete('/url-registry/:key', (req, res) => {
+ router.delete('/url-registry/:key', async (req, res) => {
try {
try {
const { key } = req.params;
const { key } = req.params;
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!guardRegistryKey(req, res, key)) return;
if (!guardRegistryKey(req, res, key)) return;
- urlRegistry.resetRegistryEntry(db, key);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await urlRegistry.resetRegistryEntry(db, key);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_reset', JSON.stringify({ key })
req.user.id, 'url_registry_reset', JSON.stringify({ key })
);
);
res.json({ ok: true, key });
res.json({ ok: true, key });
@@ -495,7 +495,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -495,7 +495,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const result = await refreshService(req, serviceName);
const result = await refreshService(req, serviceName);
refreshResults.push({ service: serviceName, results: [result] });
refreshResults.push({ service: serviceName, results: [result] });
}
}
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_refresh_all', JSON.stringify(refreshResults)
req.user.id, 'url_registry_refresh_all', JSON.stringify(refreshResults)
);
);
res.json({ ok: true, refreshResults });
res.json({ ok: true, refreshResults });
@@ -504,13 +504,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -504,13 +504,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.post('/url-registry/:key/reset', (req, res) => {
+ router.post('/url-registry/:key/reset', async (req, res) => {
try {
try {
const { key } = req.params;
const { key } = req.params;
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!guardRegistryKey(req, res, key)) return;
if (!guardRegistryKey(req, res, key)) return;
- urlRegistry.resetRegistryEntry(db, key);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await urlRegistry.resetRegistryEntry(db, key);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'url_registry_reset', JSON.stringify({ key })
req.user.id, 'url_registry_reset', JSON.stringify({ key })
);
);
res.json({ ok: true, key });
res.json({ ok: true, key });
@@ -519,7 +519,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -519,7 +519,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.post('/reset-db', requireOwner, (req, res) => {
+ router.post('/reset-db', requireOwner, async (req, res) => {
try {
try {
const serviceRoot = path.resolve(__dirname, '..');
const serviceRoot = path.resolve(__dirname, '..');
const scriptPath = path.join(serviceRoot, 'server', 'reset-db.js');
const scriptPath = path.join(serviceRoot, 'server', 'reset-db.js');
@@ -536,7 +536,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -536,7 +536,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (result.status !== 0) {
if (result.status !== 0) {
return res.status(500).json({ ok: false, error: result.stderr || result.stdout || `Exit code ${result.status}` });
return res.status(500).json({ ok: false, error: result.stderr || result.stdout || `Exit code ${result.status}` });
}
}
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
.run(req.user.id, 'reset_db', JSON.stringify({ output: result.stdout.trim() }));
.run(req.user.id, 'reset_db', JSON.stringify({ output: result.stdout.trim() }));
res.json({ ok: true, message: result.stdout.trim() });
res.json({ ok: true, message: result.stdout.trim() });
} catch (err) {
} catch (err) {
@@ -546,7 +546,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -546,7 +546,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Only the owner may grant admin — otherwise an admin could self-escalate here,
// Only the owner may grant admin — otherwise an admin could self-escalate here,
// bypassing the owner gate on PUT /users/:id/role.
// bypassing the owner gate on PUT /users/:id/role.
- router.post('/users/grant-admin', requireOwner, (req, res) => {
+ router.post('/users/grant-admin', requireOwner, async (req, res) => {
try {
try {
const { id, username, email } = req.body;
const { id, username, email } = req.body;
if (!id && !username && !email) {
if (!id && !username && !email) {
@@ -555,18 +555,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -555,18 +555,18 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
let user;
let user;
if (id) {
if (id) {
- user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(id);
+ user = await db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(id);
} else if (username) {
} else if (username) {
- user = db.prepare('SELECT id, username, email FROM users WHERE LOWER(username) = LOWER(?)').get(username);
+ user = await db.prepare('SELECT id, username, email FROM users WHERE LOWER(username) = LOWER(?)').get(username);
} else {
} else {
- user = db.prepare('SELECT id, username, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);
+ user = await db.prepare('SELECT id, username, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);
}
}
if (!user) {
if (!user) {
return res.status(404).json({ ok: false, error: 'User not found' });
return res.status(404).json({ ok: false, error: 'User not found' });
}
}
- db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', user.id);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id,
req.user.id,
'grant_admin',
'grant_admin',
JSON.stringify({ targetId: user.id, targetUsername: user.username, targetEmail: user.email }),
JSON.stringify({ targetId: user.id, targetUsername: user.username, targetEmail: user.email }),
@@ -581,7 +581,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -581,7 +581,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// User Management
// User Management
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/users', (req, res) => {
+ router.get('/users', async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const offset = parseInt(req.query.offset) || 0;
const offset = parseInt(req.query.offset) || 0;
@@ -589,19 +589,19 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -589,19 +589,19 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
let users;
let users;
if (search) {
if (search) {
- users = db.prepare(`
+ users = await db.prepare(`
SELECT id, username, display_name, email, role, is_banned, created_at, last_seen
SELECT id, username, display_name, email, role, is_banned, created_at, last_seen
FROM users WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?
FROM users WHERE username LIKE ? OR display_name LIKE ? OR email LIKE ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(`%${search}%`, `%${search}%`, `%${search}%`, limit, offset);
`).all(`%${search}%`, `%${search}%`, `%${search}%`, limit, offset);
} else {
} else {
- users = db.prepare(`
+ users = await db.prepare(`
SELECT id, username, display_name, email, role, is_banned, created_at, last_seen
SELECT id, username, display_name, email, role, is_banned, created_at, last_seen
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(limit, offset);
`).all(limit, offset);
}
}
- const total = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
+ const total = (await db.prepare('SELECT COUNT(*) as cnt FROM users').get()).cnt;
res.json({ ok: true, users, total });
res.json({ ok: true, users, total });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
@@ -613,7 +613,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -613,7 +613,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// whoever controls the address controls the account. So both are requireOwner, not
// whoever controls the address controls the account. So both are requireOwner, not
// requireAdmin, matching how the admin role itself is gated below.
// requireAdmin, matching how the admin role itself is gated below.
- router.put('/users/:id/email', requireOwner, (req, res) => {
+ router.put('/users/:id/email', requireOwner, async (req, res) => {
try {
try {
const userId = parseInt(req.params.id, 10);
const userId = parseInt(req.params.id, 10);
if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });
if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });
@@ -628,20 +628,20 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -628,20 +628,20 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
return res.status(400).json({ ok: false, error: 'Email address is too long' });
return res.status(400).json({ ok: false, error: 'Email address is too long' });
}
}
- const target = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);
+ const target = await db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(userId);
if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
// users.email is UNIQUE, so a collision would throw a raw SQLite error —
// users.email is UNIQUE, so a collision would throw a raw SQLite error —
// check first and return something the admin can actually act on.
// check first and return something the admin can actually act on.
if (email) {
if (email) {
- const clash = db.prepare('SELECT id, username FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(email, userId);
+ const clash = await db.prepare('SELECT id, username FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(email, userId);
if (clash) {
if (clash) {
return res.status(409).json({ ok: false, error: `That email is already used by @${clash.username}` });
return res.status(409).json({ ok: false, error: `That email is already used by @${clash.username}` });
}
}
}
}
- db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(email, userId);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('UPDATE users SET email = ?, updated_at = ov_now() WHERE id = ?').run(email, userId);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'user_email_change',
req.user.id, 'user_email_change',
// Record both sides: this is the audit trail for a takeover-capable action.
// Record both sides: this is the audit trail for a takeover-capable action.
JSON.stringify({ targetId: userId, targetUsername: target.username, from: target.email || null, to: email })
JSON.stringify({ targetId: userId, targetUsername: target.username, from: target.email || null, to: email })
@@ -657,7 +657,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -657,7 +657,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const userId = parseInt(req.params.id, 10);
const userId = parseInt(req.params.id, 10);
if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });
if (!Number.isFinite(userId)) return res.status(400).json({ ok: false, error: 'Invalid user id' });
- const target = db.prepare('SELECT id, username, display_name, email FROM users WHERE id = ?').get(userId);
+ const target = await db.prepare('SELECT id, username, display_name, email FROM users WHERE id = ?').get(userId);
if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
if (!target) return res.status(404).json({ ok: false, error: 'User not found' });
if (!target.email) return res.status(400).json({ ok: false, error: 'That account has no email address set' });
if (!target.email) return res.status(400).json({ ok: false, error: 'That account has no email address set' });
@@ -668,7 +668,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -668,7 +668,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Same minting path as the self-service flow, so a link issued here behaves
// Same minting path as the self-service flow, so a link issued here behaves
// identically — hashed at rest, one live token per user, same TTL.
// identically — hashed at rest, one live token per user, same TTL.
- const { resetUrl, expiresMinutes } = issueResetToken(db, req, userId);
+ const { resetUrl, expiresMinutes } = await issueResetToken(db, req, userId);
const sent = await emailService.sendPasswordResetEmail({
const sent = await emailService.sendPasswordResetEmail({
to: target.email,
to: target.email,
username: target.display_name || target.username,
username: target.display_name || target.username,
@@ -686,7 +686,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -686,7 +686,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// generic message gave no way to know that without reading server logs.
// generic message gave no way to know that without reading server logs.
let reason = null;
let reason = null;
try {
try {
- const row = db.prepare(`
+ const row = await db.prepare(`
SELECT error_message FROM email_delivery_log
SELECT error_message FROM email_delivery_log
WHERE recipient = ? AND status = 'failed'
WHERE recipient = ? AND status = 'failed'
ORDER BY id DESC LIMIT 1
ORDER BY id DESC LIMIT 1
@@ -699,7 +699,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -699,7 +699,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
});
});
}
}
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'user_password_reset_sent',
req.user.id, 'user_password_reset_sent',
JSON.stringify({ targetId: userId, targetUsername: target.username, to: target.email })
JSON.stringify({ targetId: userId, targetUsername: target.username, to: target.email })
);
);
@@ -711,7 +711,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -711,7 +711,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.put('/users/:id/role', (req, res) => {
+ router.put('/users/:id/role', async (req, res) => {
try {
try {
const { role } = req.body;
const { role } = req.body;
const validRoles = ['user', 'streamer', 'global_mod', 'admin'];
const validRoles = ['user', 'streamer', 'global_mod', 'admin'];
@@ -720,12 +720,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -720,12 +720,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// still manage streamers / global mods / users, but not other admins.
// still manage streamers / global mods / users, but not other admins.
const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
const OWNER = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
const isOwner = req.user && String(req.user.username || '').toLowerCase() === OWNER;
const isOwner = req.user && String(req.user.username || '').toLowerCase() === OWNER;
- const target = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id);
+ const target = await db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id);
if (!isOwner && (role === 'admin' || (target && target.role === 'admin'))) {
if (!isOwner && (role === 'admin' || (target && target.role === 'admin'))) {
return res.status(403).json({ ok: false, error: 'Only the owner can grant or change the admin role' });
return res.status(403).json({ ok: false, error: 'Only the owner can grant or change the admin role' });
}
}
- db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'user_role_change', JSON.stringify({ targetId: req.params.id, role })
req.user.id, 'user_role_change', JSON.stringify({ targetId: req.params.id, role })
);
);
res.json({ ok: true });
res.json({ ok: true });
@@ -734,19 +734,19 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -734,19 +734,19 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.put('/users/:id/ban', (req, res) => {
+ router.put('/users/:id/ban', async (req, res) => {
try {
try {
const { banned, reason } = req.body;
const { banned, reason } = req.body;
- db.prepare('UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?').run(banned ? 1 : 0, reason || null, req.params.id);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('UPDATE users SET is_banned = ?, ban_reason = ? WHERE id = ?').run(banned ? 1 : 0, reason || null, req.params.id);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason })
req.user.id, banned ? 'user_ban' : 'user_unban', JSON.stringify({ targetId: req.params.id, reason })
);
);
// A ban ends the person's tokens on every site at once (network.user.token_valid_after).
// A ban ends the person's tokens on every site at once (network.user.token_valid_after).
- if (banned) { revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }
+ if (banned) { await revocation.revokeTokens(db, Number(req.params.id), { reason: 'banned', actor: staffActor(req.user) }); revocation.kick(db); }
// Notify the user
// Notify the user
if (banned) {
if (banned) {
- notificationService.create({
+ await notificationService.create({
user_id: parseInt(req.params.id),
user_id: parseInt(req.params.id),
type: 'BAN',
type: 'BAN',
title: 'Account Suspended',
title: 'Account Suspended',
@@ -763,13 +763,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -763,13 +763,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
});
});
// Rename someone (their old name redirects on Live and stays reserved; server/identity/usernames.js).
// Rename someone (their old name redirects on Live and stays reserved; server/identity/usernames.js).
- router.put('/users/:id/username', (req, res) => {
+ router.put('/users/:id/username', async (req, res) => {
try {
try {
- const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
+ const target = await db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner renames the owner" });
if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner renames the owner" });
- const out = require('../identity/usernames').rename(db, target.id, String((req.body && req.body.username) || ''), { actorId: req.user.id });
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_rename', JSON.stringify({ targetId: target.id, ...out }));
+ const out = await require('../identity/usernames').rename(db, target.id, String((req.body && req.body.username) || ''), { actorId: req.user.id });
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_rename', JSON.stringify({ targetId: target.id, ...out }));
res.json({ ok: true, ...out });
res.json({ ok: true, ...out });
} catch (err) {
} catch (err) {
res.status(err.status || 500).json({ ok: false, error: err.message });
res.status(err.status || 500).json({ ok: false, error: err.message });
@@ -777,14 +777,14 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -777,14 +777,14 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
});
});
// End someone's sessions on every device and site (a stolen token, a shared computer).
// End someone's sessions on every device and site (a stolen token, a shared computer).
- router.post('/users/:id/sign-out', (req, res) => {
+ router.post('/users/:id/sign-out', async (req, res) => {
try {
try {
- const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
+ const target = await db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.params.id);
if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
if (!target) return res.status(404).json({ ok: false, error: 'No such user' });
if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner ends the owner's sessions" });
if (isOwner(target) && !isOwner(req.user)) return res.status(403).json({ ok: false, error: "Only the owner ends the owner's sessions" });
- const { validAfter } = revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });
+ const { validAfter } = await revocation.revokeTokens(db, target.id, { reason: 'staff_revoked', actor: staffActor(req.user) });
revocation.kick(db);
revocation.kick(db);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'user_sign_out', JSON.stringify({ targetId: target.id, valid_after: validAfter }));
res.json({ ok: true, valid_after: validAfter });
res.json({ ok: true, valid_after: validAfter });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
@@ -795,13 +795,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -795,13 +795,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Broadcast Notifications
// Broadcast Notifications
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.post('/broadcast', (req, res) => {
+ router.post('/broadcast', async (req, res) => {
try {
try {
const { title, message, icon, url, priority, category } = req.body;
const { title, message, icon, url, priority, category } = req.body;
if (!title) return res.status(400).json({ ok: false, error: 'Title required' });
if (!title) return res.status(400).json({ ok: false, error: 'Title required' });
// Abusable at volume — admins get one global notification per 6h; owners are exempt.
// Abusable at volume — admins get one global notification per 6h; owners are exempt.
- const limit = checkAdminLimit(db, req.user, 'broadcast');
+ const limit = await checkAdminLimit(db, req.user, 'broadcast');
if (!limit.ok) {
if (!limit.ok) {
return res.status(429).json({
return res.status(429).json({
ok: false,
ok: false,
@@ -811,8 +811,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -811,8 +811,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
// Get all non-banned user IDs
// Get all non-banned user IDs
- const userIds = db.prepare('SELECT id FROM users WHERE is_banned = 0').all().map(u => u.id);
- const results = notificationService.createBulk(userIds, {
+ const userIds = (await db.prepare('SELECT id FROM users WHERE is_banned = 0').all()).map(u => u.id);
+ const results = await notificationService.createBulk(userIds, {
type: 'ADMIN_BROADCAST',
type: 'ADMIN_BROADCAST',
title,
title,
message,
message,
@@ -825,8 +825,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -825,8 +825,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
service: 'network',
service: 'network',
});
});
- recordAdminAction(db, req.user, 'broadcast');
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await recordAdminAction(db, req.user, 'broadcast');
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'broadcast_notification', JSON.stringify({ title, recipients: results.length })
req.user.id, 'broadcast_notification', JSON.stringify({ title, recipients: results.length })
);
);
@@ -840,13 +840,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -840,13 +840,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// System Health
// System Health
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/health', (req, res) => {
+ router.get('/health', async (req, res) => {
try {
try {
- const userCount = db.prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
- const notifCount = db.prepare('SELECT COUNT(*) as cnt FROM notifications').get().cnt;
- const unreadCount = db.prepare('SELECT COUNT(*) as cnt FROM notifications WHERE is_read = 0').get().cnt;
- const anonCount = db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get().cnt;
- const sessionCount = db.prepare('SELECT COUNT(*) as cnt FROM user_sessions WHERE is_active = 1').get().cnt;
+ const userCount = (await db.prepare('SELECT COUNT(*) as cnt FROM users').get()).cnt;
+ const notifCount = (await db.prepare('SELECT COUNT(*) as cnt FROM notifications').get()).cnt;
+ const unreadCount = (await db.prepare('SELECT COUNT(*) as cnt FROM notifications WHERE is_read = 0').get()).cnt;
+ const anonCount = (await db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get()).cnt;
+ const sessionCount = (await db.prepare('SELECT COUNT(*) as cnt FROM user_sessions WHERE is_active = 1').get()).cnt;
const emailStatus = emailService.getStatus();
const emailStatus = emailService.getStatus();
res.json({
res.json({
@@ -871,11 +871,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -871,11 +871,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Audit Log
// Audit Log
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/audit', (req, res) => {
+ router.get('/audit', async (req, res) => {
try {
try {
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
const limit = Math.min(parseInt(req.query.limit) || 50, 500);
const offset = parseInt(req.query.offset) || 0;
const offset = parseInt(req.query.offset) || 0;
- const rows = db.prepare(`
+ const rows = await db.prepare(`
SELECT a.*, u.username FROM audit_log a
SELECT a.*, u.username FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.created_at DESC LIMIT ? OFFSET ?
ORDER BY a.created_at DESC LIMIT ? OFFSET ?
@@ -898,16 +898,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -898,16 +898,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Verification Keys (reserved username claims)
// Verification Keys (reserved username claims)
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/verification-keys', (req, res) => {
+ router.get('/verification-keys', async (req, res) => {
try {
try {
- const keys = db.getAllVerificationKeys();
+ const keys = await db.getAllVerificationKeys();
res.json({ ok: true, keys });
res.json({ ok: true, keys });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
}
}
});
});
- router.post('/verification-keys', (req, res) => {
+ router.post('/verification-keys', async (req, res) => {
try {
try {
const { target_username, note } = req.body;
const { target_username, note } = req.body;
if (!target_username) {
if (!target_username) {
@@ -918,13 +918,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -918,13 +918,13 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
// Check if username already taken
// Check if username already taken
- const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);
+ const existingUser = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(target_username);
if (existingUser) {
if (existingUser) {
return res.status(409).json({ ok: false, error: `Username "${target_username}" is already registered` });
return res.status(409).json({ ok: false, error: `Username "${target_username}" is already registered` });
}
}
// Check for duplicate active key
// Check for duplicate active key
- const existingKey = db.getVerificationKeyByUsername(target_username);
+ const existingKey = await db.getVerificationKeyByUsername(target_username);
if (existingKey) {
if (existingKey) {
return res.status(409).json({ ok: false, error: `Active key already exists for "${target_username}"` });
return res.status(409).json({ ok: false, error: `Active key already exists for "${target_username}"` });
}
}
@@ -935,16 +935,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -935,16 +935,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
crypto.randomBytes(2).toString('hex').toUpperCase()
crypto.randomBytes(2).toString('hex').toUpperCase()
).join('-');
).join('-');
- db.createVerificationKey({
+ await db.createVerificationKey({
key,
key,
target_username,
target_username,
note: note || '',
note: note || '',
created_by: req.user.id,
created_by: req.user.id,
});
});
- const created = db.getVerificationKeyByKey(key);
+ const created = await db.getVerificationKeyByKey(key);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'verification_key_create', JSON.stringify({ key, target_username })
req.user.id, 'verification_key_create', JSON.stringify({ key, target_username })
);
);
@@ -955,14 +955,14 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -955,14 +955,14 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.delete('/verification-keys/:id', (req, res) => {
+ router.delete('/verification-keys/:id', async (req, res) => {
try {
try {
- const result = db.revokeVerificationKey(req.params.id);
+ const result = await db.revokeVerificationKey(req.params.id);
if (result.changes === 0) {
if (result.changes === 0) {
return res.status(404).json({ ok: false, error: 'Key not found or already used/revoked' });
return res.status(404).json({ ok: false, error: 'Key not found or already used/revoked' });
}
}
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'verification_key_revoke', JSON.stringify({ keyId: req.params.id })
req.user.id, 'verification_key_revoke', JSON.stringify({ keyId: req.params.id })
);
);
@@ -981,11 +981,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -981,11 +981,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
'net.globalping_token', // Globalping API token (optional, 100 credits/hr free)
'net.globalping_token', // Globalping API token (optional, 100 credits/hr free)
];
];
- router.get('/net-config', requireOwner, (req, res) => {
+ router.get('/net-config', requireOwner, async (req, res) => {
try {
try {
const config = {};
const config = {};
for (const key of NET_SETTING_KEYS) {
for (const key of NET_SETTING_KEYS) {
- const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key);
+ const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key);
config[key] = row ? row.value : '';
config[key] = row ? row.value : '';
}
}
// Mask tokens for display
// Mask tokens for display
@@ -1000,7 +1000,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -1000,7 +1000,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.put('/net-config', requireOwner, (req, res) => {
+ router.put('/net-config', requireOwner, async (req, res) => {
try {
try {
const { key, value } = req.body;
const { key, value } = req.body;
if (!key || !NET_SETTING_KEYS.includes(key)) {
if (!key || !NET_SETTING_KEYS.includes(key)) {
@@ -1008,8 +1008,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -1008,8 +1008,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
// Skip if masked
// Skip if masked
if (value?.startsWith('••••')) return res.json({ ok: true, skipped: true });
if (value?.startsWith('••••')) return res.json({ ok: true, skipped: true });
- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), 'secret');
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(key, String(value), 'secret');
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'net_config_update', JSON.stringify({ key })
req.user.id, 'net_config_update', JSON.stringify({ key })
);
);
res.json({ ok: true });
res.json({ ok: true });
@@ -28,14 +28,14 @@ const byDb = new WeakMap();
@@ -28,14 +28,14 @@ const byDb = new WeakMap();
const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
/** The journal for one database handle. */
/** The journal for one database handle. */
-function forDb(db) {
+async function forDb(db) {
if (byDb.has(db)) return byDb.get(db);
if (byDb.has(db)) return byDb.get(db);
const types = new Map(); // key → the row type last seen (a rollback that re-creates a row reuses it)
const types = new Map(); // key → the row type last seen (a rollback that re-creates a row reuses it)
const secretRows = new Set(); // keys stored with type 'secret'
const secretRows = new Set(); // keys stored with type 'secret'
- function rowsNow() {
+ async function rowsNow() {
const out = {};
const out = {};
- for (const r of db.prepare('SELECT key, value, type FROM site_settings').all()) {
+ for (const r of await db.prepare('SELECT key, value, type FROM site_settings').all()) {
if (!isConfigKey(r.key)) continue;
if (!isConfigKey(r.key)) continue;
out[r.key] = r.value == null ? '' : String(r.value);
out[r.key] = r.value == null ? '' : String(r.value);
types.set(r.key, r.type || 'string');
types.set(r.key, r.type || 'string');
@@ -46,34 +46,36 @@ function forDb(db) {
@@ -46,34 +46,36 @@ function forDb(db) {
const classify = (key) => (secretRows.has(key) || SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
const classify = (key) => (secretRows.has(key) || SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
let pendingTypes = {}; // the types the change being applied names for new rows
let pendingTypes = {}; // the types the change being applied names for new rows
- function writeRows(target) {
- const now = rowsNow();
+ async function writeRows(target) {
+ const now = await rowsNow();
const upd = db.prepare('UPDATE site_settings SET value = ? WHERE key = ?');
const upd = db.prepare('UPDATE site_settings SET value = ? WHERE key = ?');
const ins = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?)');
const ins = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?)');
const del = db.prepare('DELETE FROM site_settings WHERE key = ?');
const del = db.prepare('DELETE FROM site_settings WHERE key = ?');
- db.transaction(() => {
+ await db.tx(async () => {
for (const [k, v] of Object.entries(target)) {
for (const [k, v] of Object.entries(target)) {
if (!isConfigKey(k)) continue;
if (!isConfigKey(k)) continue;
- if (!(k in now)) ins.run(k, String(v), pendingTypes[k] || types.get(k) || 'string');
- else if (now[k] !== String(v)) upd.run(String(v), k);
+ if (!(k in now)) await ins.run(k, String(v), pendingTypes[k] || types.get(k) || 'string');
+ else if (now[k] !== String(v)) await upd.run(String(v), k);
}
}
- for (const k of Object.keys(now)) if (!(k in target)) del.run(k);
- })();
+ for (const k of Object.keys(now)) if (!(k in target)) await del.run(k);
+ });
}
}
- rowsNow(); // learn the types and secret rows before the store classifies anything
- const store = config.createConfigStore({
+ const initial = await rowsNow(); // learn the types and secret rows before the store classifies anything
+ const store = await config.createConfigStore({
db, service: 'network', namespace: 'network.site_settings',
db, service: 'network', namespace: 'network.site_settings',
classify,
classify,
- legacy: () => rowsNow(),
- onActivate: async (values) => writeRows(values),
+ // The legacy source is snapshotted now (an object, not a callback): the revision it seeds is the rows
+ // as they are at boot.
+ legacy: initial,
+ onActivate: async (values) => await writeRows(values),
log: { info: (m) => console.log(`[Config] ${m}`), warn: (m) => console.warn(`[Config] ${m}`), error: (m) => console.error(`[Config] ${m}`) },
log: { info: (m) => console.log(`[Config] ${m}`), warn: (m) => console.warn(`[Config] ${m}`), error: (m) => console.error(`[Config] ${m}`) },
});
});
const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
/** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */
/** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */
async function sync() {
async function sync() {
- const now = rowsNow();
+ const now = await rowsNow();
if (canonical({ ...store.get() }) === canonical(now)) return null;
if (canonical({ ...store.get() }) === canonical(now)) return null;
return store.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
return store.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
}
}
@@ -18,10 +18,10 @@ const { isOwner } = require('../auth/owner-guard');
@@ -18,10 +18,10 @@ const { isOwner } = require('../auth/owner-guard');
// Which staff capability moderates a service; anything else is general content moderation.
// Which staff capability moderates a service; anything else is general content moderation.
const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };
const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };
-function staffList(db, { service = null } = {}) {
+async function staffList(db, { service = null } = {}) {
const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;
const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;
- return db.prepare("SELECT id, username, display_name, role, subject_id FROM users WHERE role IN ('global_mod', 'admin') AND COALESCE(is_banned, 0) = 0 ORDER BY role DESC, username")
- .all()
+ return (await db.prepare("SELECT id, username, display_name, role, subject_id FROM users WHERE role IN ('global_mod', 'admin') AND COALESCE(is_banned, 0) = 0 ORDER BY role DESC, username")
+ .all())
.map((u) => {
.map((u) => {
const owner = u.role === 'admin' && isOwner(u);
const owner = u.role === 'admin' && isOwner(u);
return { subject: u.subject_id || null, username: u.username, display_name: u.display_name || u.username, role: owner ? 'owner' : u.role, is_owner: owner, capabilities: staff.capabilitiesOf({ role: u.role, is_owner: owner }) };
return { subject: u.subject_id || null, username: u.username, display_name: u.display_name || u.username, role: owner ? 'owner' : u.role, is_owner: owner, capabilities: staff.capabilitiesOf({ role: u.role, is_owner: owner }) };
@@ -37,20 +37,20 @@ function createStaffApi({ db, requireAuth, guard }) {
@@ -37,20 +37,20 @@ function createStaffApi({ db, requireAuth, guard }) {
res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });
res.json({ role: claims.is_owner ? 'owner' : req.user.role || 'user', is_owner: !!claims.is_owner, staff_map: staff.map.version, capabilities: claims.staff_caps || [] });
});
});
const serviceGuard = guard('network.staff.read');
const serviceGuard = guard('network.staff.read');
- const list = (req, res) => {
+ const list = async (req, res) => {
const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;
const service = typeof req.query.service === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(req.query.service) ? req.query.service : null;
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
- res.json({ service, staff: staffList(db, { service }) });
+ res.json({ service, staff: await staffList(db, { service }) });
};
};
r.get('/moderators', (req, res, next) => {
r.get('/moderators', (req, res, next) => {
// A service token (sub svc:*) goes through the capability guard; a person must be staff.
// A service token (sub svc:*) goes through the capability guard; a person must be staff.
const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
let sub = '';
let sub = '';
try { sub = JSON.parse(Buffer.from(bearer.split('.')[1] || '', 'base64url').toString('utf8')).sub || ''; } catch { /* not a JWT */ }
try { sub = JSON.parse(Buffer.from(bearer.split('.')[1] || '', 'base64url').toString('utf8')).sub || ''; } catch { /* not a JWT */ }
- if (String(sub).startsWith('svc:')) return serviceGuard(req, res, () => list(req, res));
- return requireAuth(req, res, () => {
+ if (String(sub).startsWith('svc:')) return serviceGuard(req, res, async () => await list(req, res));
+ return requireAuth(req, res, async () => {
if (!staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
if (!staff.can(staffClaims(req.user), 'staff.moderation.logs')) return res.status(403).json({ error: 'forbidden', detail: 'staff.moderation.logs required' });
- return list(req, res);
+ return await list(req, res);
});
});
});
});
return r;
return r;
@@ -23,29 +23,12 @@ const KEEP_DAYS = 400;
@@ -23,29 +23,12 @@ const KEEP_DAYS = 400;
const COLUMNS = ['stream_id', 'creator_subject', 'title', 'category', 'started_at', 'ended_at', 'duration_seconds',
const COLUMNS = ['stream_id', 'creator_subject', 'title', 'category', 'started_at', 'ended_at', 'duration_seconds',
'peak_viewers', 'avg_viewers', 'unique_chatters', 'messages', 'watch_minutes', 'received_at'];
'peak_viewers', 'avg_viewers', 'unique_chatters', 'messages', 'watch_minutes', 'received_at'];
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS creator_streams (
- stream_id INTEGER PRIMARY KEY,
- creator_subject TEXT NOT NULL,
- title TEXT,
- category TEXT,
- started_at TEXT NOT NULL,
- ended_at TEXT NOT NULL,
- duration_seconds INTEGER NOT NULL DEFAULT 0,
- peak_viewers INTEGER,
- avg_viewers REAL,
- unique_chatters INTEGER,
- messages INTEGER,
- watch_minutes INTEGER,
- received_at TEXT NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_creator_streams_creator ON creator_streams(creator_subject, started_at);`);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
const int = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null);
const int = (v) => (Number.isFinite(Number(v)) && Number(v) >= 0 ? Math.round(Number(v)) : null);
/** One live.stream.ended envelope → 'analytics:recorded' | 'ignored:<why>'. Inside the consumer's inbox claim. */
/** One live.stream.ended envelope → 'analytics:recorded' | 'ignored:<why>'. Inside the consumer's inbox claim. */
-function record(db, event, { now = new Date().toISOString() } = {}) {
+async function record(db, event, { now = new Date().toISOString() } = {}) {
ensureSchema(db);
ensureSchema(db);
if (event.source !== 'live') return 'ignored:source';
if (event.source !== 'live') return 'ignored:source';
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
@@ -55,7 +38,7 @@ function record(db, event, { now = new Date().toISOString() } = {}) {
@@ -55,7 +38,7 @@ function record(db, event, { now = new Date().toISOString() } = {}) {
const started = Date.parse(p.started_at); const ended = Date.parse(p.ended_at);
const started = Date.parse(p.started_at); const ended = Date.parse(p.ended_at);
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return 'ignored:times';
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return 'ignored:times';
const s = p.stats && typeof p.stats === 'object' ? p.stats : {};
const s = p.stats && typeof p.stats === 'object' ? p.stats : {};
- db.prepare(`INSERT INTO creator_streams (stream_id, creator_subject, title, category, started_at, ended_at, duration_seconds, peak_viewers, avg_viewers, unique_chatters, messages, watch_minutes, received_at)
+ await db.prepare(`INSERT INTO creator_streams (stream_id, creator_subject, title, category, started_at, ended_at, duration_seconds, peak_viewers, avg_viewers, unique_chatters, messages, watch_minutes, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(stream_id) DO UPDATE SET creator_subject = excluded.creator_subject, title = excluded.title, category = excluded.category,
ON CONFLICT(stream_id) DO UPDATE SET creator_subject = excluded.creator_subject, title = excluded.title, category = excluded.category,
started_at = excluded.started_at, ended_at = excluded.ended_at, duration_seconds = excluded.duration_seconds,
started_at = excluded.started_at, ended_at = excluded.ended_at, duration_seconds = excluded.duration_seconds,
@@ -65,16 +48,16 @@ function record(db, event, { now = new Date().toISOString() } = {}) {
@@ -65,16 +48,16 @@ function record(db, event, { now = new Date().toISOString() } = {}) {
new Date(started).toISOString(), new Date(ended).toISOString(), int(p.duration_seconds) ?? Math.round((ended - started) / 1000),
new Date(started).toISOString(), new Date(ended).toISOString(), int(p.duration_seconds) ?? Math.round((ended - started) / 1000),
int(s.peak_viewers), Number.isFinite(Number(s.avg_viewers)) && Number(s.avg_viewers) >= 0 ? Math.round(Number(s.avg_viewers) * 10) / 10 : null,
int(s.peak_viewers), Number.isFinite(Number(s.avg_viewers)) && Number(s.avg_viewers) >= 0 ? Math.round(Number(s.avg_viewers) * 10) / 10 : null,
int(s.unique_chatters), int(s.messages), int(s.watch_minutes), now);
int(s.unique_chatters), int(s.messages), int(s.watch_minutes), now);
- db.prepare('DELETE FROM creator_streams WHERE ended_at < ?').run(new Date(Date.parse(now) - KEEP_DAYS * 86400000).toISOString());
+ await db.prepare('DELETE FROM creator_streams WHERE ended_at < ?').run(new Date(Date.parse(now) - KEEP_DAYS * 86400000).toISOString());
return 'analytics:recorded';
return 'analytics:recorded';
}
}
/** network.creator-analytics-result@1 for a creator over `days`; `full` adds the audience figures. */
/** network.creator-analytics-result@1 for a creator over `days`; `full` adds the audience figures. */
-function query(db, creator, { days = 30, full = false, now = Date.now() } = {}) {
+async function query(db, creator, { days = 30, full = false, now = Date.now() } = {}) {
ensureSchema(db);
ensureSchema(db);
const d = Math.min(Math.max(parseInt(days, 10) || 30, 1), 365);
const d = Math.min(Math.max(parseInt(days, 10) || 30, 1), 365);
const since = new Date(now - d * 86400000).toISOString();
const since = new Date(now - d * 86400000).toISOString();
- const rows = db.prepare('SELECT * FROM creator_streams WHERE creator_subject = ? AND started_at >= ? ORDER BY started_at DESC').all(creator, since);
+ const rows = await db.prepare('SELECT * FROM creator_streams WHERE creator_subject = ? AND started_at >= ? ORDER BY started_at DESC').all(creator, since);
const pick = (r) => {
const pick = (r) => {
const o = { stream_id: r.stream_id, title: r.title, category: r.category, started_at: r.started_at, ended_at: r.ended_at, duration_seconds: r.duration_seconds };
const o = { stream_id: r.stream_id, title: r.title, category: r.category, started_at: r.started_at, ended_at: r.ended_at, duration_seconds: r.duration_seconds };
if (r.peak_viewers != null) o.peak_viewers = r.peak_viewers;
if (r.peak_viewers != null) o.peak_viewers = r.peak_viewers;
@@ -104,12 +87,12 @@ function query(db, creator, { days = 30, full = false, now = Date.now() } = {})
@@ -104,12 +87,12 @@ function query(db, creator, { days = 30, full = false, now = Date.now() } = {})
return { creator, days: d, full: !!full, totals, daily, streams: rows.slice(0, 100).map(pick) };
return { creator, days: d, full: !!full, totals, daily, streams: rows.slice(0, 100).map(pick) };
}
}
-function viewerOf(req) {
+async function viewerOf(req) {
const h = req.headers.authorization || '';
const h = req.headers.authorization || '';
const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
if (!token) return null;
if (!token) return null;
try {
try {
- const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
+ const out = await require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
return out && out.user ? out.user : null;
return out && out.user ? out.user : null;
} catch { return null; }
} catch { return null; }
}
}
@@ -118,18 +101,18 @@ function viewerOf(req) {
@@ -118,18 +101,18 @@ function viewerOf(req) {
function router({ fullGuard }) {
function router({ fullGuard }) {
const r = express.Router();
const r = express.Router();
r.use(http.middleware());
r.use(http.middleware());
- r.get('/:creator/analytics', (req, res) => {
+ r.get('/:creator/analytics', async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
const key = String(req.params.creator || '');
const key = String(req.params.creator || '');
- const u = SUBJECT_RE.test(key) ? db.prepare('SELECT subject_id, is_anon FROM users WHERE subject_id = ?').get(key)
- : NAME_RE.test(key) ? db.prepare('SELECT subject_id, is_anon FROM users WHERE username = ? COLLATE NOCASE').get(key) : null;
+ const u = SUBJECT_RE.test(key) ? await db.prepare('SELECT subject_id, is_anon FROM users WHERE subject_id = ?').get(key)
+ : NAME_RE.test(key) ? await db.prepare('SELECT subject_id, is_anon FROM users WHERE lower(username) = lower(?)').get(key) : null;
if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) return http.sendProblem(res, 404, 'analytics.unknown_creator', { detail: 'no such creator', ctx: req.ov });
if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) return http.sendProblem(res, 404, 'analytics.unknown_creator', { detail: 'no such creator', ctx: req.ov });
- const answer = (full) => res.set('Cache-Control', full ? 'private, no-store' : 'public, max-age=60').json(query(db, u.subject_id, { days: req.query.days, full }));
- const viewer = viewerOf(req);
- if (viewer) return answer(viewer.subject_id === u.subject_id);
+ const answer = async (full) => res.set('Cache-Control', full ? 'private, no-store' : 'public, max-age=60').json(await query(db, u.subject_id, { days: req.query.days, full }));
+ const viewer = await viewerOf(req);
+ if (viewer) return await answer(viewer.subject_id === u.subject_id);
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
- if (h.startsWith('Bearer ')) return fullGuard(req, res, () => answer(true)); // a service token: full, or refused
- return answer(false);
+ if (h.startsWith('Bearer ')) return fullGuard(req, res, async () => await answer(true)); // a service token: full, or refused
+ return await answer(false);
});
});
return r;
return r;
}
}
@@ -3,23 +3,19 @@
@@ -3,23 +3,19 @@
* OpenVibe.Network's wiring of the ADR-021 analytics module, openvibe-shared/analytics (the one module
* OpenVibe.Network's wiring of the ADR-021 analytics module, openvibe-shared/analytics (the one module
* Live, Tools and Network use). Only what is Network-specific lives here:
* Live, Tools and Network use). Only what is Network-specific lives here:
*
*
- * openAnalytics(dbPath) the tracker for service 'openvibe-network'. Network's analytics tables
- * (analytics_events / _hourly / _daily …) live in network.db, as they always
- * have; the tracker gets a better-sqlite3 connection of its own so its
- * per-connection settings (busy_timeout 250, secure_delete) never apply to
- * the identity connection (busy_timeout 5000).
- * schedulePrune(tracker) the nightly `analytics-prune` job (retention.schedulePrune): raw events
- * older than 30 days are deleted in bounded batches; rollups are kept.
- * First run 5 minutes after boot, then every 24 hours; timers are unref'd.
- * PATH_OPTS { paramPrefixes, pathRules }: the path options the tracker and
- * scripts/analytics-prune.js (prune-cli, for the one-time scrub) both use.
+ * openAnalytics(db) the tracker for service 'openvibe-network' on the service's own PostgreSQL
+ * handle (analyticsSchema() is migrations/0002_analytics.sql). The request
+ * path never waits for the database: the tracker buffers and flushes every
+ * 5 s (and at 100 rows); reads and rollups are async.
+ * schedulePrune(tracker) ADR-021's raw-event retention (pruneRawEventsPg): raw events older than 30
+ * days go, in batches; rollups stay. The tracker schedules this itself at boot,
+ * so this is the on-demand form for operator scripts.
+ * PATH_OPTS { paramPrefixes, pathRules }: the path options the tracker uses.
*
*
* A request with `Sec-GPC: 1` or `DNT: 1` is not recorded (openvibe-shared v1.4.0).
* A request with `Sec-GPC: 1` or `DNT: 1` is not recorded (openvibe-shared v1.4.0).
*/
*/
-const path = require('path');
-const Database = require('better-sqlite3');
-const { AnalyticsTracker, retention } = require('openvibe-shared/analytics');
+const { AnalyticsTrackerPg, analyticsSchema, pruneRawEventsPg } = require('openvibe-shared/analytics/pg');
const SERVICE = 'openvibe-network';
const SERVICE = 'openvibe-network';
@@ -39,14 +35,13 @@ const PATH_RULES = [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']];
@@ -39,14 +35,13 @@ const PATH_RULES = [[/\/by-username\/[^/?#]+/gi, '/by-username/:username']];
const PATH_OPTS = { paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES };
const PATH_OPTS = { paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES };
-function openAnalytics(dbPath, opts = {}) {
- const db = new Database(path.resolve(dbPath));
- db.pragma('journal_mode = WAL');
- return new AnalyticsTracker(db, SERVICE, { retention: false, paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES, ...opts });
+function openAnalytics(db, opts = {}) {
+ return new AnalyticsTrackerPg(db, SERVICE, { paramPrefixes: PARAM_PREFIXES, pathRules: PATH_RULES, ...opts });
}
}
+/** ADR-021 raw-event retention on demand (the tracker also schedules it at boot, after 5 minutes). */
function schedulePrune(tracker, opts = {}) {
function schedulePrune(tracker, opts = {}) {
- return retention.schedulePrune(tracker.db, { label: 'Analytics', ...opts });
+ return pruneRawEventsPg(tracker.db, opts);
}
}
-module.exports = { SERVICE, PARAM_PREFIXES, PATH_RULES, PATH_OPTS, openAnalytics, schedulePrune };
+module.exports = { SERVICE, PARAM_PREFIXES, PATH_RULES, PATH_OPTS, analyticsSchema, openAnalytics, schedulePrune };
@@ -9,7 +9,9 @@
@@ -9,7 +9,9 @@
* Every write takes NETWORK_LIMITS_MINUTE / NETWORK_LIMITS_HOUR (120 and 3000) as `network.api.write`; the table
* Every write takes NETWORK_LIMITS_MINUTE / NETWORK_LIMITS_HOUR (120 and 3000) as `network.api.write`; the table
* below gives sensitive writes their own, tighter numbers on top. Past a limit the request answers 429 problem+json
* below gives sensitive writes their own, tighter numbers on top. Past a limit the request answers 429 problem+json
* `rate_limited` with Retry-After before any route runs; the refusal is logged by subject (never a token) and counted
* `rate_limited` with Retry-After before any route runs; the refusal is logged by subject (never a token) and counted
- * in network_rate_limited_total{limit,window}. Counters live in this process.
+ * in network_rate_limited_total{limit,window}. Counters live in this process unless a shared Valkey store is passed
+ * (server/index.js builds it from VALKEY_URL; ADR-035) — and if Valkey is unreachable the SDK degrades to the
+ * in-process counters, so a store outage slows nothing to a 500.
*
*
* Never counted: /api/auth/* (sign-in has its own limiter), /api/webhooks (providers), /api/v1/realtime tickets.
* Never counted: /api/auth/* (sign-in has its own limiter), /api/webhooks (providers), /api/v1/realtime tickets.
* /internal, /oauth, /api/health, /api/ready, /metrics and the JWKS are outside what this sees or are reads.
* /internal, /oauth, /api/health, /api/ready, /metrics and the JWKS are outside what this sees or are reads.
@@ -39,7 +41,7 @@ const ROUTES = [
@@ -39,7 +41,7 @@ const ROUTES = [
['network.block', /^(PUT|POST|DELETE)$/, /^\/v1\/me\/blocks(\/|$)/, { minute: 30, hour: 300 }],
['network.block', /^(PUT|POST|DELETE)$/, /^\/v1\/me\/blocks(\/|$)/, { minute: 30, hour: 300 }],
];
];
-function createNetworkActorLimits({ env = process.env, publicKey, issuer, registry = null, now } = {}) {
+function createNetworkActorLimits({ env = process.env, publicKey, issuer, registry = null, now, store = null } = {}) {
let refused = null;
let refused = null;
if (registry && typeof registry.counter === 'function') {
if (registry && typeof registry.counter === 'function') {
refused = registry.counter({ name: 'network_rate_limited_total', help: 'API writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });
refused = registry.counter({ name: 'network_rate_limited_total', help: 'API writes refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] });
@@ -60,6 +62,7 @@ function createNetworkActorLimits({ env = process.env, publicKey, issuer, regist
@@ -60,6 +62,7 @@ function createNetworkActorLimits({ env = process.env, publicKey, issuer, regist
limits: { minute: num(env.NETWORK_LIMITS_MINUTE, 120), hour: num(env.NETWORK_LIMITS_HOUR, 3000) },
limits: { minute: num(env.NETWORK_LIMITS_MINUTE, 120), hour: num(env.NETWORK_LIMITS_HOUR, 3000) },
actor,
actor,
...(now ? { now } : {}),
...(now ? { now } : {}),
+ ...(store ? { store } : {}),
onLimited(e) {
onLimited(e) {
console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
console.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
if (refused) refused.inc({ limit: e.name, window: e.window });
if (refused) refused.inc({ limit: e.name, window: e.window });
@@ -19,16 +19,7 @@ const ADMIN_LIMITS = {
@@ -19,16 +19,7 @@ const ADMIN_LIMITS = {
};
};
let _ensured = false;
let _ensured = false;
-function ensureTable(db) {
- if (_ensured) return;
- db.exec(`CREATE TABLE IF NOT EXISTS admin_rate_limits (
- user_id INTEGER NOT NULL,
- action TEXT NOT NULL,
- last_at INTEGER NOT NULL,
- PRIMARY KEY (user_id, action)
- )`);
- _ensured = true;
-}
+function ensureTable(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
function humanizeMs(ms) {
function humanizeMs(ms) {
const mins = Math.ceil(ms / 60000);
const mins = Math.ceil(ms / 60000);
@@ -40,12 +31,12 @@ function humanizeMs(ms) {
@@ -40,12 +31,12 @@ function humanizeMs(ms) {
// Check (without consuming) whether `user` may perform `action` now.
// Check (without consuming) whether `user` may perform `action` now.
// Returns { ok:true } or { ok:false, retryMs, retryHuman }. Owners always ok.
// Returns { ok:true } or { ok:false, retryMs, retryHuman }. Owners always ok.
-function checkAdminLimit(db, user, action) {
+async function checkAdminLimit(db, user, action) {
if (isOwner(user)) return { ok: true };
if (isOwner(user)) return { ok: true };
const windowMs = ADMIN_LIMITS[action];
const windowMs = ADMIN_LIMITS[action];
if (!windowMs) return { ok: true };
if (!windowMs) return { ok: true };
ensureTable(db);
ensureTable(db);
- const row = db.prepare('SELECT last_at FROM admin_rate_limits WHERE user_id = ? AND action = ?').get(user.id, action);
+ const row = await db.prepare('SELECT last_at FROM admin_rate_limits WHERE user_id = ? AND action = ?').get(user.id, action);
if (row && row.last_at) {
if (row && row.last_at) {
const elapsed = Date.now() - row.last_at;
const elapsed = Date.now() - row.last_at;
if (elapsed < windowMs) {
if (elapsed < windowMs) {
@@ -57,11 +48,11 @@ function checkAdminLimit(db, user, action) {
@@ -57,11 +48,11 @@ function checkAdminLimit(db, user, action) {
}
}
// Start the cooldown after a successful action. No-op for owners / unlimited actions.
// Start the cooldown after a successful action. No-op for owners / unlimited actions.
-function recordAdminAction(db, user, action) {
+async function recordAdminAction(db, user, action) {
if (isOwner(user)) return;
if (isOwner(user)) return;
if (!ADMIN_LIMITS[action]) return;
if (!ADMIN_LIMITS[action]) return;
ensureTable(db);
ensureTable(db);
- db.prepare(`INSERT INTO admin_rate_limits (user_id, action, last_at) VALUES (?, ?, ?)
+ await db.prepare(`INSERT INTO admin_rate_limits (user_id, action, last_at) VALUES (?, ?, ?)
ON CONFLICT(user_id, action) DO UPDATE SET last_at = excluded.last_at`)
ON CONFLICT(user_id, action) DO UPDATE SET last_at = excluded.last_at`)
.run(user.id, action, Date.now());
.run(user.id, action, Date.now());
}
}
@@ -28,9 +28,9 @@ setInterval(() => {
@@ -28,9 +28,9 @@ setInterval(() => {
* GET /api/auth/discord/link — Start Discord OAuth2 flow
* GET /api/auth/discord/link — Start Discord OAuth2 flow
* Requires authenticated user. Redirects to Discord authorization.
* Requires authenticated user. Redirects to Discord authorization.
*/
*/
-router.get('/link', (req, res) => {
+router.get('/link', async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const clientId = db.getSetting('discord_oauth_client_id');
+ const clientId = await db.getSetting('discord_oauth_client_id');
if (!clientId) {
if (!clientId) {
return res.status(503).json({ error: 'Discord linking is not configured' });
return res.status(503).json({ error: 'Discord linking is not configured' });
}
}
@@ -77,8 +77,8 @@ router.get('/callback', async (req, res) => {
@@ -77,8 +77,8 @@ router.get('/callback', async (req, res) => {
}
}
const db = req.app.locals.db;
const db = req.app.locals.db;
- const clientId = db.getSetting('discord_oauth_client_id');
- const clientSecret = db.getSetting('discord_oauth_client_secret');
+ const clientId = await db.getSetting('discord_oauth_client_id');
+ const clientSecret = await db.getSetting('discord_oauth_client_secret');
if (!clientId || !clientSecret) {
if (!clientId || !clientSecret) {
return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=not_configured`);
return res.redirect(`${req.app.locals.config.baseUrl}/linked?discord=error&reason=not_configured`);
}
}
@@ -115,7 +115,7 @@ router.get('/callback', async (req, res) => {
@@ -115,7 +115,7 @@ router.get('/callback', async (req, res) => {
const discordUser = await userRes.json();
const discordUser = await userRes.json();
// Check if this Discord account is already linked to a different user
// Check if this Discord account is already linked to a different user
- const existing = db.prepare(
+ const existing = await db.prepare(
"SELECT user_id FROM linked_accounts WHERE service = 'discord' AND service_user_id = ?"
"SELECT user_id FROM linked_accounts WHERE service = 'discord' AND service_user_id = ?"
).get(discordUser.id);
).get(discordUser.id);
@@ -124,13 +124,13 @@ router.get('/callback', async (req, res) => {
@@ -124,13 +124,13 @@ router.get('/callback', async (req, res) => {
}
}
// Link the account
// Link the account
- db.prepare(`
+ await db.prepare(`
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)
- VALUES (?, 'discord', ?, ?, CURRENT_TIMESTAMP)
+ VALUES (?, 'discord', ?, ?, ov_now())
ON CONFLICT(user_id, service) DO UPDATE SET
ON CONFLICT(user_id, service) DO UPDATE SET
service_user_id = ?,
service_user_id = ?,
service_username = ?,
service_username = ?,
- linked_at = CURRENT_TIMESTAMP
+ linked_at = ov_now()
`).run(
`).run(
stateData.userId,
stateData.userId,
discordUser.id,
discordUser.id,
@@ -140,7 +140,7 @@ router.get('/callback', async (req, res) => {
@@ -140,7 +140,7 @@ router.get('/callback', async (req, res) => {
);
);
// Audit log
// Audit log
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
stateData.userId, 'discord_linked', `Discord: ${discordUser.username} (${discordUser.id})`
stateData.userId, 'discord_linked', `Discord: ${discordUser.username} (${discordUser.id})`
);
);
@@ -155,16 +155,16 @@ router.get('/callback', async (req, res) => {
@@ -155,16 +155,16 @@ router.get('/callback', async (req, res) => {
* DELETE /api/auth/discord/link — Unlink Discord account
* DELETE /api/auth/discord/link — Unlink Discord account
* Requires authenticated user.
* Requires authenticated user.
*/
*/
-router.delete('/link', (req, res) => {
+router.delete('/link', async (req, res) => {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
const db = req.app.locals.db;
const db = req.app.locals.db;
- const result = db.prepare(
+ const result = await db.prepare(
"DELETE FROM linked_accounts WHERE user_id = ? AND service = 'discord'"
"DELETE FROM linked_accounts WHERE user_id = ? AND service = 'discord'"
).run(req.user.id);
).run(req.user.id);
if (result.changes > 0) {
if (result.changes > 0) {
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'discord_unlinked', 'Discord account unlinked'
req.user.id, 'discord_unlinked', 'Discord account unlinked'
);
);
}
}
@@ -175,15 +175,15 @@ router.delete('/link', (req, res) => {
@@ -175,15 +175,15 @@ router.delete('/link', (req, res) => {
/**
/**
* GET /api/auth/discord/status — Check if Discord is linked
* GET /api/auth/discord/status — Check if Discord is linked
*/
*/
-router.get('/status', (req, res) => {
+router.get('/status', async (req, res) => {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
const db = req.app.locals.db;
const db = req.app.locals.db;
- const linked = db.prepare(
+ const linked = await db.prepare(
"SELECT service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ? AND service = 'discord'"
"SELECT service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ? AND service = 'discord'"
).get(req.user.id);
).get(req.user.id);
- const configured = !!(db.getSetting('discord_oauth_client_id') && db.getSetting('discord_oauth_client_secret'));
+ const configured = !!(await db.getSetting('discord_oauth_client_id') && await db.getSetting('discord_oauth_client_secret'));
res.json({ ok: true, configured, linked: linked || null });
res.json({ ok: true, configured, linked: linked || null });
});
});
@@ -29,19 +29,19 @@ async function sendVerification(req, user, { force = false } = {}) {
@@ -29,19 +29,19 @@ async function sendVerification(req, user, { force = false } = {}) {
if (!emailService?.isEnabled) return { ok: false, reason: 'email-disabled' };
if (!emailService?.isEnabled) return { ok: false, reason: 'email-disabled' };
// Per-user: 1 per 3 minutes, N per day (site_settings email_verify_user_daily_cap, default 6).
// Per-user: 1 per 3 minutes, N per day (site_settings email_verify_user_daily_cap, default 6).
- const recent = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(user.id)?.c || 0;
+ const recent = (await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(user.id))?.c || 0;
if (recent > 0) return { ok: false, reason: 'rate-limited' };
if (recent > 0) return { ok: false, reason: 'rate-limited' };
- const dayCap = parseInt(db.getSetting('email_verify_user_daily_cap'), 10) || 6;
- const today = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(user.id)?.c || 0;
+ const dayCap = parseInt(await db.getSetting('email_verify_user_daily_cap'), 10) || 6;
+ const today = (await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-1 day')").get(user.id))?.c || 0;
if (today >= dayCap) return { ok: false, reason: 'cap' };
if (today >= dayCap) return { ok: false, reason: 'cap' };
// Same address hammered from many accounts (abuse) — 5/day per address.
// Same address hammered from many accounts (abuse) — 5/day per address.
- const perAddr = db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(user.email)?.c || 0;
+ const perAddr = (await db.prepare("SELECT COUNT(*) AS c FROM email_verification_tokens WHERE LOWER(email) = LOWER(?) AND created_at > datetime('now','-1 day')").get(user.email))?.c || 0;
if (perAddr >= 5) return { ok: false, reason: 'cap' };
if (perAddr >= 5) return { ok: false, reason: 'cap' };
// A bouncing address gets no more mail until the user changes it.
// A bouncing address gets no more mail until the user changes it.
if (user.email_bounced_at) return { ok: false, reason: 'bounced' };
if (user.email_bounced_at) return { ok: false, reason: 'bounced' };
const raw = crypto.randomBytes(32).toString('base64url');
const raw = crypto.randomBytes(32).toString('base64url');
- db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+${TOKEN_TTL_MIN} minutes'))`)
+ await db.prepare(`INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+${TOKEN_TTL_MIN} minutes'))`)
.run(hashToken(raw), user.id, user.email);
.run(hashToken(raw), user.id, user.email);
const verifyUrl = `${baseUrl(req)}/verify-email?token=${encodeURIComponent(raw)}`;
const verifyUrl = `${baseUrl(req)}/verify-email?token=${encodeURIComponent(raw)}`;
const sent = await emailService.sendVerificationEmail({ to: user.email, username: user.display_name || user.username, verifyUrl, expiresMinutes: TOKEN_TTL_MIN });
const sent = await emailService.sendVerificationEmail({ to: user.email, username: user.display_name || user.username, verifyUrl, expiresMinutes: TOKEN_TTL_MIN });
@@ -49,30 +49,34 @@ async function sendVerification(req, user, { force = false } = {}) {
@@ -49,30 +49,34 @@ async function sendVerification(req, user, { force = false } = {}) {
}
}
/** Consume a token. Returns { ok, error? , user? }. */
/** Consume a token. Returns { ok, error? , user? }. */
-function consumeToken(db, raw) {
+async function consumeToken(db, raw) {
if (!raw) return { ok: false, error: 'Missing token' };
if (!raw) return { ok: false, error: 'Missing token' };
- const row = db.prepare("SELECT * FROM email_verification_tokens WHERE token_hash = ?").get(hashToken(raw));
+ const row = await db.prepare("SELECT * FROM email_verification_tokens WHERE token_hash = ?").get(hashToken(raw));
if (!row) return { ok: false, error: 'This verification link is invalid.' };
if (!row) return { ok: false, error: 'This verification link is invalid.' };
if (row.used_at) return { ok: false, error: 'This verification link was already used.' };
if (row.used_at) return { ok: false, error: 'This verification link was already used.' };
if (new Date(String(row.expires_at).replace(' ', 'T') + 'Z').getTime() < Date.now()) return { ok: false, error: 'This verification link has expired — request a new one from your account page.' };
if (new Date(String(row.expires_at).replace(' ', 'T') + 'Z').getTime() < Date.now()) return { ok: false, error: 'This verification link has expired — request a new one from your account page.' };
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
if (!user) return { ok: false, error: 'Account not found.' };
if (!user) return { ok: false, error: 'Account not found.' };
if (!user.email || user.email.toLowerCase() !== String(row.email).toLowerCase()) {
if (!user.email || user.email.toLowerCase() !== String(row.email).toLowerCase()) {
return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };
return { ok: false, error: 'Your email address changed since this link was sent — request a new one.' };
}
}
- const tx = db.transaction(() => {
- db.prepare("UPDATE email_verification_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?").run(row.token_hash);
- db.prepare("UPDATE users SET email_verified = 1, email_verified_at = CURRENT_TIMESTAMP, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);
+ // Claim the token and verify the account in one transaction: the conditional UPDATE means of two
+ // concurrent uses of one link exactly one succeeds (decision 1, plan T2).
+ const claimed = await db.tx(async (t) => {
+ const n = (await t.prepare("UPDATE email_verification_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL").run(row.token_hash)).changes;
+ if (!n) return false;
+ await t.prepare("UPDATE users SET email_verified = 1, email_verified_at = ov_now(), email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?").run(user.id);
+ return true;
});
});
- tx();
+ if (!claimed) return { ok: false, error: 'This verification link was already used.' };
return { ok: true, user };
return { ok: true, user };
}
}
/** Called when a user sets/changes their address: unverify + (best-effort) send a new link. */
/** Called when a user sets/changes their address: unverify + (best-effort) send a new link. */
-function onEmailChanged(req, userId) {
+async function onEmailChanged(req, userId) {
const db = req.app.locals.db;
const db = req.app.locals.db;
- db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
+ await db.prepare('UPDATE users SET email_verified = 0, email_verified_at = NULL, email_bounced_at = NULL, email_bounce_reason = NULL WHERE id = ?').run(userId);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (user?.email) sendVerification(req, user).catch(() => {});
if (user?.email) sendVerification(req, user).catch(() => {});
}
}
@@ -80,10 +84,10 @@ function routes(requireAuth) {
@@ -80,10 +84,10 @@ function routes(requireAuth) {
const router = express.Router();
const router = express.Router();
// Status for the account page: { email, verified, bounced, can_resend }
// Status for the account page: { email, verified, bounced, can_resend }
- router.get('/email/status', requireAuth, (req, res) => {
+ router.get('/email/status', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const u = db.prepare('SELECT email, email_verified, email_verified_at, email_bounced_at, email_bounce_reason FROM users WHERE id = ?').get(req.user.id) || {};
- const recent = db.prepare("SELECT MAX(created_at) AS t FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(req.user.id)?.t || null;
+ const u = await db.prepare('SELECT email, email_verified, email_verified_at, email_bounced_at, email_bounce_reason FROM users WHERE id = ?').get(req.user.id) || {};
+ const recent = (await db.prepare("SELECT MAX(created_at) AS t FROM email_verification_tokens WHERE user_id = ? AND created_at > datetime('now','-3 minutes')").get(req.user.id))?.t || null;
res.json({
res.json({
ok: true,
ok: true,
email: u.email || null,
email: u.email || null,
@@ -99,7 +103,7 @@ function routes(requireAuth) {
@@ -99,7 +103,7 @@ function routes(requireAuth) {
router.post('/email/send-verification', requireAuth, async (req, res) => {
router.post('/email/send-verification', requireAuth, async (req, res) => {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
const r = await sendVerification(req, user);
const r = await sendVerification(req, user);
if (r.ok) return res.json({ ok: true, message: `Verification email sent to ${user.email}. Check your inbox (and spam).` });
if (r.ok) return res.json({ ok: true, message: `Verification email sent to ${user.email}. Check your inbox (and spam).` });
const msg = {
const msg = {
@@ -119,9 +123,9 @@ function routes(requireAuth) {
@@ -119,9 +123,9 @@ function routes(requireAuth) {
});
});
// JSON verify (used by the /verify-email page)
// JSON verify (used by the /verify-email page)
- router.post('/email/verify', (req, res) => {
+ router.post('/email/verify', async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const r = consumeToken(db, String(req.body?.token || ''));
+ const r = await consumeToken(db, String(req.body?.token || ''));
if (!r.ok) return res.status(400).json({ ok: false, error: r.error });
if (!r.ok) return res.status(400).json({ ok: false, error: r.error });
try {
try {
req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.user.email} is confirmed. Go-live alerts from streamers you follow will now reach your inbox.`, service: 'network', url: 'https://openvibe.network/notifications' });
req.app.locals.notificationService?.create({ user_id: r.user.id, type: 'EMAIL_VERIFIED', title: 'Email verified', message: `${r.user.email} is confirmed. Go-live alerts from streamers you follow will now reach your inbox.`, service: 'network', url: 'https://openvibe.network/notifications' });
@@ -88,9 +88,9 @@ function createFedcmRoutes(getCtx) {
@@ -88,9 +88,9 @@ function createFedcmRoutes(getCtx) {
// Browser-initiated fetches from other origins: helmet's same-origin CORP must not apply.
// Browser-initiated fetches from other origins: helmet's same-origin CORP must not apply.
router.use((req, res, next) => { res.set('Cross-Origin-Resource-Policy', 'cross-origin'); next(); });
router.use((req, res, next) => { res.set('Cross-Origin-Resource-Policy', 'cross-origin'); next(); });
const base = () => (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');
const base = () => (getCtx().config.networkUrl || 'https://openvibe.network').replace(/\/$/, '');
- const sessionUser = (req) => {
+ const sessionUser = async (req) => {
const { verifySession, requestToken } = require('./session');
const { verifySession, requestToken } = require('./session');
- const out = verifySession(requestToken(req), getCtx(req));
+ const out = await verifySession(requestToken(req), getCtx(req));
return out.error || !out.user || out.user.is_anon || out.user.is_banned ? null : out.user;
return out.error || !out.user || out.user.is_anon || out.user.is_banned ? null : out.user;
};
};
@@ -108,10 +108,10 @@ function createFedcmRoutes(getCtx) {
@@ -108,10 +108,10 @@ function createFedcmRoutes(getCtx) {
});
});
});
});
- router.get('/accounts', (req, res) => {
+ router.get('/accounts', async (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
if (!isWebIdentity(req)) return res.status(400).json({ error: 'not a FedCM request' });
if (!isWebIdentity(req)) return res.status(400).json({ error: 'not a FedCM request' });
- const user = sessionUser(req);
+ const user = await sessionUser(req);
if (!user) { res.set('Set-Login', 'logged-out'); return res.status(401).json({ accounts: [] }); }
if (!user) { res.set('Set-Login', 'logged-out'); return res.status(401).json({ accounts: [] }); }
res.set('Set-Login', 'logged-in');
res.set('Set-Login', 'logged-in');
res.json({ accounts: [accountOf(user, base())] });
res.json({ accounts: [accountOf(user, base())] });
@@ -122,7 +122,7 @@ function createFedcmRoutes(getCtx) {
@@ -122,7 +122,7 @@ function createFedcmRoutes(getCtx) {
res.json({ privacy_policy_url: 'https://openvibe.live/privacy', terms_of_service_url: 'https://openvibe.live/tos' });
res.json({ privacy_policy_url: 'https://openvibe.live/privacy', terms_of_service_url: 'https://openvibe.live/tos' });
});
});
- router.post('/assertion', express.urlencoded({ extended: false }), (req, res) => {
+ router.post('/assertion', express.urlencoded({ extended: false }), async (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
const origin = rpOrigin(req.headers.origin);
const origin = rpOrigin(req.headers.origin);
if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: { code: 'invalid_request' } });
if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: { code: 'invalid_request' } });
@@ -131,7 +131,7 @@ function createFedcmRoutes(getCtx) {
@@ -131,7 +131,7 @@ function createFedcmRoutes(getCtx) {
res.set('Vary', 'Origin');
res.set('Vary', 'Origin');
const clientId = String(req.body?.client_id || '').replace(/\/$/, '');
const clientId = String(req.body?.client_id || '').replace(/\/$/, '');
if (clientId !== origin) return res.status(403).json({ error: { code: 'unauthorized_client' } });
if (clientId !== origin) return res.status(403).json({ error: { code: 'unauthorized_client' } });
- const user = sessionUser(req);
+ const user = await sessionUser(req);
if (!user) return res.status(401).json({ error: { code: 'access_denied' } });
if (!user) return res.status(401).json({ error: { code: 'access_denied' } });
if (String(req.body?.account_id || '') !== String(user.id)) return res.status(403).json({ error: { code: 'access_denied' } });
if (String(req.body?.account_id || '') !== String(user.id)) return res.status(403).json({ error: { code: 'access_denied' } });
let nonce = req.body?.nonce || null;
let nonce = req.body?.nonce || null;
@@ -139,12 +139,12 @@ function createFedcmRoutes(getCtx) {
@@ -139,12 +139,12 @@ function createFedcmRoutes(getCtx) {
res.json({ token: signAssertion(user, origin, nonce, getCtx(req)) });
res.json({ token: signAssertion(user, origin, nonce, getCtx(req)) });
});
});
- router.post('/disconnect', express.urlencoded({ extended: false }), (req, res) => {
+ router.post('/disconnect', express.urlencoded({ extended: false }), async (req, res) => {
const origin = rpOrigin(req.headers.origin);
const origin = rpOrigin(req.headers.origin);
if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: 'invalid_request' });
if (!isWebIdentity(req) || !origin) return res.status(400).json({ error: 'invalid_request' });
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
res.set('Access-Control-Allow-Credentials', 'true');
- const user = sessionUser(req);
+ const user = await sessionUser(req);
// Nothing is stored per RP today (approval lives in the browser); answer with the account id.
// Nothing is stored per RP today (approval lives in the browser); answer with the account id.
res.json({ account_id: user ? String(user.id) : String(req.body?.account_hint || '') });
res.json({ account_id: user ? String(user.id) : String(req.body?.account_hint || '') });
});
});
@@ -39,10 +39,10 @@ function sameSecret(a, b) {
@@ -39,10 +39,10 @@ function sameSecret(a, b) {
const y = Buffer.from(String(b || ''));
const y = Buffer.from(String(b || ''));
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
}
}
-function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {
+async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {
const code = crypto.randomBytes(32).toString('hex');
const code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
- db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)
+ await db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method, nonce || null);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method, nonce || null);
return code;
return code;
}
}
@@ -53,8 +53,8 @@ function getConfig(req) { return req.app.locals.config; }
@@ -53,8 +53,8 @@ function getConfig(req) { return req.app.locals.config; }
// OAuth2 authorization endpoint. Always shows the account chooser
// OAuth2 authorization endpoint. Always shows the account chooser
// so the user can pick which account to continue with, add a new
// so the user can pick which account to continue with, add a new
// one, or create an account.
// one, or create an account.
-router.get('/authorize', (req, res) => {
- const db = getDb(req);
+router.get('/authorize', async (req, res) => {
+ const db = await getDb(req);
const { client_id, redirect_uri, response_type, scope, state } = req.query;
const { client_id, redirect_uri, response_type, scope, state } = req.query;
if (response_type !== 'code') {
if (response_type !== 'code') {
@@ -67,7 +67,7 @@ router.get('/authorize', (req, res) => {
@@ -67,7 +67,7 @@ router.get('/authorize', (req, res) => {
// Developer apps (app_<ULID>, server/developer): exact redirect URI, PKCE S256 always, no silent
// Developer apps (app_<ULID>, server/developer): exact redirect URI, PKCE S256 always, no silent
// prompt=none (a third-party app never gets a code without the person choosing to continue).
// prompt=none (a third-party app never gets a code without the person choosing to continue).
if (devTokens.isAppClient(client_id)) {
if (devTokens.isAppClient(client_id)) {
- const found = devTokens.checkAuthorizeRequest(db, req.query);
+ const found = await devTokens.checkAuthorizeRequest(db, req.query);
if (found.error) return res.status(400).json({ error: found.pkce ? 'invalid_request' : found.error, error_description: found.error });
if (found.error) return res.status(400).json({ error: found.pkce ? 'invalid_request' : found.error, error_description: found.error });
if (String(req.query.prompt || '') === 'none') {
if (String(req.query.prompt || '') === 'none') {
return res.redirect(`${redirect_uri}${redirect_uri.includes('?') ? '&' : '?'}error=interaction_required&state=${encodeURIComponent(state || '')}`);
return res.redirect(`${redirect_uri}${redirect_uri.includes('?') ? '&' : '?'}error=interaction_required&state=${encodeURIComponent(state || '')}`);
@@ -78,7 +78,7 @@ router.get('/authorize', (req, res) => {
@@ -78,7 +78,7 @@ router.get('/authorize', (req, res) => {
}
}
// Validate client
// Validate client
- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
+ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
if (!client) return res.status(400).json({ error: 'Unknown client_id' });
if (!client) return res.status(400).json({ error: 'Unknown client_id' });
const allowedUris = JSON.parse(client.redirect_uris || '[]');
const allowedUris = JSON.parse(client.redirect_uris || '[]');
@@ -99,11 +99,11 @@ router.get('/authorize', (req, res) => {
@@ -99,11 +99,11 @@ router.get('/authorize', (req, res) => {
try {
try {
const { verifySession, setSessionCookies } = require('./session');
const { verifySession, setSessionCookies } = require('./session');
const have = req.cookies?.ov_token || req.cookies?.ov_sso;
const have = req.cookies?.ov_token || req.cookies?.ov_sso;
- const out = verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
+ const out = await verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
if (!out.error) {
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req), { renew: out.decoded }) : have;
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req), { renew: out.decoded }) : have;
setSessionCookies(res, token);
setSessionCookies(res, token);
- const code = issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });
+ const code = await issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });
return res.redirect(withCode(redirect_uri, code, state));
return res.redirect(withCode(redirect_uri, code, state));
}
}
} catch { /* fall through */ }
} catch { /* fall through */ }
@@ -127,21 +127,21 @@ router.get('/authorize', (req, res) => {
@@ -127,21 +127,21 @@ router.get('/authorize', (req, res) => {
// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
// link could claim to be any app. redirect_host is shown beside it: where the code will go.
// link could claim to be any app. redirect_host is shown beside it: where the code will go.
-router.get('/client-info', (req, res) => {
- const db = getDb(req);
+router.get('/client-info', async (req, res) => {
+ const db = await getDb(req);
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
const { client_id, redirect_uri } = req.query;
const { client_id, redirect_uri } = req.query;
if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
let host = '';
let host = '';
try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }
try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }
if (devTokens.isAppClient(client_id)) {
if (devTokens.isAppClient(client_id)) {
- const found = devTokens.checkAuthorizeRequest(db, req.query);
+ const found = await devTokens.checkAuthorizeRequest(db, req.query);
if (found.error && !found.pkce) return res.status(404).json({ error: found.error });
if (found.error && !found.pkce) return res.status(404).json({ error: found.error });
const app = found.app || null;
const app = found.app || null;
if (!app) return res.status(404).json({ error: 'Unknown client_id' });
if (!app) return res.status(404).json({ error: 'Unknown client_id' });
return res.json({ name: app.name, third_party: true, redirect_host: host });
return res.json({ name: app.name, third_party: true, redirect_host: host });
}
}
- const client = db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
+ const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
@@ -152,8 +152,8 @@ router.get('/client-info', (req, res) => {
@@ -152,8 +152,8 @@ router.get('/client-info', (req, res) => {
// Called from the account chooser UI. Accepts an openvibe.network JWT
// Called from the account chooser UI. Accepts an openvibe.network JWT
// token and OAuth params, validates everything, issues an
// token and OAuth params, validates everything, issues an
// authorization code, and returns the redirect URL.
// authorization code, and returns the redirect URL.
-router.post('/confirm', (req, res) => {
- const db = getDb(req);
+router.post('/confirm', async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const { token, client_id, redirect_uri, scope, state } = req.body;
const { token, client_id, redirect_uri, scope, state } = req.body;
@@ -161,12 +161,12 @@ router.post('/confirm', (req, res) => {
@@ -161,12 +161,12 @@ router.post('/confirm', (req, res) => {
return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });
return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });
}
}
- const devApp = devTokens.isAppClient(client_id) ? devTokens.checkAuthorizeRequest(db, req.body) : null;
+ const devApp = devTokens.isAppClient(client_id) ? await devTokens.checkAuthorizeRequest(db, req.body) : null;
if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });
if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });
let pkce = null;
let pkce = null;
if (!devApp) {
if (!devApp) {
// Validate client
// Validate client
- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
+ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
if (!client) return res.status(400).json({ error: 'Unknown client_id' });
if (!client) return res.status(400).json({ error: 'Unknown client_id' });
const allowedUris = JSON.parse(client.redirect_uris || '[]');
const allowedUris = JSON.parse(client.redirect_uris || '[]');
@@ -182,7 +182,7 @@ router.post('/confirm', (req, res) => {
@@ -182,7 +182,7 @@ router.post('/confirm', (req, res) => {
// Verify the token as a live, unexpired session: not revoked by a password change
// Verify the token as a live, unexpired session: not revoked by a password change
// (token_valid_after), not a FedCM assertion or a service/app token.
// (token_valid_after), not a FedCM assertion or a service/app token.
- const out = require('./session').verifySession(String(token), { db, publicKey: req.app.locals.publicKey, config });
+ const out = await require('./session').verifySession(String(token), { db, publicKey: req.app.locals.publicKey, config });
if (out.error) {
if (out.error) {
if (out.status === 403 || out.error === 'User not found') return res.status(403).json({ error: 'User not found or banned' });
if (out.status === 403 || out.error === 'User not found') return res.status(403).json({ error: 'User not found or banned' });
return res.status(401).json({ error: 'Invalid or expired token' });
return res.status(401).json({ error: 'Invalid or expired token' });
@@ -193,14 +193,14 @@ router.post('/confirm', (req, res) => {
@@ -193,14 +193,14 @@ router.post('/confirm', (req, res) => {
const user = out.user;
const user = out.user;
if (devApp) {
if (devApp) {
- const out = devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });
+ const out = await devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });
if (out.error) return res.status(out.status).json({ error: out.error });
if (out.error) return res.status(out.status).json({ error: out.error });
require('./session').setSessionCookies(res, token);
require('./session').setSessionCookies(res, token);
return res.json({ redirect: withCode(redirect_uri, out.code, state) });
return res.json({ redirect: withCode(redirect_uri, out.code, state) });
}
}
// Issue authorization code (bound to the PKCE challenge when the client sent one)
// Issue authorization code (bound to the PKCE challenge when the client sent one)
- const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce, nonce });
+ const code = await issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce, nonce });
// Also set cookie to this account so openvibe.network itself knows the active session.
// Also set cookie to this account so openvibe.network itself knows the active session.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
@@ -212,15 +212,15 @@ router.post('/confirm', (req, res) => {
@@ -212,15 +212,15 @@ router.post('/confirm', (req, res) => {
// ── POST /token ──────────────────────────────────────────────
// ── POST /token ──────────────────────────────────────────────
// OAuth2 token endpoint. Exchanges authorization codes or
// OAuth2 token endpoint. Exchanges authorization codes or
// refresh tokens for access tokens.
// refresh tokens for access tokens.
-router.post('/token', (req, res) => {
- const db = getDb(req);
+router.post('/token', async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)
res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
if (devTokens.isAppClient(client_id)) {
if (devTokens.isAppClient(client_id)) {
- const out = devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });
+ const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config });
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
return res.status(out.status).json(out.body);
return res.status(out.status).json(out.body);
}
}
@@ -228,7 +228,7 @@ router.post('/token', (req, res) => {
@@ -228,7 +228,7 @@ router.post('/token', (req, res) => {
// Service principals (Wave 1): a first-party service trades its client credentials for a
// Service principals (Wave 1): a first-party service trades its client credentials for a
// short-lived, capability-scoped token. Checked before the user-grant path below.
// short-lived, capability-scoped token. Checked before the user-grant path below.
if (grant_type === 'client_credentials') {
if (grant_type === 'client_credentials') {
- const out = principals.issueToken(db, {
+ const out = await principals.issueToken(db, {
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
clientId: client_id, clientSecret: client_secret, audience: req.body.audience, scope: req.body.scope,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
});
});
@@ -237,17 +237,17 @@ router.post('/token', (req, res) => {
@@ -237,17 +237,17 @@ router.post('/token', (req, res) => {
}
}
// Validate client credentials
// Validate client credentials
- const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
+ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
if (!client || !sameSecret(client.client_secret, client_secret)) {
if (!client || !sameSecret(client.client_secret, client_secret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
}
if (grant_type === 'authorization_code') {
if (grant_type === 'authorization_code') {
- return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri, req.body.code_verifier);
+ return await handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri, req.body.code_verifier);
} else if (grant_type === 'refresh_token') {
} else if (grant_type === 'refresh_token') {
- return handleRefreshGrant(db, config, req, res, client, refresh_token);
+ return await handleRefreshGrant(db, config, req, res, client, refresh_token);
} else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
} else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
- return handleFedcmAssertionGrant(db, config, req, res, client, req.body.assertion);
+ return await handleFedcmAssertionGrant(db, config, req, res, client, req.body.assertion);
} else {
} else {
return res.status(400).json({ error: 'unsupported_grant_type' });
return res.status(400).json({ error: 'unsupported_grant_type' });
}
}
@@ -260,23 +260,23 @@ router.post('/token', (req, res) => {
@@ -260,23 +260,23 @@ router.post('/token', (req, res) => {
* right after signing in there. A site that maps the account to its own user id (Live) still
* right after signing in there. A site that maps the account to its own user id (Live) still
* owns service_user_id; the exchange never overwrites it, only bumps last_used_at.
* owns service_user_id; the exchange never overwrites it, only bumps last_used_at.
*/
*/
-function recordLinkedService(db, user, client) {
+async function recordLinkedService(db, user, client) {
if (!client?.client_id || !user?.id || user.is_anon) return;
if (!client?.client_id || !user?.id || user.is_anon) return;
try {
try {
- db.prepare(`
+ await db.prepare(`
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at, last_used_at)
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at, last_used_at)
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ VALUES (?, ?, ?, ?, ov_now(), ov_now())
ON CONFLICT(user_id, service) DO UPDATE SET
ON CONFLICT(user_id, service) DO UPDATE SET
- service_username = COALESCE(service_username, excluded.service_username),
- last_used_at = CURRENT_TIMESTAMP
+ service_username = COALESCE(linked_accounts.service_username, excluded.service_username),
+ last_used_at = ov_now()
`).run(user.id, client.client_id, `network:${user.id}`, user.username || null);
`).run(user.id, client.client_id, `network:${user.id}`, user.username || null);
} catch (err) { console.warn('[OAuth] linked service record failed:', err.message); }
} catch (err) { console.warn('[OAuth] linked service record failed:', err.message); }
}
}
-function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, codeVerifier) {
+async function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, codeVerifier) {
if (!code) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing code' });
if (!code) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing code' });
- const authCode = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(code);
+ const authCode = await db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(code);
if (!authCode) return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' });
if (!authCode) return res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' });
if (authCode.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
if (authCode.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
if (authCode.client_id !== client.client_id) return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
if (authCode.client_id !== client.client_id) return res.status(400).json({ error: 'invalid_grant', error_description: 'Client mismatch' });
@@ -288,30 +288,30 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co
@@ -288,30 +288,30 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co
// A code bound to a PKCE challenge is only redeemable with the matching verifier.
// A code bound to a PKCE challenge is only redeemable with the matching verifier.
if (authCode.code_challenge && !verifierMatches(codeVerifier, authCode.code_challenge)) {
if (authCode.code_challenge && !verifierMatches(codeVerifier, authCode.code_challenge)) {
- db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);
+ await db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
}
// Mark code as used (atomically: two concurrent exchanges of one code cannot both succeed)
// Mark code as used (atomically: two concurrent exchanges of one code cannot both succeed)
- if (db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ? AND used = 0').run(code).changes !== 1) {
+ if ((await db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ? AND used = 0').run(code)).changes !== 1) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
}
}
// Get user
// Get user
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
// Issue tokens
// Issue tokens
- const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);
- recordLinkedService(db, user, client);
+ const { accessToken, refreshToken } = await issueTokenPair(db, config, req, user, client);
+ await recordLinkedService(db, user, client);
// Get preferences for theme sync
// Get preferences for theme sync
- const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
+ const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const { password_hash, token_valid_after, ...safeUser } = user;
const { password_hash, token_valid_after, ...safeUser } = user;
// OpenID Connect (server/auth/oidc.js): scope openid adds an id_token for this client.
// OpenID Connect (server/auth/oidc.js): scope openid adds an id_token for this client.
const idToken = oidc.wantsOpenid(authCode.scope)
const idToken = oidc.wantsOpenid(authCode.scope)
- ? oidc.idToken({ user: { ...user, subject_id: subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer })
+ ? oidc.idToken({ user: { ...user, subject_id: await subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer })
: undefined;
: undefined;
res.json({
res.json({
access_token: accessToken,
access_token: accessToken,
@@ -331,7 +331,7 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co
@@ -331,7 +331,7 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co
* code grant issues, so its session logic does not change. The assertion must be ours, unused,
* code grant issues, so its session logic does not change. The assertion must be ours, unused,
* unexpired, and minted for an origin this client owns.
* unexpired, and minted for an origin this client owns.
*/
*/
-function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {
+async function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {
if (!assertion) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing assertion' });
if (!assertion) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing assertion' });
let decoded;
let decoded;
try {
try {
@@ -341,20 +341,20 @@ function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {
@@ -341,20 +341,20 @@ function handleFedcmAssertionGrant(db, config, req, res, client, assertion) {
} catch (err) {
} catch (err) {
return res.status(400).json({ error: 'invalid_grant', error_description: err.message });
return res.status(400).json({ error: 'invalid_grant', error_description: err.message });
}
}
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
- const { accessToken, refreshToken } = issueTokenPair(db, config, req, user, client);
- recordLinkedService(db, user, client);
- const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
+ const { accessToken, refreshToken } = await issueTokenPair(db, config, req, user, client);
+ await recordLinkedService(db, user, client);
+ const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const { password_hash, token_valid_after, ...safeUser } = user;
const { password_hash, token_valid_after, ...safeUser } = user;
res.json({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: 86400, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });
res.json({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: 86400, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });
}
}
-function handleRefreshGrant(db, config, req, res, client, refreshToken) {
+async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
if (!refreshToken) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
// Hashed lookup, rotation, and family revocation on reuse (server/auth/refresh-tokens.js).
// Hashed lookup, rotation, and family revocation on reuse (server/auth/refresh-tokens.js).
- const r = refreshTokens.rotate(db, refreshToken, client.client_id);
+ const r = await refreshTokens.rotate(db, refreshToken, client.client_id);
if (!r.ok) {
if (!r.ok) {
if (r.reuse) console.warn(`[OAuth] refresh token reuse (${client.client_id}): family revoked`);
if (r.reuse) console.warn(`[OAuth] refresh token reuse (${client.client_id}): family revoked`);
return res.status(400).json({ error: r.error, error_description: r.description });
return res.status(400).json({ error: r.error, error_description: r.description });
@@ -362,7 +362,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@ -362,7 +362,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
const stored = r.row;
const stored = r.row;
// Get user
// Get user
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
// A password change/reset (token_valid_after) ends every refresh token issued before it.
// A password change/reset (token_valid_after) ends every refresh token issued before it.
if (user.token_valid_after && stored.created_at) {
if (user.token_valid_after && stored.created_at) {
@@ -373,8 +373,8 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@ -373,8 +373,8 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
}
}
// Issue new token pair: the next generation of the same family
// Issue new token pair: the next generation of the same family
- const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation });
- recordLinkedService(db, user, client);
+ const { accessToken, refreshToken: newRefresh } = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation });
+ await recordLinkedService(db, user, client);
res.json({
res.json({
access_token: accessToken,
access_token: accessToken,
@@ -385,7 +385,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@ -385,7 +385,7 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
});
});
}
}
-function issueTokenPair(db, config, req, user, client, family = {}) {
+async function issueTokenPair(db, config, req, user, client, family = {}) {
const privateKey = req.app.locals.privateKey;
const privateKey = req.app.locals.privateKey;
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
@@ -393,7 +393,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {
@@ -393,7 +393,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {
{
{
sub: user.id,
sub: user.id,
id: user.id,
id: user.id,
- subject_id: subjects.ensureUserSubject(db, user) || undefined,
+ subject_id: await subjects.ensureUserSubject(db, user) || undefined,
username: user.username,
username: user.username,
display_name: user.display_name || user.username,
display_name: user.display_name || user.username,
role: user.role,
role: user.role,
@@ -411,7 +411,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {
@@ -411,7 +411,7 @@ function issueTokenPair(db, config, req, user, client, family = {}) {
);
);
// 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it.
// 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it.
- const refreshTokenValue = refreshTokens.issue(db, { clientId: client.client_id, userId: user.id, familyId: family.familyId, generation: family.generation || 0 });
+ const refreshTokenValue = await refreshTokens.issue(db, { clientId: client.client_id, userId: user.id, familyId: family.familyId, generation: family.generation || 0 });
return { accessToken, refreshToken: refreshTokenValue };
return { accessToken, refreshToken: refreshTokenValue };
}
}
@@ -89,19 +89,19 @@ function idToken({ user, clientId, nonce, privateKey, issuer }) {
@@ -89,19 +89,19 @@ function idToken({ user, clientId, nonce, privateKey, issuer }) {
}
}
/** GET|POST /oauth/userinfo with `Authorization: Bearer <Network access token>`. */
/** GET|POST /oauth/userinfo with `Authorization: Bearer <Network access token>`. */
-function userinfo(req, res) {
+async function userinfo(req, res) {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
const ah = String(req.headers.authorization || '');
const ah = String(req.headers.authorization || '');
const token = ah.startsWith('Bearer ') ? ah.slice(7).trim() : '';
const token = ah.startsWith('Bearer ') ? ah.slice(7).trim() : '';
const fail = (status, error) => res.status(status).set('WWW-Authenticate', `Bearer error="${error}"`).json({ error });
const fail = (status, error) => res.status(status).set('WWW-Authenticate', `Bearer error="${error}"`).json({ error });
if (!token) return fail(401, 'invalid_token');
if (!token) return fail(401, 'invalid_token');
- const out = require('./session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
+ const out = await require('./session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
if (out.error) return fail(out.status === 403 ? 403 : 401, out.status === 403 ? 'insufficient_scope' : 'invalid_token');
if (out.error) return fail(out.status === 403 ? 403 : 401, out.status === 403 ? 'insufficient_scope' : 'invalid_token');
// verifySession renews a recently expired session; an expired bearer token is not accepted here.
// verifySession renews a recently expired session; an expired bearer token is not accepted here.
if (typeof out.decoded.exp !== 'number' || out.decoded.exp * 1000 < Date.now()) return fail(401, 'invalid_token');
if (typeof out.decoded.exp !== 'number' || out.decoded.exp * 1000 < Date.now()) return fail(401, 'invalid_token');
const user = out.user;
const user = out.user;
let subjectId = user.subject_id || null;
let subjectId = user.subject_id || null;
- try { subjectId = require('../identity/subjects').ensureUserSubject(req.app.locals.db, user) || subjectId; } catch { /* the claim is optional */ }
+ try { subjectId = await require('../identity/subjects').ensureUserSubject(req.app.locals.db, user) || subjectId; } catch { /* the claim is optional */ }
res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });
res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });
}
}
@@ -60,13 +60,13 @@ function mintTicket({ subject, privateKey, issuer, now = Date.now(), ttlS = TTL_
@@ -60,13 +60,13 @@ function mintTicket({ subject, privateKey, issuer, now = Date.now(), ttlS = TTL_
function router({ db, requireAuth, privateKey, issuer, streamUrl, isEnabled = () => process.env.REALTIME_TICKETS !== 'off', now = () => Date.now() }) {
function router({ db, requireAuth, privateKey, issuer, streamUrl, isEnabled = () => process.env.REALTIME_TICKETS !== 'off', now = () => Date.now() }) {
const r = express.Router();
const r = express.Router();
const url = streamUrlFrom(streamUrl);
const url = streamUrlFrom(streamUrl);
- r.post('/ticket', requireAuth, (req, res) => {
+ r.post('/ticket', requireAuth, async (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
if (!isEnabled()) return res.status(503).json({ code: 'realtime.disabled', error: 'browser realtime is off; poll instead' });
if (!isEnabled()) return res.status(503).json({ code: 'realtime.disabled', error: 'browser realtime is off; poll instead' });
const user = req.user;
const user = req.user;
if (!user || user.is_anon) return res.status(403).json({ code: 'realtime.guest', error: 'sign in with an account to get a realtime ticket' });
if (!user || user.is_anon) return res.status(403).json({ code: 'realtime.guest', error: 'sign in with an account to get a realtime ticket' });
let subject = user.subject_id;
let subject = user.subject_id;
- try { subject = require('../identity/subjects').ensureUserSubject(db, user) || subject; } catch { /* keep what the row has */ }
+ try { subject = await require('../identity/subjects').ensureUserSubject(db, user) || subject; } catch { /* keep what the row has */ }
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return res.status(503).json({ code: 'realtime.no_subject', error: 'this account has no subject id yet' });
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject || ''))) return res.status(503).json({ code: 'realtime.no_subject', error: 'this account has no subject id yet' });
if (!String(privateKey || '').includes('BEGIN')) return res.status(503).json({ code: 'realtime.disabled', error: 'no RS256 signing key' });
if (!String(privateKey || '').includes('BEGIN')) return res.status(503).json({ code: 'realtime.disabled', error: 'no RS256 signing key' });
const { ticket, claims } = mintTicket({ subject, privateKey, issuer, now: now() });
const { ticket, claims } = mintTicket({ subject, privateKey, issuer, now: now() });
@@ -31,46 +31,39 @@ const newFamily = () => `fam_${crypto.randomBytes(12).toString('hex')}`;
@@ -31,46 +31,39 @@ const newFamily = () => `fam_${crypto.randomBytes(12).toString('hex')}`;
const at = (v) => (v ? new Date(String(v).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).getTime() : NaN);
const at = (v) => (v ? new Date(String(v).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(v)) ? '' : 'Z')).getTime() : NaN);
/** Columns and index (boot, idempotent). No data is changed here. */
/** Columns and index (boot, idempotent). No data is changed here. */
-function ensureSchema(db) {
- const cols = new Set(db.prepare('PRAGMA table_info(oauth_tokens)').all().map(c => c.name));
- if (!cols.has('family_id')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN family_id TEXT');
- if (!cols.has('generation')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN generation INTEGER NOT NULL DEFAULT 0');
- if (!cols.has('revoked_reason')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN revoked_reason TEXT');
- if (!cols.has('revoked_at')) db.exec('ALTER TABLE oauth_tokens ADD COLUMN revoked_at DATETIME');
- db.exec('CREATE INDEX IF NOT EXISTS idx_oauth_tokens_family ON oauth_tokens(family_id)');
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** Store a new refresh token; returns the token (the only time it exists in clear). */
/** Store a new refresh token; returns the token (the only time it exists in clear). */
-function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {
+async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {
const token = crypto.randomBytes(48).toString('hex');
const token = crypto.randomBytes(48).toString('hex');
- db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)
+ await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), familyId || newFamily(), generation);
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), familyId || newFamily(), generation);
return token;
return token;
}
}
/** The stored row for a presented token, or null. Never matches a stored hash presented as a token. */
/** The stored row for a presented token, or null. Never matches a stored hash presented as a token. */
-function find(db, presented) {
+async function find(db, presented) {
const raw = String(presented || '');
const raw = String(presented || '');
if (!RAW_RE.test(raw)) return null;
if (!RAW_RE.test(raw)) return null;
- const row = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(hash(raw));
+ const row = await db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(hash(raw));
if (row) return row;
if (row) return row;
// A row still holding the raw token (written before this change, not hashed yet): hash it now,
// A row still holding the raw token (written before this change, not hashed yet): hash it now,
// then use it.
// then use it.
- const legacy = db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(raw);
+ const legacy = await db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(raw);
if (!legacy) return null;
if (!legacy) return null;
- db.prepare('UPDATE oauth_tokens SET token = ?, family_id = COALESCE(family_id, ?) WHERE id = ? AND token = ?').run(hash(raw), `fam_legacy_${legacy.id}`, legacy.id, raw);
- return db.prepare('SELECT * FROM oauth_tokens WHERE id = ?').get(legacy.id);
+ await db.prepare('UPDATE oauth_tokens SET token = ?, family_id = COALESCE(family_id, ?) WHERE id = ? AND token = ?').run(hash(raw), `fam_legacy_${legacy.id}`, legacy.id, raw);
+ return await db.prepare('SELECT * FROM oauth_tokens WHERE id = ?').get(legacy.id);
}
}
/** Revoke every live token of a family; returns how many. */
/** Revoke every live token of a family; returns how many. */
-function revokeFamily(db, familyId, reason = 'reuse') {
+async function revokeFamily(db, familyId, reason = 'reuse') {
if (!familyId) return 0;
if (!familyId) return 0;
- return db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = ?, revoked_at = CURRENT_TIMESTAMP WHERE family_id = ? AND revoked = 0").run(reason, familyId).changes;
+ return (await db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = ?, revoked_at = ov_now() WHERE family_id = ? AND revoked = 0").run(reason, familyId)).changes;
}
}
-function audit(db, row, details) {
+async function audit(db, row, details) {
try {
try {
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(row.user_id, 'oauth_refresh_reuse', JSON.stringify({ client_id: row.client_id, family_id: row.family_id, ...details }));
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(row.user_id, 'oauth_refresh_reuse', JSON.stringify({ client_id: row.client_id, family_id: row.family_id, ...details }));
} catch { /* audit is best effort */ }
} catch { /* audit is best effort */ }
}
}
@@ -80,20 +73,20 @@ function audit(db, row, details) {
@@ -80,20 +73,20 @@ function audit(db, row, details) {
* { ok: false, error: 'invalid_grant', description, reuse? }
* { ok: false, error: 'invalid_grant', description, reuse? }
* The old token is revoked ('rotated') atomically: of two concurrent uses exactly one gets ok.
* The old token is revoked ('rotated') atomically: of two concurrent uses exactly one gets ok.
*/
*/
-function rotate(db, presented, clientId, { now = Date.now() } = {}) {
- const row = find(db, presented);
+async function rotate(db, presented, clientId, { now = Date.now() } = {}) {
+ const row = await find(db, presented);
const bad = (description, extra = {}) => ({ ok: false, error: 'invalid_grant', description, ...extra });
const bad = (description, extra = {}) => ({ ok: false, error: 'invalid_grant', description, ...extra });
if (!row) return bad('Invalid refresh token');
if (!row) return bad('Invalid refresh token');
if (row.client_id !== clientId) return bad('Client mismatch');
if (row.client_id !== clientId) return bad('Client mismatch');
if (row.revoked) {
if (row.revoked) {
if (row.revoked_reason === 'rotated' && now - at(row.revoked_at) <= REUSE_GRACE_S * 1000) return bad('Refresh token already used');
if (row.revoked_reason === 'rotated' && now - at(row.revoked_at) <= REUSE_GRACE_S * 1000) return bad('Refresh token already used');
- const current = db.prepare('SELECT MAX(generation) AS g FROM oauth_tokens WHERE family_id = ?').get(row.family_id).g;
- const n = revokeFamily(db, row.family_id, 'reuse');
- if (row.revoked_reason === 'rotated' || n) audit(db, row, { generation_presented: row.generation, generation_current: current, revoked: n });
+ const current = (await db.prepare('SELECT MAX(generation) AS g FROM oauth_tokens WHERE family_id = ?').get(row.family_id)).g;
+ const n = await revokeFamily(db, row.family_id, 'reuse');
+ if (row.revoked_reason === 'rotated' || n) await audit(db, row, { generation_presented: row.generation, generation_current: current, revoked: n });
return bad('Refresh token revoked', { reuse: row.revoked_reason === 'rotated' });
return bad('Refresh token revoked', { reuse: row.revoked_reason === 'rotated' });
}
}
if (now > at(row.expires_at)) return bad('Refresh token expired');
if (now > at(row.expires_at)) return bad('Refresh token expired');
- const won = db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = 'rotated', revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked = 0").run(row.id).changes === 1;
+ const won = (await db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = 'rotated', revoked_at = ov_now() WHERE id = ? AND revoked = 0").run(row.id)).changes === 1;
if (!won) return bad('Refresh token already used'); // a concurrent use of the same token got it
if (!won) return bad('Refresh token already used'); // a concurrent use of the same token got it
return { ok: true, row, familyId: row.family_id || `fam_legacy_${row.id}`, generation: (row.generation || 0) + 1 };
return { ok: true, row, familyId: row.family_id || `fam_legacy_${row.id}`, generation: (row.generation || 0) + 1 };
}
}
@@ -27,12 +27,12 @@ function buildBaseUrl(req) {
@@ -27,12 +27,12 @@ function buildBaseUrl(req) {
* Returns { rawToken, resetUrl, expiresMinutes } — rawToken is the only time the
* Returns { rawToken, resetUrl, expiresMinutes } — rawToken is the only time the
* plaintext exists, so it must go straight into the email and never be stored.
* plaintext exists, so it must go straight into the email and never be stored.
*/
*/
-function issueResetToken(db, req, userId) {
+async function issueResetToken(db, req, userId) {
const rawToken = crypto.randomBytes(32).toString('hex');
const rawToken = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
// One live token per user: issuing a new link revokes the previous one.
// One live token per user: issuing a new link revokes the previous one.
- db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
- db.prepare(`
+ await db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
+ await db.prepare(`
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip, requested_user_agent)
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip, requested_user_agent)
VALUES (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?)
`).run(userId, hashResetToken(rawToken), expiresAt, req.ip || null, req.headers['user-agent'] || null);
`).run(userId, hashResetToken(rawToken), expiresAt, req.ip || null, req.headers['user-agent'] || null);
@@ -45,22 +45,22 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
@@ -45,22 +45,22 @@ function buildEnvelope({ subjectId, validAfter, reason, actor, ctx }) {
*/
*/
// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:
// strict: the cutoff is one second ahead, so a token minted in this same second is refused too (an account merge:
// the folded-in account's fresh sign-in token must not outlive the merge).
// the folded-in account's fresh sign-in token must not outlive the merge).
-function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {
+async function revokeTokens(db, userId, { reason, actor, ctx, strict = false } = {}) {
if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
if (!REASONS.includes(reason)) throw new Error(`revocation: unknown reason ${reason}`);
- return db.transaction(() => {
- const user = db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);
+ return await db.tx(async () => {
+ const user = await db.prepare('SELECT id, subject_id FROM users WHERE id = ?').get(userId);
if (!user) throw new Error(`revocation: no user ${userId}`);
if (!user) throw new Error(`revocation: no user ${userId}`);
- db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(CURRENT_TIMESTAMP, '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
- db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
- const validAfter = iso(db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id).t);
+ await db.prepare(strict ? "UPDATE users SET token_valid_after = datetime(ov_now(), '+1 second') WHERE id = ?" : 'UPDATE users SET token_valid_after = ov_now() WHERE id = ?').run(user.id);
+ await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE user_id = ?').run(user.id);
+ const validAfter = iso((await db.prepare('SELECT token_valid_after AS t FROM users WHERE id = ?').get(user.id)).t);
let event = null;
let event = null;
if (ids.isSubjectId('user', user.subject_id)) {
if (ids.isSubjectId('user', user.subject_id)) {
const by = actor || { type: 'user', id: user.subject_id };
const by = actor || { type: 'user', id: user.subject_id };
event = buildEnvelope({ subjectId: user.subject_id, validAfter, reason, actor: by, ctx });
event = buildEnvelope({ subjectId: user.subject_id, validAfter, reason, actor: by, ctx });
- eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });
+ await eventRelay.writerFor(db).enqueue(event, { traceparent: ctx && ctx.traceparent });
}
}
return { validAfter, event };
return { validAfter, event };
- })();
+ });
}
}
/** After the commit: wake the relay if this process runs one. */
/** After the commit: wake the relay if this process runs one. */
@@ -26,14 +26,14 @@ function getConfig(req) { return req.app.locals.config; }
@@ -26,14 +26,14 @@ function getConfig(req) { return req.app.locals.config; }
const { hashResetToken, issueResetToken } = require('./reset-tokens');
const { hashResetToken, issueResetToken } = require('./reset-tokens');
-function findValidResetToken(db, token) {
- return db.prepare(`
- SELECT prt.id, prt.user_id, prt.expires_at, u.username, u.display_name, u.email
+async function findValidResetToken(db, token) {
+ return await db.prepare(`
+ SELECT prt.id, prt.token_hash, prt.user_id, prt.expires_at, u.username, u.display_name, u.email
FROM password_reset_tokens prt
FROM password_reset_tokens prt
JOIN users u ON u.id = prt.user_id
JOIN users u ON u.id = prt.user_id
WHERE prt.token_hash = ?
WHERE prt.token_hash = ?
AND prt.used_at IS NULL
AND prt.used_at IS NULL
- AND prt.expires_at > CURRENT_TIMESTAMP
+ AND prt.expires_at > ov_now()
LIMIT 1
LIMIT 1
`).get(hashResetToken(token));
`).get(hashResetToken(token));
}
}
@@ -96,14 +96,14 @@ function sanitizeUser(user) {
@@ -96,14 +96,14 @@ function sanitizeUser(user) {
const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');
const { makeRequireAuth, GRACE_MS: SESSION_GRACE_MS } = require('./session');
// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).
// Sliding session guard: expired-but-renewable tokens are accepted and renewed (cookie + X-OV-Token).
-const requireAuth = makeRequireAuth((req) => ({ db: getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);
+const requireAuth = makeRequireAuth(async (req) => ({ db: await getDb(req), publicKey: req.app.locals.publicKey, config: getConfig(req) }), signToken);
// ── Register ─────────────────────────────────────────────────
// ── Register ─────────────────────────────────────────────────
-router.post('/register', (req, res) => {
- const db = getDb(req);
+router.post('/register', async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
- if (!db.getSetting('registration_open')) {
+ if (!await db.getSetting('registration_open')) {
return res.status(403).json({ error: 'Registration is currently closed' });
return res.status(403).json({ error: 'Registration is currently closed' });
}
}
@@ -127,13 +127,13 @@ router.post('/register', (req, res) => {
@@ -127,13 +127,13 @@ router.post('/register', (req, res) => {
return res.status(400).json({ error: 'This username is reserved by the system and cannot be registered.' });
return res.status(400).json({ error: 'This username is reserved by the system and cannot be registered.' });
}
}
- const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
+ const existing = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) return res.status(409).json({ error: 'Username already taken' });
if (existing) return res.status(409).json({ error: 'Username already taken' });
// A name someone was renamed away from stays theirs for a while (links, mentions, reputation).
// A name someone was renamed away from stays theirs for a while (links, mentions, reputation).
- if (require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });
+ if (await require('../identity/usernames').isReserved(db, username)) return res.status(409).json({ error: 'Username already taken' });
// Check if username is reserved (has an active verification key)
// Check if username is reserved (has an active verification key)
- const reserved = db.isUsernameReserved(username);
+ const reserved = await db.isUsernameReserved(username);
if (reserved) {
if (reserved) {
if (!verification_key) {
if (!verification_key) {
return res.status(403).json({
return res.status(403).json({
@@ -141,7 +141,7 @@ router.post('/register', (req, res) => {
@@ -141,7 +141,7 @@ router.post('/register', (req, res) => {
reserved: true,
reserved: true,
});
});
}
}
- const vk = db.getVerificationKeyByKey(verification_key);
+ const vk = await db.getVerificationKeyByKey(verification_key);
if (!vk || vk.status !== 'active') {
if (!vk || vk.status !== 'active') {
return res.status(403).json({ error: 'Invalid or expired verification key' });
return res.status(403).json({ error: 'Invalid or expired verification key' });
}
}
@@ -151,25 +151,25 @@ router.post('/register', (req, res) => {
@@ -151,25 +151,25 @@ router.post('/register', (req, res) => {
}
}
if (email) {
if (email) {
- const emailExists = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
+ const emailExists = await db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (emailExists) return res.status(409).json({ error: 'Email already in use' });
if (emailExists) return res.status(409).json({ error: 'Email already in use' });
}
}
const passwordHash = bcrypt.hashSync(password, 10);
const passwordHash = bcrypt.hashSync(password, 10);
- const result = db.prepare(`
+ const result = await db.prepare(`
INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)
INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)
- VALUES (?, ?, ?, ?, '#8b5cf6', ?)
+ VALUES (?, ?, ?, ?, '#8b5cf6', ?) RETURNING id
`).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());
`).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());
// Create default preferences
// Create default preferences
- db.prepare('INSERT OR IGNORE INTO user_preferences (user_id) VALUES (?)').run(result.lastInsertRowid);
+ await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);
// Redeem verification key if used
// Redeem verification key if used
if (verification_key && reserved) {
if (verification_key && reserved) {
- db.redeemVerificationKey(verification_key, result.lastInsertRowid);
+ await db.redeemVerificationKey(verification_key, result.lastInsertRowid);
}
}
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
const token = signToken(user, req.app.locals.privateKey, config);
const token = signToken(user, req.app.locals.privateKey, config);
console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);
console.log(`[Auth] New user registered: ${username} (id: ${user.id})${reserved ? ' [verification key redeemed]' : ''}`);
@@ -181,7 +181,7 @@ router.post('/register', (req, res) => {
@@ -181,7 +181,7 @@ router.post('/register', (req, res) => {
try {
try {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (notifService) {
if (notifService) {
- notifService.create({
+ await notifService.create({
user_id: user.id,
user_id: user.id,
type: 'WELCOME',
type: 'WELCOME',
title: 'Welcome to OpenVibe',
title: 'Welcome to OpenVibe',
@@ -196,24 +196,24 @@ router.post('/register', (req, res) => {
@@ -196,24 +196,24 @@ router.post('/register', (req, res) => {
});
});
// ── Login ────────────────────────────────────────────────────
// ── Login ────────────────────────────────────────────────────
-router.post('/login', (req, res) => {
- const db = getDb(req);
+router.post('/login', async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const { username, password } = req.body;
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
- const user = db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);
+ const user = await db.prepare('SELECT * FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (!user) return res.status(401).json({ error: 'Invalid username or password' });
if (!user) return res.status(401).json({ error: 'Invalid username or password' });
if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
if (user.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: user.ban_reason });
if (!bcrypt.compareSync(password, user.password_hash)) {
if (!bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid username or password' });
return res.status(401).json({ error: 'Invalid username or password' });
}
}
// An account merged into another (ADR-029) signs in to the survivor.
// An account merged into another (ADR-029) signs in to the survivor.
- const signedIn = require('../identity/account-merge').effectiveUser(db, user);
+ const signedIn = await require('../identity/account-merge').effectiveUser(db, user);
if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });
if (signedIn.is_banned) return res.status(403).json({ error: 'Account banned', ban_reason: signedIn.ban_reason });
- db.prepare('UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(signedIn.id);
+ await db.prepare('UPDATE users SET last_seen = ov_now() WHERE id = ?').run(signedIn.id);
const token = signToken(signedIn, req.app.locals.privateKey, config);
const token = signToken(signedIn, req.app.locals.privateKey, config);
console.log(`[Auth] Login: ${user.username}${signedIn.id !== user.id ? ` (merged into ${signedIn.username})` : ''}`);
console.log(`[Auth] Login: ${user.username}${signedIn.id !== user.id ? ` (merged into ${signedIn.username})` : ''}`);
@@ -223,7 +223,7 @@ router.post('/login', (req, res) => {
@@ -223,7 +223,7 @@ router.post('/login', (req, res) => {
// ── Forgot Password ─────────────────────────────────────────
// ── Forgot Password ─────────────────────────────────────────
router.post('/forgot-password', async (req, res) => {
router.post('/forgot-password', async (req, res) => {
- const db = getDb(req);
+ const db = await getDb(req);
const emailService = req.app.locals.emailService;
const emailService = req.app.locals.emailService;
const genericResponse = {
const genericResponse = {
ok: true,
ok: true,
@@ -235,14 +235,14 @@ router.post('/forgot-password', async (req, res) => {
@@ -235,14 +235,14 @@ router.post('/forgot-password', async (req, res) => {
return res.status(400).json({ error: 'Email required' });
return res.status(400).json({ error: 'Email required' });
}
}
- const user = db.prepare('SELECT id, username, display_name, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);
+ const user = await db.prepare('SELECT id, username, display_name, email FROM users WHERE LOWER(email) = LOWER(?)').get(email);
if (!user || !user.email || !emailService?.isEnabled) {
if (!user || !user.email || !emailService?.isEnabled) {
return res.json(genericResponse);
return res.json(genericResponse);
}
}
// Shared with the owner-initiated admin flow so both mint tokens identically.
// Shared with the owner-initiated admin flow so both mint tokens identically.
- const { resetUrl, expiresMinutes } = issueResetToken(db, req, user.id);
+ const { resetUrl, expiresMinutes } = await issueResetToken(db, req, user.id);
await emailService.sendPasswordResetEmail({
await emailService.sendPasswordResetEmail({
to: user.email,
to: user.email,
username: user.display_name || user.username,
username: user.display_name || user.username,
@@ -254,12 +254,12 @@ router.post('/forgot-password', async (req, res) => {
@@ -254,12 +254,12 @@ router.post('/forgot-password', async (req, res) => {
});
});
// ── Validate Reset Token ────────────────────────────────────
// ── Validate Reset Token ────────────────────────────────────
-router.get('/reset-password/validate', (req, res) => {
- const db = getDb(req);
+router.get('/reset-password/validate', async (req, res) => {
+ const db = await getDb(req);
const token = String(req.query.token || '').trim();
const token = String(req.query.token || '').trim();
if (!token) return res.status(400).json({ error: 'Reset token required' });
if (!token) return res.status(400).json({ error: 'Reset token required' });
- const reset = findValidResetToken(db, token);
+ const reset = await findValidResetToken(db, token);
if (!reset) return res.status(400).json({ valid: false, error: 'Invalid or expired reset link' });
if (!reset) return res.status(400).json({ valid: false, error: 'Invalid or expired reset link' });
res.json({
res.json({
@@ -270,8 +270,8 @@ router.get('/reset-password/validate', (req, res) => {
@@ -270,8 +270,8 @@ router.get('/reset-password/validate', (req, res) => {
});
});
// ── Complete Password Reset ────────────────────────────────
// ── Complete Password Reset ────────────────────────────────
-router.post('/reset-password', (req, res) => {
- const db = getDb(req);
+router.post('/reset-password', async (req, res) => {
+ const db = await getDb(req);
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
const token = String(req.body?.token || '').trim();
const token = String(req.body?.token || '').trim();
const newPassword = String(req.body?.new_password || '');
const newPassword = String(req.body?.new_password || '');
@@ -279,21 +279,25 @@ router.post('/reset-password', (req, res) => {
@@ -279,21 +279,25 @@ router.post('/reset-password', (req, res) => {
if (!token || !newPassword) return res.status(400).json({ error: 'Reset token and new password required' });
if (!token || !newPassword) return res.status(400).json({ error: 'Reset token and new password required' });
if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
- const reset = findValidResetToken(db, token);
+ const reset = await findValidResetToken(db, token);
if (!reset) return res.status(400).json({ error: 'Invalid or expired reset link' });
if (!reset) return res.status(400).json({ error: 'Invalid or expired reset link' });
const hash = bcrypt.hashSync(newPassword, 10);
const hash = bcrypt.hashSync(newPassword, 10);
- const tx = db.transaction(() => {
- db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, reset.user_id);
+ // Claim the token first: of two concurrent uses of one reset link exactly one succeeds (decision 1).
+ const won = await db.tx(async (t) => {
+ const n = (await t.prepare('UPDATE password_reset_tokens SET used_at = ov_now() WHERE token_hash = ? AND used_at IS NULL').run(reset.token_hash)).changes;
+ if (!n) return false;
+ await t.prepare('UPDATE users SET password_hash = ?, updated_at = ov_now() WHERE id = ?').run(hash, reset.user_id);
// Every token and session from before the reset ends, on every site (network.user.token_valid_after).
// Every token and session from before the reset ends, on every site (network.user.token_valid_after).
- revocation.revokeTokens(db, reset.user_id, { reason: 'password_reset', ctx: req.ov });
- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);
+ await revocation.revokeTokens(t, reset.user_id, { reason: 'password_reset', ctx: req.ov });
+ await t.prepare('UPDATE password_reset_tokens SET used_at = ov_now() WHERE user_id = ? AND used_at IS NULL').run(reset.user_id);
+ return true;
});
});
- tx();
+ if (!won) return res.status(400).json({ error: 'Invalid or expired reset link' });
revocation.kick(db);
revocation.kick(db);
if (notifService) {
if (notifService) {
- notifService.create({
+ await notifService.create({
user_id: reset.user_id,
user_id: reset.user_id,
type: 'PASSWORD_CHANGED',
type: 'PASSWORD_CHANGED',
title: 'Password Reset Complete',
title: 'Password Reset Complete',
@@ -314,7 +318,7 @@ router.post('/reset-password', (req, res) => {
@@ -314,7 +318,7 @@ router.post('/reset-password', (req, res) => {
// requiring a full OAuth2 refresh_token flow.
// requiring a full OAuth2 refresh_token flow.
// Also accepts tokens expired within the last 7 days (grace period), so
// Also accepts tokens expired within the last 7 days (grace period), so
// users who return after a day away can still get a fresh token.
// users who return after a day away can still get a fresh token.
-router.post('/refresh', (req, res) => {
+router.post('/refresh', async (req, res) => {
const config = getConfig(req);
const config = getConfig(req);
const publicKey = req.app.locals.publicKey;
const publicKey = req.app.locals.publicKey;
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
@@ -353,8 +357,8 @@ router.post('/refresh', (req, res) => {
@@ -353,8 +357,8 @@ router.post('/refresh', (req, res) => {
if (!require('./session').isUserSessionClaims(decoded)) return res.status(401).json({ error: 'Invalid token' });
if (!require('./session').isUserSessionClaims(decoded)) return res.status(401).json({ error: 'Invalid token' });
// Verify user still exists and is valid
// Verify user still exists and is valid
- const db = getDb(req);
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
+ const db = await getDb(req);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' });
if (!user) return res.status(401).json({ error: 'User not found' });
if (user.is_banned) return res.status(403).json({ error: 'Account banned' });
if (user.is_banned) return res.status(403).json({ error: 'Account banned' });
@@ -379,10 +383,10 @@ router.post('/refresh', (req, res) => {
@@ -379,10 +383,10 @@ router.post('/refresh', (req, res) => {
});
});
// ── Get Current User ─────────────────────────────────────────
// ── Get Current User ─────────────────────────────────────────
-router.get('/me', requireAuth, (req, res) => {
- const db = getDb(req);
- const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
- subjects.ensureUserSubject(db, req.user);
+router.get('/me', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
+ await subjects.ensureUserSubject(db, req.user);
res.json({
res.json({
user: sanitizeUser(req.user),
user: sanitizeUser(req.user),
preferences: prefs || { theme_id: 'vibe' },
preferences: prefs || { theme_id: 'vibe' },
@@ -390,8 +394,8 @@ router.get('/me', requireAuth, (req, res) => {
@@ -390,8 +394,8 @@ router.get('/me', requireAuth, (req, res) => {
});
});
// ── Update Profile ───────────────────────────────────────────
// ── Update Profile ───────────────────────────────────────────
-router.put('/profile', requireAuth, (req, res) => {
- const db = getDb(req);
+router.put('/profile', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const { display_name, bio, avatar_url, email, profile_color } = req.body;
const { display_name, bio, avatar_url, email, profile_color } = req.body;
const updates = [];
const updates = [];
const params = [];
const params = [];
@@ -424,24 +428,24 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -424,24 +428,24 @@ router.put('/profile', requireAuth, (req, res) => {
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
- updates.push('updated_at = CURRENT_TIMESTAMP');
+ updates.push('updated_at = ov_now()');
params.push(req.user.id);
params.push(req.user.id);
try {
try {
- db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
+ await db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
} catch (e) {
} catch (e) {
if (/UNIQUE/.test(e.message)) return res.status(409).json({ error: 'That email address is already used by another account' });
if (/UNIQUE/.test(e.message)) return res.status(409).json({ error: 'That email address is already used by another account' });
throw e;
throw e;
}
}
// A new address is unverified until confirmed; kick off the confirmation email.
// A new address is unverified until confirmed; kick off the confirmation email.
- if (emailChanged) { try { require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }
+ if (emailChanged) { try { await require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
res.json({ user: sanitizeUser(user) });
res.json({ user: sanitizeUser(user) });
});
});
// ── Change Password ──────────────────────────────────────────
// ── Change Password ──────────────────────────────────────────
-router.post('/change-password', requireAuth, (req, res) => {
- const db = getDb(req);
+router.post('/change-password', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const { current_password, new_password } = req.body;
const { current_password, new_password } = req.body;
@@ -452,22 +456,22 @@ router.post('/change-password', requireAuth, (req, res) => {
@@ -452,22 +456,22 @@ router.post('/change-password', requireAuth, (req, res) => {
}
}
const hash = bcrypt.hashSync(new_password, 10);
const hash = bcrypt.hashSync(new_password, 10);
- db.transaction(() => {
- db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);
+ await db.tx(async () => {
+ await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);
// Every other token and session ends, on every site; this one gets a fresh token below.
// Every other token and session ends, on every site; this one gets a fresh token below.
- revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });
- db.prepare('UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
- })();
+ await revocation.revokeTokens(db, req.user.id, { reason: 'password_changed', ctx: req.ov });
+ await db.prepare('UPDATE password_reset_tokens SET used_at = ov_now() WHERE user_id = ? AND used_at IS NULL').run(req.user.id);
+ });
revocation.kick(db);
revocation.kick(db);
// Issue fresh token
// Issue fresh token
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
const token = signToken(user, req.app.locals.privateKey, config);
const token = signToken(user, req.app.locals.privateKey, config);
// Notify about password change
// Notify about password change
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (notifService) {
if (notifService) {
- notifService.create({
+ await notifService.create({
user_id: user.id,
user_id: user.id,
type: 'PASSWORD_CHANGED',
type: 'PASSWORD_CHANGED',
title: 'Password Changed',
title: 'Password Changed',
@@ -509,14 +513,14 @@ function formatAnonUser(anon) {
@@ -509,14 +513,14 @@ function formatAnonUser(anon) {
}
}
/** Log an IP association for an anon user (upsert) */
/** Log an IP association for an anon user (upsert) */
-function logAnonIp(db, anonId, ip) {
+async function logAnonIp(db, anonId, ip) {
if (!ip || ip === 'unknown') return;
if (!ip || ip === 'unknown') return;
try {
try {
- const existing = db.prepare('SELECT id FROM anon_ip_log WHERE anon_id = ? AND ip = ?').get(anonId, ip);
+ const existing = await db.prepare('SELECT id FROM anon_ip_log WHERE anon_id = ? AND ip = ?').get(anonId, ip);
if (existing) {
if (existing) {
- db.prepare('UPDATE anon_ip_log SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(existing.id);
+ await db.prepare('UPDATE anon_ip_log SET last_seen = ov_now() WHERE id = ?').run(existing.id);
} else {
} else {
- db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(anonId, ip);
+ await db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(anonId, ip);
}
}
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -529,11 +533,11 @@ function logAnonIp(db, anonId, ip) {
@@ -529,11 +533,11 @@ function logAnonIp(db, anonId, ip) {
// POST /api/auth/sign-out-everywhere: every token and session this person holds ends, here and (via
// POST /api/auth/sign-out-everywhere: every token and session this person holds ends, here and (via
// network.user.token_valid_after) on every OpenVibe site; open chat and game sockets close. The page
// network.user.token_valid_after) on every OpenVibe site; open chat and game sockets close. The page
// then runs the sign-out fanout so this browser's site sessions are cleared too.
// then runs the sign-out fanout so this browser's site sessions are cleared too.
-router.post('/sign-out-everywhere', requireAuth, (req, res) => {
- const db = getDb(req);
- const { validAfter } = revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
+router.post('/sign-out-everywhere', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const { validAfter } = await revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
revocation.kick(db);
revocation.kick(db);
- try { db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }
+ try { await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'sign_out_everywhere', JSON.stringify({ valid_after: validAfter })); } catch { /* audit is best effort */ }
require('./session').clearSessionCookies(res);
require('./session').clearSessionCookies(res);
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.json({ ok: true, valid_after: validAfter });
res.json({ ok: true, valid_after: validAfter });
@@ -547,8 +551,8 @@ router.post('/logout', (req, res) => {
@@ -547,8 +551,8 @@ router.post('/logout', (req, res) => {
res.json({ ok: true });
res.json({ ok: true });
});
});
-router.post('/anon-session', (req, res) => {
- const db = getDb(req);
+router.post('/anon-session', async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const ip = getRequestIp(req);
const ip = getRequestIp(req);
@@ -559,12 +563,12 @@ router.post('/anon-session', (req, res) => {
@@ -559,12 +563,12 @@ router.post('/anon-session', (req, res) => {
// Check if this fingerprint already has an anon user (unless forcing new)
// Check if this fingerprint already has an anon user (unless forcing new)
if (fingerprint && !forceNew) {
if (fingerprint && !forceNew) {
- const existing = db.prepare('SELECT * FROM anon_users WHERE fingerprint = ?').get(fingerprint);
+ const existing = await db.prepare('SELECT * FROM anon_users WHERE fingerprint = ?').get(fingerprint);
if (existing) {
if (existing) {
// Return existing anon user with a fresh session token
// Return existing anon user with a fresh session token
- db.prepare('UPDATE anon_users SET session_token = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?')
+ await db.prepare('UPDATE anon_users SET session_token = ?, last_seen = ov_now() WHERE id = ?')
.run(sessionToken, existing.id);
.run(sessionToken, existing.id);
- logAnonIp(db, existing.id, ip);
+ await logAnonIp(db, existing.id, ip);
return res.json({
return res.json({
token: sessionToken,
token: sessionToken,
user: formatAnonUser({ ...existing, last_seen: new Date().toISOString() }),
user: formatAnonUser({ ...existing, last_seen: new Date().toISOString() }),
@@ -574,11 +578,11 @@ router.post('/anon-session', (req, res) => {
@@ -574,11 +578,11 @@ router.post('/anon-session', (req, res) => {
// Check if this IP already has a default anon (unless forcing new)
// Check if this IP already has a default anon (unless forcing new)
if (!forceNew) {
if (!forceNew) {
- const byIp = db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);
+ const byIp = await db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);
if (byIp) {
if (byIp) {
- db.prepare('UPDATE anon_users SET session_token = ?, last_seen = CURRENT_TIMESTAMP WHERE id = ?')
+ await db.prepare('UPDATE anon_users SET session_token = ?, last_seen = ov_now() WHERE id = ?')
.run(sessionToken, byIp.id);
.run(sessionToken, byIp.id);
- logAnonIp(db, byIp.id, ip);
+ await logAnonIp(db, byIp.id, ip);
return res.json({
return res.json({
token: sessionToken,
token: sessionToken,
user: formatAnonUser({ ...byIp, last_seen: new Date().toISOString() }),
user: formatAnonUser({ ...byIp, last_seen: new Date().toISOString() }),
@@ -587,14 +591,14 @@ router.post('/anon-session', (req, res) => {
@@ -587,14 +591,14 @@ router.post('/anon-session', (req, res) => {
}
}
// Generate next anon number
// Generate next anon number
- const maxNum = db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get().max || 0;
+ const maxNum = (await db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get()).max || 0;
const anonNumber = maxNum + 1;
const anonNumber = maxNum + 1;
- const result = db.prepare(
- 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip, subject_id) VALUES (?, ?, ?, ?, ?)'
+ const result = await db.prepare(
+ 'INSERT INTO anon_users (anon_number, fingerprint, session_token, ip, subject_id) VALUES (?, ?, ?, ?, ?) RETURNING id'
).run(anonNumber, fingerprint, sessionToken, ip, subjects.newGuestSubjectId());
).run(anonNumber, fingerprint, sessionToken, ip, subjects.newGuestSubjectId());
- logAnonIp(db, result.lastInsertRowid, ip);
+ await logAnonIp(db, result.lastInsertRowid, ip);
console.log(`[Auth] New anonymous user: #${anonNumber} (IP: ${ip})`);
console.log(`[Auth] New anonymous user: #${anonNumber} (IP: ${ip})`);
res.json({
res.json({
@@ -617,23 +621,23 @@ router.post('/anon-session', (req, res) => {
@@ -617,23 +621,23 @@ router.post('/anon-session', (req, res) => {
// ── Get Anonymous User Info ──────────────────────────────────
// ── Get Anonymous User Info ──────────────────────────────────
// GET /api/auth/anon/:token
// GET /api/auth/anon/:token
-router.get('/anon/:token', (req, res) => {
- const db = getDb(req);
- const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
+router.get('/anon/:token', async (req, res) => {
+ const db = await getDb(req);
+ const anon = await db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
- db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(anon.id);
+ await db.prepare('UPDATE anon_users SET last_seen = ov_now() WHERE id = ?').run(anon.id);
const ip = getRequestIp(req);
const ip = getRequestIp(req);
- logAnonIp(db, anon.id, ip);
+ await logAnonIp(db, anon.id, ip);
res.json({ user: formatAnonUser(anon) });
res.json({ user: formatAnonUser(anon) });
});
});
// ── Update Anonymous User Preferences ────────────────────────
// ── Update Anonymous User Preferences ────────────────────────
// PUT /api/auth/anon/:token/preferences
// PUT /api/auth/anon/:token/preferences
-router.put('/anon/:token/preferences', (req, res) => {
- const db = getDb(req);
- const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
+router.put('/anon/:token/preferences', async (req, res) => {
+ const db = await getDb(req);
+ const anon = await db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
const current = JSON.parse(anon.preferences || '{}');
const current = JSON.parse(anon.preferences || '{}');
@@ -645,7 +649,7 @@ router.put('/anon/:token/preferences', (req, res) => {
@@ -645,7 +649,7 @@ router.put('/anon/:token/preferences', (req, res) => {
if (updated[key] !== undefined) safe[key] = updated[key];
if (updated[key] !== undefined) safe[key] = updated[key];
}
}
- db.prepare('UPDATE anon_users SET preferences = ?, display_name = ? WHERE id = ?')
+ await db.prepare('UPDATE anon_users SET preferences = ?, display_name = ? WHERE id = ?')
.run(JSON.stringify(safe), safe.display_name || anon.display_name, anon.id);
.run(JSON.stringify(safe), safe.display_name || anon.display_name, anon.id);
res.json({ ok: true, preferences: safe });
res.json({ ok: true, preferences: safe });
@@ -654,20 +658,20 @@ router.put('/anon/:token/preferences', (req, res) => {
@@ -654,20 +658,20 @@ router.put('/anon/:token/preferences', (req, res) => {
// ── Link Anonymous to Registered Account ─────────────────────
// ── Link Anonymous to Registered Account ─────────────────────
// POST /api/auth/anon/:token/link
// POST /api/auth/anon/:token/link
// Merges anon stats into the authenticated user's account.
// Merges anon stats into the authenticated user's account.
-router.post('/anon/:token/link', requireAuth, (req, res) => {
- const db = getDb(req);
- const anon = db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
+router.post('/anon/:token/link', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const anon = await db.prepare('SELECT * FROM anon_users WHERE session_token = ?').get(req.params.token);
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
if (!anon) return res.status(404).json({ error: 'Anonymous session not found' });
- const userSubject = subjects.ensureUserSubject(db, req.user);
- db.transaction(() => {
+ const userSubject = await subjects.ensureUserSubject(db, req.user);
+ await db.tx(async () => {
// Store anon number on the user for reference
// Store anon number on the user for reference
- db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
+ await db.prepare('UPDATE users SET anon_number = ? WHERE id = ? AND anon_number IS NULL')
.run(anon.anon_number, req.user.id);
.run(anon.anon_number, req.user.id);
// The guest's user modules (portable preferences) move to the account; where the account already
// The guest's user modules (portable preferences) move to the account; where the account already
// has a record in a namespace, the account's is kept (server/identity/modules.js onSubjectMerged).
// has a record in a namespace, the account's is kept (server/identity/modules.js onSubjectMerged).
- if (anon.subject_id && userSubject) require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });
- })();
+ if (anon.subject_id && userSubject) await require('../identity/modules').onSubjectMerged(db, { from: anon.subject_id, into: userSubject });
+ });
console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);
console.log(`[Auth] Linked anon #${anon.anon_number} → user ${req.user.username}`);
res.json({ ok: true, anon_number: anon.anon_number });
res.json({ ok: true, anon_number: anon.anon_number });
@@ -676,13 +680,13 @@ router.post('/anon/:token/link', requireAuth, (req, res) => {
@@ -676,13 +680,13 @@ router.post('/anon/:token/link', requireAuth, (req, res) => {
// ── List All Anon Identities for Current IP ──────────────────
// ── List All Anon Identities for Current IP ──────────────────
// GET /api/auth/anon-identities
// GET /api/auth/anon-identities
// Returns all anon identities associated with the caller's IP.
// Returns all anon identities associated with the caller's IP.
-router.get('/anon-identities', (req, res) => {
- const db = getDb(req);
+router.get('/anon-identities', async (req, res) => {
+ const db = await getDb(req);
const ip = getRequestIp(req);
const ip = getRequestIp(req);
try {
try {
// Find all anon IDs that have been seen from this IP
// Find all anon IDs that have been seen from this IP
- const anons = db.prepare(`
+ const anons = await db.prepare(`
SELECT DISTINCT a.* FROM anon_users a
SELECT DISTINCT a.* FROM anon_users a
INNER JOIN anon_ip_log l ON l.anon_id = a.id
INNER JOIN anon_ip_log l ON l.anon_id = a.id
WHERE l.ip = ?
WHERE l.ip = ?
@@ -690,7 +694,7 @@ router.get('/anon-identities', (req, res) => {
@@ -690,7 +694,7 @@ router.get('/anon-identities', (req, res) => {
`).all(ip);
`).all(ip);
// Also include any anon whose creating IP matches
// Also include any anon whose creating IP matches
- const byCreatingIp = db.prepare('SELECT * FROM anon_users WHERE ip = ?').all(ip);
+ const byCreatingIp = await db.prepare('SELECT * FROM anon_users WHERE ip = ?').all(ip);
const seen = new Set(anons.map(a => a.id));
const seen = new Set(anons.map(a => a.id));
for (const a of byCreatingIp) {
for (const a of byCreatingIp) {
if (!seen.has(a.id)) anons.push(a);
if (!seen.has(a.id)) anons.push(a);
@@ -714,9 +718,9 @@ router.get('/anon-identities', (req, res) => {
@@ -714,9 +718,9 @@ router.get('/anon-identities', (req, res) => {
// ── List Active Sessions ─────────────────────────────────────
// ── List Active Sessions ─────────────────────────────────────
// GET /api/auth/sessions
// GET /api/auth/sessions
-router.get('/sessions', requireAuth, (req, res) => {
- const db = getDb(req);
- const sessions = db.prepare(`
+router.get('/sessions', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const sessions = await db.prepare(`
SELECT id, device_name, ip, last_used, created_at FROM user_sessions
SELECT id, device_name, ip, last_used, created_at FROM user_sessions
WHERE user_id = ? AND is_active = 1
WHERE user_id = ? AND is_active = 1
ORDER BY last_used DESC
ORDER BY last_used DESC
@@ -727,8 +731,8 @@ router.get('/sessions', requireAuth, (req, res) => {
@@ -727,8 +731,8 @@ router.get('/sessions', requireAuth, (req, res) => {
// ── Create Session (for multi-account) ───────────────────────
// ── Create Session (for multi-account) ───────────────────────
// POST /api/auth/sessions
// POST /api/auth/sessions
// Called when logging in with "add account" — stores session token.
// Called when logging in with "add account" — stores session token.
-router.post('/sessions', requireAuth, (req, res) => {
- const db = getDb(req);
+router.post('/sessions', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const config = getConfig(req);
const config = getConfig(req);
const sessionToken = uuidv4();
const sessionToken = uuidv4();
@@ -736,7 +740,7 @@ router.post('/sessions', requireAuth, (req, res) => {
@@ -736,7 +740,7 @@ router.post('/sessions', requireAuth, (req, res) => {
const ip = req.ip;
const ip = req.ip;
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days
- db.prepare(`
+ await db.prepare(`
INSERT INTO user_sessions (user_id, session_token, device_name, ip, user_agent, expires_at)
INSERT INTO user_sessions (user_id, session_token, device_name, ip, user_agent, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?)
`).run(req.user.id, sessionToken, deviceName, ip, req.headers['user-agent'] || '', expiresAt);
`).run(req.user.id, sessionToken, deviceName, ip, req.headers['user-agent'] || '', expiresAt);
@@ -749,9 +753,9 @@ router.post('/sessions', requireAuth, (req, res) => {
@@ -749,9 +753,9 @@ router.post('/sessions', requireAuth, (req, res) => {
// ── Revoke Session ───────────────────────────────────────────
// ── Revoke Session ───────────────────────────────────────────
// DELETE /api/auth/sessions/:id
// DELETE /api/auth/sessions/:id
-router.delete('/sessions/:id', requireAuth, (req, res) => {
- const db = getDb(req);
- const result = db.prepare('UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?')
+router.delete('/sessions/:id', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const result = await db.prepare('UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?')
.run(req.params.id, req.user.id);
.run(req.params.id, req.user.id);
res.json({ ok: true, revoked: result.changes > 0 });
res.json({ ok: true, revoked: result.changes > 0 });
});
});
@@ -759,12 +763,12 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {
@@ -759,12 +763,12 @@ router.delete('/sessions/:id', requireAuth, (req, res) => {
// ── Sign out other devices ───────────────────────────────────
// ── Sign out other devices ───────────────────────────────────
// DELETE /api/auth/sessions: every token and session ends on every site (network.user.token_valid_after),
// DELETE /api/auth/sessions: every token and session ends on every site (network.user.token_valid_after),
// and this browser gets a fresh token so it stays signed in (same as a password change).
// and this browser gets a fresh token so it stays signed in (same as a password change).
-router.delete('/sessions', requireAuth, (req, res) => {
- const db = getDb(req);
- const active = db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id).n;
- revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
+router.delete('/sessions', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const active = (await db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(req.user.id)).n;
+ await revocation.revokeTokens(db, req.user.id, { reason: 'signed_out_everywhere', ctx: req.ov });
revocation.kick(db);
revocation.kick(db);
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req), { renew: req.tokenClaims || {} }) });
res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req), { renew: req.tokenClaims || {} }) });
});
});
@@ -774,11 +778,11 @@ router.delete('/sessions', requireAuth, (req, res) => {
@@ -774,11 +778,11 @@ router.delete('/sessions', requireAuth, (req, res) => {
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// GET /api/users/:id/card — public user card data for context menus
// GET /api/users/:id/card — public user card data for context menus
-router.get('/users/:id/card', (req, res) => {
- const db = getDb(req);
+router.get('/users/:id/card', async (req, res) => {
+ const db = await getDb(req);
const userId = req.params.id;
const userId = req.params.id;
- const user = db.prepare(`
+ const user = await db.prepare(`
SELECT id, username, display_name, avatar_url, bio, role, profile_color,
SELECT id, username, display_name, avatar_url, bio, role, profile_color,
name_effect, particle_effect, is_anon, anon_number, created_at
name_effect, particle_effect, is_anon, anon_number, created_at
FROM users WHERE id = ?
FROM users WHERE id = ?
@@ -786,11 +790,11 @@ router.get('/users/:id/card', (req, res) => {
@@ -786,11 +790,11 @@ router.get('/users/:id/card', (req, res) => {
if (!user) return res.status(404).json({ error: 'User not found' });
if (!user) return res.status(404).json({ error: 'User not found' });
// Linked services
// Linked services
- const linked = db.prepare('SELECT service, service_username FROM linked_accounts WHERE user_id = ?').all(userId);
+ const linked = await db.prepare('SELECT service, service_username FROM linked_accounts WHERE user_id = ?').all(userId);
// Follower count
// Follower count
- const followers = db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE followed_id = ?').get(userId)?.cnt || 0;
- const following = db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE follower_id = ?').get(userId)?.cnt || 0;
+ const followers = (await db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE followed_id = ?').get(userId))?.cnt || 0;
+ const following = (await db.prepare('SELECT COUNT(*) as cnt FROM follows WHERE follower_id = ?').get(userId))?.cnt || 0;
// Is current user following this user?
// Is current user following this user?
let isFollowing = false;
let isFollowing = false;
@@ -801,13 +805,13 @@ router.get('/users/:id/card', (req, res) => {
@@ -801,13 +805,13 @@ router.get('/users/:id/card', (req, res) => {
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
const token = req.headers.authorization.replace('Bearer ', '');
const token = req.headers.authorization.replace('Bearer ', '');
const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
const decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
- const follow = db.prepare('SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?').get(decoded.sub || decoded.id, userId);
+ const follow = await db.prepare('SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?').get(decoded.sub || decoded.id, userId);
isFollowing = !!follow;
isFollowing = !!follow;
} catch { /* not logged in, fine */ }
} catch { /* not logged in, fine */ }
}
}
// Active effects
// Active effects
- const effects = db.prepare(`
+ const effects = await db.prepare(`
SELECT effect_type, effect_id FROM user_effects
SELECT effect_type, effect_id FROM user_effects
WHERE user_id = ? AND is_active = 1
WHERE user_id = ? AND is_active = 1
`).all(userId);
`).all(userId);
@@ -826,18 +830,18 @@ router.get('/users/:id/card', (req, res) => {
@@ -826,18 +830,18 @@ router.get('/users/:id/card', (req, res) => {
});
});
// POST /api/users/:id/follow
// POST /api/users/:id/follow
-router.post('/users/:id/follow', requireAuth, (req, res) => {
- const db = getDb(req);
+router.post('/users/:id/follow', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const targetId = parseInt(req.params.id);
const targetId = parseInt(req.params.id);
if (targetId === req.user.id) return res.status(400).json({ error: 'Cannot follow yourself' });
if (targetId === req.user.id) return res.status(400).json({ error: 'Cannot follow yourself' });
try {
try {
- db.prepare('INSERT OR IGNORE INTO follows (follower_id, followed_id) VALUES (?, ?)').run(req.user.id, targetId);
+ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES(?, ?) ON CONFLICT DO NOTHING').run(req.user.id, targetId);
// Notify target
// Notify target
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (notifService) {
if (notifService) {
- notifService.create({
+ await notifService.create({
user_id: targetId,
user_id: targetId,
type: 'FOLLOW',
type: 'FOLLOW',
title: 'New Follower',
title: 'New Follower',
@@ -857,10 +861,10 @@ router.post('/users/:id/follow', requireAuth, (req, res) => {
@@ -857,10 +861,10 @@ router.post('/users/:id/follow', requireAuth, (req, res) => {
});
});
// DELETE /api/users/:id/follow
// DELETE /api/users/:id/follow
-router.delete('/users/:id/follow', requireAuth, (req, res) => {
- const db = getDb(req);
+router.delete('/users/:id/follow', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const targetId = parseInt(req.params.id);
const targetId = parseInt(req.params.id);
- db.prepare('DELETE FROM follows WHERE follower_id = ? AND followed_id = ?').run(req.user.id, targetId);
+ await db.prepare('DELETE FROM follows WHERE follower_id = ? AND followed_id = ?').run(req.user.id, targetId);
res.json({ ok: true });
res.json({ ok: true });
});
});
@@ -18,7 +18,7 @@ function isUserSessionClaims(decoded) {
@@ -18,7 +18,7 @@ function isUserSessionClaims(decoded) {
return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;
return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;
}
}
-function verifySession(token, { db, publicKey, config }) {
+async function verifySession(token, { db, publicKey, config }) {
if (!token) return { error: 'Authentication required', status: 401 };
if (!token) return { error: 'Authentication required', status: 401 };
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
let decoded, expired = false;
let decoded, expired = false;
@@ -36,7 +36,7 @@ function verifySession(token, { db, publicKey, config }) {
@@ -36,7 +36,7 @@ function verifySession(token, { db, publicKey, config }) {
// Same signing key, different token kinds: a FedCM assertion (minted for one RP origin, to be
// Same signing key, different token kinds: a FedCM assertion (minted for one RP origin, to be
// exchanged by that RP's server) and service/app tokens are never a person's session.
// exchanged by that RP's server) and service/app tokens are never a person's session.
if (!isUserSessionClaims(decoded)) return { error: 'Invalid token', status: 401 };
if (!isUserSessionClaims(decoded)) return { error: 'Invalid token', status: 401 };
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user) return { error: 'User not found', status: 401 };
if (!user) return { error: 'User not found', status: 401 };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
// An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.
// An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.
@@ -81,14 +81,14 @@ function requestToken(req) {
@@ -81,14 +81,14 @@ function requestToken(req) {
}
}
/** Express guard factory: attaches req.user / req.token; slides the session when due. */
/** Express guard factory: attaches req.user / req.token; slides the session when due. */
function makeRequireAuth(getCtx, signToken) {
function makeRequireAuth(getCtx, signToken) {
- return function requireAuth(req, res, next) {
+ return async function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
// Never ov_sso here: it is SameSite=None, so a hostile page could make the browser send it
// Never ov_sso here: it is SameSite=None, so a hostile page could make the browser send it
// on a cross-site POST. Only the read-only /sso/check and the prompt=none authorize
// on a cross-site POST. Only the read-only /sso/check and the prompt=none authorize
// (which yields a code bound to a registered redirect_uri) may consult it.
// (which yields a code bound to a registered redirect_uri) may consult it.
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
- const ctx = getCtx(req);
- const out = verifySession(token, ctx);
+ const ctx = await getCtx(req);
+ const out = await verifySession(token, ctx);
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
req.user = out.user;
req.user = out.user;
req.token = token;
req.token = token;
@@ -35,13 +35,13 @@ function renderCheck(origin, state) {
@@ -35,13 +35,13 @@ function renderCheck(origin, state) {
}
}
function createSsoCheckRoute(getCtx) {
function createSsoCheckRoute(getCtx) {
- return function ssoCheck(req, res) {
+ return async function ssoCheck(req, res) {
const origin = allowedOrigin(req.query.origin);
const origin = allowedOrigin(req.query.origin);
if (!origin) return res.status(400).type('text').send('origin must be an OpenVibe origin');
if (!origin) return res.status(400).type('text').send('origin must be an OpenVibe origin');
const { verifySession, requestToken } = require('./session');
const { verifySession, requestToken } = require('./session');
let signedIn = false, username = null;
let signedIn = false, username = null;
try {
try {
- const out = verifySession(requestToken(req), getCtx(req));
+ const out = await verifySession(requestToken(req), getCtx(req));
if (!out.error && out.user && !out.user.is_anon) { signedIn = true; username = out.user.username || null; }
if (!out.error && out.user && !out.user.is_anon) { signedIn = true; username = out.user.username || null; }
} catch { /* not signed in */ }
} catch { /* not signed in */ }
res.removeHeader('X-Frame-Options');
res.removeHeader('X-Frame-Options');
@@ -15,16 +15,16 @@ module.exports = function createCoinsRoutes(db, requireAuth) {
@@ -15,16 +15,16 @@ module.exports = function createCoinsRoutes(db, requireAuth) {
// ── GET /api/coins/me ────────────────────────────────────
// ── GET /api/coins/me ────────────────────────────────────
// → { balance }
// → { balance }
- router.get('/me', requireAuth, (req, res) => {
- res.json({ balance: wallet.getBalance(db, req.user.id) });
+ router.get('/me', requireAuth, async (req, res) => {
+ res.json({ balance: await wallet.getBalance(db, req.user.id) });
});
});
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
// → { transactions: [{ id, app_id, delta, reason, created_at }] }
// → { transactions: [{ id, app_id, delta, reason, created_at }] }
- router.get('/me/history', requireAuth, (req, res) => {
+ router.get('/me/history', requireAuth, async (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
- const transactions = db.prepare(`
+ const transactions = await db.prepare(`
SELECT id, app_id, delta, reason, created_at
SELECT id, app_id, delta, reason, created_at
FROM coin_transactions
FROM coin_transactions
WHERE user_id = ?
WHERE user_id = ?
@@ -25,33 +25,33 @@ function isPositiveInt(value) {
@@ -25,33 +25,33 @@ function isPositiveInt(value) {
return Number.isInteger(value) && value > 0;
return Number.isInteger(value) && value > 0;
}
}
-function assertUser(db, userId, label = 'user_id') {
+async function assertUser(db, userId, label = 'user_id') {
const id = Number(userId);
const id = Number(userId);
if (!Number.isInteger(id) || id <= 0) {
if (!Number.isInteger(id) || id <= 0) {
throw new WalletError(400, { error: `invalid_${label}` });
throw new WalletError(400, { error: `invalid_${label}` });
}
}
- const user = db.prepare('SELECT id FROM users WHERE id = ?').get(id);
+ const user = await db.prepare('SELECT id FROM users WHERE id = ?').get(id);
if (!user) {
if (!user) {
throw new WalletError(404, { error: 'user_not_found', [label]: id });
throw new WalletError(404, { error: 'user_not_found', [label]: id });
}
}
return id;
return id;
}
}
-function getBalance(db, userId) {
- const row = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(userId);
+async function getBalance(db, userId) {
+ const row = await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(userId);
return row ? row.balance : 0;
return row ? row.balance : 0;
}
}
-function balanceAfterTx(db, tx) {
- const row = db.prepare(
+async function balanceAfterTx(db, tx) {
+ const row = await db.prepare(
'SELECT COALESCE(SUM(delta), 0) AS bal FROM coin_transactions WHERE user_id = ? AND id <= ?'
'SELECT COALESCE(SUM(delta), 0) AS bal FROM coin_transactions WHERE user_id = ? AND id <= ?'
).get(tx.user_id, tx.id);
).get(tx.user_id, tx.id);
return row.bal;
return row.bal;
}
}
-function findByIdempotencyKey(db, key) {
+async function findByIdempotencyKey(db, key) {
if (!key) return null;
if (!key) return null;
- return db.prepare('SELECT * FROM coin_transactions WHERE idempotency_key = ?').get(key);
+ return await db.prepare('SELECT * FROM coin_transactions WHERE idempotency_key = ?').get(key);
}
}
/**
/**
@@ -60,40 +60,41 @@ function findByIdempotencyKey(db, key) {
@@ -60,40 +60,41 @@ function findByIdempotencyKey(db, key) {
* Throws WalletError(409, insufficient_funds) on overdraft.
* Throws WalletError(409, insufficient_funds) on overdraft.
* @returns {number} the new balance
* @returns {number} the new balance
*/
*/
-function applyDelta(db, { user_id, app_id, delta, reason, ref, idempotency_key }) {
- db.prepare('INSERT OR IGNORE INTO wallets (user_id, balance) VALUES (?, 0)').run(user_id);
- const balance = getBalance(db, user_id);
- const next = balance + delta;
- if (next < 0) {
- throw new WalletError(409, { error: 'insufficient_funds', balance });
+async function applyDelta(db, { user_id, app_id, delta, reason, ref, idempotency_key }) {
+ await db.prepare('INSERT INTO wallets (user_id, balance) VALUES(?, 0) ON CONFLICT DO NOTHING').run(user_id);
+ // The balance moves with one conditional UPDATE: of two concurrent spends of one wallet exactly one
+ // succeeds even at the same starting balance (decision 1, plan T2). The WHERE keeps it from going
+ // negative and RETURNING-less: the caller's transaction sees its own write below.
+ const moved = (await db.prepare('UPDATE wallets SET balance = balance + ?, updated_at = ov_now() WHERE user_id = ? AND balance + ? >= 0').run(delta, user_id, delta)).changes;
+ if (!moved) {
+ throw new WalletError(409, { error: 'insufficient_funds', balance: await getBalance(db, user_id) });
}
}
- db.prepare(`
+ await db.prepare(`
INSERT INTO coin_transactions (user_id, app_id, delta, reason, ref, idempotency_key)
INSERT INTO coin_transactions (user_id, app_id, delta, reason, ref, idempotency_key)
VALUES (?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?)
`).run(user_id, app_id || null, delta, reason || null, ref || null, idempotency_key || null);
`).run(user_id, app_id || null, delta, reason || null, ref || null, idempotency_key || null);
- db.prepare('UPDATE wallets SET balance = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?').run(next, user_id);
- return next;
+ return await getBalance(db, user_id);
}
}
/**
/**
* Credit (sign=+1) or debit (sign=-1) a wallet. Atomic + idempotent.
* Credit (sign=+1) or debit (sign=-1) a wallet. Atomic + idempotent.
* @returns {{ balance: number, deduped: boolean }}
* @returns {{ balance: number, deduped: boolean }}
*/
*/
-function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempotency_key }) {
+async function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempotency_key }) {
if (!isPositiveInt(amount)) {
if (!isPositiveInt(amount)) {
throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });
throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });
}
}
if (!idempotency_key || typeof idempotency_key !== 'string') {
if (!idempotency_key || typeof idempotency_key !== 'string') {
throw new WalletError(400, { error: 'missing_idempotency_key' });
throw new WalletError(400, { error: 'missing_idempotency_key' });
}
}
- const uid = assertUser(db, user_id);
+ const uid = await assertUser(db, user_id);
- const run = db.transaction(() => {
- const existing = findByIdempotencyKey(db, idempotency_key);
+ const run = db.txFn(async () => {
+ const existing = await findByIdempotencyKey(db, idempotency_key);
if (existing) {
if (existing) {
- return { balance: balanceAfterTx(db, existing), deduped: true };
+ return { balance: await balanceAfterTx(db, existing), deduped: true };
}
}
- const balance = applyDelta(db, {
+ const balance = await applyDelta(db, {
user_id: uid,
user_id: uid,
app_id,
app_id,
delta: sign * amount,
delta: sign * amount,
@@ -106,12 +107,12 @@ function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempot
@@ -106,12 +107,12 @@ function creditOrDebit(db, sign, { user_id, app_id, amount, reason, ref, idempot
return run();
return run();
}
}
-function credit(db, opts) {
- return creditOrDebit(db, +1, opts);
+async function credit(db, opts) {
+ return await creditOrDebit(db, +1, opts);
}
}
-function debit(db, opts) {
- return creditOrDebit(db, -1, opts);
+async function debit(db, opts) {
+ return await creditOrDebit(db, -1, opts);
}
}
/**
/**
@@ -120,34 +121,34 @@ function debit(db, opts) {
@@ -120,34 +121,34 @@ function debit(db, opts) {
* the credit side carries `${idempotency_key}:in`.
* the credit side carries `${idempotency_key}:in`.
* @returns {{ from_balance: number, to_balance: number, deduped: boolean }}
* @returns {{ from_balance: number, to_balance: number, deduped: boolean }}
*/
*/
-function transfer(db, { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key }) {
+async function transfer(db, { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key }) {
if (!isPositiveInt(amount)) {
if (!isPositiveInt(amount)) {
throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });
throw new WalletError(400, { error: 'invalid_amount', message: 'amount must be a positive integer' });
}
}
if (!idempotency_key || typeof idempotency_key !== 'string') {
if (!idempotency_key || typeof idempotency_key !== 'string') {
throw new WalletError(400, { error: 'missing_idempotency_key' });
throw new WalletError(400, { error: 'missing_idempotency_key' });
}
}
- const fromId = assertUser(db, from_user_id, 'from_user_id');
- const toId = assertUser(db, to_user_id, 'to_user_id');
+ const fromId = await assertUser(db, from_user_id, 'from_user_id');
+ const toId = await assertUser(db, to_user_id, 'to_user_id');
if (fromId === toId) {
if (fromId === toId) {
throw new WalletError(400, { error: 'invalid_transfer', message: 'from_user_id and to_user_id must differ' });
throw new WalletError(400, { error: 'invalid_transfer', message: 'from_user_id and to_user_id must differ' });
}
}
const inKey = `${idempotency_key}:in`;
const inKey = `${idempotency_key}:in`;
- const run = db.transaction(() => {
- const existingOut = findByIdempotencyKey(db, idempotency_key);
+ const run = db.txFn(async () => {
+ const existingOut = await findByIdempotencyKey(db, idempotency_key);
if (existingOut) {
if (existingOut) {
- const existingIn = findByIdempotencyKey(db, inKey);
+ const existingIn = await findByIdempotencyKey(db, inKey);
return {
return {
- from_balance: balanceAfterTx(db, existingOut),
- to_balance: existingIn ? balanceAfterTx(db, existingIn) : getBalance(db, toId),
+ from_balance: await balanceAfterTx(db, existingOut),
+ to_balance: existingIn ? await balanceAfterTx(db, existingIn) : await getBalance(db, toId),
deduped: true,
deduped: true,
};
};
}
}
- const from_balance = applyDelta(db, {
+ const from_balance = await applyDelta(db, {
user_id: fromId, app_id, delta: -amount, reason, ref, idempotency_key,
user_id: fromId, app_id, delta: -amount, reason, ref, idempotency_key,
});
});
- const to_balance = applyDelta(db, {
+ const to_balance = await applyDelta(db, {
user_id: toId, app_id, delta: amount, reason, ref, idempotency_key: inKey,
user_id: toId, app_id, delta: amount, reason, ref, idempotency_key: inKey,
});
});
return { from_balance, to_balance, deduped: false };
return { from_balance, to_balance, deduped: false };
@@ -56,21 +56,24 @@ module.exports = {
@@ -56,21 +56,24 @@ module.exports = {
// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves
// Database (ADR-035, plan T2): PostgreSQL through PgBouncer in production. `url` (DATABASE_URL) serves
// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.
// requests; `directUrl` (DATABASE_DIRECT_URL, the owner role on a direct connection) runs migrations.
// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`
// Without DATABASE_URL, development and tests run an embedded PGlite database in `pgliteDir`
- // (PGLITE_DIR overrides it); production refuses to boot. `path` is the retired SQLite file: it is
- // read only by the one-time import (scripts/migrate-to-postgres.js) and reset-db, never by serving code.
+ // (PGLITE_DIR overrides it); production refuses to boot. There is no SQLite file: DB_PATH is read only
+ // by the one-time import tools (scripts/migrate-to-postgres.js, scripts/pg-preflight-collisions.js).
db: {
db: {
- path: process.env.DB_PATH || './data/network.db',
url: process.env.DATABASE_URL || '',
url: process.env.DATABASE_URL || '',
directUrl: process.env.DATABASE_DIRECT_URL || '',
directUrl: process.env.DATABASE_DIRECT_URL || '',
pgliteDir: process.env.PGLITE_DIR || 'data/pglite',
pgliteDir: process.env.PGLITE_DIR || 'data/pglite',
},
},
+ // Local data directory (account exports, and the avatar path's default parent). DATA_DIR overrides it.
+ dataDir: process.env.DATA_DIR || 'data',
+
// Valkey (ADR-035, plan T2): the shared store behind the per-actor limits and any short-lived shared
// Valkey (ADR-035, plan T2): the shared store behind the per-actor limits and any short-lived shared
// state, so every process and host counts one actor together. Unset = this process's own counters
// state, so every process and host counts one actor together. Unset = this process's own counters
// (and, for the limits, a Valkey outage degrades to them rather than answering 500). PostgreSQL stays
// (and, for the limits, a Valkey outage degrades to them rather than answering 500). PostgreSQL stays
// the source of truth; nothing here is a cache of record.
// the source of truth; nothing here is a cache of record.
valkey: {
valkey: {
url: process.env.VALKEY_URL || '',
url: process.env.VALKEY_URL || '',
+ prefix: process.env.VALKEY_PREFIX || 'ov:network:',
},
},
// Admin auto-creation
// Admin auto-creation
@@ -1,14 +1,24 @@
@@ -1,14 +1,24 @@
'use strict';
'use strict';
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// openvibe.network — Central Database
-// Authoritative source for user accounts, OAuth2 clients,
-// themes, the OpenCoins wallet, and cross-platform preferences.
+// openvibe.network — Central Database (PostgreSQL through openvibe-sdk/db, ADR-035 / plan T2)
+//
+// The schema lives in migrations/NNNN_*.sql; this module opens the process-wide handle, runs the
+// migrations as the owner (DATABASE_DIRECT_URL) and seeds the boot data that is not schema
+// (OAuth clients, site settings, built-in themes). Every query is async; the handle is shaped like
+// better-sqlite3's (db.prepare(sql).get/all/run) but must be awaited.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const Database = require('better-sqlite3');
const path = require('path');
const path = require('path');
const fs = require('fs');
const fs = require('fs');
+const { createDb } = require('openvibe-sdk/db');
+const config = require('../config');
+
+const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', '..', 'data', 'pglite');
+
+let database = null;
+let seeded = false;
function previewFromVars(vars) {
function previewFromVars(vars) {
return JSON.stringify({
return JSON.stringify({
@@ -18,496 +28,66 @@ function previewFromVars(vars) {
@@ -18,496 +28,66 @@ function previewFromVars(vars) {
});
});
}
}
-function initDb(dbPath) {
- const dir = path.dirname(path.resolve(dbPath));
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
-
- const db = new Database(path.resolve(dbPath));
- db.pragma('journal_mode = WAL');
- db.pragma('foreign_keys = ON');
- db.pragma('busy_timeout = 5000');
-
- // ── Schema ───────────────────────────────────────────────
- db.exec(`
- -- Users (canonical identity for the OpenVibe)
- CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- username TEXT UNIQUE NOT NULL,
- email TEXT UNIQUE,
- password_hash TEXT NOT NULL,
- display_name TEXT,
- avatar_url TEXT,
- bio TEXT DEFAULT '',
- role TEXT DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),
- profile_color TEXT DEFAULT '#8b5cf6',
- is_banned INTEGER DEFAULT 0,
- ban_reason TEXT,
- token_valid_after TEXT DEFAULT NULL,
- legacy_source TEXT, -- 'live' for migrated accounts
- legacy_id INTEGER, -- original user ID in source platform
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- -- OAuth2 client registry
- CREATE TABLE IF NOT EXISTS oauth_clients (
- client_id TEXT PRIMARY KEY,
- client_secret TEXT NOT NULL,
- name TEXT NOT NULL,
- redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs
- is_first_party INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- -- OAuth2 authorization codes (short-lived, single-use)
- CREATE TABLE IF NOT EXISTS oauth_codes (
- code TEXT PRIMARY KEY,
- client_id TEXT NOT NULL,
- user_id INTEGER NOT NULL,
- redirect_uri TEXT NOT NULL,
- scope TEXT DEFAULT 'profile theme',
- expires_at DATETIME NOT NULL,
- used INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
- FOREIGN KEY (user_id) REFERENCES users(id)
- );
-
- -- OAuth2 refresh tokens
- CREATE TABLE IF NOT EXISTS oauth_tokens (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- token TEXT UNIQUE NOT NULL,
- client_id TEXT NOT NULL,
- user_id INTEGER NOT NULL,
- scope TEXT DEFAULT 'profile theme',
- expires_at DATETIME NOT NULL,
- revoked INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
- FOREIGN KEY (user_id) REFERENCES users(id)
- );
-
- -- User preferences (theme, language, notification settings)
- CREATE TABLE IF NOT EXISTS user_preferences (
- user_id INTEGER PRIMARY KEY,
- theme_id TEXT DEFAULT 'vibe',
- custom_theme_variables TEXT, -- JSON: custom CSS var overrides
- language TEXT DEFAULT 'en',
- notifications_enabled INTEGER DEFAULT 1,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id)
- );
-
- -- Theme catalog (built-in + community)
- CREATE TABLE IF NOT EXISTS themes (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- slug TEXT UNIQUE NOT NULL,
- author_id INTEGER,
- description TEXT DEFAULT '',
- mode TEXT DEFAULT 'dark' CHECK(mode IN ('dark','light')),
- variables TEXT NOT NULL, -- JSON: CSS variable map
- preview_colors TEXT, -- JSON: preview color swatches
- is_builtin INTEGER DEFAULT 0,
- is_public INTEGER DEFAULT 1,
- downloads INTEGER DEFAULT 0,
- rating_sum INTEGER DEFAULT 0,
- rating_count INTEGER DEFAULT 0,
- tags TEXT DEFAULT '[]', -- JSON array
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (author_id) REFERENCES users(id)
- );
-
- -- Linked accounts (which OpenVibe user owns which service-specific account)
- CREATE TABLE IF NOT EXISTS linked_accounts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- service TEXT NOT NULL, -- 'live', 'games', etc.
- service_user_id TEXT NOT NULL,
- linked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id),
- UNIQUE(service, service_user_id)
- );
-
- -- Audit log
- CREATE TABLE IF NOT EXISTS audit_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER,
- action TEXT NOT NULL,
- details TEXT, -- JSON context
- ip TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- -- IP log (cross-platform)
- CREATE TABLE IF NOT EXISTS ip_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER,
- ip TEXT NOT NULL,
- action TEXT DEFAULT 'login',
- country TEXT,
- region TEXT,
- city TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id)
- );
-
- -- Site settings (key-value)
- CREATE TABLE IF NOT EXISTS site_settings (
- key TEXT PRIMARY KEY,
- value TEXT,
- type TEXT DEFAULT 'string'
- );
-
- -- URL registry for admin-managed first-party and protocol URLs
- CREATE TABLE IF NOT EXISTS url_registry (
- key TEXT PRIMARY KEY,
- label TEXT NOT NULL,
- category TEXT NOT NULL,
- service TEXT NOT NULL,
- scope TEXT NOT NULL,
- type TEXT NOT NULL,
- value TEXT,
- description TEXT,
- updated_by INTEGER,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- -- ═══════════════════════════════════════════════════════
- -- Notifications
- -- ═══════════════════════════════════════════════════════
-
- -- Central notification store
- CREATE TABLE IF NOT EXISTS notifications (
- id TEXT PRIMARY KEY, -- UUID
- user_id INTEGER NOT NULL,
- type TEXT NOT NULL, -- from TYPES enum (FOLLOW, MENTION, etc.)
- category TEXT NOT NULL, -- social, chat, game, stream, economy, etc.
- priority TEXT NOT NULL DEFAULT 'normal', -- low, normal, high, critical
- title TEXT NOT NULL,
- message TEXT,
- icon TEXT,
- sender_id INTEGER, -- who triggered this (nullable)
- sender_name TEXT, -- denormalized for display
- sender_avatar TEXT,
- service TEXT, -- originating service (openvibelive, etc.)
- url TEXT, -- click-through destination
- rich_content TEXT, -- JSON: images, actions, embeds
- is_read INTEGER DEFAULT 0,
- is_dismissed INTEGER DEFAULT 0,
- is_emailed INTEGER DEFAULT 0,
- expires_at DATETIME,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);
- CREATE INDEX IF NOT EXISTS idx_notif_user_cat ON notifications(user_id, category);
- CREATE INDEX IF NOT EXISTS idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;
-
- -- Per-user notification preferences (overrides per category)
- CREATE TABLE IF NOT EXISTS notification_preferences (
- user_id INTEGER NOT NULL,
- category TEXT NOT NULL, -- or '*' for global
- enabled INTEGER DEFAULT 1,
- sound INTEGER DEFAULT 1,
- toasts INTEGER DEFAULT 1,
- email INTEGER DEFAULT 0,
- PRIMARY KEY (user_id, category),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
-
- -- Email delivery log (email metrics / admin visibility)
- CREATE TABLE IF NOT EXISTS email_delivery_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- email_type TEXT NOT NULL,
- recipient TEXT NOT NULL,
- subject TEXT,
- status TEXT NOT NULL CHECK(status IN ('sent', 'failed')),
- error_message TEXT,
- user_id INTEGER,
- notification_id TEXT,
- metadata TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
- FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL
- );
- CREATE INDEX IF NOT EXISTS idx_email_delivery_created ON email_delivery_log(created_at DESC);
- CREATE INDEX IF NOT EXISTS idx_email_delivery_status ON email_delivery_log(status, created_at DESC);
- CREATE INDEX IF NOT EXISTS idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);
-
- -- Password reset tokens
- CREATE TABLE IF NOT EXISTS password_reset_tokens (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- token_hash TEXT UNIQUE NOT NULL,
- expires_at DATETIME NOT NULL,
- used_at DATETIME,
- requested_ip TEXT,
- requested_user_agent TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);
- CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at);
-
- -- ═══════════════════════════════════════════════════════
- -- Anonymous Users & Multi-Account Sessions
- -- ═══════════════════════════════════════════════════════
-
- -- Anonymous user tracking
- CREATE TABLE IF NOT EXISTS anon_users (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- anon_number INTEGER UNIQUE NOT NULL,
- fingerprint TEXT, -- browser fingerprint hash (optional)
- session_token TEXT UNIQUE NOT NULL,
- display_name TEXT,
- preferences TEXT DEFAULT '{}', -- JSON
- total_messages INTEGER DEFAULT 0,
- total_commands INTEGER DEFAULT 0,
- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- -- Multi-account session tracking (like Google multi-login)
- CREATE TABLE IF NOT EXISTS user_sessions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- session_token TEXT UNIQUE NOT NULL,
- device_name TEXT,
- ip TEXT,
- user_agent TEXT,
- is_active INTEGER DEFAULT 1,
- last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- expires_at DATETIME NOT NULL,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, is_active);
- CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(session_token);
-
- -- ═══════════════════════════════════════════════════════
- -- User Profile Extras
- -- ═══════════════════════════════════════════════════════
-
- -- Name & particle effects ownership
- CREATE TABLE IF NOT EXISTS user_effects (
- user_id INTEGER NOT NULL,
- effect_type TEXT NOT NULL, -- 'name' or 'particle'
- effect_id TEXT NOT NULL, -- e.g. 'rainbow', 'fire', 'neon'
- is_active INTEGER DEFAULT 0,
- acquired_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, effect_type, effect_id),
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
-
- -- User followers
- CREATE TABLE IF NOT EXISTS follows (
- follower_id INTEGER NOT NULL,
- followed_id INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (follower_id, followed_id),
- FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (followed_id) REFERENCES users(id) ON DELETE CASCADE
- );
-
- -- ═══════════════════════════════════════════════════════
- -- OpenCoins Wallet (network-wide currency)
- -- user_id is ALWAYS the Network (SSO) user id.
- -- ═══════════════════════════════════════════════════════
-
- CREATE TABLE IF NOT EXISTS wallets (
- user_id INTEGER PRIMARY KEY,
- balance INTEGER NOT NULL DEFAULT 0,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS coin_transactions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- app_id TEXT,
- delta INTEGER NOT NULL,
- reason TEXT,
- ref TEXT,
- idempotency_key TEXT UNIQUE,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);
- CREATE INDEX IF NOT EXISTS idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);
-
- -- Verification keys (reserved username claims)
- CREATE TABLE IF NOT EXISTS verification_keys (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- key TEXT UNIQUE NOT NULL,
- target_username TEXT NOT NULL,
- note TEXT DEFAULT '',
- created_by INTEGER NOT NULL,
- used_by INTEGER,
- status TEXT DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- used_at DATETIME,
- FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
- FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
- );
- CREATE INDEX IF NOT EXISTS idx_vkeys_key ON verification_keys(key);
- CREATE INDEX IF NOT EXISTS idx_vkeys_target ON verification_keys(target_username);
- CREATE INDEX IF NOT EXISTS idx_vkeys_status ON verification_keys(status);
- `);
-
- // ── Anon IP tracking (unified cross-service anon resolution) ──
- try {
- db.exec(`
- CREATE TABLE IF NOT EXISTS anon_ip_log (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- anon_id INTEGER NOT NULL,
- ip TEXT NOT NULL,
- first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,
- UNIQUE(anon_id, ip)
- );
- CREATE INDEX IF NOT EXISTS idx_anon_ip_log_ip ON anon_ip_log(ip);
- CREATE INDEX IF NOT EXISTS idx_anon_ip_log_anon ON anon_ip_log(anon_id);
- `);
- } catch (e) { /* already exists */ }
+/**
+ * The serving handle: DATABASE_URL through PgBouncer; in development without it, an embedded PGlite
+ * database in config.db.pgliteDir. Migrations run first, as the owner (DATABASE_DIRECT_URL), or on the
+ * embedded handle. Production without DATABASE_URL refuses to boot.
+ */
+async function openDb(cfg = config, { log = console, registry } = {}) {
+ if (!cfg.db.url) {
+ if (cfg.nodeEnv === 'production') throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh network)');
+ const dir = cfg.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[DB] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'network', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!cfg.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: cfg.db.directUrl, service: 'network-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: cfg.db.url, service: 'network', registry, log });
+}
- // ── Push Subscriptions (Web Push / VAPID) ──
- try {
- db.exec(`
- CREATE TABLE IF NOT EXISTS push_subscriptions (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- endpoint TEXT NOT NULL UNIQUE,
- keys_p256dh TEXT NOT NULL,
- keys_auth TEXT NOT NULL,
- user_agent TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_id);
- CREATE INDEX IF NOT EXISTS idx_push_sub_endpoint ON push_subscriptions(endpoint);
- `);
- } catch (e) { /* already exists */ }
+/** Open the process-wide database once, at boot (server/index.js, scripts). Seeds the boot data. */
+async function initDb(cfg = config, opts) {
+ if (!database && globalThis.__ovNetworkTestDb) database = globalThis.__ovNetworkTestDb; // tests (test/helpers/pg-preload.mjs)
+ if (!database) database = await openDb(cfg, opts);
+ await attachHelpers(database);
+ if (!seeded) { seeded = true; await seedDb(database); }
+ return database;
+}
- // ── Migration: Add columns that may not exist ────────────
- const migrations = [
- { table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },
- { table: 'oauth_codes', column: 'code_challenge', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge TEXT" },
- { table: 'oauth_codes', column: 'code_challenge_method', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge_method TEXT" },
- // OpenID Connect: the nonce sent to /oauth/authorize, echoed in the id_token (server/auth/oidc.js).
- { table: 'oauth_codes', column: 'nonce', sql: "ALTER TABLE oauth_codes ADD COLUMN nonce TEXT" },
- { table: 'url_registry', column: 'source', sql: "ALTER TABLE url_registry ADD COLUMN source TEXT NOT NULL DEFAULT 'admin'" },
- { table: 'users', column: 'is_anon', sql: "ALTER TABLE users ADD COLUMN is_anon INTEGER DEFAULT 0" },
- { table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },
- { table: 'users', column: 'name_effect', sql: "ALTER TABLE users ADD COLUMN name_effect TEXT" },
- { table: 'users', column: 'particle_effect', sql: "ALTER TABLE users ADD COLUMN particle_effect TEXT" },
- { table: 'anon_users', column: 'ip', sql: "ALTER TABLE anon_users ADD COLUMN ip TEXT" },
- // Email deliverability state — verification gates opt-in mail, bounces suppress it.
- { table: 'users', column: 'email_verified', sql: "ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0" },
- { table: 'users', column: 'email_verified_at', sql: "ALTER TABLE users ADD COLUMN email_verified_at DATETIME" },
- { table: 'users', column: 'email_bounced_at', sql: "ALTER TABLE users ADD COLUMN email_bounced_at DATETIME" },
- { table: 'users', column: 'email_bounce_reason', sql: "ALTER TABLE users ADD COLUMN email_bounce_reason TEXT" },
- // When the account was last used on a service (bumped on every OAuth exchange).
- { table: 'linked_accounts', column: 'last_used_at', sql: "ALTER TABLE linked_accounts ADD COLUMN last_used_at DATETIME" },
- // Cross-site history can be paused by the user (server/history/routes.js).
- { table: 'user_preferences', column: 'display_prefs', sql: "ALTER TABLE user_preferences ADD COLUMN display_prefs TEXT" },
- { table: 'users', column: 'history_paused', sql: "ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0" },
- // Canonical subject ids (roadmap Wave 1): usr_/gst_ ULIDs alongside the integer ids.
- { table: 'users', column: 'subject_id', sql: "ALTER TABLE users ADD COLUMN subject_id TEXT" },
- { table: 'anon_users', column: 'subject_id', sql: "ALTER TABLE anon_users ADD COLUMN subject_id TEXT" },
- ];
- for (const m of migrations) {
- const cols = db.prepare(`PRAGMA table_info(${m.table})`).all();
- if (!cols.find(c => c.name === m.column)) {
- try { db.exec(m.sql); console.log(`[DB] Migrated: ${m.table}.${m.column}`); }
- catch (e) { /* already exists — silently skip */ }
- }
- }
+/** The process-wide database initDb() opened. */
+function getDb() {
+ if (!database && globalThis.__ovNetworkTestDb) database = globalThis.__ovNetworkTestDb;
+ if (!database) throw new Error('the database is not open: await initDb() at boot');
+ return database;
+}
- // ── Refresh tokens: hash-only storage, families, generations (server/auth/refresh-tokens.js) ──
- // Columns only. Rows still holding a raw token work and are hashed on first use (the one-time
- // hash-refresh-tokens.js was retired in plan T2).
- require('../auth/refresh-tokens').ensureSchema(db);
+/** Tests: use this handle as the process-wide database. */
+async function setDb(db) { database = db; await attachHelpers(db); }
- // ── Email verification tokens + preference default semantics ──
- try {
- db.exec(`
- CREATE TABLE IF NOT EXISTS email_verification_tokens (
- token_hash TEXT PRIMARY KEY,
- user_id INTEGER NOT NULL,
- email TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- expires_at DATETIME NOT NULL,
- used_at DATETIME,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);
- `);
- // notification_preferences.email: 0 used to be the implicit default written whenever a
- // user toggled anything else in the row. It now means an EXPLICIT "no email"; NULL is
- // "no choice → defaults apply" (go-live alerts email by default). One-time reset.
- const flagged = db.prepare("SELECT value FROM site_settings WHERE key = 'migr_pref_email_null'").get();
- if (!flagged) {
- const n = db.prepare('UPDATE notification_preferences SET email = NULL WHERE email = 0').run().changes;
- db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES ('migr_pref_email_null', '1', 'boolean')").run();
- if (n) console.log(`[DB] notification_preferences: ${n} email=0 rows reset to default (NULL)`);
- }
- const seed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
- seed.run('email_user_daily_cap', '30', 'number');
- seed.run('email_daily_cap', '2000', 'number');
- seed.run('email_verify_user_daily_cap', '6', 'number');
- seed.run('resend_webhook_secret', '', 'string');
- seed.run('stream_live_cooldown_min', '60', 'number');
- seed.run('stream_live_daily_cap', '8', 'number');
- } catch (e) { console.warn('[DB] email verification migration:', e.message); }
+// ── Boot data (the schema is the migration; this is what is not) ─────────────
- // ── Migration: one linked account per (user, service) ────────
- // The link-account upsert uses ON CONFLICT(user_id, service), which needs a
- // matching unique index (the table only declared UNIQUE(service, service_user_id)).
- try {
- // Drop any pre-existing duplicate (user_id, service) rows, keeping the newest.
- db.exec(`
- DELETE FROM linked_accounts WHERE id NOT IN (
- SELECT MAX(id) FROM linked_accounts GROUP BY user_id, service
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_linked_user_service ON linked_accounts(user_id, service);
- `);
- } catch (e) { console.warn('[DB] linked_accounts unique index migration:', e.message); }
+/**
+ * Seed OAuth2 clients, site settings and the built-in theme catalogue, idempotently. Safe under
+ * concurrent writers: every write is an upsert, and the redirect-URI merge takes the row with
+ * SELECT … FOR UPDATE inside a transaction (decision 1).
+ */
+async function seedDb(db, { log = console } = {}) {
+ const warn = (m) => log.warn(`[DB] ${m}`);
- // ── Seed OAuth2 Clients (per CONTRACTS) ──────────────────
- // Each first-party client is created if missing, with a fresh UUID secret
- // printed exactly once on creation.
+ // ── Seed OAuth2 clients (per CONTRACTS) ──────────────────
+ // Each first-party client is created if missing, with a fresh UUID secret printed exactly once.
{
{
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const contractClients = [
const contractClients = [
- {
- client_id: 'live',
- name: 'OpenVibe.Live',
- redirect_uris: ['https://openvibe.live/api/auth/callback'],
- },
- {
- client_id: 'tools',
- name: 'OpenVibe.Tools',
- redirect_uris: ['https://openvibe.tools/auth/callback'],
- },
- {
- client_id: 'games',
- name: 'OpenVibe.Games',
- redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'],
- },
- {
- client_id: 'media',
- name: 'OpenVibe.Media',
- redirect_uris: ['https://openvibe.media/auth/callback'],
- },
- {
- client_id: 'community',
- name: 'OpenVibe.Community',
- redirect_uris: ['https://openvibe.community/auth/callback'],
- },
+ { client_id: 'live', name: 'OpenVibe.Live', redirect_uris: ['https://openvibe.live/api/auth/callback'] },
+ { client_id: 'tools', name: 'OpenVibe.Tools', redirect_uris: ['https://openvibe.tools/auth/callback'] },
+ { client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },
+ { client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },
+ { client_id: 'community', name: 'OpenVibe.Community', redirect_uris: ['https://openvibe.community/auth/callback'] },
// Waves 9-16 products with a signed-in UI. Create each with server/setup/service-principal.js
// Waves 9-16 products with a signed-in UI. Create each with server/setup/service-principal.js
// first (secret into its env file); this only adds the redirect URI to that client.
// first (secret into its env file); this only adds the redirect URI to that client.
{ client_id: 'tips', name: 'OpenVibe.Tips', redirect_uris: ['https://openvibe.tips/auth/callback'] },
{ client_id: 'tips', name: 'OpenVibe.Tips', redirect_uris: ['https://openvibe.tips/auth/callback'] },
@@ -522,109 +102,80 @@ function initDb(dbPath) {
@@ -522,109 +102,80 @@ function initDb(dbPath) {
{ client_id: 'coupons', name: 'OpenVibe.Coupons', redirect_uris: ['https://openvibe.coupons/auth/callback'] },
{ client_id: 'coupons', name: 'OpenVibe.Coupons', redirect_uris: ['https://openvibe.coupons/auth/callback'] },
{ client_id: 'host', name: 'OpenVibe.Host', redirect_uris: ['https://openvibe.host/auth/callback'] },
{ client_id: 'host', name: 'OpenVibe.Host', redirect_uris: ['https://openvibe.host/auth/callback'] },
{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },
{ client_id: 'codes', name: 'OpenVibe.Codes', redirect_uris: ['https://openvibe.codes/auth/callback'] },
- // Billing's staff console (Wave 8): Network admins listed in BILLING_STAFF_SUBJECTS.
{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },
{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] },
- // OpenVibe.AI's operator console (WS-O task 4): Network staff, capabilities from the staff role map.
{ client_id: 'ai', name: 'OpenVibe.AI', redirect_uris: ['https://ai.openvibe.network/auth/callback'] },
{ client_id: 'ai', name: 'OpenVibe.AI', redirect_uris: ['https://ai.openvibe.network/auth/callback'] },
];
];
- const insert = db.prepare(
- 'INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)'
- );
let seededAny = false;
let seededAny = false;
for (const c of contractClients) {
for (const c of contractClients) {
- const existing = db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
+ const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
if (!existing) {
if (!existing) {
- const secret = uuidv4();
- insert.run(c.client_id, secret, c.name, JSON.stringify(c.redirect_uris));
- console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret stored in oauth_clients; read it with sqlite, never from logs)`);
+ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
+ .run(c.client_id, uuidv4(), c.name, JSON.stringify(c.redirect_uris));
+ console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret stored in oauth_clients; read it from the database, never from logs)`);
seededAny = true;
seededAny = true;
continue;
continue;
}
}
- // Ensure existing clients include the contract redirect URIs
- try {
- const uris = new Set(JSON.parse(existing.redirect_uris || '[]'));
- let changed = false;
- for (const uri of c.redirect_uris) {
- if (!uris.has(uri)) { uris.add(uri); changed = true; }
- }
- if (changed) {
- db.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?')
- .run(JSON.stringify([...uris]), c.client_id);
- console.log(`[DB] Updated ${c.client_id} redirect_uris to include contract URIs`);
- }
- } catch { /* malformed redirect_uris — leave untouched */ }
+ await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });
}
}
if (seededAny) console.log('[DB] New OAuth2 clients seeded; their secrets are in oauth_clients (they are never logged).');
if (seededAny) console.log('[DB] New OAuth2 clients seeded; their secrets are in oauth_clients (they are never logged).');
}
}
- function ensureLocalOauthRedirects() {
- if (process.env.NODE_ENV === 'production' && process.env.BOOTSTRAP_PROFILE !== 'local-dev') return;
-
+ // ── Local development redirect URIs ──────────────────────
+ if (!(process.env.NODE_ENV === 'production' && process.env.BOOTSTRAP_PROFILE !== 'local-dev')) {
const localClients = [
const localClients = [
- {
- clientId: 'live',
- extraUris: ['http://localhost:3000/auth/callback', 'http://localhost:3000/api/auth/callback'],
- },
- {
- clientId: 'tools',
- extraUris: ['http://localhost:4001/auth/callback'],
- },
- {
- clientId: 'games',
- extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'],
- },
- {
- clientId: 'media',
- extraUris: ['http://localhost:4100/auth/callback'],
- },
+ { clientId: 'live', extraUris: ['http://localhost:3000/auth/callback', 'http://localhost:3000/api/auth/callback'] },
+ { clientId: 'tools', extraUris: ['http://localhost:4001/auth/callback'] },
+ { clientId: 'games', extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'] },
+ { clientId: 'media', extraUris: ['http://localhost:4100/auth/callback'] },
];
];
-
for (const { clientId, extraUris } of localClients) {
for (const { clientId, extraUris } of localClients) {
- try {
- const client = db.prepare('SELECT redirect_uris FROM oauth_clients WHERE client_id = ?').get(clientId);
- if (!client) continue;
- const uris = new Set(JSON.parse(client.redirect_uris || '[]'));
- let changed = false;
- for (const uri of extraUris) {
- if (!uris.has(uri)) {
- uris.add(uri);
- changed = true;
- }
- }
- if (changed) {
- db.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?')
- .run(JSON.stringify([...uris]), clientId);
- console.log(`[DB] Added local redirect_uris for ${clientId}: ${extraUris.join(', ')}`);
- }
- } catch (err) {
- console.warn(`[DB] Failed to add local redirect_uris for ${clientId}:`, err.message);
- }
+ try { await mergeRedirectUris(db, clientId, extraUris, { log, local: true }); }
+ catch (err) { warn(`Failed to add local redirect_uris for ${clientId}: ${err.message}`); }
}
}
}
}
- ensureLocalOauthRedirects();
- // ── Seed Default Settings ────────────────────────────────
- const settingsCount = db.prepare('SELECT COUNT(*) as cnt FROM site_settings').get().cnt;
- if (settingsCount === 0) {
- const defaults = [
- ['registration_open', 'true', 'boolean'],
- ['platform_name', 'OpenVibe', 'string'],
- ['default_theme', 'vibe', 'string'],
- // Email provider defaults
- ['email_enabled', 'false', 'boolean'],
- ['resend_api_key', '', 'string'],
- ['email_from_address', '[email protected]', 'string'],
- ['email_from_name', 'OpenVibe', 'string'],
- // Notification defaults
- ['notifications_enabled', 'true', 'boolean'],
- ['notification_max_age_days', '90', 'number'],
- ['notification_email_critical_only', 'true', 'boolean'],
- ];
- const insertSetting = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
- for (const [k, v, t] of defaults) insertSetting.run(k, v, t);
+ // ── Email verification preference reset + seeds ──────────
+ try {
+ const flagged = await db.prepare("SELECT value FROM site_settings WHERE key = 'migr_pref_email_null'").get();
+ if (!flagged) {
+ // notification_preferences.email: 0 used to be the implicit default written whenever a
+ // user toggled anything else in the row. It now means an EXPLICIT "no email"; NULL is
+ // "no choice → defaults apply". One-time reset.
+ const n = (await db.prepare('UPDATE notification_preferences SET email = NULL WHERE email = 0').run()).changes;
+ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('migr_pref_email_null', '1', 'boolean') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run();
+ if (n) console.log(`[DB] notification_preferences: ${n} email=0 rows reset to default (NULL)`);
+ }
+ const seed = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING');
+ await seed.run('email_user_daily_cap', '30', 'number');
+ await seed.run('email_daily_cap', '2000', 'number');
+ await seed.run('email_verify_user_daily_cap', '6', 'number');
+ await seed.run('resend_webhook_secret', '', 'string');
+ await seed.run('stream_live_cooldown_min', '60', 'number');
+ await seed.run('stream_live_daily_cap', '8', 'number');
+ } catch (e) { warn(`email verification migration: ${e.message}`); }
+
+ // ── Seed default settings (only on a fresh settings table) ──
+ {
+ const settingsCount = (await db.prepare('SELECT COUNT(*) AS cnt FROM site_settings').get()).cnt;
+ if (Number(settingsCount) === 0) {
+ const defaults = [
+ ['registration_open', 'true', 'boolean'],
+ ['platform_name', 'OpenVibe', 'string'],
+ ['default_theme', 'vibe', 'string'],
+ ['email_enabled', 'false', 'boolean'],
+ ['resend_api_key', '', 'string'],
+ ['email_from_address', '[email protected]', 'string'],
+ ['email_from_name', 'OpenVibe', 'string'],
+ ['notifications_enabled', 'true', 'boolean'],
+ ['notification_max_age_days', '90', 'number'],
+ ['notification_email_critical_only', 'true', 'boolean'],
+ ];
+ const insertSetting = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING');
+ for (const [k, v, t] of defaults) await insertSetting.run(k, v, t);
+ }
}
}
- // ── Always-Seed Discord + Integration Settings (idempotent) ──
+ // ── Always-seed Discord + integration settings (idempotent) ──
{
{
const alwaysSeed = [
const alwaysSeed = [
['discord_bot_token', '', 'secret'],
['discord_bot_token', '', 'secret'],
@@ -635,18 +186,18 @@ function initDb(dbPath) {
@@ -635,18 +186,18 @@ function initDb(dbPath) {
['discord_alert_message', '', 'string'],
['discord_alert_message', '', 'string'],
['discord_oauth_client_id', '', 'string'],
['discord_oauth_client_id', '', 'string'],
['discord_oauth_client_secret', '', 'secret'],
['discord_oauth_client_secret', '', 'secret'],
- ['github_token', '', 'secret'], // server/integrations/github.js (admin → Settings → GitHub)
+ ['github_token', '', 'secret'],
];
];
- const insertSeed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
- for (const [k, v, t] of alwaysSeed) insertSeed.run(k, v, t);
+ const insertSeed = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING');
+ for (const [k, v, t] of alwaysSeed) await insertSeed.run(k, v, t);
}
}
- // ── Sync Built-in Themes ─────────────────────────────────
+ // ── Sync built-in themes ─────────────────────────────────
{
{
const { BUILTIN_THEMES } = require('openvibe-shared/theme-sync');
const { BUILTIN_THEMES } = require('openvibe-shared/theme-sync');
const upsertTheme = db.prepare(`
const upsertTheme = db.prepare(`
INSERT INTO themes (id, name, slug, description, mode, variables, preview_colors, is_builtin, is_public, tags, updated_at)
INSERT INTO themes (id, name, slug, description, mode, variables, preview_colors, is_builtin, is_public, tags, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, CURRENT_TIMESTAMP)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ov_now())
ON CONFLICT(id) DO UPDATE SET
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
name = excluded.name,
slug = excluded.slug,
slug = excluded.slug,
@@ -657,62 +208,96 @@ function initDb(dbPath) {
@@ -657,62 +208,96 @@ function initDb(dbPath) {
is_builtin = 1,
is_builtin = 1,
is_public = 1,
is_public = 1,
tags = excluded.tags,
tags = excluded.tags,
- updated_at = CURRENT_TIMESTAMP
+ updated_at = ov_now()
`);
`);
for (const t of BUILTIN_THEMES) {
for (const t of BUILTIN_THEMES) {
- upsertTheme.run(
- t.id,
- t.name,
- t.slug,
- t.description,
- t.mode,
- JSON.stringify(t.variables || {}),
- previewFromVars(t.variables || {}),
- JSON.stringify(t.tags || [])
- );
+ await upsertTheme.run(t.id, t.name, t.slug, t.description, t.mode,
+ JSON.stringify(t.variables || {}), previewFromVars(t.variables || {}), JSON.stringify(t.tags || []));
}
}
// A built-in that was renamed or removed must not linger as a stale catalog row.
// A built-in that was renamed or removed must not linger as a stale catalog row.
const ids = BUILTIN_THEMES.map((t) => t.id);
const ids = BUILTIN_THEMES.map((t) => t.id);
- const gone = db.prepare(`DELETE FROM themes WHERE is_builtin = 1 AND id NOT IN (${ids.map(() => '?').join(',')})`).run(...ids).changes;
+ const gone = (await db.prepare(`DELETE FROM themes WHERE is_builtin = 1 AND id NOT IN (${ids.map(() => '?').join(',')})`).run(...ids)).changes;
console.log(`[DB] Synced ${BUILTIN_THEMES.length} built-in themes${gone ? ` (removed ${gone} stale)` : ''}`);
console.log(`[DB] Synced ${BUILTIN_THEMES.length} built-in themes${gone ? ` (removed ${gone} stale)` : ''}`);
}
}
- // Roles are Network's own data. Network never opens another service's database (ADR-007):
- // role changes made here reach Live as network.user.updated (server/identity/profile-events.js;
- // the key-only POST /internal/user-role push was retired, register C-54/C-55), and Live never
- // downgrades from a stale SSO token. The boot-time read of Live's database that used to live
- // here was removed (compatibility register C-58).
+ // ── Per-module boot work (the tables are migrations/NNNN_*.sql; this is the idempotent data work
+ // each module did at boot on SQLite) ─────────────────────────────────────────────────────────
+ // Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed.
+ await require('../identity/subjects').ensureSchema(db);
+ // Service principals: grants per OAuth client (server/identity/principals.js) — seeds the default
+ // grants and re-revokes the ones ADR-012 forbids.
+ await require('../identity/principals').ensureSchema(db);
+ // Versioned user modules (server/identity/modules.js).
+ await require('../identity/modules').ensureSchema(db);
+ // Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).
+ await require('../developer/store').ensureSchema(db);
+ // Their usage, from the services' rollups (server/developer/usage.js).
+ await require('../developer/usage').ensure(db);
+ // Platform blocks, keyed by subjects (server/identity/blocks.js).
+ await require('../identity/blocks').ensureSchema(db);
+ // The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).
+ await require('../identity/follows').ensureSchema(db);
+ // Incidents and maintenance on /status (server/status/incidents.js).
+ await require('../status/incidents').ensureSchema(db);
+ // Creator analytics from live.stream.ended (server/analytics/creators.js).
+ await require('../analytics/creators').ensureSchema(db);
+ // The platform's machines (server/registry/nodes.js; ADR-034 §12).
+ await require('../registry/nodes').ensureSchema(db);
+ // Refresh tokens: hash-only storage (server/auth/refresh-tokens.js).
+ await require('../auth/refresh-tokens').ensureSchema(db);
+
+ console.log('[DB] Central database seeded');
+}
+
+/** Union `uris` into a client's redirect_uris under SELECT … FOR UPDATE (decision 1: many writers). */
+async function mergeRedirectUris(db, clientId, uris, { log = console, local = false } = {}) {
+ await db.tx(async (t) => {
+ const row = await t.prepare('SELECT redirect_uris FROM oauth_clients WHERE client_id = ? FOR UPDATE').get(clientId);
+ if (!row) return;
+ let current;
+ try { current = new Set(JSON.parse(row.redirect_uris || '[]')); } catch { return; } // malformed: leave untouched
+ let changed = false;
+ for (const uri of uris) { if (!current.has(uri)) { current.add(uri); changed = true; } }
+ if (!changed) return;
+ await t.prepare('UPDATE oauth_clients SET redirect_uris = ? WHERE client_id = ?').run(JSON.stringify([...current]), clientId);
+ if (local) console.log(`[DB] Added local redirect_uris for ${clientId}: ${uris.join(', ')}`);
+ else console.log(`[DB] Updated ${clientId} redirect_uris to include contract URIs`);
+ });
+}
+
+// ── Handle helpers (attached to the database handle, async) ──────────────────
- // ── Helper: getSetting ───────────────────────────────────
- // Provider secrets (server/secrets.js) come from their environment variable when it is set.
+function attachHelpers(db) {
+ if (db._ovHelpers) return db;
const secrets = require('../secrets');
const secrets = require('../secrets');
- db.getSetting = function (key) {
+
+ // better-sqlite3's db.transaction(fn) returned a function; db.tx(fn) runs now. txFn keeps that
+ // shape (a runner the caller invokes, possibly more than once, e.g. a dry-run path).
+ db.txFn = (fn) => async (...args) => await db.tx(() => fn(...args));
+
+ // Provider secrets come from their environment variable when it is set.
+ db.getSetting = async function (key) {
const fromEnv = secrets.fromEnv(key);
const fromEnv = secrets.fromEnv(key);
if (fromEnv !== null) return fromEnv;
if (fromEnv !== null) return fromEnv;
- const row = this.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
+ const row = await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
if (!row) return null;
if (!row) return null;
if (row.type === 'boolean') return row.value === 'true';
if (row.type === 'boolean') return row.value === 'true';
if (row.type === 'number') return Number(row.value);
if (row.type === 'number') return Number(row.value);
return row.value;
return row.value;
};
};
- // ── Verification Key helpers ─────────────────────────────
- db.createVerificationKey = function ({ key, target_username, note, created_by }) {
- return this.prepare(
- 'INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)'
- ).run(key, target_username, note || '', created_by);
+ db.createVerificationKey = async function ({ key, target_username, note, created_by }) {
+ return await db.prepare('INSERT INTO verification_keys (key, target_username, note, created_by) VALUES (?, ?, ?, ?)')
+ .run(key, target_username, note || '', created_by);
};
};
-
- db.getVerificationKeyByKey = function (key) {
- return this.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);
+ db.getVerificationKeyByKey = async function (key) {
+ return await db.prepare('SELECT * FROM verification_keys WHERE key = ?').get(key);
};
};
-
- db.getVerificationKeyByUsername = function (username) {
- return this.prepare("SELECT * FROM verification_keys WHERE target_username = ? COLLATE NOCASE AND status = 'active'").get(username);
+ db.getVerificationKeyByUsername = async function (username) {
+ return await db.prepare("SELECT * FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);
};
};
-
- db.getAllVerificationKeys = function () {
- return this.prepare(`
+ db.getAllVerificationKeys = async function () {
+ return await db.prepare(`
SELECT vk.*, u1.username as created_by_name, u2.username as used_by_name
SELECT vk.*, u1.username as created_by_name, u2.username as used_by_name
FROM verification_keys vk
FROM verification_keys vk
LEFT JOIN users u1 ON vk.created_by = u1.id
LEFT JOIN users u1 ON vk.created_by = u1.id
@@ -720,45 +305,20 @@ function initDb(dbPath) {
@@ -720,45 +305,20 @@ function initDb(dbPath) {
ORDER BY vk.created_at DESC
ORDER BY vk.created_at DESC
`).all();
`).all();
};
};
-
- db.redeemVerificationKey = function (key, userId) {
- return this.prepare(
- "UPDATE verification_keys SET status = 'used', used_by = ?, used_at = CURRENT_TIMESTAMP WHERE key = ? AND status = 'active'"
- ).run(userId, key);
+ db.redeemVerificationKey = async function (key, userId) {
+ return await db.prepare("UPDATE verification_keys SET status = 'used', used_by = ?, used_at = ov_now() WHERE key = ? AND status = 'active'")
+ .run(userId, key);
};
};
-
- db.revokeVerificationKey = function (id) {
- return this.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);
+ db.revokeVerificationKey = async function (id) {
+ return await db.prepare("UPDATE verification_keys SET status = 'revoked' WHERE id = ? AND status = 'active'").run(id);
};
};
-
- db.isUsernameReserved = function (username) {
- const vk = this.prepare("SELECT id FROM verification_keys WHERE target_username = ? COLLATE NOCASE AND status = 'active'").get(username);
+ db.isUsernameReserved = async function (username) {
+ const vk = await db.prepare("SELECT id FROM verification_keys WHERE lower(target_username) = lower(?) AND status = 'active'").get(username);
return !!vk;
return !!vk;
};
};
- // Subject ids + identity_legacy_map (server/identity/subjects.js): backfill and seed, idempotent.
- require('../identity/subjects').ensureSchema(db);
- // Service principals: grants per OAuth client + usage counters (server/identity/principals.js).
- require('../identity/principals').ensureSchema(db);
- // Versioned user modules (server/identity/modules.js).
- require('../identity/modules').ensureSchema(db);
- // Developer projects, apps, credentials, grants, quotas and audit (server/developer/store.js).
- require('../developer/store').ensureSchema(db);
- // Their usage, from the services' rollups (server/developer/usage.js).
- require('../developer/usage').ensure(db);
- // Platform blocks, keyed by subjects (server/identity/blocks.js).
- require('../identity/blocks').ensureSchema(db);
- // The follow graph, keyed by subjects (server/identity/follows.js; ADR-030).
- require('../identity/follows').ensureSchema(db);
- // Incidents and maintenance on /status (server/status/incidents.js).
- require('../status/incidents').ensureSchema(db);
- // Creator analytics from live.stream.ended (server/analytics/creators.js).
- require('../analytics/creators').ensureSchema(db);
- // The platform's machines (server/registry/nodes.js; ADR-034 §12).
- require('../registry/nodes').ensureSchema(db);
-
- console.log('[DB] Central database initialized');
+ Object.defineProperty(db, '_ovHelpers', { value: true, enumerable: false });
return db;
return db;
}
}
-module.exports = { initDb };
+module.exports = { openDb, initDb, getDb, setDb, seedDb, attachHelpers, MIGRATIONS, mergeRedirectUris };
@@ -24,9 +24,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -24,9 +24,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
next();
next();
}
}
- function auditLog(req, action, details) {
+ async function auditLog(req, action, details) {
try {
try {
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, action, JSON.stringify(details)
req.user.id, action, JSON.stringify(details)
);
);
} catch (err) {
} catch (err) {
@@ -34,8 +34,8 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -34,8 +34,8 @@ module.exports = function createDeployRoutes(db, requireAuth) {
}
}
}
}
- function getDeployConfig() {
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ async function getDeployConfig() {
+ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
return {
return {
acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',
acmeEmail: resolved.DEPLOY_ACME_EMAIL?.value || '',
certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',
certMode: resolved.DEPLOY_CERT_MODE?.value || 'manual',
@@ -54,10 +54,10 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -54,10 +54,10 @@ module.exports = function createDeployRoutes(db, requireAuth) {
// System Prerequisites
// System Prerequisites
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/prerequisites', (req, res) => {
+ router.get('/prerequisites', async (req, res) => {
try {
try {
const prereqs = certManager.checkPrerequisites();
const prereqs = certManager.checkPrerequisites();
- const config = getDeployConfig();
+ const config = await getDeployConfig();
res.json({
res.json({
ok: true,
ok: true,
prerequisites: prereqs,
prerequisites: prereqs,
@@ -78,9 +78,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -78,9 +78,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
// Deploy Configuration
// Deploy Configuration
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/config', (req, res) => {
+ router.get('/config', async (req, res) => {
try {
try {
- const config = getDeployConfig();
+ const config = await getDeployConfig();
// Mask the Cloudflare token
// Mask the Cloudflare token
const masked = { ...config };
const masked = { ...config };
if (masked.cloudflareToken && masked.cloudflareToken.length > 4) {
if (masked.cloudflareToken && masked.cloudflareToken.length > 4) {
@@ -92,7 +92,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -92,7 +92,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
}
}
});
});
- router.put('/config', (req, res) => {
+ router.put('/config', async (req, res) => {
try {
try {
const allowedKeys = {
const allowedKeys = {
acmeEmail: 'DEPLOY_ACME_EMAIL',
acmeEmail: 'DEPLOY_ACME_EMAIL',
@@ -121,11 +121,11 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -121,11 +121,11 @@ module.exports = function createDeployRoutes(db, requireAuth) {
if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {
if (registryKey === 'DEPLOY_NGINX_MODE' && !['preview', 'apply', 'disabled'].includes(value)) {
return res.status(400).json({ ok: false, error: 'nginxMode must be "preview", "apply", or "disabled"' });
return res.status(400).json({ ok: false, error: 'nginxMode must be "preview", "apply", or "disabled"' });
}
}
- const entry = urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);
+ const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, req.user.id);
updated[bodyKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
updated[bodyKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
}
}
- auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });
+ await auditLog(req, 'deploy_config_update', { keys: Object.keys(updated) });
res.json({ ok: true, updated });
res.json({ ok: true, updated });
} catch (err) {
} catch (err) {
res.status(400).json({ ok: false, error: err.message });
res.status(400).json({ ok: false, error: err.message });
@@ -139,7 +139,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -139,7 +139,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
router.get('/certs', async (req, res) => {
router.get('/certs', async (req, res) => {
try {
try {
const status = await certManager.getCertificateStatus();
const status = await certManager.getCertificateStatus();
- const config = getDeployConfig();
+ const config = await getDeployConfig();
// Annotate certs with domain config
// Annotate certs with domain config
for (const cert of status.certs) {
for (const cert of status.certs) {
@@ -166,7 +166,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -166,7 +166,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
const { domain } = req.body;
const { domain } = req.body;
if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
- const config = getDeployConfig();
+ const config = await getDeployConfig();
if (!config.acmeEmail) {
if (!config.acmeEmail) {
return res.status(400).json({ ok: false, error: 'ACME email not configured. Set it in Deploy Config.' });
return res.status(400).json({ ok: false, error: 'ACME email not configured. Set it in Deploy Config.' });
}
}
@@ -174,7 +174,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -174,7 +174,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
return res.status(400).json({ ok: false, error: 'Cloudflare token not configured. Set it in Deploy Config.' });
return res.status(400).json({ ok: false, error: 'Cloudflare token not configured. Set it in Deploy Config.' });
}
}
- auditLog(req, 'cert_issue_cloudflare_start', { domain });
+ await auditLog(req, 'cert_issue_cloudflare_start', { domain });
const result = await certManager.issueWildcardCloudflare({
const result = await certManager.issueWildcardCloudflare({
domain,
domain,
@@ -182,7 +182,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -182,7 +182,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
cloudflareToken: config.cloudflareToken,
cloudflareToken: config.cloudflareToken,
});
});
- auditLog(req, 'cert_issue_cloudflare_result', {
+ await auditLog(req, 'cert_issue_cloudflare_result', {
domain,
domain,
ok: result.ok,
ok: result.ok,
certName: result.certName,
certName: result.certName,
@@ -199,7 +199,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -199,7 +199,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
if (!domains.find(d => d.domain === domain)) {
if (!domains.find(d => d.domain === domain)) {
domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
}
}
- urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
+ await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
}
}
res.json({ ok: result.ok, ...result });
res.json({ ok: result.ok, ...result });
@@ -217,7 +217,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -217,7 +217,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
const { domain } = req.body;
const { domain } = req.body;
if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
- const config = getDeployConfig();
+ const config = await getDeployConfig();
if (!config.acmeEmail) {
if (!config.acmeEmail) {
return res.status(400).json({ ok: false, error: 'ACME email not configured' });
return res.status(400).json({ ok: false, error: 'ACME email not configured' });
}
}
@@ -238,19 +238,19 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -238,19 +238,19 @@ module.exports = function createDeployRoutes(db, requireAuth) {
const { domain } = req.body;
const { domain } = req.body;
if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
if (!domain) return res.status(400).json({ ok: false, error: 'domain is required' });
- const config = getDeployConfig();
+ const config = await getDeployConfig();
if (!config.acmeEmail) {
if (!config.acmeEmail) {
return res.status(400).json({ ok: false, error: 'ACME email not configured' });
return res.status(400).json({ ok: false, error: 'ACME email not configured' });
}
}
- auditLog(req, 'cert_issue_manual_start', { domain });
+ await auditLog(req, 'cert_issue_manual_start', { domain });
const result = await certManager.issueWildcardManual({
const result = await certManager.issueWildcardManual({
domain,
domain,
email: config.acmeEmail,
email: config.acmeEmail,
});
});
- auditLog(req, 'cert_issue_manual_result', {
+ await auditLog(req, 'cert_issue_manual_result', {
domain,
domain,
ok: result.ok,
ok: result.ok,
certName: result.certName,
certName: result.certName,
@@ -265,7 +265,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -265,7 +265,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
if (!domains.find(d => d.domain === domain)) {
if (!domains.find(d => d.domain === domain)) {
domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
domains.push({ domain, wildcard: true, certName: result.certName, services: [] });
}
}
- urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
+ await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
}
}
res.json({ ok: result.ok, ...result });
res.json({ ok: result.ok, ...result });
@@ -280,9 +280,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -280,9 +280,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
router.post('/certs/renew', async (req, res) => {
router.post('/certs/renew', async (req, res) => {
try {
try {
- auditLog(req, 'cert_renew_start', {});
+ await auditLog(req, 'cert_renew_start', {});
const result = await certManager.renewCertificates();
const result = await certManager.renewCertificates();
- auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });
+ await auditLog(req, 'cert_renew_result', { ok: result.ok, error: result.error });
res.json({ ok: result.ok, ...result });
res.json({ ok: result.ok, ...result });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
@@ -293,9 +293,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -293,9 +293,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
// Nginx Config Preview
// Nginx Config Preview
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/nginx/preview', (req, res) => {
+ router.get('/nginx/preview', async (req, res) => {
try {
try {
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
const configs = nginxGenerator.generateAllConfigs(resolved);
const configs = nginxGenerator.generateAllConfigs(resolved);
res.json({
res.json({
ok: true,
ok: true,
@@ -311,9 +311,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -311,9 +311,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
}
}
});
});
- router.get('/nginx/preview/:serviceId', (req, res) => {
+ router.get('/nginx/preview/:serviceId', async (req, res) => {
try {
try {
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
const configs = nginxGenerator.generateAllConfigs(resolved);
const configs = nginxGenerator.generateAllConfigs(resolved);
const cfg = configs.find(c => c.serviceId === req.params.serviceId);
const cfg = configs.find(c => c.serviceId === req.params.serviceId);
if (!cfg) return res.status(404).json({ ok: false, error: 'Service not found' });
if (!cfg) return res.status(404).json({ ok: false, error: 'Service not found' });
@@ -342,7 +342,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -342,7 +342,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
router.post('/nginx/apply', async (req, res) => {
router.post('/nginx/apply', async (req, res) => {
try {
try {
- const config = getDeployConfig();
+ const config = await getDeployConfig();
if (config.nginxMode === 'disabled') {
if (config.nginxMode === 'disabled') {
return res.status(400).json({ ok: false, error: 'Nginx management is disabled' });
return res.status(400).json({ ok: false, error: 'Nginx management is disabled' });
}
}
@@ -350,9 +350,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -350,9 +350,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
const dryRun = config.nginxMode === 'preview' || req.body.dryRun === true;
const dryRun = config.nginxMode === 'preview' || req.body.dryRun === true;
const reload = req.body.reload !== false && !dryRun;
const reload = req.body.reload !== false && !dryRun;
- auditLog(req, 'nginx_apply_start', { dryRun, reload });
+ await auditLog(req, 'nginx_apply_start', { dryRun, reload });
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
const configs = nginxGenerator.generateAllConfigs(resolved);
const configs = nginxGenerator.generateAllConfigs(resolved);
const result = await nginxGenerator.applyConfigs(configs, {
const result = await nginxGenerator.applyConfigs(configs, {
@@ -362,7 +362,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -362,7 +362,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
dryRun,
dryRun,
});
});
- auditLog(req, 'nginx_apply_result', {
+ await auditLog(req, 'nginx_apply_result', {
ok: result.ok,
ok: result.ok,
dryRun: result.dryRun,
dryRun: result.dryRun,
applied: result.applied,
applied: result.applied,
@@ -391,7 +391,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -391,7 +391,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
});
});
}
}
- auditLog(req, 'nginx_reload', {});
+ await auditLog(req, 'nginx_reload', {});
const result = await nginxGenerator.reloadNginx();
const result = await nginxGenerator.reloadNginx();
res.json({ ok: result.ok, ...result, validation });
res.json({ ok: result.ok, ...result, validation });
} catch (err) {
} catch (err) {
@@ -403,9 +403,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -403,9 +403,9 @@ module.exports = function createDeployRoutes(db, requireAuth) {
// Managed Domains CRUD
// Managed Domains CRUD
// ═══════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════
- router.get('/domains', (req, res) => {
+ router.get('/domains', async (req, res) => {
try {
try {
- const config = getDeployConfig();
+ const config = await getDeployConfig();
// Annotate each domain with cert status
// Annotate each domain with cert status
const domains = (config.domains || []).map(d => {
const domains = (config.domains || []).map(d => {
const cert = certManager.findBestCert(d.certName || d.domain);
const cert = certManager.findBestCert(d.certName || d.domain);
@@ -423,7 +423,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -423,7 +423,7 @@ module.exports = function createDeployRoutes(db, requireAuth) {
}
}
});
});
- router.put('/domains', (req, res) => {
+ router.put('/domains', async (req, res) => {
try {
try {
const { domains } = req.body;
const { domains } = req.body;
if (!Array.isArray(domains)) {
if (!Array.isArray(domains)) {
@@ -438,8 +438,8 @@ module.exports = function createDeployRoutes(db, requireAuth) {
@@ -438,8 +438,8 @@ module.exports = function createDeployRoutes(db, requireAuth) {
return res.status(400).json({ ok: false, error: `Invalid domain: ${d.domain}` });
return res.status(400).json({ ok: false, error: `Invalid domain: ${d.domain}` });
}
}
}
}
- const entry = urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
- auditLog(req, 'deploy_domains_update', { count: domains.length });
+ const entry = await urlRegistry.setRegistryEntry(db, 'DEPLOY_DOMAINS', domains, req.user.id);
+ await auditLog(req, 'deploy_domains_update', { count: domains.length });
res.json({ ok: true, domains: entry.value });
res.json({ ok: true, domains: entry.value });
} catch (err) {
} catch (err) {
res.status(400).json({ ok: false, error: err.message });
res.status(400).json({ ok: false, error: err.message });
@@ -18,12 +18,13 @@
@@ -18,12 +18,13 @@
const crypto = require('crypto');
const crypto = require('crypto');
const { serviceAuth, assertValid } = require('openvibe-contracts');
const { serviceAuth, assertValid } = require('openvibe-contracts');
const { createClient } = require('openvibe-sdk/core');
const { createClient } = require('openvibe-sdk/core');
-const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
const TABLE = 'network_event_outbox';
const TABLE = 'network_event_outbox';
const TOKEN_TTL_S = 300;
const TOKEN_TTL_S = 300;
const outboxes = new WeakMap(); // db -> outbox, only while the relay is configured
const outboxes = new WeakMap(); // db -> outbox, only while the relay is configured
const passives = new WeakMap(); // db -> outbox that only writes rows (never started)
const passives = new WeakMap(); // db -> outbox that only writes rows (never started)
+const writers = new WeakMap(); // db -> the enqueue facade writerFor() returns (one per database)
/** The outbox registered for this database, or null (relay off). store.audit() enqueues into it. */
/** The outbox registered for this database, or null (relay off). store.audit() enqueues into it. */
function outboxFor(db) {
function outboxFor(db) {
@@ -38,16 +39,28 @@ function outboxFor(db) {
@@ -38,16 +39,28 @@ function outboxFor(db) {
* (user modules: server/identity/module-events.js); dev_audit events keep their backfill.
* (user modules: server/identity/module-events.js); dev_audit events keep their backfill.
*/
*/
function writerFor(db) {
function writerFor(db) {
+ const cached = writers.get(db);
+ if (cached) return cached;
const live = outboxes.get(db);
const live = outboxes.get(db);
- if (live) return live;
- let p = passives.get(db);
- if (!p) {
- const events = createEventsClient(createClient({ network: 'https://openvibe.network', baseUrls: { events: 'http://127.0.0.1:1' } }), { source: 'network' });
- p = createOutbox(db, { events, table: TABLE });
- p.ensureSchema();
- passives.set(db, p);
+ let outbox = live;
+ if (!outbox) {
+ outbox = passives.get(db);
+ if (!outbox) {
+ const events = createEventsClient(createClient({ network: 'https://openvibe.network', baseUrls: { events: 'http://127.0.0.1:1' } }), { source: 'network' });
+ outbox = createPgOutbox(db, { events, table: TABLE });
+ passives.set(db, outbox);
+ }
}
}
- return p;
+ // The PostgreSQL outbox enqueues on the transaction handle (enqueue(t, envelope, opts)); caller sites
+ // also pass the envelope first. Accept both shapes, always binding this database as the handle so the
+ // row joins the caller's ambient db.tx. One facade per database, so a caller (a test) can swap enqueue.
+ const writer = {
+ enqueue: (a, b, c) => (a && typeof a.query === 'function' && typeof a.tx === 'function')
+ ? outbox.enqueue(a, b, c)
+ : outbox.enqueue(db, a, b),
+ };
+ writers.set(db, writer);
+ return writer;
}
}
/** A self-signed Network service token for publishing to Events, cached until a minute before exp. */
/** A self-signed Network service token for publishing to Events, cached until a minute before exp. */
@@ -70,17 +83,17 @@ function createSelfTokenSource({ privateKey, issuer }) {
@@ -70,17 +83,17 @@ function createSelfTokenSource({ privateKey, issuer }) {
}
}
/** Copy dev_audit events that are not in the outbox yet, oldest first. Returns how many were added. */
/** Copy dev_audit events that are not in the outbox yet, oldest first. Returns how many were added. */
-function backfill(db) {
- const rows = db.prepare(`SELECT id, event FROM dev_audit WHERE event IS NOT NULL
- AND json_extract(event, '$.event_id') NOT IN (SELECT event_id FROM ${TABLE}) ORDER BY id`).all();
- const insert = db.prepare(`INSERT OR IGNORE INTO ${TABLE} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, NULL, ?, 0)`);
+async function backfill(db) {
+ const rows = await db.prepare(`SELECT id, event FROM dev_audit WHERE event IS NOT NULL
+ AND (event::jsonb ->> 'event_id') NOT IN (SELECT event_id FROM ${TABLE}) ORDER BY id`).all();
+ const insert = db.prepare(`INSERT INTO ${TABLE} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, NULL, ?, 0) ON CONFLICT DO NOTHING`);
let added = 0;
let added = 0;
- db.transaction(() => {
+ await db.tx(async () => {
for (const r of rows) {
for (const r of rows) {
const env = JSON.parse(r.event);
const env = JSON.parse(r.event);
- added += insert.run(env.event_id, r.event, Date.now()).changes;
+ added += (await insert.run(env.event_id, r.event, Date.now())).changes;
}
}
- })();
+ });
return added;
return added;
}
}
@@ -88,7 +101,7 @@ function backfill(db) {
@@ -88,7 +101,7 @@ function backfill(db) {
* Start the relay for `db` when `eventsUrl` is set; returns the outbox (or null when off).
* Start the relay for `db` when `eventsUrl` is set; returns the outbox (or null when off).
* fetch/intervalMs are injectable for tests.
* fetch/intervalMs are injectable for tests.
*/
*/
-function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, intervalMs = 2000, log = console, autoStart = true } = {}) {
+async function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, intervalMs = 2000, log = console, autoStart = true } = {}) {
if (!eventsUrl) return null;
if (!eventsUrl) return null;
if (!privateKey || !String(privateKey).includes('BEGIN')) {
if (!privateKey || !String(privateKey).includes('BEGIN')) {
log.warn('[events-relay] OV_EVENTS_INTERNAL_URL is set but Network has no RS256 private key; relay stays off');
log.warn('[events-relay] OV_EVENTS_INTERNAL_URL is set but Network has no RS256 private key; relay stays off');
@@ -103,13 +116,12 @@ function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, inter
@@ -103,13 +116,12 @@ function startRelay(db, { eventsUrl, privateKey, issuer, fetch: fetchImpl, inter
...(fetchImpl ? { fetch: fetchImpl } : {}),
...(fetchImpl ? { fetch: fetchImpl } : {}),
});
});
const events = createEventsClient(client, { source: 'network' });
const events = createEventsClient(client, { source: 'network' });
- const outbox = createOutbox(db, {
+ const outbox = createPgOutbox(db, {
events, table: TABLE, intervalMs,
events, table: TABLE, intervalMs,
onError: (err) => log.warn(`[events-relay] publish failed: ${String((err && err.message) || err).slice(0, 200)}`),
onError: (err) => log.warn(`[events-relay] publish failed: ${String((err && err.message) || err).slice(0, 200)}`),
});
});
- outbox.ensureSchema();
outboxes.set(db, outbox);
outboxes.set(db, outbox);
- const added = backfill(db);
+ const added = await backfill(db);
if (added) log.log(`[events-relay] backfilled ${added} developer-project event(s) from dev_audit`);
if (added) log.log(`[events-relay] backfilled ${added} developer-project event(s) from dev_audit`);
if (autoStart) outbox.start();
if (autoStart) outbox.start();
return outbox;
return outbox;
@@ -57,23 +57,23 @@ function router() {
@@ -57,23 +57,23 @@ function router() {
// Bearer user tokens only. An expired token is refused here even inside the session grace period:
// Bearer user tokens only. An expired token is refused here even inside the session grace period:
// this API hands out secrets, so it does not slide sessions.
// this API hands out secrets, so it does not slide sessions.
- r.use((req, res, next) => {
+ r.use(async (req, res, next) => {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'auth.required', { detail: 'send Authorization: Bearer <Network access token>', ctx: req.ov });
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'auth.required', { detail: 'send Authorization: Bearer <Network access token>', ctx: req.ov });
const { db, publicKey, config } = req.app.locals;
const { db, publicKey, config } = req.app.locals;
- const out = verifySession(h.slice(7).trim(), { db, publicKey, config });
+ const out = await verifySession(h.slice(7).trim(), { db, publicKey, config });
if (out.error) return http.sendProblem(res, out.status === 403 ? 403 : 401, out.status === 403 ? 'auth.banned' : 'auth.invalid', { detail: out.error, ctx: req.ov });
if (out.error) return http.sendProblem(res, out.status === 403 ? 403 : 401, out.status === 403 ? 'auth.banned' : 'auth.invalid', { detail: out.error, ctx: req.ov });
if (out.decoded && typeof out.decoded.exp === 'number' && out.decoded.exp * 1000 < Date.now()) {
if (out.decoded && typeof out.decoded.exp === 'number' && out.decoded.exp * 1000 < Date.now()) {
return http.sendProblem(res, 401, 'auth.expired', { detail: 'access token expired; refresh it', ctx: req.ov });
return http.sendProblem(res, 401, 'auth.expired', { detail: 'access token expired; refresh it', ctx: req.ov });
}
}
- try { req.actor = store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }
+ try { req.actor = await store.actorOf(db, out.user); } catch (err) { return send(req, res, err); }
next();
next();
});
});
const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });
const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });
- const handle = (fn, status = 200) => (req, res) => {
+ const handle = (fn, status = 200) => async (req, res) => {
try {
try {
- const out = fn(req.app.locals.db, req.actor, req, ctxOf(req));
+ const out = await fn(req.app.locals.db, req.actor, req, ctxOf(req));
if (out === undefined) return res.status(204).end();
if (out === undefined) return res.status(204).end();
res.status(status).json(out);
res.status(status).json(out);
} catch (err) { send(req, res, err); }
} catch (err) { send(req, res, err); }
@@ -85,55 +85,55 @@ function router() {
@@ -85,55 +85,55 @@ function router() {
rule: 'only active capabilities with visibility public (or partner, by staff allowance) are grantable to apps; sandbox apps may hold sandbox_allowance without staff, production apps only the staff-set project allowance',
rule: 'only active capabilities with visibility public (or partner, by staff allowance) are grantable to apps; sandbox apps may hold sandbox_allowance without staff, production apps only the staff-set project allowance',
}));
}));
- r.post('/', handle((db, a, req, o) => store.createProject(db, a, req.body || {}, o), 201));
- r.get('/', handle((db, a, req, o) => ({ projects: store.listProjects(db, a, { all: req.query.all === '1', settings: o.settings }) })));
- r.get('/:project', handle((db, a, req, o) => {
- const { project, role } = store.access(db, a, req.params.project, { allowArchived: true });
- return store.projectView(db, project, role, o.settings);
+ r.post('/', handle(async (db, a, req, o) => await store.createProject(db, a, req.body || {}, o), 201));
+ r.get('/', handle(async (db, a, req, o) => ({ projects: await store.listProjects(db, a, { all: req.query.all === '1', settings: o.settings }) })));
+ r.get('/:project', handle(async (db, a, req, o) => {
+ const { project, role } = await store.access(db, a, req.params.project, { allowArchived: true });
+ return await store.projectView(db, project, role, o.settings);
}));
}));
- r.patch('/:project', handle((db, a, req, o) => store.renameProject(db, a, req.params.project, req.body || {}, o)));
- r.post('/:project/archive', handle((db, a, req, o) => store.archiveProject(db, a, req.params.project, o)));
- r.put('/:project/allowance', handle((db, a, req, o) => store.setAllowance(db, a, req.params.project, req.body || {}, o)));
- r.put('/:project/environment-policy', handle((db, a, req, o) => store.setEnvironmentPolicy(db, a, req.params.project, req.body || {}, o)));
-
- r.get('/:project/members', handle((db, a, req) => ({ members: store.listMembers(db, a, req.params.project) })));
- r.post('/:project/members', handle((db, a, req, o) => store.addMember(db, a, req.params.project, req.body || {}, o), 201));
- r.patch('/:project/members/:subject', handle((db, a, req, o) => store.updateMember(db, a, req.params.project, req.params.subject, req.body || {}, o)));
- r.delete('/:project/members/:subject', handle((db, a, req, o) => store.removeMember(db, a, req.params.project, req.params.subject, o)));
-
- r.get('/:project/apps', handle((db, a, req) => ({ apps: store.listApps(db, a, req.params.project) })));
- r.post('/:project/apps', handle((db, a, req, o) => store.createApp(db, a, req.params.project, req.body || {}, o), 201));
- r.get('/:project/apps/:app', handle((db, a, req) => store.getApp(db, a, req.params.project, req.params.app)));
- r.patch('/:project/apps/:app', handle((db, a, req, o) => store.updateApp(db, a, req.params.project, req.params.app, req.body || {}, o)));
- r.delete('/:project/apps/:app', handle((db, a, req, o) => store.revokeApp(db, a, req.params.project, req.params.app, o)));
-
- r.get('/:project/apps/:app/credentials', handle((db, a, req) => ({ credentials: store.listCredentials(db, a, req.params.project, req.params.app) })));
- r.post('/:project/apps/:app/credentials/rotate', handle((db, a, req, o) => store.rotateCredential(db, a, req.params.project, req.params.app, req.body || {}, o), 201));
- r.post('/:project/apps/:app/credentials/:credential/revoke', handle((db, a, req, o) => store.revokeCredential(db, a, req.params.project, req.params.app, req.params.credential, o)));
-
- r.get('/:project/apps/:app/grants', handle((db, a, req) => ({ grants: store.listGrants(db, a, req.params.project, req.params.app) })));
- r.post('/:project/apps/:app/grants', handle((db, a, req, o) => store.requestGrant(db, a, req.params.project, req.params.app, req.body || {}, o), 201));
- r.post('/:project/apps/:app/grants/:capability/approve', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'approved', o)));
- r.post('/:project/apps/:app/grants/:capability/deny', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));
- r.delete('/:project/apps/:app/grants/:capability', handle((db, a, req, o) => store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));
-
- r.get('/:project/quotas', handle((db, a, req) => ({ quotas: store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
- r.put('/:project/quotas/:capability', handle((db, a, req, o) => store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
- r.delete('/:project/quotas/:capability', handle((db, a, req, o) => store.deleteQuota(db, a, req.params.project, req.params.capability, o)));
+ r.patch('/:project', handle(async (db, a, req, o) => await store.renameProject(db, a, req.params.project, req.body || {}, o)));
+ r.post('/:project/archive', handle(async (db, a, req, o) => await store.archiveProject(db, a, req.params.project, o)));
+ r.put('/:project/allowance', handle(async (db, a, req, o) => await store.setAllowance(db, a, req.params.project, req.body || {}, o)));
+ r.put('/:project/environment-policy', handle(async (db, a, req, o) => await store.setEnvironmentPolicy(db, a, req.params.project, req.body || {}, o)));
+
+ r.get('/:project/members', handle(async (db, a, req) => ({ members: await store.listMembers(db, a, req.params.project) })));
+ r.post('/:project/members', handle(async (db, a, req, o) => await store.addMember(db, a, req.params.project, req.body || {}, o), 201));
+ r.patch('/:project/members/:subject', handle(async (db, a, req, o) => await store.updateMember(db, a, req.params.project, req.params.subject, req.body || {}, o)));
+ r.delete('/:project/members/:subject', handle(async (db, a, req, o) => await store.removeMember(db, a, req.params.project, req.params.subject, o)));
+
+ r.get('/:project/apps', handle(async (db, a, req) => ({ apps: await store.listApps(db, a, req.params.project) })));
+ r.post('/:project/apps', handle(async (db, a, req, o) => await store.createApp(db, a, req.params.project, req.body || {}, o), 201));
+ r.get('/:project/apps/:app', handle(async (db, a, req) => await store.getApp(db, a, req.params.project, req.params.app)));
+ r.patch('/:project/apps/:app', handle(async (db, a, req, o) => await store.updateApp(db, a, req.params.project, req.params.app, req.body || {}, o)));
+ r.delete('/:project/apps/:app', handle(async (db, a, req, o) => await store.revokeApp(db, a, req.params.project, req.params.app, o)));
+
+ r.get('/:project/apps/:app/credentials', handle(async (db, a, req) => ({ credentials: await store.listCredentials(db, a, req.params.project, req.params.app) })));
+ r.post('/:project/apps/:app/credentials/rotate', handle(async (db, a, req, o) => await store.rotateCredential(db, a, req.params.project, req.params.app, req.body || {}, o), 201));
+ r.post('/:project/apps/:app/credentials/:credential/revoke', handle(async (db, a, req, o) => await store.revokeCredential(db, a, req.params.project, req.params.app, req.params.credential, o)));
+
+ r.get('/:project/apps/:app/grants', handle(async (db, a, req) => ({ grants: await store.listGrants(db, a, req.params.project, req.params.app) })));
+ r.post('/:project/apps/:app/grants', handle(async (db, a, req, o) => await store.requestGrant(db, a, req.params.project, req.params.app, req.body || {}, o), 201));
+ r.post('/:project/apps/:app/grants/:capability/approve', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'approved', o)));
+ r.post('/:project/apps/:app/grants/:capability/deny', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'denied', o)));
+ r.delete('/:project/apps/:app/grants/:capability', handle(async (db, a, req, o) => await store.decideGrant(db, a, req.params.project, req.params.app, req.params.capability, 'revoked', o)));
+
+ r.get('/:project/quotas', handle(async (db, a, req) => ({ quotas: await store.listQuotas(db, a, req.params.project), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' })));
+ r.put('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.setQuota(db, a, req.params.project, req.params.capability, req.body || {}, o)));
+ r.delete('/:project/quotas/:capability', handle(async (db, a, req, o) => await store.deleteQuota(db, a, req.params.project, req.params.capability, o)));
// Usage (WS-N task 4): network.project-usage-result@1, for the owner and admins (and staff).
// Usage (WS-N task 4): network.project-usage-result@1, for the owner and admins (and staff).
- r.get('/:project/usage', handle((db, a, req) => {
- const { project } = store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
- return usage.summary(db, project.id, usage.parseQuery(req.query));
+ r.get('/:project/usage', handle(async (db, a, req) => {
+ const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
+ return await usage.summary(db, project.id, usage.parseQuery(req.query));
}));
}));
- r.get('/:project/audit', handle((db, a, req) => {
- const { project } = store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
- return store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
+ r.get('/:project/audit', handle(async (db, a, req) => {
+ const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
+ return await store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
}));
}));
// Project export (WS-N task 9): Codes asks with the person's token, once per audience and env.
// Project export (WS-N task 9): Codes asks with the person's token, once per audience and env.
- r.post('/:project/export-tokens', handle((db, a, req, o) => tokens.mintExportToken(db, a, req.params.project, req.body || {}, {
+ r.post('/:project/export-tokens', handle(async (db, a, req, o) => await tokens.mintExportToken(db, a, req.params.project, req.body || {}, {
privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,
privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,
}), 201));
}), 201));
@@ -40,106 +40,7 @@ class DevError extends Error {
@@ -40,106 +40,7 @@ class DevError extends Error {
const fail = (status, code, detail) => { throw new DevError(status, code, detail); };
const fail = (status, code, detail) => { throw new DevError(status, code, detail); };
const nowIso = () => new Date().toISOString();
const nowIso = () => new Date().toISOString();
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS dev_projects (
- id TEXT PRIMARY KEY,
- owner_subject TEXT NOT NULL,
- name TEXT NOT NULL,
- environment_policy TEXT NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),
- allowance TEXT NOT NULL DEFAULT '[]',
- created_at TEXT NOT NULL,
- created_by TEXT NOT NULL,
- archived_at TEXT,
- archived_by TEXT
- );
- CREATE INDEX IF NOT EXISTS idx_dev_projects_owner ON dev_projects(owner_subject);
- CREATE TABLE IF NOT EXISTS dev_project_members (
- project_id TEXT NOT NULL REFERENCES dev_projects(id),
- subject_id TEXT NOT NULL,
- role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),
- added_at TEXT NOT NULL,
- added_by TEXT NOT NULL,
- PRIMARY KEY (project_id, subject_id)
- );
- CREATE INDEX IF NOT EXISTS idx_dev_members_subject ON dev_project_members(subject_id);
- CREATE TABLE IF NOT EXISTS dev_apps (
- id TEXT PRIMARY KEY,
- project_id TEXT NOT NULL REFERENCES dev_projects(id),
- name TEXT NOT NULL,
- environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),
- oauth_client_id TEXT NOT NULL UNIQUE,
- client_type TEXT NOT NULL CHECK (client_type IN ('confidential', 'public')),
- redirect_uris TEXT NOT NULL DEFAULT '[]',
- created_at TEXT NOT NULL,
- created_by TEXT NOT NULL,
- revoked_at TEXT,
- revoked_by TEXT
- );
- CREATE INDEX IF NOT EXISTS idx_dev_apps_project ON dev_apps(project_id);
- CREATE TABLE IF NOT EXISTS dev_credentials (
- id TEXT PRIMARY KEY,
- app_id TEXT NOT NULL REFERENCES dev_apps(id),
- secret_hash TEXT NOT NULL UNIQUE,
- hint TEXT NOT NULL,
- created_at TEXT NOT NULL,
- created_by TEXT NOT NULL,
- expires_at TEXT,
- revoked_at TEXT,
- revoked_by TEXT,
- last_used_at TEXT
- );
- CREATE INDEX IF NOT EXISTS idx_dev_credentials_app ON dev_credentials(app_id);
- CREATE TABLE IF NOT EXISTS dev_grants (
- app_id TEXT NOT NULL REFERENCES dev_apps(id),
- capability TEXT NOT NULL,
- audience TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),
- requested_by TEXT NOT NULL,
- requested_at TEXT NOT NULL,
- decided_by TEXT,
- decided_at TEXT,
- PRIMARY KEY (app_id, capability)
- );
- CREATE TABLE IF NOT EXISTS dev_quotas (
- project_id TEXT NOT NULL REFERENCES dev_projects(id),
- capability TEXT NOT NULL,
- limit_value INTEGER NOT NULL CHECK (limit_value >= 0),
- quota_window TEXT NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),
- unit TEXT NOT NULL DEFAULT 'requests',
- updated_at TEXT NOT NULL,
- updated_by TEXT NOT NULL,
- PRIMARY KEY (project_id, capability)
- );
- CREATE TABLE IF NOT EXISTS dev_auth_codes (
- code_hash TEXT PRIMARY KEY,
- app_id TEXT NOT NULL REFERENCES dev_apps(id),
- user_subject TEXT NOT NULL,
- redirect_uri TEXT NOT NULL,
- scope TEXT NOT NULL DEFAULT '',
- code_challenge TEXT NOT NULL,
- expires_at TEXT NOT NULL,
- used INTEGER NOT NULL DEFAULT 0
- );
- CREATE TABLE IF NOT EXISTS dev_audit (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- at TEXT NOT NULL,
- project_id TEXT,
- actor TEXT NOT NULL,
- action TEXT NOT NULL,
- target TEXT,
- detail TEXT NOT NULL DEFAULT '{}',
- request_id TEXT,
- event_type TEXT,
- event TEXT
- );
- CREATE INDEX IF NOT EXISTS idx_dev_audit_project ON dev_audit(project_id, id);
- CREATE TRIGGER IF NOT EXISTS dev_audit_append_only_update BEFORE UPDATE ON dev_audit
- BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;
- CREATE TRIGGER IF NOT EXISTS dev_audit_append_only_delete BEFORE DELETE ON dev_audit
- BEGIN SELECT RAISE(ABORT, 'dev_audit is append-only'); END;
- `);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
// ── Audit and events ───────────────────────────────────────────
// ── Audit and events ───────────────────────────────────────────
@@ -148,7 +49,7 @@ function ensureSchema(db) {
@@ -148,7 +49,7 @@ function ensureSchema(db) {
* With `event` = { type, subject: { type, id }, payload }, the row also carries an event envelope.
* With `event` = { type, subject: { type, id }, payload }, the row also carries an event envelope.
* `detail` must never contain secret material (callers pass ids, hints and names only).
* `detail` must never contain secret material (callers pass ids, hints and names only).
*/
*/
-function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
+async function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
let envelope = null;
let envelope = null;
if (event) {
if (event) {
const [atype, aid] = String(actor).split(':');
const [atype, aid] = String(actor).split(':');
@@ -165,21 +66,21 @@ function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
@@ -165,21 +66,21 @@ function audit(db, { projectId, actor, action, target, detail, ctx, event }) {
// One transaction (a savepoint when the caller already has one): the audit row and, when the
// One transaction (a savepoint when the caller already has one): the audit row and, when the
// Events relay is on, its outbox row exist together or not at all.
// Events relay is on, its outbox row exist together or not at all.
const outbox = envelope ? eventRelay.outboxFor(db) : null;
const outbox = envelope ? eventRelay.outboxFor(db) : null;
- db.transaction(() => {
- db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO dev_audit (at, project_id, actor, action, target, detail, request_id, event_type, event)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(nowIso(), projectId || null, actor, action, target || null, JSON.stringify(detail || {}), (ctx && ctx.requestId) || null,
.run(nowIso(), projectId || null, actor, action, target || null, JSON.stringify(detail || {}), (ctx && ctx.requestId) || null,
envelope ? envelope.event_type : null, envelope ? JSON.stringify(envelope) : null);
envelope ? envelope.event_type : null, envelope ? JSON.stringify(envelope) : null);
- if (outbox) outbox.enqueue(envelope, { traceparent: ctx && ctx.traceparent });
- })();
+ if (outbox) await outbox.enqueue(db, envelope, { traceparent: ctx && ctx.traceparent });
+ });
if (outbox) outbox.kick();
if (outbox) outbox.kick();
}
}
-function listAudit(db, projectId, { before, limit = 50 } = {}) {
+async function listAudit(db, projectId, { before, limit = 50 } = {}) {
const n = Math.min(200, Math.max(1, Number(limit) || 50));
const n = Math.min(200, Math.max(1, Number(limit) || 50));
const rows = before
const rows = before
- ? db.prepare('SELECT * FROM dev_audit WHERE project_id = ? AND id < ? ORDER BY id DESC LIMIT ?').all(projectId, Number(before), n)
- : db.prepare('SELECT * FROM dev_audit WHERE project_id = ? ORDER BY id DESC LIMIT ?').all(projectId, n);
+ ? await db.prepare('SELECT * FROM dev_audit WHERE project_id = ? AND id < ? ORDER BY id DESC LIMIT ?').all(projectId, Number(before), n)
+ : await db.prepare('SELECT * FROM dev_audit WHERE project_id = ? ORDER BY id DESC LIMIT ?').all(projectId, n);
return {
return {
entries: rows.map(r => ({ id: r.id, at: r.at, actor: r.actor, action: r.action, target: r.target, detail: JSON.parse(r.detail || '{}'),
entries: rows.map(r => ({ id: r.id, at: r.at, actor: r.actor, action: r.action, target: r.target, detail: JSON.parse(r.detail || '{}'),
request_id: r.request_id, event_type: r.event_type || undefined, event_id: r.event ? JSON.parse(r.event).event_id : undefined })),
request_id: r.request_id, event_type: r.event_type || undefined, event_id: r.event ? JSON.parse(r.event).event_id : undefined })),
@@ -190,14 +91,14 @@ function listAudit(db, projectId, { before, limit = 50 } = {}) {
@@ -190,14 +91,14 @@ function listAudit(db, projectId, { before, limit = 50 } = {}) {
// ── Actors ─────────────────────────────────────────────────────
// ── Actors ─────────────────────────────────────────────────────
/** { subject, user, staff } for an authenticated Network user row. */
/** { subject, user, staff } for an authenticated Network user row. */
-function actorOf(db, user) {
- const subject = subjects.ensureUserSubject(db, user);
+async function actorOf(db, user) {
+ const subject = await subjects.ensureUserSubject(db, user);
if (!subject) fail(401, 'auth.no_subject', 'account has no subject id');
if (!subject) fail(401, 'auth.no_subject', 'account has no subject id');
return { subject, user, staff: user.role === 'admin', label: `user:${subject}` };
return { subject, user, staff: user.role === 'admin', label: `user:${subject}` };
}
}
-function memberRole(db, projectId, subject) {
- const r = db.prepare('SELECT role FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(projectId, subject);
+async function memberRole(db, projectId, subject) {
+ const r = await db.prepare('SELECT role FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(projectId, subject);
return r ? r.role : null;
return r ? r.role : null;
}
}
@@ -205,9 +106,9 @@ function memberRole(db, projectId, subject) {
@@ -205,9 +106,9 @@ function memberRole(db, projectId, subject) {
* Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.
* Load a project the actor may see. Non-members get 404 (existence is not disclosed); staff see all.
* `need` is the minimum member role; `staffOk` lets staff through regardless of membership.
* `need` is the minimum member role; `staffOk` lets staff through regardless of membership.
*/
*/
-function access(db, actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {
- const p = PROJECT_ID_RE.test(String(projectId)) ? db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(projectId) : null;
- const role = p ? memberRole(db, p.id, actor.subject) : null;
+async function access(db, actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {
+ const p = PROJECT_ID_RE.test(String(projectId)) ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(projectId) : null;
+ const role = p ? await memberRole(db, p.id, actor.subject) : null;
if (!p || (!role && !actor.staff)) fail(404, 'project.not_found', 'no such project');
if (!p || (!role && !actor.staff)) fail(404, 'project.not_found', 'no such project');
const ok = (role && RANK[role] >= RANK[need]) || (actor.staff && staffOk);
const ok = (role && RANK[role] >= RANK[need]) || (actor.staff && staffOk);
if (!ok) fail(403, 'project.forbidden', `requires ${need} role`);
if (!ok) fail(403, 'project.forbidden', `requires ${need} role`);
@@ -223,7 +124,7 @@ const cleanName = (v, field = 'name') => {
@@ -223,7 +124,7 @@ const cleanName = (v, field = 'name') => {
return s;
return s;
};
};
-function projectView(db, p, role, settings) {
+async function projectView(db, p, role, settings) {
return {
return {
id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,
id: p.id, name: p.name, owner: { type: 'user', id: p.owner_subject }, role: role || null,
environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],
environment_policy: p.environment_policy, environments: policy.ENVIRONMENT_POLICIES[p.environment_policy],
@@ -232,60 +133,60 @@ function projectView(db, p, role, settings) {
@@ -232,60 +133,60 @@ function projectView(db, p, role, settings) {
sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
created_at: p.created_at, archived_at: p.archived_at || null,
created_at: p.created_at, archived_at: p.archived_at || null,
counts: {
counts: {
- members: db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id).n,
- apps: db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(p.id).n,
+ members: (await db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id)).n,
+ apps: (await db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(p.id)).n,
},
},
};
};
}
}
-function createProject(db, actor, { name }, { settings, ctx }) {
+async function createProject(db, actor, { name }, { settings, ctx }) {
const n = cleanName(name);
const n = cleanName(name);
- const owned = db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject).n;
+ const owned = (await db.prepare('SELECT COUNT(*) AS n FROM dev_projects WHERE owner_subject = ? AND archived_at IS NULL').get(actor.subject)).n;
if (owned >= settings.maxProjectsPerOwner) fail(409, 'project.limit', `at most ${settings.maxProjectsPerOwner} active projects per owner`);
if (owned >= settings.maxProjectsPerOwner) fail(409, 'project.limit', `at most ${settings.maxProjectsPerOwner} active projects per owner`);
const id = `prj_${ids.ulid()}`;
const id = `prj_${ids.ulid()}`;
const t = nowIso();
const t = nowIso();
- db.transaction(() => {
- db.prepare(`INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by)
VALUES (?, ?, ?, 'sandbox', ?, ?, ?)`).run(id, actor.subject, n, JSON.stringify(settings.defaultAllowance), t, actor.label);
VALUES (?, ?, ?, 'sandbox', ?, ?, ?)`).run(id, actor.subject, n, JSON.stringify(settings.defaultAllowance), t, actor.label);
- db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)')
.run(id, actor.subject, 'owner', t, actor.label);
.run(id, actor.subject, 'owner', t, actor.label);
- audit(db, { projectId: id, actor: actor.label, action: 'project.created', target: id, detail: { name: n, allowance: settings.defaultAllowance }, ctx });
- })();
- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner', settings);
+ await audit(db, { projectId: id, actor: actor.label, action: 'project.created', target: id, detail: { name: n, allowance: settings.defaultAllowance }, ctx });
+ });
+ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(id), 'owner', settings);
}
}
-function listProjects(db, actor, { all = false, settings } = {}) {
+async function listProjects(db, actor, { all = false, settings } = {}) {
const rows = all && actor.staff
const rows = all && actor.staff
- ? db.prepare('SELECT p.*, m.role FROM dev_projects p LEFT JOIN dev_project_members m ON m.project_id = p.id AND m.subject_id = ? ORDER BY p.id DESC').all(actor.subject)
- : db.prepare('SELECT p.*, m.role FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id = ? ORDER BY p.id DESC').all(actor.subject);
- return rows.map(r => projectView(db, r, r.role, settings));
+ ? await db.prepare('SELECT p.*, m.role FROM dev_projects p LEFT JOIN dev_project_members m ON m.project_id = p.id AND m.subject_id = ? ORDER BY p.id DESC').all(actor.subject)
+ : await db.prepare('SELECT p.*, m.role FROM dev_projects p JOIN dev_project_members m ON m.project_id = p.id WHERE m.subject_id = ? ORDER BY p.id DESC').all(actor.subject);
+ return (await Promise.all(rows.map(async r => await projectView(db, r, r.role, settings))));
}
}
-function renameProject(db, actor, projectId, { name }, { ctx, settings }) {
- const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
+async function renameProject(db, actor, projectId, { name }, { ctx, settings }) {
+ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });
const n = cleanName(name);
const n = cleanName(name);
- db.transaction(() => {
- db.prepare('UPDATE dev_projects SET name = ? WHERE id = ?').run(n, project.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'project.renamed', target: project.id, detail: { from: project.name, to: n }, ctx });
- })();
- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_projects SET name = ? WHERE id = ?').run(n, project.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.renamed', target: project.id, detail: { from: project.name, to: n }, ctx });
+ });
+ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
}
}
/** Archive: irreversible. Every app of the project is revoked (credentials included). */
/** Archive: irreversible. Every app of the project is revoked (credentials included). */
-function archiveProject(db, actor, projectId, { ctx, settings }) {
- const { project, role } = access(db, actor, projectId, { need: 'owner', staffOk: true });
- db.transaction(() => {
- for (const app of db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) revokeAppTx(db, actor, project, app, ctx, 'project archived');
- db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
- })();
- return projectView(db, db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
+async function archiveProject(db, actor, projectId, { ctx, settings }) {
+ const { project, role } = await access(db, actor, projectId, { need: 'owner', staffOk: true });
+ await db.tx(async () => {
+ for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');
+ await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
+ });
+ return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
}
}
/** Staff: the capabilities this project's apps may hold. Grants outside the new allowance are revoked. */
/** Staff: the capabilities this project's apps may hold. Grants outside the new allowance are revoked. */
-function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, settings }) {
+async function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, settings }) {
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set a project allowance');
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set a project allowance');
- const { project } = access(db, actor, projectId);
+ const { project } = await access(db, actor, projectId);
if (!Array.isArray(list) || list.length > 200) fail(422, 'project.invalid', 'capabilities must be an array (at most 200)');
if (!Array.isArray(list) || list.length > 200) fail(422, 'project.invalid', 'capabilities must be an array (at most 200)');
const wanted = [...new Set(list.map(String))].sort();
const wanted = [...new Set(list.map(String))].sort();
for (const id of wanted) {
for (const id of wanted) {
@@ -293,52 +194,52 @@ function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, setti
@@ -293,52 +194,52 @@ function setAllowance(db, actor, projectId, { capabilities: list }, { ctx, setti
if (!g.grantable) fail(422, g.code, g.reason);
if (!g.grantable) fail(422, g.code, g.reason);
}
}
const trimmed = [];
const trimmed = [];
- db.transaction(() => {
- db.prepare('UPDATE dev_projects SET allowance = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'project.allowance_set', target: project.id, detail: { from: JSON.parse(project.allowance), to: wanted }, ctx });
- const rows = db.prepare(`SELECT g.*, a.project_id, a.environment FROM dev_grants g JOIN dev_apps a ON a.id = g.app_id
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_projects SET allowance = ? WHERE id = ?').run(JSON.stringify(wanted), project.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.allowance_set', target: project.id, detail: { from: JSON.parse(project.allowance), to: wanted }, ctx });
+ const rows = await db.prepare(`SELECT g.*, a.project_id, a.environment FROM dev_grants g JOIN dev_apps a ON a.id = g.app_id
WHERE a.project_id = ? AND g.status IN ('approved', 'requested')`).all(project.id);
WHERE a.project_id = ? AND g.status IN ('approved', 'requested')`).all(project.id);
// A sandbox app's grant that is still inside the sandbox allowance survives a staff change.
// A sandbox app's grant that is still inside the sandbox allowance survives a staff change.
const updated = { ...project, allowance: JSON.stringify(wanted) };
const updated = { ...project, allowance: JSON.stringify(wanted) };
for (const g of rows.filter(r => !policy.allowanceFor(updated, { environment: r.environment }, settings).has(r.capability))) {
for (const g of rows.filter(r => !policy.allowanceFor(updated, { environment: r.environment }, settings).has(r.capability))) {
const next = g.status === 'approved' ? 'revoked' : 'denied';
const next = g.status === 'approved' ? 'revoked' : 'denied';
- db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(next, actor.label, nowIso(), g.app_id, g.capability);
- grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
+ await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(next, actor.label, nowIso(), g.app_id, g.capability);
+ await grantEvent(db, actor, project.id, g.app_id, g.capability, g.audience, g.status, next, ctx, 'outside allowance');
trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });
trimmed.push({ app_id: g.app_id, capability: g.capability, status: next });
}
}
- })();
+ });
return { allowance: wanted, trimmed };
return { allowance: wanted, trimmed };
}
}
-function setEnvironmentPolicy(db, actor, projectId, { environment_policy: value }, { ctx }) {
+async function setEnvironmentPolicy(db, actor, projectId, { environment_policy: value }, { ctx }) {
if (!actor.staff) fail(403, 'project.staff_only', 'only staff change the environment policy');
if (!actor.staff) fail(403, 'project.staff_only', 'only staff change the environment policy');
- const { project } = access(db, actor, projectId);
+ const { project } = await access(db, actor, projectId);
if (!policy.ENVIRONMENT_POLICIES[value]) fail(422, 'project.invalid', `environment_policy is one of ${Object.keys(policy.ENVIRONMENT_POLICIES).join(', ')}`);
if (!policy.ENVIRONMENT_POLICIES[value]) fail(422, 'project.invalid', `environment_policy is one of ${Object.keys(policy.ENVIRONMENT_POLICIES).join(', ')}`);
- db.transaction(() => {
- db.prepare('UPDATE dev_projects SET environment_policy = ? WHERE id = ?').run(value, project.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'project.environment_policy_set', target: project.id, detail: { from: project.environment_policy, to: value }, ctx });
- })();
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_projects SET environment_policy = ? WHERE id = ?').run(value, project.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.environment_policy_set', target: project.id, detail: { from: project.environment_policy, to: value }, ctx });
+ });
return { environment_policy: value, environments: policy.ENVIRONMENT_POLICIES[value] };
return { environment_policy: value, environments: policy.ENVIRONMENT_POLICIES[value] };
}
}
// ── Members ────────────────────────────────────────────────────
// ── Members ────────────────────────────────────────────────────
-function memberView(db, r) {
- const u = db.prepare('SELECT username, display_name FROM users WHERE subject_id = ?').get(r.subject_id);
+async function memberView(db, r) {
+ const u = await db.prepare('SELECT username, display_name FROM users WHERE subject_id = ?').get(r.subject_id);
return { subject: { type: 'user', id: r.subject_id }, username: u ? u.username : null, display_name: u ? (u.display_name || u.username) : null, role: r.role, added_at: r.added_at };
return { subject: { type: 'user', id: r.subject_id }, username: u ? u.username : null, display_name: u ? (u.display_name || u.username) : null, role: r.role, added_at: r.added_at };
}
}
-function listMembers(db, actor, projectId) {
- const { project } = access(db, actor, projectId, { allowArchived: true });
- return db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? ORDER BY added_at').all(project.id).map(r => memberView(db, r));
+async function listMembers(db, actor, projectId) {
+ const { project } = await access(db, actor, projectId, { allowArchived: true });
+ return (await Promise.all((await db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? ORDER BY added_at').all(project.id)).map(async r => await memberView(db, r))));
}
}
-function resolveUserSubject(db, { subject_id, username }) {
+async function resolveUserSubject(db, { subject_id, username }) {
let u = null;
let u = null;
- if (subject_id && USER_SUBJECT_RE.test(String(subject_id))) u = db.prepare('SELECT id, subject_id, is_banned FROM users WHERE subject_id = ?').get(String(subject_id));
- else if (username) u = db.prepare('SELECT id, subject_id, created_at, is_banned FROM users WHERE username = ? COLLATE NOCASE').get(String(username));
+ if (subject_id && USER_SUBJECT_RE.test(String(subject_id))) u = await db.prepare('SELECT id, subject_id, is_banned FROM users WHERE subject_id = ?').get(String(subject_id));
+ else if (username) u = await db.prepare('SELECT id, subject_id, created_at, is_banned FROM users WHERE lower(username) = lower(?)').get(String(username));
if (!u || u.is_banned) fail(404, 'member.user_not_found', 'no such account');
if (!u || u.is_banned) fail(404, 'member.user_not_found', 'no such account');
- return u.subject_id || subjects.ensureUserSubject(db, u);
+ return u.subject_id || await subjects.ensureUserSubject(db, u);
}
}
/** Admins manage developers and viewers; only the owner manages admins. Ownership is not assignable here. */
/** Admins manage developers and viewers; only the owner manages admins. Ownership is not assignable here. */
@@ -348,46 +249,46 @@ function canAssign(role, targetRole) {
@@ -348,46 +249,46 @@ function canAssign(role, targetRole) {
return RANK[role] >= RANK.admin;
return RANK[role] >= RANK.admin;
}
}
-function addMember(db, actor, projectId, body, { ctx }) {
- const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
+async function addMember(db, actor, projectId, body, { ctx }) {
+ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });
const newRole = String(body.role || 'developer');
const newRole = String(body.role || 'developer');
if (!ROLES.includes(newRole)) fail(422, 'member.invalid', `role is one of ${ROLES.filter(r => r !== 'owner').join(', ')}`);
if (!ROLES.includes(newRole)) fail(422, 'member.invalid', `role is one of ${ROLES.filter(r => r !== 'owner').join(', ')}`);
if (!canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot assign ${newRole}`);
if (!canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot assign ${newRole}`);
- const subject = resolveUserSubject(db, body);
- if (memberRole(db, project.id, subject)) fail(409, 'member.exists', 'already a member');
- db.transaction(() => {
- db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)').run(project.id, subject, newRole, nowIso(), actor.label);
- audit(db, { projectId: project.id, actor: actor.label, action: 'member.added', target: `user:${subject}`, detail: { role: newRole }, ctx });
- })();
- return memberView(db, db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));
-}
-
-function updateMember(db, actor, projectId, subject, body, { ctx }) {
- const { project, role } = access(db, actor, projectId, { need: 'admin', staffOk: false });
- const current = memberRole(db, project.id, subject);
+ const subject = await resolveUserSubject(db, body);
+ if (await memberRole(db, project.id, subject)) fail(409, 'member.exists', 'already a member');
+ await db.tx(async () => {
+ await db.prepare('INSERT INTO dev_project_members (project_id, subject_id, role, added_at, added_by) VALUES (?, ?, ?, ?, ?)').run(project.id, subject, newRole, nowIso(), actor.label);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'member.added', target: `user:${subject}`, detail: { role: newRole }, ctx });
+ });
+ return await memberView(db, await db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));
+}
+
+async function updateMember(db, actor, projectId, subject, body, { ctx }) {
+ const { project, role } = await access(db, actor, projectId, { need: 'admin', staffOk: false });
+ const current = await memberRole(db, project.id, subject);
if (!current) fail(404, 'member.not_found', 'not a member');
if (!current) fail(404, 'member.not_found', 'not a member');
const newRole = String(body.role || '');
const newRole = String(body.role || '');
if (!ROLES.includes(newRole)) fail(422, 'member.invalid', 'unknown role');
if (!ROLES.includes(newRole)) fail(422, 'member.invalid', 'unknown role');
if (current === 'owner') fail(403, 'member.forbidden', 'the owner role is not changed here');
if (current === 'owner') fail(403, 'member.forbidden', 'the owner role is not changed here');
if (!canAssign(role, current) || !canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot change ${current} to ${newRole}`);
if (!canAssign(role, current) || !canAssign(role, newRole)) fail(403, 'member.forbidden', `a ${role} cannot change ${current} to ${newRole}`);
- db.transaction(() => {
- db.prepare('UPDATE dev_project_members SET role = ? WHERE project_id = ? AND subject_id = ?').run(newRole, project.id, subject);
- audit(db, { projectId: project.id, actor: actor.label, action: 'member.role_changed', target: `user:${subject}`, detail: { from: current, to: newRole }, ctx });
- })();
- return memberView(db, db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_project_members SET role = ? WHERE project_id = ? AND subject_id = ?').run(newRole, project.id, subject);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'member.role_changed', target: `user:${subject}`, detail: { from: current, to: newRole }, ctx });
+ });
+ return await memberView(db, await db.prepare('SELECT * FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(project.id, subject));
}
}
-function removeMember(db, actor, projectId, subject, { ctx }) {
+async function removeMember(db, actor, projectId, subject, { ctx }) {
const self = subject === actor.subject;
const self = subject === actor.subject;
- const { project, role } = access(db, actor, projectId, { need: self ? 'viewer' : 'admin', staffOk: false, allowArchived: true });
- const current = memberRole(db, project.id, subject);
+ const { project, role } = await access(db, actor, projectId, { need: self ? 'viewer' : 'admin', staffOk: false, allowArchived: true });
+ const current = await memberRole(db, project.id, subject);
if (!current) fail(404, 'member.not_found', 'not a member');
if (!current) fail(404, 'member.not_found', 'not a member');
if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');
if (current === 'owner') fail(403, 'member.forbidden', 'the owner cannot be removed');
if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);
if (!self && !canAssign(role, current)) fail(403, 'member.forbidden', `a ${role} cannot remove a ${current}`);
- db.transaction(() => {
- db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);
- audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });
- })();
+ await db.tx(async () => {
+ await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(project.id, subject);
+ await audit(db, { projectId: project.id, actor: actor.label, action: self ? 'member.left' : 'member.removed', target: `user:${subject}`, detail: { role: current }, ctx });
+ });
}
}
// ── Apps ───────────────────────────────────────────────────────
// ── Apps ───────────────────────────────────────────────────────
@@ -410,17 +311,17 @@ function validRedirects(list, environment) {
@@ -410,17 +311,17 @@ function validRedirects(list, environment) {
/** Role needed to manage an app: developers manage sandbox apps, admins production ones. */
/** Role needed to manage an app: developers manage sandbox apps, admins production ones. */
const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
const manageRole = (environment) => (environment === 'production' ? 'admin' : 'developer');
-function appView(db, a) {
+async function appView(db, a) {
return {
return {
id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment,
id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment,
client_id: a.oauth_client_id, client_type: a.client_type, redirect_uris: JSON.parse(a.redirect_uris || '[]'),
client_id: a.oauth_client_id, client_type: a.client_type, redirect_uris: JSON.parse(a.redirect_uris || '[]'),
created_at: a.created_at, revoked_at: a.revoked_at || null,
created_at: a.created_at, revoked_at: a.revoked_at || null,
- grants: db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND status = 'approved' ORDER BY capability").all(a.id).map(g => g.capability),
+ grants: (await db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND status = 'approved' ORDER BY capability").all(a.id)).map(g => g.capability),
};
};
}
}
-function loadApp(db, projectId, appId) {
- const a = APP_ID_RE.test(String(appId)) ? db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ?').get(appId, projectId) : null;
+async function loadApp(db, projectId, appId) {
+ const a = APP_ID_RE.test(String(appId)) ? await db.prepare('SELECT * FROM dev_apps WHERE id = ? AND project_id = ?').get(appId, projectId) : null;
if (!a) fail(404, 'app.not_found', 'no such app');
if (!a) fail(404, 'app.not_found', 'no such app');
return a;
return a;
}
}
@@ -431,85 +332,85 @@ function newSecret() {
@@ -431,85 +332,85 @@ function newSecret() {
}
}
const hashSecret = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
const hashSecret = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
-function insertCredential(db, app, actor) {
+async function insertCredential(db, app, actor) {
const s = newSecret();
const s = newSecret();
const id = `crd_${ids.ulid()}`;
const id = `crd_${ids.ulid()}`;
- db.prepare('INSERT INTO dev_credentials (id, app_id, secret_hash, hint, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO dev_credentials (id, app_id, secret_hash, hint, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, app.id, s.hash, s.hint, nowIso(), actor.label);
.run(id, app.id, s.hash, s.hint, nowIso(), actor.label);
return { id, secret: s.secret, hint: s.hint };
return { id, secret: s.secret, hint: s.hint };
}
}
-function listApps(db, actor, projectId) {
- const { project } = access(db, actor, projectId, { allowArchived: true });
- return db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project.id).map(a => appView(db, a));
+async function listApps(db, actor, projectId) {
+ const { project } = await access(db, actor, projectId, { allowArchived: true });
+ return (await Promise.all((await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project.id)).map(async a => await appView(db, a))));
}
}
-function getApp(db, actor, projectId, appId) {
- const { project } = access(db, actor, projectId, { allowArchived: true });
- return appView(db, loadApp(db, project.id, appId));
+async function getApp(db, actor, projectId, appId) {
+ const { project } = await access(db, actor, projectId, { allowArchived: true });
+ return await appView(db, await loadApp(db, project.id, appId));
}
}
/** Create an app. Confidential apps get their first secret, returned here once and never again. */
/** Create an app. Confidential apps get their first secret, returned here once and never again. */
-function createApp(db, actor, projectId, body, { settings, ctx }) {
+async function createApp(db, actor, projectId, body, { settings, ctx }) {
const environment = String(body.environment || 'sandbox');
const environment = String(body.environment || 'sandbox');
if (!policy.ENVIRONMENTS.includes(environment)) fail(422, 'app.invalid', 'environment is sandbox or production');
if (!policy.ENVIRONMENTS.includes(environment)) fail(422, 'app.invalid', 'environment is sandbox or production');
- const { project } = access(db, actor, projectId, { need: manageRole(environment), staffOk: false });
+ const { project } = await access(db, actor, projectId, { need: manageRole(environment), staffOk: false });
if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(environment)) fail(403, 'app.environment_not_allowed', `this project may only have ${policy.ENVIRONMENT_POLICIES[project.environment_policy].join(', ')} apps (staff enable production)`);
if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(environment)) fail(403, 'app.environment_not_allowed', `this project may only have ${policy.ENVIRONMENT_POLICIES[project.environment_policy].join(', ')} apps (staff enable production)`);
const clientType = String(body.type || body.client_type || 'confidential');
const clientType = String(body.type || body.client_type || 'confidential');
if (!CLIENT_TYPES.includes(clientType)) fail(422, 'app.invalid', 'type is confidential or public');
if (!CLIENT_TYPES.includes(clientType)) fail(422, 'app.invalid', 'type is confidential or public');
const name = cleanName(body.name);
const name = cleanName(body.name);
const redirects = validRedirects(body.redirect_uris, environment);
const redirects = validRedirects(body.redirect_uris, environment);
if (clientType === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI (it can only use authorization code + PKCE)');
if (clientType === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI (it can only use authorization code + PKCE)');
- const active = db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(project.id).n;
+ const active = (await db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(project.id)).n;
if (active >= settings.maxAppsPerProject) fail(409, 'app.limit', `at most ${settings.maxAppsPerProject} active apps per project`);
if (active >= settings.maxAppsPerProject) fail(409, 'app.limit', `at most ${settings.maxAppsPerProject} active apps per project`);
const id = ids.newId('app');
const id = ids.newId('app');
let credential = null;
let credential = null;
- db.transaction(() => {
- db.prepare(`INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, redirect_uris, created_at, created_by)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, redirect_uris, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, project.id, name, environment, id, clientType, JSON.stringify(redirects), nowIso(), actor.label);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, project.id, name, environment, id, clientType, JSON.stringify(redirects), nowIso(), actor.label);
- const app = db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id);
- if (clientType === 'confidential') credential = insertCredential(db, app, actor);
- audit(db, { projectId: project.id, actor: actor.label, action: 'app.created', target: `app:${id}`,
+ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id);
+ if (clientType === 'confidential') credential = await insertCredential(db, app, actor);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'app.created', target: `app:${id}`,
detail: { name, environment, client_type: clientType, credential_id: credential ? credential.id : undefined }, ctx,
detail: { name, environment, client_type: clientType, credential_id: credential ? credential.id : undefined }, ctx,
event: { type: 'network.app.created', subject: { type: 'app', id }, payload: { project_id: project.id, environment, client_type: clientType } } });
event: { type: 'network.app.created', subject: { type: 'app', id }, payload: { project_id: project.id, environment, client_type: clientType } } });
- })();
- const out = appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id));
+ });
+ const out = await appView(db, await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(id));
if (credential) out.credential = { id: credential.id, client_secret: credential.secret, hint: credential.hint, shown_once: true };
if (credential) out.credential = { id: credential.id, client_secret: credential.secret, hint: credential.hint, shown_once: true };
return out;
return out;
}
}
-function updateApp(db, actor, projectId, appId, body, { ctx }) {
- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: false });
- const app = loadApp(db, project.id, appId);
- access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
+async function updateApp(db, actor, projectId, appId, body, { ctx }) {
+ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: false });
+ const app = await loadApp(db, project.id, appId);
+ await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
const name = body.name !== undefined ? cleanName(body.name) : app.name;
const name = body.name !== undefined ? cleanName(body.name) : app.name;
const redirects = body.redirect_uris !== undefined ? validRedirects(body.redirect_uris, app.environment) : JSON.parse(app.redirect_uris);
const redirects = body.redirect_uris !== undefined ? validRedirects(body.redirect_uris, app.environment) : JSON.parse(app.redirect_uris);
if (app.client_type === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI');
if (app.client_type === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI');
- db.transaction(() => {
- db.prepare('UPDATE dev_apps SET name = ?, redirect_uris = ? WHERE id = ?').run(name, JSON.stringify(redirects), app.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'app.updated', target: `app:${app.id}`, detail: { name, redirect_uris: redirects }, ctx });
- })();
- return appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_apps SET name = ?, redirect_uris = ? WHERE id = ?').run(name, JSON.stringify(redirects), app.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'app.updated', target: `app:${app.id}`, detail: { name, redirect_uris: redirects }, ctx });
+ });
+ return await appView(db, await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));
}
}
-function revokeAppTx(db, actor, project, app, ctx, reason) {
+async function revokeAppTx(db, actor, project, app, ctx, reason) {
const t = nowIso();
const t = nowIso();
- db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);
- db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);
- db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,
+ await db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);
+ await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);
+ await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,
event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });
event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });
}
}
/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */
/** Revoke an app: immediate for new tokens; issued tokens expire within their 5-minute lifetime. */
-function revokeApp(db, actor, projectId, appId, { ctx }) {
- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: true });
- const app = loadApp(db, project.id, appId);
- if (!actor.staff) access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
- if (app.revoked_at) return appView(db, app);
- db.transaction(() => revokeAppTx(db, actor, project, app, ctx, actor.staff && !memberRole(db, project.id, actor.subject) ? 'staff' : null))();
- return appView(db, db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));
+async function revokeApp(db, actor, projectId, appId, { ctx }) {
+ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: true });
+ const app = await loadApp(db, project.id, appId);
+ if (!actor.staff) await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
+ if (app.revoked_at) return await appView(db, app);
+ await db.tx(async () => await revokeAppTx(db, actor, project, app, ctx, actor.staff && !await memberRole(db, project.id, actor.subject) ? 'staff' : null));
+ return await appView(db, await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(app.id));
}
}
// ── Credentials ────────────────────────────────────────────────
// ── Credentials ────────────────────────────────────────────────
@@ -524,20 +425,20 @@ function credentialView(c) {
@@ -524,20 +425,20 @@ function credentialView(c) {
return { id: c.id, hint: c.hint, state: credentialState(c), created_at: c.created_at, expires_at: c.expires_at || null, revoked_at: c.revoked_at || null, last_used_at: c.last_used_at || null };
return { id: c.id, hint: c.hint, state: credentialState(c), created_at: c.created_at, expires_at: c.expires_at || null, revoked_at: c.revoked_at || null, last_used_at: c.last_used_at || null };
}
}
-function listCredentials(db, actor, projectId, appId) {
- const { project } = access(db, actor, projectId, { allowArchived: true });
- const app = loadApp(db, project.id, appId);
- return db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? ORDER BY id DESC').all(app.id).map(credentialView);
+async function listCredentials(db, actor, projectId, appId) {
+ const { project } = await access(db, actor, projectId, { allowArchived: true });
+ const app = await loadApp(db, project.id, appId);
+ return (await db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? ORDER BY id DESC').all(app.id)).map(credentialView);
}
}
/**
/**
* Rotate: a new secret (returned once); every still-valid previous secret stays valid for
* Rotate: a new secret (returned once); every still-valid previous secret stays valid for
* `overlap_seconds` (default settings.credentialOverlapS, 0..7 days), never longer than it already had.
* `overlap_seconds` (default settings.credentialOverlapS, 0..7 days), never longer than it already had.
*/
*/
-function rotateCredential(db, actor, projectId, appId, body, { settings, ctx }) {
- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: false });
- const app = loadApp(db, project.id, appId);
- access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
+async function rotateCredential(db, actor, projectId, appId, body, { settings, ctx }) {
+ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: false });
+ const app = await loadApp(db, project.id, appId);
+ await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false });
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
if (app.client_type !== 'confidential') fail(409, 'credential.public_client', 'public apps have no client secret');
if (app.client_type !== 'confidential') fail(409, 'credential.public_client', 'public apps have no client secret');
let overlap = settings.credentialOverlapS;
let overlap = settings.credentialOverlapS;
@@ -549,47 +450,47 @@ function rotateCredential(db, actor, projectId, appId, body, { settings, ctx })
@@ -549,47 +450,47 @@ function rotateCredential(db, actor, projectId, appId, body, { settings, ctx })
const until = new Date(Date.now() + overlap * 1000).toISOString();
const until = new Date(Date.now() + overlap * 1000).toISOString();
let created;
let created;
const kept = [];
const kept = [];
- db.transaction(() => {
- for (const c of db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(app.id)) {
+ await db.tx(async () => {
+ for (const c of await db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(app.id)) {
if (credentialState(c) === 'expired') continue;
if (credentialState(c) === 'expired') continue;
const exp = c.expires_at && Date.parse(c.expires_at) < Date.parse(until) ? c.expires_at : until;
const exp = c.expires_at && Date.parse(c.expires_at) < Date.parse(until) ? c.expires_at : until;
- db.prepare('UPDATE dev_credentials SET expires_at = ? WHERE id = ?').run(exp, c.id);
+ await db.prepare('UPDATE dev_credentials SET expires_at = ? WHERE id = ?').run(exp, c.id);
kept.push({ id: c.id, expires_at: exp });
kept.push({ id: c.id, expires_at: exp });
}
}
- created = insertCredential(db, app, actor);
- audit(db, { projectId: project.id, actor: actor.label, action: 'credential.rotated', target: `app:${app.id}`,
+ created = await insertCredential(db, app, actor);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'credential.rotated', target: `app:${app.id}`,
detail: { credential_id: created.id, hint: created.hint, overlap_seconds: overlap, previous: kept }, ctx,
detail: { credential_id: created.id, hint: created.hint, overlap_seconds: overlap, previous: kept }, ctx,
event: { type: 'network.credential.rotated', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: created.id, previous_valid_until: kept.length ? until : null } } });
event: { type: 'network.credential.rotated', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: created.id, previous_valid_until: kept.length ? until : null } } });
- })();
+ });
return { credential: { id: created.id, client_secret: created.secret, hint: created.hint, shown_once: true }, previous: kept };
return { credential: { id: created.id, client_secret: created.secret, hint: created.hint, shown_once: true }, previous: kept };
}
}
/** Revoke one credential: it stops authenticating at once. */
/** Revoke one credential: it stops authenticating at once. */
-function revokeCredential(db, actor, projectId, appId, credId, { ctx }) {
- const { project } = access(db, actor, projectId, { need: 'viewer', staffOk: true, allowArchived: true });
- const app = loadApp(db, project.id, appId);
- if (!actor.staff) access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false, allowArchived: true });
- const c = CRED_ID_RE.test(String(credId)) ? db.prepare('SELECT * FROM dev_credentials WHERE id = ? AND app_id = ?').get(credId, app.id) : null;
+async function revokeCredential(db, actor, projectId, appId, credId, { ctx }) {
+ const { project } = await access(db, actor, projectId, { need: 'viewer', staffOk: true, allowArchived: true });
+ const app = await loadApp(db, project.id, appId);
+ if (!actor.staff) await access(db, actor, projectId, { need: manageRole(app.environment), staffOk: false, allowArchived: true });
+ const c = CRED_ID_RE.test(String(credId)) ? await db.prepare('SELECT * FROM dev_credentials WHERE id = ? AND app_id = ?').get(credId, app.id) : null;
if (!c) fail(404, 'credential.not_found', 'no such credential');
if (!c) fail(404, 'credential.not_found', 'no such credential');
if (!c.revoked_at) {
if (!c.revoked_at) {
- db.transaction(() => {
- db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(nowIso(), actor.label, c.id);
- audit(db, { projectId: project.id, actor: actor.label, action: 'credential.revoked', target: `app:${app.id}`, detail: { credential_id: c.id, hint: c.hint }, ctx,
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(nowIso(), actor.label, c.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'credential.revoked', target: `app:${app.id}`, detail: { credential_id: c.id, hint: c.hint }, ctx,
event: { type: 'network.credential.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: c.id } } });
event: { type: 'network.credential.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, credential_id: c.id } } });
- })();
+ });
}
}
- return credentialView(db.prepare('SELECT * FROM dev_credentials WHERE id = ?').get(c.id));
+ return credentialView(await db.prepare('SELECT * FROM dev_credentials WHERE id = ?').get(c.id));
}
}
/** Token path: the credential row a presented secret matches, if it is still valid. Updates last_used_at. */
/** Token path: the credential row a presented secret matches, if it is still valid. Updates last_used_at. */
-function matchSecret(db, appId, secret) {
+async function matchSecret(db, appId, secret) {
if (typeof secret !== 'string' || !secret.startsWith(SECRET_PREFIX) || secret.length > 200) return null;
if (typeof secret !== 'string' || !secret.startsWith(SECRET_PREFIX) || secret.length > 200) return null;
const want = Buffer.from(hashSecret(secret), 'hex');
const want = Buffer.from(hashSecret(secret), 'hex');
const now = Date.now();
const now = Date.now();
- for (const c of db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(appId)) {
+ for (const c of await db.prepare('SELECT * FROM dev_credentials WHERE app_id = ? AND revoked_at IS NULL').all(appId)) {
if (credentialState(c, now) === 'expired') continue;
if (credentialState(c, now) === 'expired') continue;
if (crypto.timingSafeEqual(Buffer.from(c.secret_hash, 'hex'), want)) {
if (crypto.timingSafeEqual(Buffer.from(c.secret_hash, 'hex'), want)) {
- db.prepare('UPDATE dev_credentials SET last_used_at = ? WHERE id = ?').run(nowIso(), c.id);
+ await db.prepare('UPDATE dev_credentials SET last_used_at = ? WHERE id = ?').run(nowIso(), c.id);
return c;
return c;
}
}
}
}
@@ -602,15 +503,15 @@ function grantView(g) {
@@ -602,15 +503,15 @@ function grantView(g) {
return { app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null };
return { app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null };
}
}
-function grantEvent(db, actor, projectId, appId, capability, audience, from, to, ctx, reason) {
- audit(db, { projectId, actor: actor.label, action: `grant.${to}`, target: `app:${appId}`, detail: { capability, audience, from, reason: reason || undefined }, ctx,
+async function grantEvent(db, actor, projectId, appId, capability, audience, from, to, ctx, reason) {
+ await audit(db, { projectId, actor: actor.label, action: `grant.${to}`, target: `app:${appId}`, detail: { capability, audience, from, reason: reason || undefined }, ctx,
event: { type: 'network.grant.changed', subject: { type: 'app', id: appId }, payload: { project_id: projectId, capability, audience, from, to } } });
event: { type: 'network.grant.changed', subject: { type: 'app', id: appId }, payload: { project_id: projectId, capability, audience, from, to } } });
}
}
-function listGrants(db, actor, projectId, appId) {
- const { project } = access(db, actor, projectId, { allowArchived: true });
- const app = loadApp(db, project.id, appId);
- return db.prepare('SELECT * FROM dev_grants WHERE app_id = ? ORDER BY capability').all(app.id).map(grantView);
+async function listGrants(db, actor, projectId, appId) {
+ const { project } = await access(db, actor, projectId, { allowArchived: true });
+ const app = await loadApp(db, project.id, appId);
+ return (await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? ORDER BY capability').all(app.id)).map(grantView);
}
}
/** Inside the project's allowance, or (sandbox apps) the sandbox allowance. */
/** Inside the project's allowance, or (sandbox apps) the sandbox allowance. */
@@ -622,34 +523,34 @@ function withinAllowance(project, app, capability, settings) {
@@ -622,34 +523,34 @@ function withinAllowance(project, app, capability, settings) {
* Request a capability for an app (developer+). Owners and admins get it approved at once when it
* Request a capability for an app (developer+). Owners and admins get it approved at once when it
* is inside the allowance; otherwise it waits for an owner/admin.
* is inside the allowance; otherwise it waits for an owner/admin.
*/
*/
-function requestGrant(db, actor, projectId, appId, { capability }, { ctx, settings }) {
- const { project, role } = access(db, actor, projectId, { need: 'developer', staffOk: false });
- const app = loadApp(db, project.id, appId);
+async function requestGrant(db, actor, projectId, appId, { capability }, { ctx, settings }) {
+ const { project, role } = await access(db, actor, projectId, { need: 'developer', staffOk: false });
+ const app = await loadApp(db, project.id, appId);
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
const g = policy.grantability(capability);
const g = policy.grantability(capability);
if (!g.grantable) fail(g.code === 'grant.unknown_capability' ? 422 : 403, g.code, g.reason);
if (!g.grantable) fail(g.code === 'grant.unknown_capability' ? 422 : 403, g.code, g.reason);
const audience = policy.audienceOf(capability);
const audience = policy.audienceOf(capability);
- const existing = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability);
+ const existing = await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability);
if (existing && ['approved', 'requested'].includes(existing.status)) return grantView(existing);
if (existing && ['approved', 'requested'].includes(existing.status)) return grantView(existing);
const approveNow = RANK[role] >= RANK.admin && withinAllowance(project, app, capability, settings);
const approveNow = RANK[role] >= RANK.admin && withinAllowance(project, app, capability, settings);
const status = approveNow ? 'approved' : 'requested';
const status = approveNow ? 'approved' : 'requested';
const t = nowIso();
const t = nowIso();
- db.transaction(() => {
- db.prepare(`INSERT INTO dev_grants (app_id, capability, audience, status, requested_by, requested_at, decided_by, decided_at)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO dev_grants (app_id, capability, audience, status, requested_by, requested_at, decided_by, decided_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(app_id, capability) DO UPDATE SET audience = excluded.audience, status = excluded.status, requested_by = excluded.requested_by,
ON CONFLICT(app_id, capability) DO UPDATE SET audience = excluded.audience, status = excluded.status, requested_by = excluded.requested_by,
requested_at = excluded.requested_at, decided_by = excluded.decided_by, decided_at = excluded.decided_at`)
requested_at = excluded.requested_at, decided_by = excluded.decided_by, decided_at = excluded.decided_at`)
.run(app.id, capability, audience, status, actor.label, t, approveNow ? actor.label : null, approveNow ? t : null);
.run(app.id, capability, audience, status, actor.label, t, approveNow ? actor.label : null, approveNow ? t : null);
- if (approveNow) grantEvent(db, actor, project.id, app.id, capability, audience, existing ? existing.status : 'none', 'approved', ctx);
- else audit(db, { projectId: project.id, actor: actor.label, action: 'grant.requested', target: `app:${app.id}`, detail: { capability, audience }, ctx });
- })();
- return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability));
+ if (approveNow) await grantEvent(db, actor, project.id, app.id, capability, audience, existing ? existing.status : 'none', 'approved', ctx);
+ else await audit(db, { projectId: project.id, actor: actor.label, action: 'grant.requested', target: `app:${app.id}`, detail: { capability, audience }, ctx });
+ });
+ return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, capability));
}
}
-function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, settings }) {
- const { project } = access(db, actor, projectId, { need: 'admin', staffOk: decision === 'revoked', allowArchived: decision === 'revoked' });
- const app = loadApp(db, project.id, appId);
- const g = db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, String(capability));
+async function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, settings }) {
+ const { project } = await access(db, actor, projectId, { need: 'admin', staffOk: decision === 'revoked', allowArchived: decision === 'revoked' });
+ const app = await loadApp(db, project.id, appId);
+ const g = await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, String(capability));
if (!g) fail(404, 'grant.not_found', 'no such grant');
if (!g) fail(404, 'grant.not_found', 'no such grant');
if (decision === 'approved') {
if (decision === 'approved') {
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
@@ -664,11 +565,11 @@ function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, s
@@ -664,11 +565,11 @@ function decideGrant(db, actor, projectId, appId, capability, decision, { ctx, s
} else if (decision === 'revoked') {
} else if (decision === 'revoked') {
if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
}
}
- db.transaction(() => {
- db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);
- grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);
- })();
- return grantView(db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));
+ await db.tx(async () => {
+ await db.prepare('UPDATE dev_grants SET status = ?, decided_by = ?, decided_at = ? WHERE app_id = ? AND capability = ?').run(decision, actor.label, nowIso(), app.id, g.capability);
+ await grantEvent(db, actor, project.id, app.id, g.capability, g.audience, g.status, decision, ctx);
+ });
+ return grantView(await db.prepare('SELECT * FROM dev_grants WHERE app_id = ? AND capability = ?').get(app.id, g.capability));
}
}
// ── Quotas ─────────────────────────────────────────────────────
// ── Quotas ─────────────────────────────────────────────────────
@@ -677,14 +578,14 @@ function quotaView(q) {
@@ -677,14 +578,14 @@ function quotaView(q) {
return { capability: q.capability, limit: q.limit_value, window: q.quota_window, unit: q.unit, enforced_by: policy.audienceOf(q.capability), updated_at: q.updated_at };
return { capability: q.capability, limit: q.limit_value, window: q.quota_window, unit: q.unit, enforced_by: policy.audienceOf(q.capability), updated_at: q.updated_at };
}
}
-function listQuotas(db, actor, projectId) {
- const { project } = access(db, actor, projectId, { allowArchived: true });
- return db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(project.id).map(quotaView);
+async function listQuotas(db, actor, projectId) {
+ const { project } = await access(db, actor, projectId, { allowArchived: true });
+ return (await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(project.id)).map(quotaView);
}
}
-function setQuota(db, actor, projectId, capability, body, { ctx }) {
+async function setQuota(db, actor, projectId, capability, body, { ctx }) {
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');
- const { project } = access(db, actor, projectId);
+ const { project } = await access(db, actor, projectId);
const g = policy.grantability(capability);
const g = policy.grantability(capability);
if (!g.grantable) fail(422, g.code, g.reason);
if (!g.grantable) fail(422, g.code, g.reason);
const limit = Number(body.limit);
const limit = Number(body.limit);
@@ -693,22 +594,22 @@ function setQuota(db, actor, projectId, capability, body, { ctx }) {
@@ -693,22 +594,22 @@ function setQuota(db, actor, projectId, capability, body, { ctx }) {
if (!QUOTA_WINDOWS.includes(window)) fail(422, 'quota.invalid', `window is one of ${QUOTA_WINDOWS.join(', ')}`);
if (!QUOTA_WINDOWS.includes(window)) fail(422, 'quota.invalid', `window is one of ${QUOTA_WINDOWS.join(', ')}`);
const unit = String(body.unit || 'requests');
const unit = String(body.unit || 'requests');
if (!/^[a-z][a-z0-9_]{0,31}$/.test(unit)) fail(422, 'quota.invalid', 'unit is a short lowercase word (requests, bytes, tokens, ...)');
if (!/^[a-z][a-z0-9_]{0,31}$/.test(unit)) fail(422, 'quota.invalid', 'unit is a short lowercase word (requests, bytes, tokens, ...)');
- db.transaction(() => {
- db.prepare(`INSERT INTO dev_quotas (project_id, capability, limit_value, quota_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO dev_quotas (project_id, capability, limit_value, quota_window, unit, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, capability) DO UPDATE SET limit_value = excluded.limit_value, quota_window = excluded.quota_window, unit = excluded.unit,
ON CONFLICT(project_id, capability) DO UPDATE SET limit_value = excluded.limit_value, quota_window = excluded.quota_window, unit = excluded.unit,
updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
updated_at = excluded.updated_at, updated_by = excluded.updated_by`)
.run(project.id, capability, limit, window, unit, nowIso(), actor.label);
.run(project.id, capability, limit, window, unit, nowIso(), actor.label);
- audit(db, { projectId: project.id, actor: actor.label, action: 'quota.set', target: project.id, detail: { capability, limit, window, unit }, ctx });
- })();
- return quotaView(db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, capability));
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'quota.set', target: project.id, detail: { capability, limit, window, unit }, ctx });
+ });
+ return quotaView(await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? AND capability = ?').get(project.id, capability));
}
}
-function deleteQuota(db, actor, projectId, capability, { ctx }) {
+async function deleteQuota(db, actor, projectId, capability, { ctx }) {
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');
if (!actor.staff) fail(403, 'project.staff_only', 'only staff set quotas');
- const { project } = access(db, actor, projectId);
- const n = db.prepare('DELETE FROM dev_quotas WHERE project_id = ? AND capability = ?').run(project.id, String(capability)).changes;
+ const { project } = await access(db, actor, projectId);
+ const n = (await db.prepare('DELETE FROM dev_quotas WHERE project_id = ? AND capability = ?').run(project.id, String(capability))).changes;
if (!n) fail(404, 'quota.not_found', 'no quota for that capability');
if (!n) fail(404, 'quota.not_found', 'no quota for that capability');
- audit(db, { projectId: project.id, actor: actor.label, action: 'quota.deleted', target: project.id, detail: { capability }, ctx });
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'quota.deleted', target: project.id, detail: { capability }, ctx });
}
}
module.exports = {
module.exports = {
@@ -33,10 +33,10 @@ const isAppClient = (clientId) => store.APP_ID_RE.test(String(clientId || ''));
@@ -33,10 +33,10 @@ const isAppClient = (clientId) => store.APP_ID_RE.test(String(clientId || ''));
const oauthError = (status, error, description) => ({ status, body: { error, error_description: description } });
const oauthError = (status, error, description) => ({ status, body: { error, error_description: description } });
/** The app plus its project, if both are usable for issuing tokens now. */
/** The app plus its project, if both are usable for issuing tokens now. */
-function usableApp(db, clientId) {
- const app = isAppClient(clientId) ? db.prepare('SELECT * FROM dev_apps WHERE oauth_client_id = ?').get(String(clientId)) : null;
+async function usableApp(db, clientId) {
+ const app = isAppClient(clientId) ? await db.prepare('SELECT * FROM dev_apps WHERE oauth_client_id = ?').get(String(clientId)) : null;
if (!app || app.revoked_at) return { error: oauthError(401, 'invalid_client', 'unknown or revoked app') };
if (!app || app.revoked_at) return { error: oauthError(401, 'invalid_client', 'unknown or revoked app') };
- const project = db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(app.project_id);
+ const project = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(app.project_id);
if (!project || project.archived_at) return { error: oauthError(401, 'invalid_client', 'project archived') };
if (!project || project.archived_at) return { error: oauthError(401, 'invalid_client', 'project archived') };
if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(app.environment)) {
if (!policy.ENVIRONMENT_POLICIES[project.environment_policy].includes(app.environment)) {
return { error: oauthError(400, 'unauthorized_client', `${app.environment} apps are not enabled for this project`) };
return { error: oauthError(400, 'unauthorized_client', `${app.environment} apps are not enabled for this project`) };
@@ -45,10 +45,10 @@ function usableApp(db, clientId) {
@@ -45,10 +45,10 @@ function usableApp(db, clientId) {
}
}
/** Capabilities the app holds for an audience right now (sandbox apps: allowance ∪ sandbox allowance). */
/** Capabilities the app holds for an audience right now (sandbox apps: allowance ∪ sandbox allowance). */
-function effectiveGrants(db, app, project, audience, settings) {
+async function effectiveGrants(db, app, project, audience, settings) {
const allowance = policy.allowanceFor(project, app, settings);
const allowance = policy.allowanceFor(project, app, settings);
- return db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND audience = ? AND status = 'approved' ORDER BY capability")
- .all(app.id, audience).map(r => r.capability)
+ return (await db.prepare("SELECT capability FROM dev_grants WHERE app_id = ? AND audience = ? AND status = 'approved' ORDER BY capability")
+ .all(app.id, audience)).map(r => r.capability)
.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
.filter(c => allowance.has(c) && policy.isGrantable(c) && policy.audienceOf(c) === audience);
}
}
@@ -57,13 +57,13 @@ function projectNamespaces(projectId) {
@@ -57,13 +57,13 @@ function projectNamespaces(projectId) {
return [projectId, `app.${projectId}.*`];
return [projectId, `app.${projectId}.*`];
}
}
-function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
+async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
const aud = String(audience || '').trim();
const aud = String(audience || '').trim();
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
return oauthError(400, 'invalid_target', `${aud} does not accept sandbox tokens`);
return oauthError(400, 'invalid_target', `${aud} does not accept sandbox tokens`);
}
}
- const held = effectiveGrants(db, app, project, aud, settings).filter(c => !limit || limit.includes(c));
+ const held = (await effectiveGrants(db, app, project, aud, settings)).filter(c => !limit || limit.includes(c));
const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
@@ -118,8 +118,8 @@ const exportSubject = (projectId) => `app:app_${String(projectId).replace(/^prj_
@@ -118,8 +118,8 @@ const exportSubject = (projectId) => `app:app_${String(projectId).replace(/^prj_
* Every mint writes a dev_audit row (project.export_token_issued: audience, env, cap, jti, expiry);
* Every mint writes a dev_audit row (project.export_token_issued: audience, env, cap, jti, expiry);
* the token itself is never stored or logged. Returns the token response; throws DevError.
* the token itself is never stored or logged. Returns the token response; throws DevError.
*/
*/
-function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }) {
- const { project } = store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });
+async function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }) {
+ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });
const audience = String((body && body.audience) || '');
const audience = String((body && body.audience) || '');
const cap = EXPORT_CAPS[audience];
const cap = EXPORT_CAPS[audience];
if (!cap) throw new store.DevError(422, 'export.invalid_audience', `audience is one of ${Object.keys(EXPORT_CAPS).join(', ')}`);
if (!cap) throw new store.DevError(422, 'export.invalid_audience', `audience is one of ${Object.keys(EXPORT_CAPS).join(', ')}`);
@@ -134,7 +134,7 @@ function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }
@@ -134,7 +134,7 @@ function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }
assertValid('identity.service-token-claims@1', claims);
assertValid('identity.service-token-claims@1', claims);
const token = serviceAuth.signServiceToken(claims, privateKey);
const token = serviceAuth.signServiceToken(claims, privateKey);
const expiresAt = new Date(claims.exp * 1000).toISOString();
const expiresAt = new Date(claims.exp * 1000).toISOString();
- store.audit(db, {
+ await store.audit(db, {
projectId: project.id, actor: actor.label, action: 'project.export_token_issued', target: claims.sub,
projectId: project.id, actor: actor.label, action: 'project.export_token_issued', target: claims.sub,
detail: { audience, env, cap: claims.cap, jti: claims.jti, expires_at: expiresAt, purpose: EXPORT_PURPOSE }, ctx,
detail: { audience, env, cap: claims.cap, jti: claims.jti, expires_at: expiresAt, purpose: EXPORT_PURPOSE }, ctx,
});
});
@@ -145,47 +145,47 @@ function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }
@@ -145,47 +145,47 @@ function mintExportToken(db, actor, projectId, body, { privateKey, issuer, ctx }
}
}
/** Authenticate the client of a token request. Public apps present no secret. */
/** Authenticate the client of a token request. Public apps present no secret. */
-function authenticate(db, app, clientSecret) {
+async function authenticate(db, app, clientSecret) {
if (app.client_type === 'public') {
if (app.client_type === 'public') {
if (clientSecret) return oauthError(401, 'invalid_client', 'public apps have no client secret');
if (clientSecret) return oauthError(401, 'invalid_client', 'public apps have no client secret');
return null;
return null;
}
}
- if (!store.matchSecret(db, app.id, clientSecret)) return oauthError(401, 'invalid_client', 'Invalid client credentials');
+ if (!await store.matchSecret(db, app.id, clientSecret)) return oauthError(401, 'invalid_client', 'Invalid client credentials');
return null;
return null;
}
}
/** /oauth/token for an app client id. Returns { status, body }. */
/** /oauth/token for an app client id. Returns { status, body }. */
-function handleTokenRequest(db, body, { privateKey, issuer, config }) {
+async function handleTokenRequest(db, body, { privateKey, issuer, config }) {
const settings = policy.settings(config);
const settings = policy.settings(config);
- const found = usableApp(db, body.client_id);
+ const found = await usableApp(db, body.client_id);
if (found.error) return found.error;
if (found.error) return found.error;
const { app, project } = found;
const { app, project } = found;
if (body.grant_type === 'client_credentials') {
if (body.grant_type === 'client_credentials') {
if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');
if (app.client_type !== 'confidential') return oauthError(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');
- const bad = authenticate(db, app, body.client_secret);
+ const bad = await authenticate(db, app, body.client_secret);
if (bad) return bad;
if (bad) return bad;
- return mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });
+ return await mint({ app, project, audience: body.audience, scope: body.scope, privateKey, issuer, settings, db });
}
}
if (body.grant_type === 'authorization_code') {
if (body.grant_type === 'authorization_code') {
- const bad = authenticate(db, app, body.client_secret);
+ const bad = await authenticate(db, app, body.client_secret);
if (bad) return bad;
if (bad) return bad;
const code = String(body.code || '');
const code = String(body.code || '');
if (!code) return oauthError(400, 'invalid_request', 'Missing code');
if (!code) return oauthError(400, 'invalid_request', 'Missing code');
const hash = store.hashSecret(code);
const hash = store.hashSecret(code);
- const row = db.prepare('SELECT * FROM dev_auth_codes WHERE code_hash = ?').get(hash);
+ const row = await db.prepare('SELECT * FROM dev_auth_codes WHERE code_hash = ?').get(hash);
if (!row || row.app_id !== app.id) return oauthError(400, 'invalid_grant', 'Invalid authorization code');
if (!row || row.app_id !== app.id) return oauthError(400, 'invalid_grant', 'Invalid authorization code');
// Single use, atomically; a failed verification below still burns the code.
// Single use, atomically; a failed verification below still burns the code.
- if (db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE code_hash = ? AND used = 0').run(hash).changes !== 1) return oauthError(400, 'invalid_grant', 'Code already used');
+ if ((await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE code_hash = ? AND used = 0').run(hash)).changes !== 1) return oauthError(400, 'invalid_grant', 'Code already used');
if (Date.parse(row.expires_at) < Date.now()) return oauthError(400, 'invalid_grant', 'Authorization code expired');
if (Date.parse(row.expires_at) < Date.now()) return oauthError(400, 'invalid_grant', 'Authorization code expired');
if (row.redirect_uri !== String(body.redirect_uri || '')) return oauthError(400, 'invalid_grant', 'Redirect URI mismatch');
if (row.redirect_uri !== String(body.redirect_uri || '')) return oauthError(400, 'invalid_grant', 'Redirect URI mismatch');
if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');
if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');
- const user = db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);
+ const user = await db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);
if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');
if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');
// The exchange may narrow what was authorized, never widen it.
// The exchange may narrow what was authorized, never widen it.
const authorized = row.scope ? row.scope.split(' ') : null;
const authorized = row.scope ? row.scope.split(' ') : null;
const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;
const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;
if (authorized && asked && asked.some(s => !authorized.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
if (authorized && asked && asked.some(s => !authorized.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
- return mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
+ return await mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
}
}
return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
}
}
@@ -201,8 +201,8 @@ function verifierMatches(verifier, challenge) {
@@ -201,8 +201,8 @@ function verifierMatches(verifier, challenge) {
* /oauth/authorize and /oauth/confirm checks for an app client id: registered redirect URI and a
* /oauth/authorize and /oauth/confirm checks for an app client id: registered redirect URI and a
* PKCE S256 challenge (required for every app). Returns { app, project } or { error: message }.
* PKCE S256 challenge (required for every app). Returns { app, project } or { error: message }.
*/
*/
-function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, code_challenge_method }) {
- const found = usableApp(db, client_id);
+async function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, code_challenge_method }) {
+ const found = await usableApp(db, client_id);
if (found.error) return { error: 'Unknown client_id' };
if (found.error) return { error: 'Unknown client_id' };
const uris = JSON.parse(found.app.redirect_uris || '[]');
const uris = JSON.parse(found.app.redirect_uris || '[]');
if (!redirect_uri || !uris.includes(String(redirect_uri))) return { error: 'Invalid redirect_uri' };
if (!redirect_uri || !uris.includes(String(redirect_uri))) return { error: 'Invalid redirect_uri' };
@@ -216,18 +216,18 @@ function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, co
@@ -216,18 +216,18 @@ function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challenge, co
* Issue an authorization code to an app for a signed-in user. A sandbox app may only be authorized
* Issue an authorization code to an app for a signed-in user. A sandbox app may only be authorized
* by members of its project. Returns { code } or { status, error }.
* by members of its project. Returns { code } or { status, error }.
*/
*/
-function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
+async function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
const subjects = require('../identity/subjects');
const subjects = require('../identity/subjects');
- const subject = subjects.ensureUserSubject(db, user);
+ const subject = await subjects.ensureUserSubject(db, user);
if (!subject) return { status: 403, error: 'account has no subject id' };
if (!subject) return { status: 403, error: 'account has no subject id' };
- if (app.environment === 'sandbox' && !store.memberRole(db, project.id, subject)) {
+ if (app.environment === 'sandbox' && !await store.memberRole(db, project.id, subject)) {
return { status: 403, error: 'sandbox apps can only be authorized by members of their project' };
return { status: 403, error: 'sandbox apps can only be authorized by members of their project' };
}
}
const code = crypto.randomBytes(32).toString('hex');
const code = crypto.randomBytes(32).toString('hex');
- db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)
+ await db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,
String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
- db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());
+ await db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());
return { code };
return { code };
}
}
@@ -45,59 +45,7 @@ const dayOf = (ms) => iso(ms).slice(0, 10);
@@ -45,59 +45,7 @@ const dayOf = (ms) => iso(ms).slice(0, 10);
const parse = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
const parse = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
-function ensure(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS dev_usage_windows (
- project_id TEXT NOT NULL,
- env TEXT NOT NULL CHECK (env IN ('sandbox', 'production')),
- service TEXT NOT NULL,
- capability TEXT NOT NULL,
- dimension TEXT NOT NULL DEFAULT '',
- unit TEXT NOT NULL,
- window_start TEXT NOT NULL,
- window_end TEXT NOT NULL,
- quantity INTEGER NOT NULL,
- errors INTEGER NOT NULL,
- error_codes TEXT NOT NULL DEFAULT '{}',
- revision INTEGER NOT NULL DEFAULT 1,
- event_id TEXT NOT NULL,
- recorded_at TEXT NOT NULL,
- PRIMARY KEY (project_id, env, service, capability, dimension, unit, window_start)
- );
- CREATE INDEX IF NOT EXISTS idx_dev_usage_windows_when ON dev_usage_windows(window_start);
- CREATE INDEX IF NOT EXISTS idx_dev_usage_windows_project ON dev_usage_windows(project_id, recorded_at);
- CREATE TABLE IF NOT EXISTS dev_usage_daily (
- project_id TEXT NOT NULL,
- env TEXT NOT NULL,
- service TEXT NOT NULL,
- capability TEXT NOT NULL,
- dimension TEXT NOT NULL DEFAULT '',
- unit TEXT NOT NULL,
- day TEXT NOT NULL,
- quantity INTEGER NOT NULL DEFAULT 0,
- errors INTEGER NOT NULL DEFAULT 0,
- error_codes TEXT NOT NULL DEFAULT '{}',
- updated_at TEXT NOT NULL,
- PRIMARY KEY (project_id, env, service, capability, dimension, unit, day)
- );
- CREATE INDEX IF NOT EXISTS idx_dev_usage_daily_day ON dev_usage_daily(project_id, day);
- CREATE TABLE IF NOT EXISTS dev_usage_errors (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- project_id TEXT NOT NULL,
- env TEXT NOT NULL,
- service TEXT NOT NULL,
- capability TEXT NOT NULL,
- at TEXT NOT NULL,
- code TEXT NOT NULL,
- status INTEGER,
- trace_id TEXT,
- ref TEXT NOT NULL DEFAULT '',
- event_id TEXT NOT NULL
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_dev_usage_errors_once ON dev_usage_errors(project_id, env, service, capability, at, code, ref);
- CREATE INDEX IF NOT EXISTS idx_dev_usage_errors_recent ON dev_usage_errors(project_id, at);
- `);
-}
+function ensure(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
function mergeCodes(into, codes) {
function mergeCodes(into, codes) {
for (const [k, n] of Object.entries(obj(codes))) if (Number.isSafeInteger(n) && n > 0) into[k] = (into[k] || 0) + n;
for (const [k, n] of Object.entries(obj(codes))) if (Number.isSafeInteger(n) && n > 0) into[k] = (into[k] || 0) + n;
@@ -126,15 +74,15 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})
@@ -126,15 +74,15 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})
VALUES (@project_id, @env, @service, @capability, @dimension, @unit, @day, @quantity, @errors, @error_codes, @updated_at)
VALUES (@project_id, @env, @service, @capability, @dimension, @unit, @day, @quantity, @errors, @error_codes, @updated_at)
ON CONFLICT(project_id, env, service, capability, dimension, unit, day) DO UPDATE SET
ON CONFLICT(project_id, env, service, capability, dimension, unit, day) DO UPDATE SET
quantity = excluded.quantity, errors = excluded.errors, error_codes = excluded.error_codes, updated_at = excluded.updated_at`),
quantity = excluded.quantity, errors = excluded.errors, error_codes = excluded.error_codes, updated_at = excluded.updated_at`),
- addError: db.prepare(`INSERT OR IGNORE INTO dev_usage_errors (project_id, env, service, capability, at, code, status, trace_id, ref, event_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
+ addError: db.prepare(`INSERT INTO dev_usage_errors (project_id, env, service, capability, at, code, status, trace_id, ref, event_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`),
capErrors: db.prepare(`DELETE FROM dev_usage_errors WHERE project_id = ? AND id NOT IN
capErrors: db.prepare(`DELETE FROM dev_usage_errors WHERE project_id = ? AND id NOT IN
(SELECT id FROM dev_usage_errors WHERE project_id = ? ORDER BY at DESC, id DESC LIMIT ${ERRORS_PER_PROJECT})`),
(SELECT id FROM dev_usage_errors WHERE project_id = ? ORDER BY at DESC, id DESC LIMIT ${ERRORS_PER_PROJECT})`),
};
};
let lastPrune = 0;
let lastPrune = 0;
/** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */
/** Inside the consumer's inbox transaction: 'recorded' or an 'ignored:*' outcome. */
- function record(event) {
+ async function record(event) {
const type = String(event && event.event_type || '');
const type = String(event && event.event_type || '');
if (!TOPICS.includes(type)) return 'ignored:type';
if (!TOPICS.includes(type)) return 'ignored:type';
const service = type.split('.')[0];
const service = type.split('.')[0];
@@ -155,14 +103,14 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})
@@ -155,14 +103,14 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})
const t = now();
const t = now();
if (start > t + HOUR_MS) return 'ignored:future';
if (start > t + HOUR_MS) return 'ignored:future';
if (start < t - WINDOW_DAYS * DAY_MS) return 'ignored:too_old';
if (start < t - WINDOW_DAYS * DAY_MS) return 'ignored:too_old';
- if (!q.project.get(p.project_id)) return 'ignored:project';
+ if (!await q.project.get(p.project_id)) return 'ignored:project';
const key = { project_id: p.project_id, env: p.env, service, capability: p.capability, dimension: p.dimension || '', unit: p.unit };
const key = { project_id: p.project_id, env: p.env, service, capability: p.capability, dimension: p.dimension || '', unit: p.unit };
const windowStart = iso(start);
const windowStart = iso(start);
const revision = p.revision || 1;
const revision = p.revision || 1;
- const prev = q.window.get(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, windowStart);
+ const prev = await q.window.get(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, windowStart);
if (prev && prev.revision > revision) return 'ignored:stale';
if (prev && prev.revision > revision) return 'ignored:stale';
const recordedAt = iso(t);
const recordedAt = iso(t);
- q.upsertWindow.run({
+ await q.upsertWindow.run({
...key, window_start: windowStart, window_end: iso(end), quantity: p.quantity, errors: p.errors,
...key, window_start: windowStart, window_end: iso(end), quantity: p.quantity, errors: p.errors,
error_codes: JSON.stringify(obj(p.error_codes)), revision, event_id: event.event_id, recorded_at: recordedAt,
error_codes: JSON.stringify(obj(p.error_codes)), revision, event_id: event.event_id, recorded_at: recordedAt,
});
});
@@ -171,25 +119,25 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})
@@ -171,25 +119,25 @@ function createProjectUsage(db, { now = () => Date.now(), log = console } = {})
const from = `${day}T00:00:00.000Z`;
const from = `${day}T00:00:00.000Z`;
const to = iso(Date.parse(from) + DAY_MS);
const to = iso(Date.parse(from) + DAY_MS);
const sum = { quantity: 0, errors: 0, codes: {} };
const sum = { quantity: 0, errors: 0, codes: {} };
- for (const w of q.dayWindows.all(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, from, to)) {
+ for (const w of await q.dayWindows.all(key.project_id, key.env, key.service, key.capability, key.dimension, key.unit, from, to)) {
sum.quantity += w.quantity; sum.errors += w.errors; mergeCodes(sum.codes, parse(w.error_codes, {}));
sum.quantity += w.quantity; sum.errors += w.errors; mergeCodes(sum.codes, parse(w.error_codes, {}));
}
}
- q.upsertDaily.run({ ...key, day, quantity: sum.quantity, errors: sum.errors, error_codes: JSON.stringify(sum.codes), updated_at: recordedAt });
+ await q.upsertDaily.run({ ...key, day, quantity: sum.quantity, errors: sum.errors, error_codes: JSON.stringify(sum.codes), updated_at: recordedAt });
for (const s of Array.isArray(p.samples) ? p.samples : []) {
for (const s of Array.isArray(p.samples) ? p.samples : []) {
- q.addError.run(key.project_id, key.env, service, key.capability, iso(Date.parse(s.at)), s.code, s.status || null, s.trace_id || null, s.ref || '', event.event_id);
+ await q.addError.run(key.project_id, key.env, service, key.capability, iso(Date.parse(s.at)), s.code, s.status || null, s.trace_id || null, s.ref || '', event.event_id);
}
}
- if (p.samples && p.samples.length) q.capErrors.run(key.project_id, key.project_id);
- if (t - lastPrune > HOUR_MS) { lastPrune = t; prune(t); }
+ if (p.samples && p.samples.length) await q.capErrors.run(key.project_id, key.project_id);
+ if (t - lastPrune > HOUR_MS) { lastPrune = t; await prune(t); }
return 'recorded';
return 'recorded';
}
}
- function prune(t = now()) {
- db.prepare('DELETE FROM dev_usage_windows WHERE window_start < ?').run(iso(t - WINDOW_DAYS * DAY_MS));
- db.prepare('DELETE FROM dev_usage_daily WHERE day < ?').run(dayOf(t - DAILY_DAYS * DAY_MS));
- db.prepare('DELETE FROM dev_usage_errors WHERE at < ?').run(iso(t - ERROR_DAYS * DAY_MS));
+ async function prune(t = now()) {
+ await db.prepare('DELETE FROM dev_usage_windows WHERE window_start < ?').run(iso(t - WINDOW_DAYS * DAY_MS));
+ await db.prepare('DELETE FROM dev_usage_daily WHERE day < ?').run(dayOf(t - DAILY_DAYS * DAY_MS));
+ await db.prepare('DELETE FROM dev_usage_errors WHERE at < ?').run(iso(t - ERROR_DAYS * DAY_MS));
}
}
- return { record, prune, summary: (projectId, o) => summary(db, projectId, { now, ...o }) };
+ return { record, prune, summary: async (projectId, o) => await summary(db, projectId, { now, ...o }) };
}
}
class UsageQueryError extends Error {
class UsageQueryError extends Error {
@@ -206,13 +154,13 @@ function parseQuery(query = {}) {
@@ -206,13 +154,13 @@ function parseQuery(query = {}) {
}
}
/** network.project-usage-result@1 for one project (the caller checked access). */
/** network.project-usage-result@1 for one project (the caller checked access). */
-function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now() } = {}) {
+async function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now() } = {}) {
const t = now();
const t = now();
const to = dayOf(t);
const to = dayOf(t);
const from = dayOf(t - (days - 1) * DAY_MS);
const from = dayOf(t - (days - 1) * DAY_MS);
const envSql = env === 'all' ? '' : ' AND env = ?';
const envSql = env === 'all' ? '' : ' AND env = ?';
const envArgs = env === 'all' ? [] : [env];
const envArgs = env === 'all' ? [] : [env];
- const rows = db.prepare(`SELECT * FROM dev_usage_daily WHERE project_id = ? AND day >= ?${envSql}
+ const rows = await db.prepare(`SELECT * FROM dev_usage_daily WHERE project_id = ? AND day >= ?${envSql}
ORDER BY day DESC, service, capability, dimension, unit, env`).all(projectId, from, ...envArgs);
ORDER BY day DESC, service, capability, dimension, unit, env`).all(projectId, from, ...envArgs);
const daily = rows.map((r) => ({ day: r.day, service: r.service, capability: r.capability, dimension: r.dimension || null, unit: r.unit, env: r.env, quantity: r.quantity, errors: r.errors }));
const daily = rows.map((r) => ({ day: r.day, service: r.service, capability: r.capability, dimension: r.dimension || null, unit: r.unit, env: r.env, quantity: r.quantity, errors: r.errors }));
@@ -234,9 +182,9 @@ function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now()
@@ -234,9 +182,9 @@ function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now()
}
}
// Quotas (dev_quotas, staff-set): what their current window used, every environment together.
// Quotas (dev_quotas, staff-set): what their current window used, every environment together.
- const seen = new Set(db.prepare('SELECT DISTINCT capability, unit FROM dev_usage_daily WHERE project_id = ?').all(projectId).map((r) => `${r.capability}|${r.unit}`));
+ const seen = new Set((await db.prepare('SELECT DISTINCT capability, unit FROM dev_usage_daily WHERE project_id = ?').all(projectId)).map((r) => `${r.capability}|${r.unit}`));
const usedSince = db.prepare('SELECT COALESCE(SUM(quantity), 0) AS n FROM dev_usage_daily WHERE project_id = ? AND capability = ? AND unit = ? AND day >= ?');
const usedSince = db.prepare('SELECT COALESCE(SUM(quantity), 0) AS n FROM dev_usage_daily WHERE project_id = ? AND capability = ? AND unit = ? AND day >= ?');
- const quotas = db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(projectId).map((qr) => {
+ const quotas = (await Promise.all((await db.prepare('SELECT * FROM dev_quotas WHERE project_id = ? ORDER BY capability').all(projectId)).map(async (qr) => {
const cap = capabilities.get(qr.capability);
const cap = capabilities.get(qr.capability);
const out = { capability: qr.capability, limit: qr.limit_value, window: qr.quota_window, unit: qr.unit, enforced_by: cap ? `openvibe.${cap.owner}` : null, used: null, remaining: null, window_start: null, note: null };
const out = { capability: qr.capability, limit: qr.limit_value, window: qr.quota_window, unit: qr.unit, enforced_by: cap ? `openvibe.${cap.owner}` : null, used: null, remaining: null, window_start: null, note: null };
const measured = seen.has(`${qr.capability}|${qr.unit}`) || (REPORTED[qr.capability] || []).includes(qr.unit);
const measured = seen.has(`${qr.capability}|${qr.unit}`) || (REPORTED[qr.capability] || []).includes(qr.unit);
@@ -253,15 +201,15 @@ function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now()
@@ -253,15 +201,15 @@ function summary(db, projectId, { days = 30, env = 'all', now = () => Date.now()
if (qr.quota_window === 'day') { since = to; out.window_start = `${to}T00:00:00.000Z`; }
if (qr.quota_window === 'day') { since = to; out.window_start = `${to}T00:00:00.000Z`; }
else if (qr.quota_window === 'month') { since = `${to.slice(0, 7)}-01`; out.window_start = `${since}T00:00:00.000Z`; }
else if (qr.quota_window === 'month') { since = `${to.slice(0, 7)}-01`; out.window_start = `${since}T00:00:00.000Z`; }
else { since = '0000-00-00'; out.note = `total of the last ${DAILY_DAYS} days kept`; }
else { since = '0000-00-00'; out.note = `total of the last ${DAILY_DAYS} days kept`; }
- out.used = usedSince.get(projectId, qr.capability, qr.unit, since).n;
- out.remaining = Math.max(0, qr.limit_value - out.used);
+ out.used = Number((await usedSince.get(projectId, qr.capability, qr.unit, since)).n);
+ out.remaining = Math.max(0, Number(qr.limit_value) - out.used);
return out;
return out;
- });
+ })));
- const recent = db.prepare(`SELECT * FROM dev_usage_errors WHERE project_id = ? AND at >= ?${envSql} ORDER BY at DESC, id DESC LIMIT ${RECENT_ERRORS}`)
- .all(projectId, `${from}T00:00:00.000Z`, ...envArgs)
+ const recent = (await db.prepare(`SELECT * FROM dev_usage_errors WHERE project_id = ? AND at >= ?${envSql} ORDER BY at DESC, id DESC LIMIT ${RECENT_ERRORS}`)
+ .all(projectId, `${from}T00:00:00.000Z`, ...envArgs))
.map((e) => ({ at: e.at, env: e.env, service: e.service, capability: e.capability, code: e.code, status: e.status == null ? null : e.status, trace_id: e.trace_id || null, ref: e.ref || null }));
.map((e) => ({ at: e.at, env: e.env, service: e.service, capability: e.capability, code: e.code, status: e.status == null ? null : e.status, trace_id: e.trace_id || null, ref: e.ref || null }));
- const last = db.prepare('SELECT MAX(recorded_at) AS at FROM dev_usage_windows WHERE project_id = ?').get(projectId).at || null;
+ const last = (await db.prepare('SELECT MAX(recorded_at) AS at FROM dev_usage_windows WHERE project_id = ?').get(projectId)).at || null;
return {
return {
project_id: projectId, env, range: { days, from, to }, generated_at: iso(t), last_recorded_at: last,
project_id: projectId, env, range: { days, from, to }, generated_at: iso(t), last_recorded_at: last,
@@ -121,7 +121,7 @@ class DiscordService {
@@ -121,7 +121,7 @@ class DiscordService {
const cooldownMin = parseInt(this._getSetting('discord_dedupe_minutes') || '15', 10);
const cooldownMin = parseInt(this._getSetting('discord_dedupe_minutes') || '15', 10);
const cooldownMs = Math.max(cooldownMin, 1) * 60 * 1000;
const cooldownMs = Math.max(cooldownMin, 1) * 60 * 1000;
const key = streamer.username.toLowerCase();
const key = streamer.username.toLowerCase();
- const lastAlert = this._liveAlertCooldowns.get(key);
+ const lastAlert = await this._liveAlertCooldowns.get(key);
if (lastAlert && (Date.now() - lastAlert) < cooldownMs) {
if (lastAlert && (Date.now() - lastAlert) < cooldownMs) {
return { sent: false, reason: 'cooldown', remaining_ms: cooldownMs - (Date.now() - lastAlert) };
return { sent: false, reason: 'cooldown', remaining_ms: cooldownMs - (Date.now() - lastAlert) };
}
}
@@ -210,9 +210,9 @@ class DiscordService {
@@ -210,9 +210,9 @@ class DiscordService {
// ─── Helpers ───────────────────────────────────────────
// ─── Helpers ───────────────────────────────────────────
- _getSetting(key) {
+ async _getSetting(key) {
try {
try {
- return this._db.getSetting(key) || null;
+ return await this._db.getSetting(key) || null;
} catch {
} catch {
return null;
return null;
}
}
@@ -15,21 +15,21 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
@@ -15,21 +15,21 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
router.use(requireAuth, requireAdmin);
router.use(requireAuth, requireAdmin);
/** GET /api/admin/discord — bot status + current config */
/** GET /api/admin/discord — bot status + current config */
- router.get('/', (req, res) => {
+ router.get('/', async (req, res) => {
const status = discordService.getStatus();
const status = discordService.getStatus();
const settings = {
const settings = {
- discord_bot_token: db.getSetting('discord_bot_token') ? '••••••••' : '',
- discord_guild_id: db.getSetting('discord_guild_id') || '',
- discord_alerts_channel_id: db.getSetting('discord_alerts_channel_id') || '',
- discord_system_channel_id: db.getSetting('discord_system_channel_id') || '',
- discord_dedupe_minutes: db.getSetting('discord_dedupe_minutes') || '15',
- discord_alert_message: db.getSetting('discord_alert_message') || '',
- discord_oauth_client_id: db.getSetting('discord_oauth_client_id') || '',
- discord_oauth_client_secret: db.getSetting('discord_oauth_client_secret') ? '••••••••' : '',
+ discord_bot_token: await db.getSetting('discord_bot_token') ? '••••••••' : '',
+ discord_guild_id: await db.getSetting('discord_guild_id') || '',
+ discord_alerts_channel_id: await db.getSetting('discord_alerts_channel_id') || '',
+ discord_system_channel_id: await db.getSetting('discord_system_channel_id') || '',
+ discord_dedupe_minutes: await db.getSetting('discord_dedupe_minutes') || '15',
+ discord_alert_message: await db.getSetting('discord_alert_message') || '',
+ discord_oauth_client_id: await db.getSetting('discord_oauth_client_id') || '',
+ discord_oauth_client_secret: await db.getSetting('discord_oauth_client_secret') ? '••••••••' : '',
};
};
// env: the variable provides it and the form cannot change it (server/secrets.js).
// env: the variable provides it and the form cannot change it (server/secrets.js).
const sources = {};
const sources = {};
- for (const k of ['discord_bot_token', 'discord_oauth_client_secret']) sources[k] = { source: secrets.source(db, k), env: secrets.envName(k) };
+ for (const k of ['discord_bot_token', 'discord_oauth_client_secret']) sources[k] = { source: await secrets.source(db, k), env: secrets.envName(k) };
res.json({ ok: true, status, settings, sources });
res.json({ ok: true, status, settings, sources });
});
});
@@ -54,7 +54,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
@@ -54,7 +54,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
for (const [key, value] of Object.entries(settings)) {
for (const [key, value] of Object.entries(settings)) {
if (!allowedKeys.includes(key)) continue;
if (!allowedKeys.includes(key)) continue;
// Never save a secret into the database while its environment variable provides it.
// Never save a secret into the database while its environment variable provides it.
- if (secrets.isManaged(key) && secrets.source(db, key) === 'env') { if (String(value ?? '').trim() && String(value).trim() !== '••••••••') skipped.push(key); continue; }
+ if (secrets.isManaged(key) && await secrets.source(db, key) === 'env') { if (String(value ?? '').trim() && String(value).trim() !== '••••••••') skipped.push(key); continue; }
// Bot token / OAuth secret are owner-only — silently skip for admins.
// Bot token / OAuth secret are owner-only — silently skip for admins.
if (isSensitiveSettingKey(key) && !owner) continue;
if (isSensitiveSettingKey(key) && !owner) continue;
const strVal = String(value ?? '').trim();
const strVal = String(value ?? '').trim();
@@ -70,7 +70,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
@@ -70,7 +70,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
if (Object.keys(changes.set).length) {
if (Object.keys(changes.set).length) {
const siteConfig = require('../admin/site-config');
const siteConfig = require('../admin/site-config');
try {
try {
- await siteConfig.forDb(db).change(changes, { actor: siteConfig.actorOf(req.user), reason: 'Discord settings' });
+ await (await siteConfig.forDb(db)).change(changes, { actor: siteConfig.actorOf(req.user), reason: 'Discord settings' });
} catch (err) {
} catch (err) {
return res.status(err.status || 500).json({ ok: false, error: err.message, code: err.code, errors: err.errors });
return res.status(err.status || 500).json({ ok: false, error: err.message, code: err.code, errors: err.errors });
}
}
@@ -27,34 +27,8 @@ const dnsCheck = require('./dns-check');
@@ -27,34 +27,8 @@ const dnsCheck = require('./dns-check');
const NOTE_MAX = 500;
const NOTE_MAX = 500;
-function ensureSchema(db) {
- // 'mirror' joined the roles after the table shipped; SQLite cannot alter a CHECK, so rebuild once.
- try {
- const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tool_domains'").get();
- if (row && /CHECK \(role IN/.test(row.sql) && !/'mirror'/.test(row.sql)) {
- db.exec('BEGIN; ALTER TABLE tool_domains RENAME TO tool_domains_old;');
- _createTable(db);
- db.exec('INSERT INTO tool_domains SELECT * FROM tool_domains_old; DROP TABLE tool_domains_old; COMMIT;');
- }
- } catch (err) { try { db.exec('ROLLBACK'); } catch { /* */ } console.error('[Domains] role migration failed:', err.message); }
- _createTable(db);
-}
-function _createTable(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS tool_domains (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- tool_id TEXT NOT NULL,
- host TEXT NOT NULL UNIQUE,
- role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),
- enabled INTEGER NOT NULL DEFAULT 1,
- note TEXT,
- created_by INTEGER,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
- CREATE INDEX IF NOT EXISTS idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);
- `);
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
+function _createTable(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
function iso(sqliteTime) {
function iso(sqliteTime) {
if (!sqliteTime) return null;
if (!sqliteTime) return null;
@@ -72,8 +46,8 @@ function catalogOwnerOf(host, catalog) {
@@ -72,8 +46,8 @@ function catalogOwnerOf(host, catalog) {
return null;
return null;
}
}
-function createDomainRoutes(db, requireAuth, opts = {}) {
- ensureSchema(db);
+async function createDomainRoutes(db, requireAuth, opts = {}) {
+ await ensureSchema(db);
const catalogSource = opts.catalog || toolsCatalog;
const catalogSource = opts.catalog || toolsCatalog;
const checkDomain = opts.checkDomain || dnsCheck.checkDomain;
const checkDomain = opts.checkDomain || dnsCheck.checkDomain;
const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};
const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};
@@ -87,15 +61,15 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -87,15 +61,15 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
byId: db.prepare('SELECT * FROM tool_domains WHERE id = ?'),
byId: db.prepare('SELECT * FROM tool_domains WHERE id = ?'),
byHost: db.prepare('SELECT * FROM tool_domains WHERE host = ?'),
byHost: db.prepare('SELECT * FROM tool_domains WHERE host = ?'),
holders: db.prepare('SELECT * FROM tool_domains WHERE tool_id = ? AND role = ? AND enabled = 1 AND id != ?'),
holders: db.prepare('SELECT * FROM tool_domains WHERE tool_id = ? AND role = ? AND enabled = 1 AND id != ?'),
- demote: db.prepare("UPDATE tool_domains SET role = 'alias', updated_at = CURRENT_TIMESTAMP WHERE id = ?"),
- insert: db.prepare('INSERT INTO tool_domains (tool_id, host, role, enabled, note, created_by) VALUES (?, ?, ?, ?, ?, ?)'),
- update: db.prepare('UPDATE tool_domains SET tool_id = ?, host = ?, role = ?, enabled = ?, note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'),
+ demote: db.prepare("UPDATE tool_domains SET role = 'alias', updated_at = ov_now() WHERE id = ?"),
+ insert: db.prepare('INSERT INTO tool_domains (tool_id, host, role, enabled, note, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING id'),
+ update: db.prepare('UPDATE tool_domains SET tool_id = ?, host = ?, role = ?, enabled = ?, note = ?, updated_at = ov_now() WHERE id = ?'),
remove: db.prepare('DELETE FROM tool_domains WHERE id = ?'),
remove: db.prepare('DELETE FROM tool_domains WHERE id = ?'),
};
};
- function audit(req, action, details) {
+ async function audit(req, action, details) {
try {
try {
- db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(req.user.id, action, JSON.stringify(details), req.ip || null);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(req.user.id, action, JSON.stringify(details), req.ip || null);
} catch (err) {
} catch (err) {
console.error('[Domains] Audit log error:', err.message);
console.error('[Domains] Audit log error:', err.message);
}
}
@@ -139,7 +113,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -139,7 +113,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
if (owner && owner !== next.tool_id) return { status: 409, error: `${next.host} already belongs to the tool "${owner}" in the Tools catalog`, warnings };
if (owner && owner !== next.tool_id) return { status: 409, error: `${next.host} already belongs to the tool "${owner}" in the Tools catalog`, warnings };
}
}
- const taken = q.byHost.get(next.host);
+ const taken = await q.byHost.get(next.host);
if (taken && (!current || taken.id !== current.id)) {
if (taken && (!current || taken.id !== current.id)) {
return { status: 409, error: `${next.host} is already registered for the tool "${taken.tool_id}" — a host belongs to one tool`, warnings };
return { status: 409, error: `${next.host} is already registered for the tool "${taken.tool_id}" — a host belongs to one tool`, warnings };
}
}
@@ -147,18 +121,18 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -147,18 +121,18 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
}
}
/** Write inside one transaction; returns the row plus whatever was demoted to make room. */
/** Write inside one transaction; returns the row plus whatever was demoted to make room. */
- const write = db.transaction((next, current, userId) => {
+ const write = db.txFn(async (next, current, userId) => {
const demoted = [];
const demoted = [];
if (next.enabled && (next.role === 'canonical' || next.role === 'short')) { // one of each; aliases and mirrors can be many
if (next.enabled && (next.role === 'canonical' || next.role === 'short')) { // one of each; aliases and mirrors can be many
- for (const row of q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {
- q.demote.run(row.id);
+ for (const row of await q.holders.all(next.tool_id, next.role, current ? current.id : 0)) {
+ await q.demote.run(row.id);
demoted.push({ id: row.id, host: row.host, from: row.role });
demoted.push({ id: row.id, host: row.host, from: row.role });
}
}
}
}
let id;
let id;
- if (current) { q.update.run(next.tool_id, next.host, next.role, next.enabled, next.note, current.id); id = current.id; }
- else id = q.insert.run(next.tool_id, next.host, next.role, next.enabled, next.note, userId).lastInsertRowid;
- return { row: q.byId.get(id), demoted };
+ if (current) { await q.update.run(next.tool_id, next.host, next.role, next.enabled, next.note, current.id); id = current.id; }
+ else id = (await q.insert.run(next.tool_id, next.host, next.role, next.enabled, next.note, userId)).lastInsertRowid;
+ return { row: await q.byId.get(id), demoted };
});
});
// ── public ───────────────────────────────────────────────
// ── public ───────────────────────────────────────────────
@@ -173,11 +147,11 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -173,11 +147,11 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
}
}
next();
next();
});
});
- publicRouter.get('/', (_req, res) => {
+ publicRouter.get('/', async (_req, res) => {
try {
try {
- const domains = q.publicList.all();
+ const domains = await q.publicList.all();
res.set('Cache-Control', 'public, max-age=60');
res.set('Cache-Control', 'public, max-age=60');
- res.json({ updated: iso(q.lastChange.get().t) || new Date(0).toISOString(), domains });
+ res.json({ updated: iso((await q.lastChange.get()).t) || new Date(0).toISOString(), domains });
} catch (err) {
} catch (err) {
console.error('[Domains] public list:', err.message);
console.error('[Domains] public list:', err.message);
res.status(500).json({ error: 'Could not read the domain registry' });
res.status(500).json({ error: 'Could not read the domain registry' });
@@ -198,7 +172,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -198,7 +172,7 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
try {
try {
const { catalog, source } = await catalogSource.getCatalog();
const { catalog, source } = await catalogSource.getCatalog();
const known = new Set([...catalog.tools.map(t => t.id), ...catalog.families.map(f => f.id)]);
const known = new Set([...catalog.tools.map(t => t.id), ...catalog.families.map(f => f.id)]);
- const rows = q.all.all().map(r => ({ ...present(r), tool_known: source === 'fallback' ? null : known.has(r.tool_id) }));
+ const rows = (await q.all.all()).map(r => ({ ...present(r), tool_known: source === 'fallback' ? null : known.has(r.tool_id) }));
res.json({ ok: true, domains: rows, catalog_source: source });
res.json({ ok: true, domains: rows, catalog_source: source });
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -225,8 +199,8 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -225,8 +199,8 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
try {
try {
const v = await validate(req.body || {}, null);
const v = await validate(req.body || {}, null);
if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });
if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });
- const { row, demoted } = write(v.value, null, req.user.id);
- audit(req, 'tool_domain_create', { id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, demoted, tool_unverified: !v.toolVerified || undefined });
+ const { row, demoted } = await write(v.value, null, req.user.id);
+ await audit(req, 'tool_domain_create', { id: row.id, tool_id: row.tool_id, host: row.host, role: row.role, enabled: !!row.enabled, demoted, tool_unverified: !v.toolVerified || undefined });
onChange();
onChange();
res.status(201).json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });
res.status(201).json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });
} catch (err) {
} catch (err) {
@@ -237,14 +211,14 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -237,14 +211,14 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
adminRouter.put('/:id', async (req, res) => {
adminRouter.put('/:id', async (req, res) => {
try {
try {
- const current = q.byId.get(Number(req.params.id));
+ const current = await q.byId.get(Number(req.params.id));
if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });
if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });
const v = await validate(req.body || {}, current);
const v = await validate(req.body || {}, current);
if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });
if (v.error) return res.status(v.status).json({ ok: false, error: v.error, warnings: v.warnings });
- const { row, demoted } = write(v.value, current, req.user.id);
+ const { row, demoted } = await write(v.value, current, req.user.id);
const changed = {};
const changed = {};
for (const k of ['tool_id', 'host', 'role', 'enabled', 'note']) if (String(current[k] ?? '') !== String(row[k] ?? '')) changed[k] = { from: current[k], to: row[k] };
for (const k of ['tool_id', 'host', 'role', 'enabled', 'note']) if (String(current[k] ?? '') !== String(row[k] ?? '')) changed[k] = { from: current[k], to: row[k] };
- audit(req, 'tool_domain_update', { id: row.id, host: row.host, tool_id: row.tool_id, changed, demoted, tool_unverified: !v.toolVerified || undefined });
+ await audit(req, 'tool_domain_update', { id: row.id, host: row.host, tool_id: row.tool_id, changed, demoted, tool_unverified: !v.toolVerified || undefined });
onChange();
onChange();
res.json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });
res.json({ ok: true, domain: present(row), demoted, tool_verified: v.toolVerified, warnings: v.warnings });
} catch (err) {
} catch (err) {
@@ -253,12 +227,12 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
@@ -253,12 +227,12 @@ function createDomainRoutes(db, requireAuth, opts = {}) {
}
}
});
});
- adminRouter.delete('/:id', (req, res) => {
+ adminRouter.delete('/:id', async (req, res) => {
try {
try {
- const current = q.byId.get(Number(req.params.id));
+ const current = await q.byId.get(Number(req.params.id));
if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });
if (!current) return res.status(404).json({ ok: false, error: 'Domain not found' });
- q.remove.run(current.id);
- audit(req, 'tool_domain_delete', { id: current.id, tool_id: current.tool_id, host: current.host, role: current.role });
+ await q.remove.run(current.id);
+ await audit(req, 'tool_domain_delete', { id: current.id, tool_id: current.tool_id, host: current.host, role: current.role });
onChange();
onChange();
res.json({ ok: true });
res.json({ ok: true });
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
} catch (err) { res.status(500).json({ ok: false, error: err.message }); }
@@ -58,24 +58,18 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
@@ -58,24 +58,18 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
};
};
}
}
-function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
+async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
- // Renamed from chrome_cache/chrome_hits (2026-09-24): carry the old tables over once.
- for (const [from, to] of [['chrome_cache', 'frame_cache'], ['chrome_hits', 'frame_hits']]) {
- const has = (t) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t));
- if (has(from) && !has(to)) db.exec(`ALTER TABLE ${from} RENAME TO ${to}`);
- }
- db.exec('CREATE TABLE IF NOT EXISTS frame_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)');
- // Anonymous page-view counts per host and day, sent by the shared navbar. No user, no IP, no path.
- db.exec('CREATE TABLE IF NOT EXISTS frame_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host))');
- const bump = db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (date('now'), ?, 1) ON CONFLICT(day, host) DO UPDATE SET hits = hits + 1");
- const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM frame_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };
- const save = (k, v) => db.prepare('INSERT INTO frame_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));
-
- const savedRank = load('rank') || {};
+ // frame_cache and frame_hits are migrations/NNNN_*.sql (plan T2). The old chrome_cache/chrome_hits
+ // rename was a SQLite-only step and is not repeated here.
+ const bump = db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (substring(ov_now(), 1, 10), ?, 1) ON CONFLICT(day, host) DO UPDATE SET hits = frame_hits.hits + 1");
+ const load = async (k) => { try { const r = await db.prepare('SELECT value, updated_at FROM frame_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };
+ const save = async (k, v) => await db.prepare('INSERT INTO frame_cache (key, value, updated_at) VALUES (?, ?, ov_now()) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = ov_now()').run(k, JSON.stringify(v));
+
+ const savedRank = await load('rank') || {};
let rank = savedRank.value || { scores: {}, tools: [] };
let rank = savedRank.value || { scores: {}, tools: [] };
let rankedAt = savedRank.value ? savedRank.at || null : null;
let rankedAt = savedRank.value ? savedRank.at || null : null;
- let copy = (load('copy') || {}).value || { sites: {} };
+ let copy = (await load('copy') || {}).value || { sites: {} };
let version = Date.now();
let version = Date.now();
const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
@@ -92,7 +86,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -92,7 +86,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
// Everything else: the navbar's page-view beacon, summed per site over 7 days.
// Everything else: the navbar's page-view beacon, summed per site over 7 days.
let tools = rank.tools || [];
let tools = rank.tools || [];
try {
try {
- const rows = db.prepare("SELECT host, SUM(hits) AS n FROM frame_hits WHERE day >= date('now', '-7 days') GROUP BY host").all();
+ const rows = await db.prepare("SELECT host, SUM(hits) AS n FROM frame_hits WHERE day >= substring(datetime('now', '-7 days'), 1, 10) GROUP BY host").all();
const perSite = {}; const perTool = new Map();
const perSite = {}; const perTool = new Map();
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
const toolOfHost = new Map();
const toolOfHost = new Map();
@@ -108,11 +102,11 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -108,11 +102,11 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
} catch { /* */ }
} catch { /* */ }
// Signed-in history is a second opinion that also covers hosts without the shared navbar.
// Signed-in history is a second opinion that also covers hosts without the shared navbar.
try {
try {
- const bySvc = db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();
+ const bySvc = await db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();
for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
} catch { /* table appears on first history write */ }
} catch { /* table appears on first history write */ }
rank = { scores, tools };
rank = { scores, tools };
- save('rank', rank); version = Date.now(); rankedAt = Date.now();
+ await save('rank', rank); version = Date.now(); rankedAt = Date.now();
}
}
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
@@ -155,7 +149,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -155,7 +149,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy stays
if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy stays
next[site.id] = { blurb, picks };
next[site.id] = { blurb, picks };
}
}
- if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; save('copy', copy); version = Date.now(); }
+ if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; await save('copy', copy); version = Date.now(); }
}
}
function payloadFor(hostname) {
function payloadFor(hostname) {
@@ -189,7 +183,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -189,7 +183,7 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
// POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
// browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
- router.post('/hit', (req, res) => {
+ router.post('/hit', async (req, res) => {
// sendBeacon is a no-cors request: without CORP cross-origin the browser logs every beacon's
// sendBeacon is a no-cors request: without CORP cross-origin the browser logs every beacon's
// (empty) answer as blocked on every page of every site (the count itself was never affected).
// (empty) answer as blocked on every page of every site (the count itself was never affected).
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'no-store' });
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'no-store' });
@@ -200,14 +194,14 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -200,14 +194,14 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
if (!w || now - w[1] > 60_000) recent.set(k, [1, now]); else w[0]++;
if (!w || now - w[1] > 60_000) recent.set(k, [1, now]); else w[0]++;
if (recent.size > 5000) recent.clear();
if (recent.size > 5000) recent.clear();
const known = host && (siteForHost(host) || toolsCatalog.peek().catalog.tools.some(t => t.hosts && (t.hosts.canonical === host || t.hosts.short === host)));
const known = host && (siteForHost(host) || toolsCatalog.peek().catalog.tools.some(t => t.hosts && (t.hosts.canonical === host || t.hosts.short === host)));
- if (known && (recent.get(k) || [0])[0] <= 40 && !/bot|crawl|spider|slurp|headless|preview|monitor|curl|wget|python|node|go-http/i.test(ua)) bump.run(host);
+ if (known && (recent.get(k) || [0])[0] <= 40 && !/bot|crawl|spider|slurp|headless|preview|monitor|curl|wget|python|node|go-http/i.test(ua)) await bump.run(host);
} catch { /* counting must never fail a page */ }
} catch { /* counting must never fail a page */ }
res.status(204).end();
res.status(204).end();
});
});
let timers = [];
let timers = [];
- function start() {
- const rankAge = Date.now() - ((load('rank') || {}).at || 0), copyAge = Date.now() - ((load('copy') || {}).at || 0);
+ async function start() {
+ const rankAge = Date.now() - ((await load('rank') || {}).at || 0), copyAge = Date.now() - ((await load('copy') || {}).at || 0);
const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();
const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();
const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
@@ -1,89 +1,64 @@
@@ -1,89 +1,64 @@
'use strict';
'use strict';
+// ═══════════════════════════════════════════════════════════════
+// grant-admin — make one person an admin from the operator's shell (roadmap §18.2(12)).
+//
+// node server/grant-admin.js --username <username>
+// node server/grant-admin.js --email <email>
+// node server/grant-admin.js --id <userId>
+//
+// The users live in the service's own PostgreSQL (DATABASE_URL, plan T2); run this where Network runs,
+// or with DATABASE_DIRECT_URL/DATABASE_URL pointing at a copy. The change is audited in audit_log.
+// ═══════════════════════════════════════════════════════════════
+const { initDb } = require('./db/database');
-const path = require('path');
-const fs = require('fs');
-const Database = require('better-sqlite3');
-const dotenv = require('dotenv');
+const USAGE = 'Usage: node server/grant-admin.js --username <username> | --email <email> | --id <userId>';
-function loadEnv() {
- const envPath = path.resolve(process.cwd(), '.env');
- if (fs.existsSync(envPath)) {
- const parsed = dotenv.config({ path: envPath });
- if (parsed.error) {
- throw parsed.error;
- }
- return parsed.parsed || {};
+function parseArgs(argv) {
+ const args = {};
+ for (let i = 0; i < argv.length; i += 1) {
+ const a = argv[i];
+ if (a === '--username' && argv[i + 1]) args.username = argv[++i];
+ else if (a === '--email' && argv[i + 1]) args.email = argv[++i];
+ else if (a === '--id' && argv[i + 1]) args.id = argv[++i];
+ else if (a === '--help') args.help = true;
}
}
- return {};
+ return args;
}
}
-function resolveDatabasePath(env) {
- if (env.DB_PATH) {
- return path.resolve(process.cwd(), env.DB_PATH);
- }
- return path.resolve(__dirname, '..', 'data', 'network.db');
-}
-
-function parseArgs() {
- const args = process.argv.slice(2);
- const parsed = {};
- for (let i = 0; i < args.length; i += 1) {
- const arg = args[i];
- if (arg === '--username' && args[i + 1]) {
- parsed.username = args[++i];
- } else if (arg === '--email' && args[i + 1]) {
- parsed.email = args[++i];
- } else if (arg === '--id' && args[i + 1]) {
- parsed.id = args[++i];
- } else if (arg === '--help') {
- parsed.help = true;
- }
- }
- return parsed;
-}
-
-function showUsage() {
- console.log('Usage: node server/grant-admin.js --username <username> | --email <email> | --id <userId>');
- process.exit(1);
-}
-
-function main() {
- const args = parseArgs();
+/**
+ * @returns {number} process exit code: 0 granted, 1 usage, 3 no such user.
+ */
+async function main(argv = process.argv.slice(2), { db: injected, log = console } = {}) {
+ const args = parseArgs(argv);
if (args.help || (!args.username && !args.email && !args.id)) {
if (args.help || (!args.username && !args.email && !args.id)) {
- showUsage();
+ log.error(USAGE);
+ return 1;
}
}
- const env = loadEnv();
- const dbPath = resolveDatabasePath(env);
- if (!fs.existsSync(dbPath)) {
- console.error(`Database file not found at ${dbPath}`);
- process.exit(2);
- }
+ const db = injected || await initDb();
- const db = new Database(dbPath);
- const query = [];
let row;
let row;
-
if (args.id) {
if (args.id) {
- row = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(args.id);
+ row = await db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(args.id);
} else if (args.username) {
} else if (args.username) {
- row = db.prepare('SELECT id, username, email, role FROM users WHERE LOWER(username) = LOWER(?)').get(args.username);
- } else if (args.email) {
- row = db.prepare('SELECT id, username, email, role FROM users WHERE LOWER(email) = LOWER(?)').get(args.email);
+ row = await db.prepare('SELECT id, username, email, role FROM users WHERE lower(username) = lower(?)').get(args.username);
+ } else {
+ row = await db.prepare('SELECT id, username, email, role FROM users WHERE lower(email) = lower(?)').get(args.email);
}
}
if (!row) {
if (!row) {
- console.error('User not found. Please verify username, email, or id.');
- process.exit(3);
+ log.error('User not found. Please verify username, email, or id.');
+ return 3;
}
}
- db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', row.id);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
+ await db.prepare('UPDATE users SET role = ? WHERE id = ?').run('admin', row.id);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
.run(null, 'grant_admin', JSON.stringify({ targetId: row.id, targetUsername: row.username, method: args.id ? 'id' : args.email ? 'email' : 'username' }));
.run(null, 'grant_admin', JSON.stringify({ targetId: row.id, targetUsername: row.username, method: args.id ? 'id' : args.email ? 'email' : 'username' }));
- console.log(`User ${row.username} (id=${row.id}) has been granted admin privileges.`);
- db.close();
- process.exit(0);
+ log.log(`User ${row.username} (id=${row.id}) has been granted admin privileges.`);
+ return 0;
}
}
-main();
+if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
+
+module.exports = { main, parseArgs, USAGE };
@@ -21,31 +21,7 @@ const SERVICE_LABELS = {
@@ -21,31 +21,7 @@ const SERVICE_LABELS = {
vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',
vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',
};
};
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS user_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- service TEXT,
- sub TEXT,
- type TEXT NOT NULL DEFAULT 'page',
- title TEXT NOT NULL,
- url TEXT NOT NULL,
- icon TEXT,
- meta TEXT,
- hits INTEGER NOT NULL DEFAULT 1,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users(id)
- );
- CREATE INDEX IF NOT EXISTS idx_user_history_user_time ON user_history(user_id, updated_at DESC);
- CREATE INDEX IF NOT EXISTS idx_user_history_user_url ON user_history(user_id, url);
- `);
- const cols = db.prepare('PRAGMA table_info(users)').all();
- if (!cols.find(c => c.name === 'history_paused')) {
- try { db.exec('ALTER TABLE users ADD COLUMN history_paused INTEGER DEFAULT 0'); } catch { /* exists */ }
- }
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** Only http(s) URLs on the network's own domains are recorded; everything else is dropped. */
/** Only http(s) URLs on the network's own domains are recorded; everything else is dropped. */
function allowedUrl(raw) {
function allowedUrl(raw) {
@@ -78,8 +54,8 @@ function rowOut(r) {
@@ -78,8 +54,8 @@ function rowOut(r) {
};
};
}
}
-function createHistoryRoutes(db, requireAuth) {
- ensureSchema(db);
+async function createHistoryRoutes(db, requireAuth) {
+ await ensureSchema(db);
const router = express.Router();
const router = express.Router();
router.use(requireAuth);
router.use(requireAuth);
@@ -89,24 +65,24 @@ function createHistoryRoutes(db, requireAuth) {
@@ -89,24 +65,24 @@ function createHistoryRoutes(db, requireAuth) {
next();
next();
});
});
- router.get('/', (req, res) => {
+ router.get('/', async (req, res) => {
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const where = ['user_id = ?']; const params = [req.user.id];
const where = ['user_id = ?']; const params = [req.user.id];
if (req.query.service) { where.push('service = ?'); params.push(String(req.query.service).slice(0, 32)); }
if (req.query.service) { where.push('service = ?'); params.push(String(req.query.service).slice(0, 32)); }
if (req.query.type) { where.push('type = ?'); params.push(String(req.query.type).slice(0, 32)); }
if (req.query.type) { where.push('type = ?'); params.push(String(req.query.type).slice(0, 32)); }
if (req.query.q) { where.push('(title LIKE ? OR url LIKE ?)'); const q = `%${String(req.query.q).slice(0, 80)}%`; params.push(q, q); }
if (req.query.q) { where.push('(title LIKE ? OR url LIKE ?)'); const q = `%${String(req.query.q).slice(0, 80)}%`; params.push(q, q); }
- const rows = db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
- const total = db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;
- const paused = !!db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id)?.history_paused;
- const services = db.prepare('SELECT service, COUNT(*) AS c FROM user_history WHERE user_id = ? GROUP BY service ORDER BY c DESC').all(req.user.id)
+ const rows = await db.prepare(`SELECT * FROM user_history WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
+ const total = (await db.prepare(`SELECT COUNT(*) AS c FROM user_history WHERE ${where.join(' AND ')}`).get(...params))?.c || 0;
+ const paused = !!(await db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id))?.history_paused;
+ const services = (await db.prepare('SELECT service, COUNT(*) AS c FROM user_history WHERE user_id = ? GROUP BY service ORDER BY c DESC').all(req.user.id))
.map(r => ({ service: r.service, label: SERVICE_LABELS[r.service] || r.service, count: r.c }));
.map(r => ({ service: r.service, label: SERVICE_LABELS[r.service] || r.service, count: r.c }));
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.json({ items: rows.map(rowOut), total, paused, services });
res.json({ items: rows.map(rowOut), total, paused, services });
});
});
- router.post('/', (req, res) => {
- const u = db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id);
+ router.post('/', async (req, res) => {
+ const u = await db.prepare('SELECT history_paused FROM users WHERE id = ?').get(req.user.id);
if (u?.history_paused) return res.json({ ok: true, paused: true });
if (u?.history_paused) return res.json({ ok: true, paused: true });
const url = allowedUrl(req.body?.url);
const url = allowedUrl(req.body?.url);
if (!url) return res.status(400).json({ error: 'A URL on the OpenVibe network is required' });
if (!url) return res.status(400).json({ error: 'A URL on the OpenVibe network is required' });
@@ -122,31 +98,31 @@ function createHistoryRoutes(db, requireAuth) {
@@ -122,31 +98,31 @@ function createHistoryRoutes(db, requireAuth) {
const s = JSON.stringify(req.body.meta);
const s = JSON.stringify(req.body.meta);
if (s.length <= 1000) meta = s;
if (s.length <= 1000) meta = s;
}
}
- const recent = db.prepare(`SELECT id FROM user_history WHERE user_id = ? AND url = ? AND updated_at > datetime('now', '-${DEDUPE_WINDOW_MIN} minutes')`).get(req.user.id, url);
+ const recent = await db.prepare(`SELECT id FROM user_history WHERE user_id = ? AND url = ? AND updated_at > datetime('now', '-${DEDUPE_WINDOW_MIN} minutes')`).get(req.user.id, url);
if (recent) {
if (recent) {
- db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(title, recent.id);
+ await db.prepare('UPDATE user_history SET hits = hits + 1, title = ?, updated_at = ov_now() WHERE id = ?').run(title, recent.id);
return res.json({ ok: true, id: recent.id, merged: true });
return res.json({ ok: true, id: recent.id, merged: true });
}
}
- const r = db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
+ const r = await db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, icon, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id')
.run(req.user.id, service, sub, type, title, url, icon, meta);
.run(req.user.id, service, sub, type, title, url, icon, meta);
// Trim: keep the newest MAX_PER_USER rows.
// Trim: keep the newest MAX_PER_USER rows.
- db.prepare(`DELETE FROM user_history WHERE user_id = ? AND id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER})`).run(req.user.id, req.user.id);
+ await db.prepare(`DELETE FROM user_history WHERE user_id = ? AND id NOT IN (SELECT id FROM user_history WHERE user_id = ? ORDER BY updated_at DESC LIMIT ${MAX_PER_USER})`).run(req.user.id, req.user.id);
res.status(201).json({ ok: true, id: r.lastInsertRowid });
res.status(201).json({ ok: true, id: r.lastInsertRowid });
});
});
- router.put('/settings', (req, res) => {
+ router.put('/settings', async (req, res) => {
const paused = req.body?.paused ? 1 : 0;
const paused = req.body?.paused ? 1 : 0;
- db.prepare('UPDATE users SET history_paused = ? WHERE id = ?').run(paused, req.user.id);
+ await db.prepare('UPDATE users SET history_paused = ? WHERE id = ?').run(paused, req.user.id);
res.json({ ok: true, paused: !!paused });
res.json({ ok: true, paused: !!paused });
});
});
- router.delete('/', (req, res) => {
- const r = db.prepare('DELETE FROM user_history WHERE user_id = ?').run(req.user.id);
+ router.delete('/', async (req, res) => {
+ const r = await db.prepare('DELETE FROM user_history WHERE user_id = ?').run(req.user.id);
res.json({ ok: true, deleted: r.changes });
res.json({ ok: true, deleted: r.changes });
});
});
- router.delete('/:id', (req, res) => {
- const r = db.prepare('DELETE FROM user_history WHERE id = ? AND user_id = ?').run(parseInt(req.params.id, 10) || 0, req.user.id);
+ router.delete('/:id', async (req, res) => {
+ const r = await db.prepare('DELETE FROM user_history WHERE id = ? AND user_id = ?').run(parseInt(req.params.id, 10) || 0, req.user.id);
if (!r.changes) return res.status(404).json({ error: 'Not found' });
if (!r.changes) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
res.json({ ok: true });
});
});
@@ -51,68 +51,16 @@ class AccountDataError extends Error {
@@ -51,68 +51,16 @@ class AccountDataError extends Error {
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS account_exports (
- id TEXT PRIMARY KEY,
- user_id INTEGER NOT NULL,
- subject TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),
- expected TEXT NOT NULL DEFAULT '[]',
- requested_at TEXT NOT NULL,
- deadline TEXT NOT NULL,
- ready_at TEXT,
- expires_at TEXT,
- size_bytes INTEGER,
- services TEXT,
- error TEXT
- );
- CREATE INDEX IF NOT EXISTS idx_account_exports_user ON account_exports(user_id, requested_at);
- CREATE TABLE IF NOT EXISTS account_export_parts (
- export_id TEXT NOT NULL,
- service TEXT NOT NULL,
- received_at TEXT NOT NULL,
- files INTEGER NOT NULL,
- bytes INTEGER NOT NULL,
- body TEXT,
- PRIMARY KEY (export_id, service)
- );
- CREATE TABLE IF NOT EXISTS account_deletions (
- id TEXT PRIMARY KEY,
- user_id INTEGER NOT NULL,
- subject TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),
- requested_at TEXT NOT NULL,
- delete_after TEXT NOT NULL,
- cancelled_at TEXT,
- cancelled_by TEXT,
- deleted_at TEXT,
- expected TEXT,
- erased TEXT
- );
- CREATE UNIQUE INDEX IF NOT EXISTS idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';
- CREATE TABLE IF NOT EXISTS account_deletion_confirmations (
- deletion_id TEXT NOT NULL,
- service TEXT NOT NULL,
- received_at TEXT NOT NULL,
- completed_at TEXT NOT NULL,
- erased TEXT NOT NULL,
- retained TEXT NOT NULL,
- PRIMARY KEY (deletion_id, service)
- );
- `);
- const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
- if (!cols.includes('deleted_at')) db.exec('ALTER TABLE users ADD COLUMN deleted_at TEXT');
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
-const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
-const columnsOf = (db, table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
+const tableExists = async (db, name) => !!await db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name);
+const columnsOf = async (db, table) => (await db.prepare('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?').all(table)).map((c) => c.column_name);
/** The services expected to answer: every principal holding the capability for Network. */
/** The services expected to answer: every principal holding the capability for Network. */
-function expectedServices(db, capability) {
- if (!tableExists(db, 'principal_grants')) return [];
- return db.prepare(`SELECT DISTINCT client_id FROM principal_grants WHERE capability = ? AND audience = ? AND revoked_at IS NULL
- AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY client_id`).all(capability, SELF_AUDIENCE).map((r) => r.client_id);
+async function expectedServices(db, capability) {
+ if (!await tableExists(db, 'principal_grants')) return [];
+ return (await db.prepare(`SELECT DISTINCT client_id FROM principal_grants WHERE capability = ? AND audience = ? AND revoked_at IS NULL
+ AND (expires_at IS NULL OR expires_at > ov_now()) ORDER BY client_id`).all(capability, SELF_AUDIENCE)).map((r) => r.client_id);
}
}
function envelope(type, payload, subject, at) {
function envelope(type, payload, subject, at) {
@@ -137,8 +85,8 @@ function assertPerson(user) {
@@ -137,8 +85,8 @@ function assertPerson(user) {
// ── Export ─────────────────────────────────────────────────────
// ── Export ─────────────────────────────────────────────────────
-function exportView(db, row) {
- const parts = db.prepare('SELECT service, files, bytes FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);
+async function exportView(db, row) {
+ const parts = await db.prepare('SELECT service, files, bytes FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);
const got = new Map(parts.map((p) => [p.service, p]));
const got = new Map(parts.map((p) => [p.service, p]));
const built = row.services ? JSON.parse(row.services) : null;
const built = row.services ? JSON.parse(row.services) : null;
const services = built || [...new Set([...JSON.parse(row.expected || '[]'), ...got.keys()])].sort().map((s) => (got.has(s)
const services = built || [...new Set([...JSON.parse(row.expected || '[]'), ...got.keys()])].sort().map((s) => (got.has(s)
@@ -152,12 +100,12 @@ function exportView(db, row) {
@@ -152,12 +100,12 @@ function exportView(db, row) {
}
}
/** The person starts an export → { status: 201|200, body: network.account-export@1 }. */
/** The person starts an export → { status: 201|200, body: network.account-export@1 }. */
-function startExport(db, user, { now = Date.now() } = {}) {
- ensureSchema(db);
+async function startExport(db, user, { now = Date.now() } = {}) {
+ await ensureSchema(db);
assertPerson(user);
assertPerson(user);
- const open = db.prepare("SELECT * FROM account_exports WHERE user_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1").get(user.id);
- if (open) return { status: 200, row: open, body: exportView(db, open) };
- const recent = db.prepare("SELECT requested_at FROM account_exports WHERE user_id = ? AND status != 'failed' AND requested_at > ? ORDER BY requested_at DESC LIMIT 1")
+ const open = await db.prepare("SELECT * FROM account_exports WHERE user_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1").get(user.id);
+ if (open) return { status: 200, row: open, body: await exportView(db, open) };
+ const recent = await db.prepare("SELECT requested_at FROM account_exports WHERE user_id = ? AND status != 'failed' AND requested_at > ? ORDER BY requested_at DESC LIMIT 1")
.get(user.id, iso(now - EXPORT_EVERY_MS));
.get(user.id, iso(now - EXPORT_EVERY_MS));
if (recent) {
if (recent) {
const retry = Math.ceil((Date.parse(recent.requested_at) + EXPORT_EVERY_MS - now) / 1000);
const retry = Math.ceil((Date.parse(recent.requested_at) + EXPORT_EVERY_MS - now) / 1000);
@@ -166,23 +114,23 @@ function startExport(db, user, { now = Date.now() } = {}) {
@@ -166,23 +114,23 @@ function startExport(db, user, { now = Date.now() } = {}) {
const id = `exp_${ids.ulid(now)}`;
const id = `exp_${ids.ulid(now)}`;
const requestedAt = iso(now);
const requestedAt = iso(now);
const deadline = iso(now + EXPORT_DEADLINE_MS);
const deadline = iso(now + EXPORT_DEADLINE_MS);
- const expected = expectedServices(db, CONTRIBUTE);
- db.transaction(() => {
- db.prepare('INSERT INTO account_exports (id, user_id, subject, expected, requested_at, deadline) VALUES (?, ?, ?, ?, ?, ?)')
+ const expected = await expectedServices(db, CONTRIBUTE);
+ await db.tx(async () => {
+ await db.prepare('INSERT INTO account_exports (id, user_id, subject, expected, requested_at, deadline) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, user.id, user.subject_id, JSON.stringify(expected), requestedAt, deadline);
.run(id, user.id, user.subject_id, JSON.stringify(expected), requestedAt, deadline);
- eventRelay.writerFor(db).enqueue(envelope('network.account.export_requested', { export_id: id, subject: user.subject_id, requested_at: requestedAt, deadline }, user.subject_id, requestedAt));
- })();
+ await eventRelay.writerFor(db).enqueue(envelope('network.account.export_requested', { export_id: id, subject: user.subject_id, requested_at: requestedAt, deadline }, user.subject_id, requestedAt));
+ });
kick(db);
kick(db);
- const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(id);
- return { status: 201, row, body: exportView(db, row) };
+ const row = await db.prepare('SELECT * FROM account_exports WHERE id = ?').get(id);
+ return { status: 201, row, body: await exportView(db, row) };
}
}
/** A service's part → network.account-data-receipt@1. */
/** A service's part → network.account-data-receipt@1. */
-function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {
- ensureSchema(db);
+async function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {
+ await ensureSchema(db);
const v = validate('network.account-export-part@1', body);
const v = validate('network.account-export-part@1', body);
if (!v.valid) throw new AccountDataError(400, 'export.invalid_part', JSON.stringify(v.errors).slice(0, 300));
if (!v.valid) throw new AccountDataError(400, 'export.invalid_part', JSON.stringify(v.errors).slice(0, 300));
- const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId) : null;
+ const row = ID_RE.exp.test(String(exportId)) ? await db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId) : null;
if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');
if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');
if (body.subject !== row.subject) throw new AccountDataError(400, 'export.wrong_subject', "the part's subject is not the export's");
if (body.subject !== row.subject) throw new AccountDataError(400, 'export.wrong_subject', "the part's subject is not the export's");
if (row.status !== 'pending' || now > Date.parse(row.deadline)) throw new AccountDataError(409, 'export.closed', 'this export was already built');
if (row.status !== 'pending' || now > Date.parse(row.deadline)) throw new AccountDataError(409, 'export.closed', 'this export was already built');
@@ -191,24 +139,26 @@ function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {
@@ -191,24 +139,26 @@ function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {
const text = JSON.stringify(body);
const text = JSON.stringify(body);
const bytes = Buffer.byteLength(text);
const bytes = Buffer.byteLength(text);
if (bytes > PART_MAX_BYTES) throw new AccountDataError(413, 'export.part_too_large', 'a part is at most 20 MB');
if (bytes > PART_MAX_BYTES) throw new AccountDataError(413, 'export.part_too_large', 'a part is at most 20 MB');
- const had = !!db.prepare('SELECT 1 FROM account_export_parts WHERE export_id = ? AND service = ?').get(row.id, service);
- db.prepare('INSERT OR REPLACE INTO account_export_parts (export_id, service, received_at, files, bytes, body) VALUES (?, ?, ?, ?, ?, ?)')
+ const had = !!await db.prepare('SELECT 1 FROM account_export_parts WHERE export_id = ? AND service = ?').get(row.id, service);
+ await db.prepare('INSERT INTO account_export_parts (export_id, service, received_at, files, bytes, body) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (export_id, service) DO UPDATE SET received_at = excluded.received_at, files = excluded.files, bytes = excluded.bytes, body = excluded.body')
.run(row.id, service, iso(now), body.files.length, bytes, text);
.run(row.id, service, iso(now), body.files.length, bytes, text);
return { id: row.id, service, received_at: iso(now), replaced: had };
return { id: row.id, service, received_at: iso(now), replaced: had };
}
}
/** Rows about a person from one table, secrets left out, newest first, at most ROW_LIMIT. */
/** Rows about a person from one table, secrets left out, newest first, at most ROW_LIMIT. */
-function rowsOf(db, table, where, params, { omit = [] } = {}) {
- if (!tableExists(db, table)) return { rows: [], truncated: false };
- const cols = columnsOf(db, table).filter((c) => !SECRET_COL.test(c) && !omit.includes(c));
+async function rowsOf(db, table, where, params, { omit = [] } = {}) {
+ if (!await tableExists(db, table)) return { rows: [], truncated: false };
+ const cols = (await columnsOf(db, table)).filter((c) => !SECRET_COL.test(c) && !omit.includes(c));
if (!cols.length) return { rows: [], truncated: false };
if (!cols.length) return { rows: [], truncated: false };
- const rows = db.prepare(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${where} ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(...params);
+ // Newest first: the `seq` identity when the table has one (migrations 0003), else the primary key.
+ const order = cols.includes('seq') ? 'ORDER BY seq DESC ' : cols.includes('id') ? 'ORDER BY id DESC ' : '';
+ const rows = await db.prepare(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${where} ${order}LIMIT ${ROW_LIMIT + 1}`).all(...params);
return { rows: rows.slice(0, ROW_LIMIT), truncated: rows.length > ROW_LIMIT };
return { rows: rows.slice(0, ROW_LIMIT), truncated: rows.length > ROW_LIMIT };
}
}
/** Network's own part: { files: [{ name, content }], truncated: [] }. */
/** Network's own part: { files: [{ name, content }], truncated: [] }. */
-function networkPart(db, userId) {
- const u = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
+async function networkPart(db, userId) {
+ const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
const files = [];
const files = [];
const truncated = [];
const truncated = [];
const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
@@ -216,40 +166,40 @@ function networkPart(db, userId) {
@@ -216,40 +166,40 @@ function networkPart(db, userId) {
'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'subject_id'];
'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'subject_id'];
const account = {};
const account = {};
for (const k of keep) if (k in u) account[k] = u[k];
for (const k of keep) if (k in u) account[k] = u[k];
- account.merged_in = tableExists(db, 'subject_aliases')
- ? db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];
+ account.merged_in = await tableExists(db, 'subject_aliases')
+ ? await db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];
add('account.json', account);
add('account.json', account);
- const byUser = (name, table, opts) => { const r = rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
- byUser('sign_in_providers.json', 'linked_accounts');
- byUser('sessions.json', 'user_sessions');
- byUser('oauth_grants.json', 'oauth_tokens');
- byUser('notifications.json', 'notifications');
+ const byUser = async (name, table, opts) => { const r = await rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
+ await byUser('sign_in_providers.json', 'linked_accounts');
+ await byUser('sessions.json', 'user_sessions');
+ await byUser('oauth_grants.json', 'oauth_tokens');
+ await byUser('notifications.json', 'notifications');
add('preferences.json', {
add('preferences.json', {
- display: rowsOf(db, 'user_preferences', 'user_id = ?', [u.id]).rows[0] || null,
- notifications: rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id]).rows,
- effects: rowsOf(db, 'user_effects', 'user_id = ?', [u.id]).rows,
- push_devices: rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] }).rows,
+ display: (await rowsOf(db, 'user_preferences', 'user_id = ?', [u.id])).rows[0] || null,
+ notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
+ effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
+ push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,
});
});
- byUser('history.json', 'user_history');
- add('username_history.json', rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] }).rows);
- byUser('profile_changes.json', 'user_profile_changes');
- byUser('sign_in_log.json', 'ip_log');
- byUser('emails_sent.json', 'email_delivery_log');
- const coins = rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);
- add('opencoins.json', { balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);
- add('modules.json', tableExists(db, 'user_modules') ? db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id)
+ await byUser('history.json', 'user_history');
+ add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);
+ await byUser('profile_changes.json', 'user_profile_changes');
+ await byUser('sign_in_log.json', 'ip_log');
+ await byUser('emails_sent.json', 'email_delivery_log');
+ const coins = await rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);
+ add('opencoins.json', { balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);
+ add('modules.json', await tableExists(db, 'user_modules') ? (await db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id))
.map((m) => { let data = m.data; try { data = JSON.parse(m.data); } catch { /* kept as text */ } return { ...m, data }; }) : []);
.map((m) => { let data = m.data; try { data = JSON.parse(m.data); } catch { /* kept as text */ } return { ...m, data }; }) : []);
- const follows = rowsOf(db, 'user_follows', 'follower_subject = ?', [u.subject_id]);
- const followers = tableExists(db, 'user_follows') ? db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id).n : 0;
+ const follows = await rowsOf(db, 'user_follows', 'follower_subject = ?', [u.subject_id]);
+ const followers = await tableExists(db, 'user_follows') ? (await db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id)).n : 0;
add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);
add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);
- add('blocks.json', rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id]).rows);
+ add('blocks.json', (await rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id])).rows);
add('developer_projects.json', {
add('developer_projects.json', {
- owned: rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id]).rows,
- memberships: rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] }).rows,
+ owned: (await rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id])).rows,
+ memberships: (await rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] })).rows,
});
});
- add('account_merges.json', tableExists(db, 'account_merges')
- ? db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);
- add('moderation.json', rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] }).rows);
+ add('account_merges.json', await tableExists(db, 'account_merges')
+ ? await db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);
+ add('moderation.json', (await rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] })).rows);
return { files, truncated };
return { files, truncated };
}
}
@@ -270,16 +220,16 @@ function readme(row, sections, now) {
@@ -270,16 +220,16 @@ function readme(row, sections, now) {
}
}
/** Build the archive when every expected service answered, at the deadline, or when forced. → true if built. */
/** Build the archive when every expected service answered, at the deadline, or when forced. → true if built. */
-function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify } = {}) {
- ensureSchema(db);
- const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId);
+async function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify } = {}) {
+ await ensureSchema(db);
+ const row = await db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId);
if (!row || row.status !== 'pending') return false;
if (!row || row.status !== 'pending') return false;
const expected = JSON.parse(row.expected || '[]');
const expected = JSON.parse(row.expected || '[]');
- const parts = db.prepare('SELECT service, received_at, files, bytes, body FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);
+ const parts = await db.prepare('SELECT service, received_at, files, bytes, body FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);
const got = new Set(parts.map((p) => p.service));
const got = new Set(parts.map((p) => p.service));
if (!force && now < Date.parse(row.deadline) && !expected.every((s) => got.has(s))) return false;
if (!force && now < Date.parse(row.deadline) && !expected.every((s) => got.has(s))) return false;
try {
try {
- const own = networkPart(db, row.user_id);
+ const own = await networkPart(db, row.user_id);
const sections = [{ service: 'network', status: 'received', files: own.files.length, truncated: own.truncated }];
const sections = [{ service: 'network', status: 'received', files: own.files.length, truncated: own.truncated }];
const entries = own.files.map((f) => ({ name: `network/${f.name}`, data: JSON.stringify(f.content, null, 2) }));
const entries = own.files.map((f) => ({ name: `network/${f.name}`, data: JSON.stringify(f.content, null, 2) }));
for (const p of parts) {
for (const p of parts) {
@@ -297,11 +247,11 @@ function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify
@@ -297,11 +247,11 @@ function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify
const services = sections.map((s) => (s.status === 'received'
const services = sections.map((s) => (s.status === 'received'
? { service: s.service, status: 'received', files: s.files, bytes: s.service === 'network' ? Buffer.byteLength(JSON.stringify(own.files)) : parts.find((p) => p.service === s.service).bytes }
? { service: s.service, status: 'received', files: s.files, bytes: s.service === 'network' ? Buffer.byteLength(JSON.stringify(own.files)) : parts.find((p) => p.service === s.service).bytes }
: { service: s.service, status: 'missing' }));
: { service: s.service, status: 'missing' }));
- db.transaction(() => {
- db.prepare('UPDATE account_exports SET status = ?, ready_at = ?, expires_at = ?, size_bytes = ?, services = ? WHERE id = ?')
+ await db.tx(async () => {
+ await db.prepare('UPDATE account_exports SET status = ?, ready_at = ?, expires_at = ?, size_bytes = ?, services = ? WHERE id = ?')
.run(status, iso(now), iso(now + EXPORT_KEEP_MS), buf.length, JSON.stringify(services), row.id);
.run(status, iso(now), iso(now + EXPORT_KEEP_MS), buf.length, JSON.stringify(services), row.id);
- db.prepare('UPDATE account_export_parts SET body = NULL WHERE export_id = ?').run(row.id);
- })();
+ await db.prepare('UPDATE account_export_parts SET body = NULL WHERE export_id = ?').run(row.id);
+ });
if (notify) {
if (notify) {
try {
try {
notify(row.user_id, { title: 'Your data export is ready', message: status === 'ready' ? 'Download it from your account page within 7 days.' : 'Some services did not answer in time; the archive says which. Download it from your account page within 7 days.' });
notify(row.user_id, { title: 'Your data export is ready', message: status === 'ready' ? 'Download it from your account page within 7 days.' : 'Some services did not answer in time; the archive says which. Download it from your account page within 7 days.' });
@@ -309,15 +259,15 @@ function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify
@@ -309,15 +259,15 @@ function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify
}
}
return true;
return true;
} catch (e) {
} catch (e) {
- db.prepare("UPDATE account_exports SET status = 'failed', error = ? WHERE id = ?").run(String(e.message).slice(0, 300), row.id);
+ await db.prepare("UPDATE account_exports SET status = 'failed', error = ? WHERE id = ?").run(String(e.message).slice(0, 300), row.id);
console.error('[AccountData] export build failed:', row.id, e.message);
console.error('[AccountData] export build failed:', row.id, e.message);
return false;
return false;
}
}
}
}
-function exportFile(db, user, exportId, { dir, now = Date.now() } = {}) {
- ensureSchema(db);
- const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(exportId, user.id) : null;
+async function exportFile(db, user, exportId, { dir, now = Date.now() } = {}) {
+ await ensureSchema(db);
+ const row = ID_RE.exp.test(String(exportId)) ? await db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(exportId, user.id) : null;
if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');
if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');
if (row.status === 'pending') throw new AccountDataError(409, 'export.pending', 'this export is still being gathered');
if (row.status === 'pending') throw new AccountDataError(409, 'export.pending', 'this export is still being gathered');
if (!['ready', 'partial'].includes(row.status) || Date.parse(row.expires_at) < now) throw new AccountDataError(410, 'export.expired', 'this export is no longer available');
if (!['ready', 'partial'].includes(row.status) || Date.parse(row.expires_at) < now) throw new AccountDataError(410, 'export.expired', 'this export is no longer available');
@@ -333,10 +283,10 @@ function deletionView(row) {
@@ -333,10 +283,10 @@ function deletionView(row) {
return out;
return out;
}
}
-function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now() } = {}) {
- ensureSchema(db);
+async function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now() } = {}) {
+ await ensureSchema(db);
assertPerson(user);
assertPerson(user);
- const open = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(user.id);
+ const open = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(user.id);
if (open) return { status: 200, body: deletionView(open) };
if (open) return { status: 200, body: deletionView(open) };
const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
if (String(user.username || '').toLowerCase() === owner) throw new AccountDataError(403, 'deletion.owner', "the owner's account cannot be deleted here");
if (String(user.username || '').toLowerCase() === owner) throw new AccountDataError(403, 'deletion.owner', "the owner's account cannot be deleted here");
@@ -348,123 +298,123 @@ function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now()
@@ -348,123 +298,123 @@ function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now()
throw new AccountDataError(400, 'deletion.confirm_username', 'type your username exactly to confirm');
throw new AccountDataError(400, 'deletion.confirm_username', 'type your username exactly to confirm');
}
}
const id = `del_${ids.ulid(now)}`;
const id = `del_${ids.ulid(now)}`;
- db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')
+ await db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')
.run(id, user.id, user.subject_id, iso(now), iso(now + GRACE_MS));
.run(id, user.id, user.subject_id, iso(now), iso(now + GRACE_MS));
- return { status: 201, body: deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(id)) };
+ return { status: 201, body: deletionView(await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(id)) };
}
}
-function cancelDeletion(db, userId, { by = 'person', now = Date.now() } = {}) {
- ensureSchema(db);
- const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(userId);
+async function cancelDeletion(db, userId, { by = 'person', now = Date.now() } = {}) {
+ await ensureSchema(db);
+ const row = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(userId);
if (!row) return null;
if (!row) return null;
- db.prepare("UPDATE account_deletions SET status = 'cancelled', cancelled_at = ?, cancelled_by = ? WHERE id = ?").run(iso(now), by, row.id);
- return deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(row.id));
+ await db.prepare("UPDATE account_deletions SET status = 'cancelled', cancelled_at = ?, cancelled_by = ? WHERE id = ?").run(iso(now), by, row.id);
+ return deletionView(await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(row.id));
}
}
/** Erase what Network owns for one due deletion, in one transaction, and queue network.account.deleted. → counts. */
/** Erase what Network owns for one due deletion, in one transaction, and queue network.account.deleted. → counts. */
-function erase(db, deletion, { now = Date.now() } = {}) {
+async function erase(db, deletion, { now = Date.now() } = {}) {
const at = iso(now);
const at = iso(now);
const erased = {};
const erased = {};
const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };
const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };
- const run = db.transaction(() => {
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);
+ const run = db.txFn(async () => {
+ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);
if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
const subject = user.subject_id;
const subject = user.subject_id;
- const folded = db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);
- const aliases = tableExists(db, 'subject_aliases') ? db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject).map((r) => r.alias_id) : [];
+ const folded = await db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);
+ const aliases = await tableExists(db, 'subject_aliases') ? (await db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject)).map((r) => r.alias_id) : [];
const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];
const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];
const userIds = [user.id, ...folded.map((f) => f.id)];
const userIds = [user.id, ...folded.map((f) => f.id)];
const inU = `(${userIds.map(() => '?').join(',')})`;
const inU = `(${userIds.map(() => '?').join(',')})`;
const inS = `(${subjects.map(() => '?').join(',')})`;
const inS = `(${subjects.map(() => '?').join(',')})`;
- const del = (table, where, params, key = table) => { if (tableExists(db, table)) count(key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes); };
+ const del = async (table, where, params, key = table) => { if (await tableExists(db, table)) count(key, (await db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params)).changes); };
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
'notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
- 'ip_log', 'email_delivery_log']) del(t, `user_id IN ${inU}`, userIds);
- if (tableExists(db, 'account_merge_intents')) del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
+ 'ip_log', 'email_delivery_log']) await del(t, `user_id IN ${inU}`, userIds);
+ if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
// Notifications this person sent others stay, without their name or picture.
// Notifications this person sent others stay, without their name or picture.
- if (tableExists(db, 'notifications')) db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
- if (tableExists(db, 'analytics_events')) db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
- del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
- del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
- del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
- if (tableExists(db, 'dev_projects')) {
- for (const p of db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {
- const heir = db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')
+ if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
+ if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
+ await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
+ await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
+ await del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
+ if (await tableExists(db, 'dev_projects')) {
+ for (const p of await db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {
+ const heir = await db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')
ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, added_at LIMIT 1`).get(p.id, ...subjects);
ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, added_at LIMIT 1`).get(p.id, ...subjects);
- if (heir) { db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE id = ?').run(heir.subject_id, p.id); count('projects_passed_on', 1); continue; }
- for (const a of db.prepare('SELECT id FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(p.id)) {
- db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);
- db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);
+ if (heir) { await db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE id = ?').run(heir.subject_id, p.id); count('projects_passed_on', 1); continue; }
+ for (const a of await db.prepare('SELECT id FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(p.id)) {
+ await db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);
+ await db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);
}
}
- db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
+ await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
count('projects_archived', 1);
count('projects_archived', 1);
}
}
- del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
+ await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
}
}
- if (tableExists(db, 'account_merges')) db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);
+ if (await tableExists(db, 'account_merges')) await db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
for (const id of userIds) {
for (const id of userIds) {
- const bal = (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
+ const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
if (bal > 0) {
if (bal > 0) {
- wallet.debit(db, { user_id: id, app_id: 'network', amount: bal, reason: 'account_deleted', ref: deletion.id, idempotency_key: `delete:${deletion.id}:${id}` });
+ await wallet.debit(db, { user_id: id, app_id: 'network', amount: bal, reason: 'account_deleted', ref: deletion.id, idempotency_key: `delete:${deletion.id}:${id}` });
count('opencoins_closed', bal);
count('opencoins_closed', bal);
}
}
}
}
// The tombstones: the username is released, the rest cleared; the subject stays (never reused).
// The tombstones: the username is released, the rest cleared; the subject stays (never reused).
- const cols = columnsOf(db, 'users');
+ const cols = await columnsOf(db, 'users');
for (const id of userIds) {
for (const id of userIds) {
- const u = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);
+ const u = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);
const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;
const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;
const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,
const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,
name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };
name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };
const keys = Object.keys(set).filter((k) => cols.includes(k));
const keys = Object.keys(set).filter((k) => cols.includes(k));
- db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);
+ await db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);
count('accounts', 1);
count('accounts', 1);
}
}
- revocation.revokeTokens(db, user.id, { reason: 'account_deleted', actor: { type: 'user', id: subject }, strict: true });
- const expected = expectedServices(db, CONFIRM);
- db.prepare("UPDATE account_deletions SET status = 'deleted', deleted_at = ?, expected = ?, erased = ? WHERE id = ?")
+ await revocation.revokeTokens(db, user.id, { reason: 'account_deleted', actor: { type: 'user', id: subject }, strict: true });
+ const expected = await expectedServices(db, CONFIRM);
+ await db.prepare("UPDATE account_deletions SET status = 'deleted', deleted_at = ?, expected = ?, erased = ? WHERE id = ?")
.run(at, JSON.stringify(expected), JSON.stringify(erased), deletion.id);
.run(at, JSON.stringify(expected), JSON.stringify(erased), deletion.id);
const payload = { deletion_id: deletion.id, subject, requested_at: deletion.requested_at, deleted_at: at };
const payload = { deletion_id: deletion.id, subject, requested_at: deletion.requested_at, deleted_at: at };
const others = subjects.filter((s) => s !== subject).slice(0, 50);
const others = subjects.filter((s) => s !== subject).slice(0, 50);
if (others.length) payload.aliases = others;
if (others.length) payload.aliases = others;
- eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));
+ await eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));
});
});
- run();
+ await run();
kick(db);
kick(db);
return erased;
return erased;
}
}
/** A service confirms a deletion → network.account-data-receipt@1. */
/** A service confirms a deletion → network.account-data-receipt@1. */
-function receiveConfirmation(db, deletionId, service, body, { now = Date.now() } = {}) {
- ensureSchema(db);
+async function receiveConfirmation(db, deletionId, service, body, { now = Date.now() } = {}) {
+ await ensureSchema(db);
const v = validate('network.account-deletion-confirmation@1', body);
const v = validate('network.account-deletion-confirmation@1', body);
if (!v.valid) throw new AccountDataError(400, 'deletion.invalid_confirmation', JSON.stringify(v.errors).slice(0, 300));
if (!v.valid) throw new AccountDataError(400, 'deletion.invalid_confirmation', JSON.stringify(v.errors).slice(0, 300));
- const row = ID_RE.del.test(String(deletionId)) ? db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId) : null;
+ const row = ID_RE.del.test(String(deletionId)) ? await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId) : null;
if (!row) throw new AccountDataError(404, 'deletion.not_found', 'no such deletion');
if (!row) throw new AccountDataError(404, 'deletion.not_found', 'no such deletion');
if (body.subject !== row.subject) throw new AccountDataError(400, 'deletion.wrong_subject', "the confirmation's subject is not the deletion's");
if (body.subject !== row.subject) throw new AccountDataError(400, 'deletion.wrong_subject', "the confirmation's subject is not the deletion's");
if (row.status !== 'deleted') throw new AccountDataError(409, 'deletion.not_deleted', 'this deletion has not happened');
if (row.status !== 'deleted') throw new AccountDataError(409, 'deletion.not_deleted', 'this deletion has not happened');
- const had = !!db.prepare('SELECT 1 FROM account_deletion_confirmations WHERE deletion_id = ? AND service = ?').get(row.id, service);
- db.prepare('INSERT OR REPLACE INTO account_deletion_confirmations (deletion_id, service, received_at, completed_at, erased, retained) VALUES (?, ?, ?, ?, ?, ?)')
+ const had = !!await db.prepare('SELECT 1 FROM account_deletion_confirmations WHERE deletion_id = ? AND service = ?').get(row.id, service);
+ await db.prepare('INSERT INTO account_deletion_confirmations (deletion_id, service, received_at, completed_at, erased, retained) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (deletion_id, service) DO UPDATE SET received_at = excluded.received_at, completed_at = excluded.completed_at, erased = excluded.erased, retained = excluded.retained')
.run(row.id, service, iso(now), body.completed_at, JSON.stringify(body.erased), JSON.stringify(body.retained || {}));
.run(row.id, service, iso(now), body.completed_at, JSON.stringify(body.erased), JSON.stringify(body.retained || {}));
return { id: row.id, service, received_at: iso(now), replaced: had };
return { id: row.id, service, received_at: iso(now), replaced: had };
}
}
/** The periodic sweep: build exports past their deadline, delete expired archives, carry out due deletions. */
/** The periodic sweep: build exports past their deadline, delete expired archives, carry out due deletions. */
-function sweep(db, { dir, now = Date.now(), notify } = {}) {
- ensureSchema(db);
+async function sweep(db, { dir, now = Date.now(), notify } = {}) {
+ await ensureSchema(db);
const out = { built: 0, expired: 0, deleted: 0 };
const out = { built: 0, expired: 0, deleted: 0 };
- for (const r of db.prepare("SELECT id FROM account_exports WHERE status = 'pending' AND deadline <= ?").all(iso(now))) {
- if (maybeBuild(db, r.id, { dir, now, force: true, notify })) out.built++;
+ for (const r of await db.prepare("SELECT id FROM account_exports WHERE status = 'pending' AND deadline <= ?").all(iso(now))) {
+ if (await maybeBuild(db, r.id, { dir, now, force: true, notify })) out.built++;
}
}
- for (const r of db.prepare("SELECT id FROM account_exports WHERE status IN ('ready', 'partial') AND expires_at <= ?").all(iso(now))) {
+ for (const r of await db.prepare("SELECT id FROM account_exports WHERE status IN ('ready', 'partial') AND expires_at <= ?").all(iso(now))) {
try { fs.rmSync(path.join(dir, `${r.id}.zip`), { force: true }); } catch { /* gone already */ }
try { fs.rmSync(path.join(dir, `${r.id}.zip`), { force: true }); } catch { /* gone already */ }
- db.prepare("UPDATE account_exports SET status = 'expired' WHERE id = ?").run(r.id);
+ await db.prepare("UPDATE account_exports SET status = 'expired' WHERE id = ?").run(r.id);
out.expired++;
out.expired++;
}
}
- for (const d of db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' AND delete_after <= ?").all(iso(now))) {
- try { const e = erase(db, d, { now }); out.deleted++; console.log(`[AccountData] deleted account ${d.subject} (${d.id}): ${JSON.stringify(e)}`); }
+ for (const d of await db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' AND delete_after <= ?").all(iso(now))) {
+ try { const e = await erase(db, d, { now }); out.deleted++; console.log(`[AccountData] deleted account ${d.subject} (${d.id}): ${JSON.stringify(e)}`); }
catch (e) { console.error('[AccountData] deletion failed:', d.id, e.message); }
catch (e) { console.error('[AccountData] deletion failed:', d.id, e.message); }
}
}
return out;
return out;
@@ -487,28 +437,28 @@ function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir,
@@ -487,28 +437,28 @@ function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir,
const me = express.Router();
const me = express.Router();
me.use(express.json({ limit: '8kb' }));
me.use(express.json({ limit: '8kb' }));
const noStore = (res) => res.set('Cache-Control', 'private, no-store');
const noStore = (res) => res.set('Cache-Control', 'private, no-store');
- me.post('/export', requireAuth, (req, res) => {
+ me.post('/export', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
try {
try {
- const out = startExport(db, req.user);
- if (out.status === 201) setImmediate(() => maybeBuild(db, out.row.id, { dir, notify }));
+ const out = await startExport(db, req.user);
+ if (out.status === 201) setImmediate(async () => await maybeBuild(db, out.row.id, { dir, notify }));
noStore(res).status(out.status).json(out.body);
noStore(res).status(out.status).json(out.body);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
- me.get('/export', requireAuth, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
- const rows = db.prepare('SELECT * FROM account_exports WHERE user_id = ? ORDER BY requested_at DESC LIMIT 5').all(req.user.id);
- noStore(res).json({ exports: rows.map((r) => exportView(db, r)) });
+ me.get('/export', requireAuth, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
+ const rows = await db.prepare('SELECT * FROM account_exports WHERE user_id = ? ORDER BY requested_at DESC LIMIT 5').all(req.user.id);
+ noStore(res).json({ exports: (await Promise.all(rows.map(async (r) => await exportView(db, r)))) });
});
});
- me.get('/export/:id', requireAuth, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
- const row = ID_RE.exp.test(req.params.id) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id) : null;
+ me.get('/export/:id', requireAuth, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
+ const row = ID_RE.exp.test(req.params.id) ? await db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id) : null;
if (!row) return res.status(404).json({ error: 'export.not_found' });
if (!row) return res.status(404).json({ error: 'export.not_found' });
- noStore(res).json(exportView(db, row));
+ noStore(res).json(await exportView(db, row));
});
});
- me.get('/export/:id/download', requireAuth, (req, res) => {
+ me.get('/export/:id/download', requireAuth, async (req, res) => {
try {
try {
- const { row, file } = exportFile(req.app.locals.db, req.user, req.params.id, { dir });
+ const { row, file } = await exportFile(req.app.locals.db, req.user, req.params.id, { dir });
if (!fs.existsSync(file)) return res.status(410).json({ error: 'export.expired' });
if (!fs.existsSync(file)) return res.status(410).json({ error: 'export.expired' });
const day = row.ready_at.slice(0, 10);
const day = row.ready_at.slice(0, 10);
noStore(res).set('Content-Type', 'application/zip')
noStore(res).set('Content-Type', 'application/zip')
@@ -517,59 +467,61 @@ function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir,
@@ -517,59 +467,61 @@ function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir,
fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
- me.get('/deletion', requireAuth, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
- const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(req.user.id);
+ me.get('/deletion', requireAuth, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
+ const row = await db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(req.user.id);
noStore(res).json({ deletion: row ? deletionView(row) : null });
noStore(res).json({ deletion: row ? deletionView(row) : null });
});
});
- me.post('/deletion', requireAuth, (req, res) => {
+ me.post('/deletion', requireAuth, async (req, res) => {
try {
try {
- const out = scheduleDeletion(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.confirm_username);
+ const out = await scheduleDeletion(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.confirm_username);
noStore(res).status(out.status).json(out.body);
noStore(res).status(out.status).json(out.body);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
- me.delete('/deletion', requireAuth, (req, res) => {
- const out = cancelDeletion(req.app.locals.db, req.user.id, { by: 'person' });
+ me.delete('/deletion', requireAuth, async (req, res) => {
+ const out = await cancelDeletion(req.app.locals.db, req.user.id, { by: 'person' });
if (!out) return res.status(404).json({ error: 'deletion.not_scheduled' });
if (!out) return res.status(404).json({ error: 'deletion.not_scheduled' });
noStore(res).json(out);
noStore(res).json(out);
});
});
const internal = express.Router();
const internal = express.Router();
const service = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');
const service = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');
- internal.post('/account-exports/:id/parts', contributeGuard, express.json({ limit: '21mb' }), (req, res) => {
+ internal.post('/account-exports/:id/parts', contributeGuard, express.json({ limit: '21mb' }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
try {
try {
- const receipt = receivePart(db, req.params.id, service(req), req.body);
- setImmediate(() => maybeBuild(db, receipt.id, { dir, notify }));
+ const receipt = await receivePart(db, req.params.id, service(req), req.body);
+ // Build before answering: a part that completes the export must read back as ready, not race the
+ // caller (the deadline path still builds in sweep).
+ await maybeBuild(db, receipt.id, { dir, notify });
res.json(receipt);
res.json(receipt);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
- internal.post('/account-deletions/:id/confirmations', confirmGuard, express.json({ limit: '64kb' }), (req, res) => {
- try { res.json(receiveConfirmation(req.app.locals.db, req.params.id, service(req), req.body)); } catch (e) { sendError(res, e); }
+ internal.post('/account-deletions/:id/confirmations', confirmGuard, express.json({ limit: '64kb' }), async (req, res) => {
+ try { res.json(await receiveConfirmation(req.app.locals.db, req.params.id, service(req), req.body)); } catch (e) { sendError(res, e); }
});
});
const admin = express.Router();
const admin = express.Router();
admin.use(express.json({ limit: '8kb' }));
admin.use(express.json({ limit: '8kb' }));
const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.users.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.users.manage required' }));
const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.users.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.users.manage required' }));
- admin.get('/', requireAuth, staffOnly, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
- const rows = db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' OR deleted_at > ? ORDER BY requested_at DESC LIMIT 200").all(iso(Date.now() - 90 * 86400000));
+ admin.get('/', requireAuth, staffOnly, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
+ const rows = await db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' OR deleted_at > ? ORDER BY requested_at DESC LIMIT 200").all(iso(Date.now() - 90 * 86400000));
const confirmed = db.prepare('SELECT service FROM account_deletion_confirmations WHERE deletion_id = ?');
const confirmed = db.prepare('SELECT service FROM account_deletion_confirmations WHERE deletion_id = ?');
noStore(res).json({
noStore(res).json({
- deletions: rows.map((r) => {
- const got = confirmed.all(r.id).map((c) => c.service);
+ deletions: (await Promise.all(rows.map(async (r) => {
+ const got = (await confirmed.all(r.id)).map((c) => c.service);
return { ...deletionView(r), subject: r.subject, confirmed: got, outstanding: JSON.parse(r.expected || '[]').filter((s) => !got.includes(s)) };
return { ...deletionView(r), subject: r.subject, confirmed: got, outstanding: JSON.parse(r.expected || '[]').filter((s) => !got.includes(s)) };
- }),
+ }))),
});
});
});
});
- admin.post('/:id/cancel', requireAuth, staffOnly, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
+ admin.post('/:id/cancel', requireAuth, staffOnly, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
const reason = String((req.body && req.body.reason) || '').trim();
const reason = String((req.body && req.body.reason) || '').trim();
if (reason.length < 10) return res.status(400).json({ error: 'deletion.reason_required', detail: 'a written reason (10 characters or more)' });
if (reason.length < 10) return res.status(400).json({ error: 'deletion.reason_required', detail: 'a written reason (10 characters or more)' });
- const row = ID_RE.del.test(req.params.id) ? db.prepare("SELECT * FROM account_deletions WHERE id = ? AND status = 'scheduled'").get(req.params.id) : null;
+ const row = ID_RE.del.test(req.params.id) ? await db.prepare("SELECT * FROM account_deletions WHERE id = ? AND status = 'scheduled'").get(req.params.id) : null;
if (!row) return res.status(404).json({ error: 'deletion.not_scheduled' });
if (!row) return res.status(404).json({ error: 'deletion.not_scheduled' });
- const out = cancelDeletion(db, row.user_id, { by: `staff:${req.user.subject_id || req.user.id}` });
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'account_deletion_cancelled', JSON.stringify({ deletion_id: row.id, subject: row.subject, reason: reason.slice(0, 500) }));
+ const out = await cancelDeletion(db, row.user_id, { by: `staff:${req.user.subject_id || req.user.id}` });
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'account_deletion_cancelled', JSON.stringify({ deletion_id: row.id, subject: row.subject, reason: reason.slice(0, 500) }));
res.json(out);
res.json(out);
});
});
return { me, internal, admin };
return { me, internal, admin };
@@ -43,55 +43,20 @@ class MergeError extends Error {
@@ -43,55 +43,20 @@ class MergeError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS subject_aliases (
- alias_id TEXT PRIMARY KEY,
- subject_id TEXT NOT NULL,
- merge_id TEXT NOT NULL,
- merged_at TEXT NOT NULL,
- merged_by TEXT NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_subject_aliases_subject ON subject_aliases(subject_id);
- CREATE TABLE IF NOT EXISTS account_merges (
- id TEXT PRIMARY KEY,
- from_subject TEXT NOT NULL UNIQUE,
- into_subject TEXT NOT NULL,
- from_user_id INTEGER NOT NULL,
- into_user_id INTEGER NOT NULL,
- initiated_by TEXT NOT NULL CHECK (initiated_by IN ('person', 'staff')),
- actor_subject TEXT NOT NULL,
- reason TEXT,
- moved TEXT NOT NULL,
- pre_state TEXT,
- merged_at TEXT NOT NULL,
- split_until TEXT NOT NULL,
- reduced_at TEXT
- );
- CREATE TABLE IF NOT EXISTS account_merge_intents (
- id TEXT PRIMARY KEY,
- into_user_id INTEGER NOT NULL,
- created_at INTEGER NOT NULL,
- expires_at INTEGER NOT NULL,
- used_at INTEGER
- );
- `);
- const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
- if (!cols.includes('merged_into')) db.exec('ALTER TABLE users ADD COLUMN merged_into INTEGER');
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */
/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */
-function survivorOf(db, subjectId) {
+async function survivorOf(db, subjectId) {
try {
try {
- const a = db.prepare('SELECT subject_id FROM subject_aliases WHERE alias_id = ?').get(String(subjectId || ''));
+ const a = await db.prepare('SELECT subject_id FROM subject_aliases WHERE alias_id = ?').get(String(subjectId || ''));
return a ? a.subject_id : subjectId;
return a ? a.subject_id : subjectId;
} catch { return subjectId; } // before the first merge the table may not exist
} catch { return subjectId; } // before the first merge the table may not exist
}
}
/** A user row, or the survivor's when it was merged (signing in to a folded-in account signs in to the survivor). */
/** A user row, or the survivor's when it was merged (signing in to a folded-in account signs in to the survivor). */
-function effectiveUser(db, user) {
+async function effectiveUser(db, user) {
if (!user || !user.merged_into) return user;
if (!user || !user.merged_into) return user;
- return db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(user.merged_into) || user;
}
}
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
@@ -114,41 +79,41 @@ function buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, ac
@@ -114,41 +79,41 @@ function buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, ac
* per user); a row whose key the survivor already holds is dropped, or with keepOnClash left on the folded-in
* per user); a row whose key the survivor already holds is dropped, or with keepOnClash left on the folded-in
* account (a linked provider: signing in with it still lands on the survivor through merged_into).
* account (a linked provider: signing in with it still lands on the survivor through merged_into).
*/
*/
-function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {
+async function moveByUserId(db, table, column, fromId, intoId, keyCols = null, { keepOnClash = false } = {}) {
let moved = 0; let dropped = 0;
let moved = 0; let dropped = 0;
- if (!tableExists(db, table)) return { moved, dropped };
- const rows = db.prepare(`SELECT rowid AS _rid, * FROM ${table} WHERE ${column} = ?`).all(fromId);
+ if (!await tableExists(db, table)) return { moved, dropped };
+ const rows = await db.prepare(`SELECT seq AS _rid, * FROM ${table} WHERE ${column} = ?`).all(fromId);
for (const r of rows) {
for (const r of rows) {
if (keyCols) {
if (keyCols) {
const where = keyCols.map((k) => ` AND ${k} = ?`).join('');
const where = keyCols.map((k) => ` AND ${k} = ?`).join('');
- const clash = db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));
+ const clash = await db.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ?${where}`).get(intoId, ...keyCols.map((k) => r[k]));
if (clash) {
if (clash) {
- if (!keepOnClash) db.prepare(`DELETE FROM ${table} WHERE rowid = ?`).run(r._rid);
+ if (!keepOnClash) await db.prepare(`DELETE FROM ${table} WHERE seq = ?`).run(r._rid);
dropped++;
dropped++;
continue;
continue;
}
}
}
}
- db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`).run(intoId, r._rid);
+ await db.prepare(`UPDATE ${table} SET ${column} = ? WHERE seq = ?`).run(intoId, r._rid);
moved++;
moved++;
}
}
return { moved, dropped };
return { moved, dropped };
}
}
-function tableExists(db, name) {
- return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
+async function tableExists(db, name) {
+ return !!await db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?").get(name);
}
}
/**
/**
* Fold `fromUser` into `intoUser`. opts: { initiatedBy: 'person'|'staff', actorSubject, reason, now }.
* Fold `fromUser` into `intoUser`. opts: { initiatedBy: 'person'|'staff', actorSubject, reason, now }.
* → network.account-merge-result@1. Throws MergeError.
* → network.account-merge-result@1. Throws MergeError.
*/
*/
-function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {
- ensureSchema(db);
+async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, reason = null, now = Date.now() } = {}) {
+ await ensureSchema(db);
if (!fromUser || !intoUser) throw new MergeError(404, 'merge.unknown_account', 'no such account');
if (!fromUser || !intoUser) throw new MergeError(404, 'merge.unknown_account', 'no such account');
if (fromUser.id === intoUser.id) throw new MergeError(400, 'merge.same_account', 'an account cannot be merged into itself');
if (fromUser.id === intoUser.id) throw new MergeError(400, 'merge.same_account', 'an account cannot be merged into itself');
const from = fromUser.subject_id; const into = intoUser.subject_id;
const from = fromUser.subject_id; const into = intoUser.subject_id;
if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new MergeError(400, 'merge.no_subject', 'both accounts need a subject');
if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new MergeError(400, 'merge.no_subject', 'both accounts need a subject');
- const done = db.prepare('SELECT * FROM account_merges WHERE from_subject = ?').get(from);
+ const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ?').get(from);
if (done) {
if (done) {
if (done.into_subject !== into) throw new MergeError(409, 'merge.already_merged', 'that account was already merged into another');
if (done.into_subject !== into) throw new MergeError(409, 'merge.already_merged', 'that account was already merged into another');
return resultOf(done, true);
return resultOf(done, true);
@@ -163,75 +128,75 @@ function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, r
@@ -163,75 +128,75 @@ function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubject, r
const mergedAt = iso(now);
const mergedAt = iso(now);
const splitUntil = iso(now + SPLIT_DAYS * 86400000);
const splitUntil = iso(now + SPLIT_DAYS * 86400000);
const actor = actorSubject || into;
const actor = actorSubject || into;
- const run = db.transaction(() => {
+ const run = db.txFn(async () => {
const pre = {
const pre = {
user: { ...fromUser, password_hash: undefined },
user: { ...fromUser, password_hash: undefined },
- linked_accounts: db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),
- sessions: db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id).map((r) => r.id),
- balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,
- projects: tableExists(db, 'dev_projects') ? db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from).map((r) => r.id) : [],
- modules: db.prepare('SELECT namespace, revision FROM user_modules WHERE subject_id = ?').all(from),
+ linked_accounts: await db.prepare('SELECT id, service, service_user_id FROM linked_accounts WHERE user_id = ?').all(fromUser.id),
+ sessions: (await db.prepare('SELECT id FROM user_sessions WHERE user_id = ?').all(fromUser.id)).map((r) => r.id),
+ balance: (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(fromUser.id) || { balance: 0 }).balance,
+ projects: await tableExists(db, 'dev_projects') ? (await db.prepare('SELECT id FROM dev_projects WHERE owner_subject = ?').all(from)).map((r) => r.id) : [],
+ modules: await db.prepare('SELECT namespace, revision FROM user_modules WHERE subject_id = ?').all(from),
};
};
const moved = { providers: 0, sessions: 0, oauth_grants: 0, projects: 0, coins: 0, modules: 0 };
const moved = { providers: 0, sessions: 0, oauth_grants: 0, projects: 0, coins: 0, modules: 0 };
const dropped = {};
const dropped = {};
const note = (k, r) => { if (r.dropped) dropped[k] = (dropped[k] || 0) + r.dropped; return r.moved; };
const note = (k, r) => { if (r.dropped) dropped[k] = (dropped[k] || 0) + r.dropped; return r.moved; };
- moved.providers = note('providers', moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));
- moved.sessions = note('sessions', moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));
- moved.oauth_grants = moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id).moved
- + moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id).moved;
- if (tableExists(db, 'dev_projects')) {
- moved.projects = db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from).changes;
- for (const m of db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {
- const has = db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);
- if (has) { db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(m.project_id, from); dropped.project_members = (dropped.project_members || 0) + 1; }
- else db.prepare('UPDATE dev_project_members SET subject_id = ? WHERE project_id = ? AND subject_id = ?').run(into, m.project_id, from);
+ moved.providers = note('providers', await moveByUserId(db, 'linked_accounts', 'user_id', fromUser.id, intoUser.id, ['service'], { keepOnClash: true }));
+ moved.sessions = note('sessions', await moveByUserId(db, 'user_sessions', 'user_id', fromUser.id, intoUser.id));
+ moved.oauth_grants = (await moveByUserId(db, 'oauth_tokens', 'user_id', fromUser.id, intoUser.id)).moved
+ + (await moveByUserId(db, 'oauth_codes', 'user_id', fromUser.id, intoUser.id)).moved;
+ if (await tableExists(db, 'dev_projects')) {
+ moved.projects = (await db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE owner_subject = ?').run(into, from)).changes;
+ for (const m of await db.prepare('SELECT project_id FROM dev_project_members WHERE subject_id = ?').all(from)) {
+ const has = await db.prepare('SELECT 1 FROM dev_project_members WHERE project_id = ? AND subject_id = ?').get(m.project_id, into);
+ if (has) { await db.prepare('DELETE FROM dev_project_members WHERE project_id = ? AND subject_id = ?').run(m.project_id, from); dropped.project_members = (dropped.project_members || 0) + 1; }
+ else await db.prepare('UPDATE dev_project_members SET subject_id = ? WHERE project_id = ? AND subject_id = ?').run(into, m.project_id, from);
}
}
}
}
// OpenCoins: loyalty (ADR-012), moved by Network itself, one ledger entry per side, once per merge.
// OpenCoins: loyalty (ADR-012), moved by Network itself, one ledger entry per side, once per merge.
if (pre.balance > 0) {
if (pre.balance > 0) {
- wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });
- wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });
+ await wallet.debit(db, { user_id: fromUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:out` });
+ await wallet.credit(db, { user_id: intoUser.id, app_id: 'network', amount: pre.balance, reason: 'account_merge', ref: mergeId, idempotency_key: `merge:${mergeId}:in` });
moved.coins = pre.balance;
moved.coins = pre.balance;
}
}
- const mods = modules.onSubjectMerged(db, { from, into });
+ const mods = await modules.onSubjectMerged(db, { from, into });
moved.modules = mods.moved + (mods.filled || 0);
moved.modules = mods.moved + (mods.filled || 0);
// Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.
// Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.
- if (tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? follows.onSubjectMerged(db, { from, into }).dropped : 0;
- if (tableExists(db, 'user_blocks')) {
+ if (await tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? (await follows.onSubjectMerged(db, { from, into })).dropped : 0;
+ if (await tableExists(db, 'user_blocks')) {
for (const col of ['blocker_subject', 'blocked_subject']) {
for (const col of ['blocker_subject', 'blocked_subject']) {
const other = col === 'blocker_subject' ? 'blocked_subject' : 'blocker_subject';
const other = col === 'blocker_subject' ? 'blocked_subject' : 'blocker_subject';
- for (const b of db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {
- const clash = b.o === into || db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
- if (clash) db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
- else db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
+ for (const b of await db.prepare(`SELECT ${other} AS o FROM user_blocks WHERE ${col} = ?`).all(from)) {
+ const clash = b.o === into || await db.prepare(`SELECT 1 FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).get(into, b.o);
+ if (clash) await db.prepare(`DELETE FROM user_blocks WHERE ${col} = ? AND ${other} = ?`).run(from, b.o);
+ else await db.prepare(`UPDATE user_blocks SET ${col} = ? WHERE ${col} = ? AND ${other} = ?`).run(into, from, b.o);
}
}
}
}
}
}
- moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);
- moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);
- note('preferences', moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));
- note('preferences', moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));
- note('effects', moveByUserId(db, 'user_effects', 'user_id', fromUser.id, intoUser.id, ['effect_type', 'effect_id']));
+ await moveByUserId(db, 'notifications', 'user_id', fromUser.id, intoUser.id);
+ await moveByUserId(db, 'push_subscriptions', 'user_id', fromUser.id, intoUser.id);
+ note('preferences', await moveByUserId(db, 'notification_preferences', 'user_id', fromUser.id, intoUser.id, ['category']));
+ note('preferences', await moveByUserId(db, 'user_preferences', 'user_id', fromUser.id, intoUser.id, []));
+ note('effects', await moveByUserId(db, 'user_effects', 'user_id', fromUser.id, intoUser.id, ['effect_type', 'effect_id']));
// The alias (and any alias of the folded-in subject now points at the survivor: aliases stay flat).
// The alias (and any alias of the folded-in subject now points at the survivor: aliases stay flat).
- db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);
- db.prepare('INSERT INTO subject_aliases (alias_id, subject_id, merge_id, merged_at, merged_by) VALUES (?, ?, ?, ?, ?)').run(from, into, mergeId, mergedAt, actor);
+ await db.prepare('UPDATE subject_aliases SET subject_id = ? WHERE subject_id = ?').run(into, from);
+ await db.prepare('INSERT INTO subject_aliases (alias_id, subject_id, merge_id, merged_at, merged_by) VALUES (?, ?, ?, ?, ?)').run(from, into, mergeId, mergedAt, actor);
// The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason
// The folded-in account: merged, its tokens revoked and announced (network.user.token_valid_after, reason
// account_merged: sites close its sockets). Its sessions already belong to the survivor.
// account_merged: sites close its sockets). Its sessions already belong to the survivor.
- db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);
- revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });
- db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)
+ await db.prepare('UPDATE users SET merged_into = ? WHERE id = ?').run(intoUser.id, fromUser.id);
+ await revocation.revokeTokens(db, fromUser.id, { reason: 'account_merged', actor: { type: 'user', id: actor }, strict: true });
+ await db.prepare(`INSERT INTO account_merges (id, from_subject, into_subject, from_user_id, into_user_id, initiated_by, actor_subject, reason, moved, pre_state, merged_at, split_until)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);
.run(mergeId, from, into, fromUser.id, intoUser.id, initiatedBy, actor, reason, JSON.stringify({ ...moved, dropped }), JSON.stringify(pre), mergedAt, splitUntil);
if (initiatedBy === 'staff') {
if (initiatedBy === 'staff') {
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
.run(null, 'account_merge', JSON.stringify({ merge_id: mergeId, from, into, actor, reason }));
.run(null, 'account_merge', JSON.stringify({ merge_id: mergeId, from, into, actor, reason }));
}
}
- eventRelay.writerFor(db).enqueue(buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));
- return db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);
+ await eventRelay.writerFor(db).enqueue(db, buildEvent({ mergeId, from, into, mergedAt, splitUntil, initiatedBy, actorSubject: actor }));
+ return await db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);
});
});
- const row = run();
+ const row = await run();
try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
return resultOf(row, false);
return resultOf(row, false);
}
}
@@ -244,35 +209,35 @@ function resultOf(row, replayed) {
@@ -244,35 +209,35 @@ function resultOf(row, replayed) {
}
}
/** After 30 days a merge record keeps only the alias facts: its pre-merge state is dropped. */
/** After 30 days a merge record keeps only the alias facts: its pre-merge state is dropped. */
-function reduceExpired(db, { now = Date.now() } = {}) {
- ensureSchema(db);
- return db.prepare('UPDATE account_merges SET pre_state = NULL, reduced_at = ? WHERE reduced_at IS NULL AND split_until < ?').run(iso(now), iso(now)).changes;
+async function reduceExpired(db, { now = Date.now() } = {}) {
+ await ensureSchema(db);
+ return (await db.prepare('UPDATE account_merges SET pre_state = NULL, reduced_at = ? WHERE reduced_at IS NULL AND split_until < ?').run(iso(now), iso(now))).changes;
}
}
-function createIntent(db, intoUser, { now = Date.now() } = {}) {
- ensureSchema(db);
+async function createIntent(db, intoUser, { now = Date.now() } = {}) {
+ await ensureSchema(db);
const id = `mgi_${ids.ulid(now)}`;
const id = `mgi_${ids.ulid(now)}`;
- db.prepare('INSERT INTO account_merge_intents (id, into_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(id, intoUser.id, now, now + INTENT_TTL_MS);
+ await db.prepare('INSERT INTO account_merge_intents (id, into_user_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(id, intoUser.id, now, now + INTENT_TTL_MS);
return { intent: id, expires_at: iso(now + INTENT_TTL_MS) };
return { intent: id, expires_at: iso(now + INTENT_TTL_MS) };
}
}
/** The person's merge: the caller is the account being folded in (freshly signed in), the intent names the survivor. */
/** The person's merge: the caller is the account being folded in (freshly signed in), the intent names the survivor. */
-function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {
- ensureSchema(db);
+async function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {
+ await ensureSchema(db);
const authTime = Number(claims && claims.auth_time);
const authTime = Number(claims && claims.auth_time);
if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {
if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {
throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');
throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');
}
}
- const intent = db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));
+ const intent = await db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));
if (!intent || intent.expires_at < now) throw new MergeError(410, 'merge.intent_expired', 'that merge request expired: start again from the account that stays');
if (!intent || intent.expires_at < now) throw new MergeError(410, 'merge.intent_expired', 'that merge request expired: start again from the account that stays');
- const into = db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);
+ const into = await db.prepare('SELECT * FROM users WHERE id = ?').get(intent.into_user_id);
if (intent.used_at) {
if (intent.used_at) {
- const done = db.prepare('SELECT * FROM account_merges WHERE from_subject = ? AND into_user_id = ?').get(caller.subject_id, intent.into_user_id);
+ const done = await db.prepare('SELECT * FROM account_merges WHERE from_subject = ? AND into_user_id = ?').get(caller.subject_id, intent.into_user_id);
if (done) return resultOf(done, true);
if (done) return resultOf(done, true);
throw new MergeError(410, 'merge.intent_used', 'that merge request was already used');
throw new MergeError(410, 'merge.intent_used', 'that merge request was already used');
}
}
- const out = merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });
- db.prepare('UPDATE account_merge_intents SET used_at = ? WHERE id = ?').run(now, intent.id);
+ const out = await merge(db, caller, into, { initiatedBy: 'person', actorSubject: into && into.subject_id, now });
+ await db.prepare('UPDATE account_merge_intents SET used_at = ? WHERE id = ?').run(now, intent.id);
return out;
return out;
}
}
@@ -286,37 +251,37 @@ function sendError(res, e) {
@@ -286,37 +251,37 @@ function sendError(res, e) {
function routers({ requireAuth, staffClaims }) {
function routers({ requireAuth, staffClaims }) {
const me = express.Router();
const me = express.Router();
me.use(express.json({ limit: '8kb' }));
me.use(express.json({ limit: '8kb' }));
- me.post('/merge/intents', requireAuth, (req, res) => {
+ me.post('/merge/intents', requireAuth, async (req, res) => {
if (req.user.merged_into) return res.status(409).json({ error: 'merge.already_merged' });
if (req.user.merged_into) return res.status(409).json({ error: 'merge.already_merged' });
- res.status(201).json(createIntent(req.app.locals.db, req.user));
+ res.status(201).json(await createIntent(req.app.locals.db, req.user));
});
});
- me.post('/merge', requireAuth, (req, res) => {
- try { res.json(mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }
+ me.post('/merge', requireAuth, async (req, res) => {
+ try { res.json(await mergeWithIntent(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.intent)); } catch (e) { sendError(res, e); }
});
});
- me.get('/merges', requireAuth, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
- const rows = db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);
+ me.get('/merges', requireAuth, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
+ const rows = await db.prepare('SELECT * FROM account_merges WHERE into_user_id = ? ORDER BY merged_at DESC LIMIT 50').all(req.user.id);
res.set('Cache-Control', 'private, no-store').json({ merges: rows.map((r) => resultOf(r, false)) });
res.set('Cache-Control', 'private, no-store').json({ merges: rows.map((r) => resultOf(r, false)) });
});
});
const admin = express.Router();
const admin = express.Router();
admin.use(express.json({ limit: '8kb' }));
admin.use(express.json({ limit: '8kb' }));
- admin.post('/', requireAuth, (req, res) => {
+ admin.post('/', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
const b = req.body || {};
const b = req.body || {};
const reason = String(b.reason || '').trim();
const reason = String(b.reason || '').trim();
if (reason.length < 10) return res.status(400).json({ error: 'merge.reason_required', detail: 'a staff merge needs a written reason (10 characters or more)' });
if (reason.length < 10) return res.status(400).json({ error: 'merge.reason_required', detail: 'a staff merge needs a written reason (10 characters or more)' });
- const byRef = (ref) => (SUBJECT_RE.test(String(ref || '')) ? db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(String(ref || '')));
+ const byRef = async (ref) => (SUBJECT_RE.test(String(ref || '')) ? await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(ref) : await db.prepare('SELECT * FROM users WHERE lower(username) = lower(?)').get(String(ref || '')));
try {
try {
- const out = merge(db, byRef(b.from), byRef(b.into), { initiatedBy: 'staff', actorSubject: req.user.subject_id, reason: reason.slice(0, 500) });
+ const out = await merge(db, await byRef(b.from), await byRef(b.into), { initiatedBy: 'staff', actorSubject: req.user.subject_id, reason: reason.slice(0, 500) });
res.json(out);
res.json(out);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
- admin.get('/', requireAuth, (req, res) => {
- const db = req.app.locals.db; ensureSchema(db);
+ admin.get('/', requireAuth, async (req, res) => {
+ const db = req.app.locals.db; await ensureSchema(db);
if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
if (!staff.can(staffClaims(req.user), 'staff.identity.merge')) return res.status(403).json({ error: 'forbidden', detail: 'staff.identity.merge required' });
- const rows = db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();
+ const rows = await db.prepare('SELECT id, from_subject, into_subject, initiated_by, actor_subject, reason, moved, merged_at, split_until, reduced_at FROM account_merges ORDER BY merged_at DESC LIMIT 200').all();
res.set('Cache-Control', 'private, no-store').json({ merges: rows });
res.set('Cache-Control', 'private, no-store').json({ merges: rows });
});
});
return { me, admin };
return { me, admin };
@@ -37,34 +37,22 @@ class BlockError extends Error {
@@ -37,34 +37,22 @@ class BlockError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS user_blocks (
- blocker_subject TEXT NOT NULL,
- blocked_subject TEXT NOT NULL,
- active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
- revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- PRIMARY KEY (blocker_subject, blocked_subject),
- CHECK (blocker_subject <> blocked_subject)
- );
- CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_subject, active);`);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** A person who can block or be blocked: a users row with a usr_ subject that is not a guest. */
/** A person who can block or be blocked: a users row with a usr_ subject that is not a guest. */
-const personRow = (db, where, value) => db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);
+const personRow = async (db, where, value) => await db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);
/** The person a path segment names: a usr_ subject, or a current username (any case). */
/** The person a path segment names: a usr_ subject, or a current username (any case). */
-function findTarget(db, key) {
+async function findTarget(db, key) {
const k = String(key || '').trim();
const k = String(key || '').trim();
- if (SUBJECT_RE.test(k)) return personRow(db, 'subject_id = ?', k) || null;
+ if (SUBJECT_RE.test(k)) return await personRow(db, 'subject_id = ?', k) || null;
if (/^gst_/.test(k)) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');
if (/^gst_/.test(k)) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');
if (!NAME_RE.test(k)) return null;
if (!NAME_RE.test(k)) return null;
- return personRow(db, 'username = ? COLLATE NOCASE', k) || null;
+ return await personRow(db, 'lower(username) = lower(?)', k) || null;
}
}
-function isBlocked(db, blockerSubject, blockedSubject) {
- return !!db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(String(blockerSubject), String(blockedSubject));
+async function isBlocked(db, blockerSubject, blockedSubject) {
+ return !!await db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ? AND active = 1').get(String(blockerSubject), String(blockedSubject));
}
}
/** The envelope for one change; throws if it would not match the contract (a bug, never input). */
/** The envelope for one change; throws if it would not match the contract (a bug, never input). */
@@ -86,38 +74,38 @@ function buildEnvelope({ blocker, blocked, active, revision, at }) {
@@ -86,38 +74,38 @@ function buildEnvelope({ blocker, blocked, active, revision, at }) {
* Set (blocker, blocked) to `active`, in one transaction with its event. Both must be usr_ subjects
* Set (blocker, blocked) to `active`, in one transaction with its event. Both must be usr_ subjects
* (the caller checked who they are). → { changed, active, revision, at }
* (the caller checked who they are). → { changed, active, revision, at }
*/
*/
-function setBlock(db, blocker, blocked, active) {
+async function setBlock(db, blocker, blocked, active) {
if (!SUBJECT_RE.test(String(blocker)) || !SUBJECT_RE.test(String(blocked))) throw new BlockError(400, 'blocks.bad_subject', 'blocks are between usr_ subjects');
if (!SUBJECT_RE.test(String(blocker)) || !SUBJECT_RE.test(String(blocked))) throw new BlockError(400, 'blocks.bad_subject', 'blocks are between usr_ subjects');
if (blocker === blocked) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');
if (blocker === blocked) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');
- return db.transaction(() => {
- const prev = db.prepare('SELECT active, revision, updated_at FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?').get(blocker, blocked);
+ return await db.tx(async () => {
+ const prev = await db.prepare('SELECT active, revision, updated_at FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?').get(blocker, blocked);
if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };
if (prev && !!prev.active === !!active) return { changed: false, active: !!active, revision: prev.revision, at: prev.updated_at };
if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };
if (!prev && !active) return { changed: false, active: false, revision: 0, at: null };
if (active) {
if (active) {
- const n = db.prepare('SELECT COUNT(*) AS n FROM user_blocks WHERE blocker_subject = ? AND active = 1').get(blocker).n;
+ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_blocks WHERE blocker_subject = ? AND active = 1').get(blocker)).n;
if (n >= MAX_ACTIVE) throw new BlockError(409, 'blocks.limit', `at most ${MAX_ACTIVE} people can be blocked`);
if (n >= MAX_ACTIVE) throw new BlockError(409, 'blocks.limit', `at most ${MAX_ACTIVE} people can be blocked`);
}
}
const at = new Date().toISOString();
const at = new Date().toISOString();
const revision = prev ? prev.revision + 1 : 1;
const revision = prev ? prev.revision + 1 : 1;
if (prev) {
if (prev) {
- db.prepare('UPDATE user_blocks SET active = ?, revision = ?, updated_at = ? WHERE blocker_subject = ? AND blocked_subject = ?')
+ await db.prepare('UPDATE user_blocks SET active = ?, revision = ?, updated_at = ? WHERE blocker_subject = ? AND blocked_subject = ?')
.run(active ? 1 : 0, revision, at, blocker, blocked);
.run(active ? 1 : 0, revision, at, blocker, blocked);
} else {
} else {
- db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')
+ await db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject, active, revision, created_at, updated_at) VALUES (?, ?, 1, 1, ?, ?)')
.run(blocker, blocked, at, at);
.run(blocker, blocked, at, at);
}
}
- eventRelay.writerFor(db).enqueue(buildEnvelope({ blocker, blocked, active, revision, at }));
+ await eventRelay.writerFor(db).enqueue(buildEnvelope({ blocker, blocked, active, revision, at }));
return { changed: true, active: !!active, revision, at };
return { changed: true, active: !!active, revision, at };
- })();
+ });
}
}
function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
/** Who `subject` blocked (with names, newest first), for the person's own list. */
/** Who `subject` blocked (with names, newest first), for the person's own list. */
-function listFor(db, subject) {
- return db.prepare(`SELECT b.blocked_subject, b.revision, b.updated_at, u.username, u.display_name, u.avatar_url
+async function listFor(db, subject) {
+ return (await db.prepare(`SELECT b.blocked_subject, b.revision, b.updated_at, u.username, u.display_name, u.avatar_url
FROM user_blocks b LEFT JOIN users u ON u.subject_id = b.blocked_subject
FROM user_blocks b LEFT JOIN users u ON u.subject_id = b.blocked_subject
- WHERE b.blocker_subject = ? AND b.active = 1 ORDER BY b.updated_at DESC, b.blocked_subject`).all(String(subject))
+ WHERE b.blocker_subject = ? AND b.active = 1 ORDER BY b.updated_at DESC, b.blocked_subject`).all(String(subject)))
.map((r) => ({
.map((r) => ({
subject: r.blocked_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,
subject: r.blocked_subject, username: r.username || null, display_name: r.username ? (r.display_name || r.username) : null,
avatar_url: r.avatar_url || null, blocked_at: r.updated_at, revision: r.revision,
avatar_url: r.avatar_url || null, blocked_at: r.updated_at, revision: r.revision,
@@ -125,19 +113,19 @@ function listFor(db, subject) {
@@ -125,19 +113,19 @@ function listFor(db, subject) {
}
}
/** What a service needs: who `subject` blocked and who blocked them (active blocks only). */
/** What a service needs: who `subject` blocked and who blocked them (active blocks only). */
-function edgesOf(db, subject) {
+async function edgesOf(db, subject) {
const s = String(subject);
const s = String(subject);
return {
return {
subject: s,
subject: s,
- blocks: db.prepare('SELECT blocked_subject AS s FROM user_blocks WHERE blocker_subject = ? AND active = 1 ORDER BY blocked_subject').all(s).map((r) => r.s),
- blocked_by: db.prepare('SELECT blocker_subject AS s FROM user_blocks WHERE blocked_subject = ? AND active = 1 ORDER BY blocker_subject').all(s).map((r) => r.s),
+ blocks: (await db.prepare('SELECT blocked_subject AS s FROM user_blocks WHERE blocker_subject = ? AND active = 1 ORDER BY blocked_subject').all(s)).map((r) => r.s),
+ blocked_by: (await db.prepare('SELECT blocker_subject AS s FROM user_blocks WHERE blocked_subject = ? AND active = 1 ORDER BY blocker_subject').all(s)).map((r) => r.s),
};
};
}
}
/** The signed-in person as a blocker, or a BlockError. */
/** The signed-in person as a blocker, or a BlockError. */
-function meOf(db, user) {
+async function meOf(db, user) {
if (!user || user.is_anon) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');
if (!user || user.is_anon) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');
- const sid = require('./subjects').ensureUserSubject(db, user);
+ const sid = await require('./subjects').ensureUserSubject(db, user);
if (!SUBJECT_RE.test(String(sid || ''))) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');
if (!SUBJECT_RE.test(String(sid || ''))) throw new BlockError(403, 'blocks.guest', 'sign in with an account to block people');
return sid;
return sid;
}
}
@@ -147,38 +135,38 @@ const view = (u, out) => ({ subject: u.subject_id, username: u.username, display
@@ -147,38 +135,38 @@ const view = (u, out) => ({ subject: u.subject_id, username: u.username, display
function userRouter(requireAuth) {
function userRouter(requireAuth) {
const router = express.Router();
const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
- const send = (req, res, fn) => {
+ const send = async (req, res, fn) => {
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
- try { return fn(req.app.locals.db); } catch (err) {
+ try { return await fn(req.app.locals.db); } catch (err) {
if (err instanceof BlockError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
if (err instanceof BlockError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Blocks]', err.message);
console.error('[Blocks]', err.message);
return http.sendProblem(res, 500, 'blocks.failed', { detail: 'the block could not be changed', ctx: req.ov });
return http.sendProblem(res, 500, 'blocks.failed', { detail: 'the block could not be changed', ctx: req.ov });
}
}
};
};
- router.get('/', requireAuth, (req, res) => send(req, res, (db) => {
- const me = meOf(db, req.user);
- res.json({ subject: me, blocks: listFor(db, me) });
+ router.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ res.json({ subject: me, blocks: await listFor(db, me) });
}));
}));
- router.put('/:target', requireAuth, (req, res) => send(req, res, (db) => {
- const me = meOf(db, req.user);
- const u = findTarget(db, req.params.target);
+ router.put('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
+ const u = await findTarget(db, req.params.target);
if (!u) throw new BlockError(404, 'blocks.unknown_person', 'no such person');
if (!u) throw new BlockError(404, 'blocks.unknown_person', 'no such person');
if (u.is_anon) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');
if (u.is_anon) throw new BlockError(400, 'blocks.guest', 'guests cannot be blocked');
if (u.subject_id === me) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');
if (u.subject_id === me) throw new BlockError(400, 'blocks.self', 'you cannot block yourself');
- const out = setBlock(db, me, u.subject_id, true);
+ const out = await setBlock(db, me, u.subject_id, true);
if (out.changed) kick(db);
if (out.changed) kick(db);
res.status(out.changed ? 201 : 200).json({ changed: out.changed, block: view(u, out) });
res.status(out.changed ? 201 : 200).json({ changed: out.changed, block: view(u, out) });
}));
}));
- router.delete('/:target', requireAuth, (req, res) => send(req, res, (db) => {
- const me = meOf(db, req.user);
+ router.delete('/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const me = await meOf(db, req.user);
const key = String(req.params.target || '');
const key = String(req.params.target || '');
// A subject can be unblocked even if its account is gone since.
// A subject can be unblocked even if its account is gone since.
- const u = findTarget(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null, avatar_url: null } : null);
- if (!u || !u.subject_id || !isBlocked(db, me, u.subject_id)) throw new BlockError(404, 'blocks.not_blocked', 'that person is not blocked');
- const out = setBlock(db, me, u.subject_id, false);
+ const u = await findTarget(db, key) || (SUBJECT_RE.test(key) ? { subject_id: key, username: null, display_name: null, avatar_url: null } : null);
+ if (!u || !u.subject_id || !await isBlocked(db, me, u.subject_id)) throw new BlockError(404, 'blocks.not_blocked', 'that person is not blocked');
+ const out = await setBlock(db, me, u.subject_id, false);
if (out.changed) kick(db);
if (out.changed) kick(db);
res.json({ changed: out.changed, block: view(u, out) });
res.json({ changed: out.changed, block: view(u, out) });
}));
}));
@@ -188,11 +176,11 @@ function userRouter(requireAuth) {
@@ -188,11 +176,11 @@ function userRouter(requireAuth) {
/** GET /internal/blocks?subject=usr_… (mounted behind principals.guard('network.blocks.read')). */
/** GET /internal/blocks?subject=usr_… (mounted behind principals.guard('network.blocks.read')). */
function internalHandler(db) {
function internalHandler(db) {
- return (req, res) => {
+ return async (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
const subject = String(req.query.subject || '');
const subject = String(req.query.subject || '');
if (!SUBJECT_RE.test(subject)) return http.sendProblem(res, 400, 'blocks.bad_subject', { detail: 'subject must be a usr_ subject id' });
if (!SUBJECT_RE.test(subject)) return http.sendProblem(res, 400, 'blocks.bad_subject', { detail: 'subject must be a usr_ subject id' });
- res.json(edgesOf(db, subject));
+ res.json(await edgesOf(db, subject));
};
};
}
}
@@ -45,41 +45,17 @@ class FollowError extends Error {
@@ -45,41 +45,17 @@ class FollowError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS user_follows (
- follower_subject TEXT NOT NULL,
- target_type TEXT NOT NULL CHECK (target_type IN ('channel')),
- target_id TEXT NOT NULL,
- active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
- notify_email INTEGER NOT NULL DEFAULT 1 CHECK (notify_email IN (0, 1)),
- notify_push INTEGER NOT NULL DEFAULT 1 CHECK (notify_push IN (0, 1)),
- revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
- source TEXT NOT NULL DEFAULT 'network',
- created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
- PRIMARY KEY (follower_subject, target_type, target_id),
- CHECK (follower_subject <> target_id)
- );
- CREATE INDEX IF NOT EXISTS idx_user_follows_target ON user_follows(target_type, target_id, active, created_at);
- CREATE TABLE IF NOT EXISTS follow_import_holds (
- source TEXT NOT NULL,
- follower_ref TEXT NOT NULL,
- target_ref TEXT NOT NULL,
- reason TEXT NOT NULL,
- seen_at TEXT NOT NULL,
- PRIMARY KEY (source, follower_ref, target_ref)
- );`);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
-const personRow = (db, where, value) => db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);
+const personRow = async (db, where, value) => await db.prepare(`SELECT id, subject_id, username, display_name, avatar_url, is_anon FROM users WHERE ${where}`).get(value);
/** The target a path names: a usr_ subject or a current username (any case) → the person row, or null. */
/** The target a path names: a usr_ subject or a current username (any case) → the person row, or null. */
-function findTarget(db, type, key) {
+async function findTarget(db, type, key) {
if (!TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);
if (!TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);
const k = String(key || '').trim();
const k = String(key || '').trim();
- if (SUBJECT_RE.test(k)) return personRow(db, 'subject_id = ?', k) || null;
+ if (SUBJECT_RE.test(k)) return await personRow(db, 'subject_id = ?', k) || null;
if (!NAME_RE.test(k)) return null;
if (!NAME_RE.test(k)) return null;
- return personRow(db, 'username = ? COLLATE NOCASE', k) || null;
+ return await personRow(db, 'lower(username) = lower(?)', k) || null;
}
}
function buildEnvelope({ follower, type, target, active, notifyEmail, notifyPush, revision, at, reason = null, actor = null }) {
function buildEnvelope({ follower, type, target, active, notifyEmail, notifyPush, revision, at, reason = null, actor = null }) {
@@ -105,19 +81,19 @@ function buildEnvelope({ follower, type, target, active, notifyEmail, notifyPush
@@ -105,19 +81,19 @@ function buildEnvelope({ follower, type, target, active, notifyEmail, notifyPush
* opts: notifyEmail/notifyPush (undefined keeps the stored value, else true), reason and actor (deletes),
* opts: notifyEmail/notifyPush (undefined keeps the stored value, else true), reason and actor (deletes),
* emit (false for an import: no event), source, at.
* emit (false for an import: no event), source, at.
*/
*/
-function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush, reason = null, actor = null, emit = true, source = 'network', at: atIn = null } = {}) {
+async function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush, reason = null, actor = null, emit = true, source = 'network', at: atIn = null } = {}) {
if (!SUBJECT_RE.test(String(follower)) || !SUBJECT_RE.test(String(target))) throw new FollowError(400, 'follows.bad_subject', 'follows are between usr_ subjects');
if (!SUBJECT_RE.test(String(follower)) || !SUBJECT_RE.test(String(target))) throw new FollowError(400, 'follows.bad_subject', 'follows are between usr_ subjects');
if (!TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);
if (!TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);
if (follower === target) throw new FollowError(400, 'follows.self', 'you cannot follow yourself');
if (follower === target) throw new FollowError(400, 'follows.self', 'you cannot follow yourself');
- return db.transaction(() => {
- const prev = db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, type, target);
+ return await db.tx(async () => {
+ const prev = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, type, target);
const email = notifyEmail === undefined ? (prev ? !!prev.notify_email : true) : !!notifyEmail;
const email = notifyEmail === undefined ? (prev ? !!prev.notify_email : true) : !!notifyEmail;
const push = notifyPush === undefined ? (prev ? !!prev.notify_push : true) : !!notifyPush;
const push = notifyPush === undefined ? (prev ? !!prev.notify_push : true) : !!notifyPush;
const same = prev && !!prev.active === !!active && (!active || (!!prev.notify_email === email && !!prev.notify_push === push));
const same = prev && !!prev.active === !!active && (!active || (!!prev.notify_email === email && !!prev.notify_push === push));
if (same) return { changed: false, active: !!active, notify_email: email, notify_push: push, revision: prev.revision, created_at: prev.created_at, at: prev.updated_at };
if (same) return { changed: false, active: !!active, notify_email: email, notify_push: push, revision: prev.revision, created_at: prev.created_at, at: prev.updated_at };
if (!prev && !active) return { changed: false, active: false, notify_email: email, notify_push: push, revision: 0, created_at: null, at: null };
if (!prev && !active) return { changed: false, active: false, notify_email: email, notify_push: push, revision: 0, created_at: null, at: null };
if (active && !(prev && prev.active)) {
if (active && !(prev && prev.active)) {
- const n = db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? AND active = 1').get(follower).n;
+ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? AND active = 1').get(follower)).n;
if (n >= MAX_FOLLOWING) throw new FollowError(409, 'follows.limit', `at most ${MAX_FOLLOWING} follows`);
if (n >= MAX_FOLLOWING) throw new FollowError(409, 'follows.limit', `at most ${MAX_FOLLOWING} follows`);
}
}
const at = atIn || new Date().toISOString();
const at = atIn || new Date().toISOString();
@@ -125,32 +101,32 @@ function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush
@@ -125,32 +101,32 @@ function setFollow(db, follower, type, target, active, { notifyEmail, notifyPush
// A follow that starts again starts its "since" again; flag changes keep it.
// A follow that starts again starts its "since" again; flag changes keep it.
const createdAt = prev && prev.active && active ? prev.created_at : at;
const createdAt = prev && prev.active && active ? prev.created_at : at;
if (prev) {
if (prev) {
- db.prepare(`UPDATE user_follows SET active = ?, notify_email = ?, notify_push = ?, revision = ?, source = ?, created_at = ?, updated_at = ?
+ await db.prepare(`UPDATE user_follows SET active = ?, notify_email = ?, notify_push = ?, revision = ?, source = ?, created_at = ?, updated_at = ?
WHERE follower_subject = ? AND target_type = ? AND target_id = ?`)
WHERE follower_subject = ? AND target_type = ? AND target_id = ?`)
.run(active ? 1 : 0, email ? 1 : 0, push ? 1 : 0, revision, source, createdAt, at, follower, type, target);
.run(active ? 1 : 0, email ? 1 : 0, push ? 1 : 0, revision, source, createdAt, at, follower, type, target);
} else {
} else {
- db.prepare(`INSERT INTO user_follows (follower_subject, target_type, target_id, active, notify_email, notify_push, revision, source, created_at, updated_at)
+ await db.prepare(`INSERT INTO user_follows (follower_subject, target_type, target_id, active, notify_email, notify_push, revision, source, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, 1, ?, ?, ?)`).run(follower, type, target, email ? 1 : 0, push ? 1 : 0, source, at, at);
VALUES (?, ?, ?, 1, ?, ?, 1, ?, ?, ?)`).run(follower, type, target, email ? 1 : 0, push ? 1 : 0, source, at, at);
}
}
- if (emit) eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));
+ if (emit) await eventRelay.writerFor(db).enqueue(buildEnvelope({ follower, type, target, active, notifyEmail: email, notifyPush: push, revision, at, reason, actor }));
if (emit && active && !(prev && prev.active) && notifier) {
if (emit && active && !(prev && prev.active) && notifier) {
- try { notifier(db, { follower, type, target, at }); } catch (err) { console.warn('[Follows] follow notification not created:', err.message); }
+ try { await notifier(db, { follower, type, target, at }); } catch (err) { console.warn('[Follows] follow notification not created:', err.message); }
}
}
return { changed: true, active: !!active, notify_email: email, notify_push: push, revision, created_at: active ? createdAt : null, at };
return { changed: true, active: !!active, notify_email: email, notify_push: push, revision, created_at: active ? createdAt : null, at };
- })();
+ });
}
}
function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
-function count(db, type, target) {
- return db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE target_type = ? AND target_id = ? AND active = 1').get(type, target).n;
+async function count(db, type, target) {
+ return (await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE target_type = ? AND target_id = ? AND active = 1').get(type, target)).n;
}
}
/** network.follow-status-result@1 for (type, target), with the viewer's own follow when given. */
/** network.follow-status-result@1 for (type, target), with the viewer's own follow when given. */
-function status(db, type, target, viewer = null) {
- const out = { target_type: type, target_id: target, followers: count(db, type, target) };
+async function status(db, type, target, viewer = null) {
+ const out = { target_type: type, target_id: target, followers: await count(db, type, target) };
if (viewer && SUBJECT_RE.test(viewer)) {
if (viewer && SUBJECT_RE.test(viewer)) {
- const r = db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ? AND active = 1').get(viewer, type, target);
+ const r = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ? AND active = 1').get(viewer, type, target);
out.following = !!r;
out.following = !!r;
if (r) Object.assign(out, { notify_email: !!r.notify_email, notify_push: !!r.notify_push, since: r.created_at });
if (r) Object.assign(out, { notify_email: !!r.notify_email, notify_push: !!r.notify_push, since: r.created_at });
}
}
@@ -167,7 +143,7 @@ function decodeCursor(c) {
@@ -167,7 +143,7 @@ function decodeCursor(c) {
const item = (r) => ({ follower: r.follower_subject, target_type: r.target_type, target_id: r.target_id, notify_email: !!r.notify_email, notify_push: !!r.notify_push, created_at: r.created_at });
const item = (r) => ({ follower: r.follower_subject, target_type: r.target_type, target_id: r.target_id, notify_email: !!r.notify_email, notify_push: !!r.notify_push, created_at: r.created_at });
/** A page of follows (newest first): by follower (what they follow) or by target (who follows it). */
/** A page of follows (newest first): by follower (what they follow) or by target (who follows it). */
-function list(db, { follower = null, type = null, target = null, cursor = null, limit = 50 } = {}) {
+async function list(db, { follower = null, type = null, target = null, cursor = null, limit = 50 } = {}) {
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const n = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const c = decodeCursor(cursor);
const c = decodeCursor(cursor);
const where = ['active = 1'];
const where = ['active = 1'];
@@ -176,7 +152,7 @@ function list(db, { follower = null, type = null, target = null, cursor = null,
@@ -176,7 +152,7 @@ function list(db, { follower = null, type = null, target = null, cursor = null,
if (type) { where.push('target_type = ?'); args.push(type); }
if (type) { where.push('target_type = ?'); args.push(type); }
if (target) { where.push('target_id = ?'); args.push(target); }
if (target) { where.push('target_id = ?'); args.push(target); }
if (c) { where.push('(created_at < ? OR (created_at = ? AND (follower_subject, target_id) < (?, ?)))'); args.push(c.at, c.at, c.a, c.b); }
if (c) { where.push('(created_at < ? OR (created_at = ? AND (follower_subject, target_id) < (?, ?)))'); args.push(c.at, c.at, c.a, c.b); }
- const rows = db.prepare(`SELECT * FROM user_follows WHERE ${where.join(' AND ')} ORDER BY created_at DESC, follower_subject DESC, target_id DESC LIMIT ?`).all(...args, n + 1);
+ const rows = await db.prepare(`SELECT * FROM user_follows WHERE ${where.join(' AND ')} ORDER BY created_at DESC, follower_subject DESC, target_id DESC LIMIT ?`).all(...args, n + 1);
const more = rows.length > n;
const more = rows.length > n;
if (more) rows.pop();
if (more) rows.pop();
return { items: rows.map(item), next_cursor: more ? encodeCursor(rows[rows.length - 1]) : null };
return { items: rows.map(item), next_cursor: more ? encodeCursor(rows[rows.length - 1]) : null };
@@ -186,10 +162,10 @@ function list(db, { follower = null, type = null, target = null, cursor = null,
@@ -186,10 +162,10 @@ function list(db, { follower = null, type = null, target = null, cursor = null,
* An account's follows go with it (both directions), each announced as a delete. Call inside the
* An account's follows go with it (both directions), each announced as a delete. Call inside the
* transaction that removes the account (account deletion, WS-B task 7). → the pairs removed
* transaction that removes the account (account deletion, WS-B task 7). → the pairs removed
*/
*/
-function onSubjectRemoved(db, subject) {
- const rows = db.prepare('SELECT * FROM user_follows WHERE active = 1 AND (follower_subject = ? OR target_id = ?)').all(subject, subject);
+async function onSubjectRemoved(db, subject) {
+ const rows = await db.prepare('SELECT * FROM user_follows WHERE active = 1 AND (follower_subject = ? OR target_id = ?)').all(subject, subject);
for (const r of rows) {
for (const r of rows) {
- setFollow(db, r.follower_subject, r.target_type, r.target_id, false, {
+ await setFollow(db, r.follower_subject, r.target_type, r.target_id, false, {
reason: r.follower_subject === subject ? 'account_removed' : 'target_removed', actor: { type: 'system', id: 'network' },
reason: r.follower_subject === subject ? 'account_removed' : 'target_removed', actor: { type: 'system', id: 'network' },
});
});
}
}
@@ -203,9 +179,9 @@ function onSubjectRemoved(db, subject) {
@@ -203,9 +179,9 @@ function onSubjectRemoved(db, subject) {
* No events (the source system already has these follows); dryRun changes nothing.
* No events (the source system already has these follows); dryRun changes nothing.
* → { imported, unchanged, held: [{ follower_ref, target_ref, reason }] }
* → { imported, unchanged, held: [{ follower_ref, target_ref, reason }] }
*/
*/
-function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new Date().toISOString() } = {}) {
+async function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new Date().toISOString() } = {}) {
const out = { imported: 0, unchanged: 0, held: [] };
const out = { imported: 0, unchanged: 0, held: [] };
- const run = () => {
+ const run = async () => {
for (const r of rows) {
for (const r of rows) {
const follower = subjectOf(r.follower_ref);
const follower = subjectOf(r.follower_ref);
const target = subjectOf(r.target_ref);
const target = subjectOf(r.target_ref);
@@ -215,13 +191,13 @@ function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new
@@ -215,13 +191,13 @@ function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new
else if (follower === target) reason = 'self_follow';
else if (follower === target) reason = 'self_follow';
if (reason) {
if (reason) {
out.held.push({ follower_ref: String(r.follower_ref), target_ref: String(r.target_ref), reason });
out.held.push({ follower_ref: String(r.follower_ref), target_ref: String(r.target_ref), reason });
- if (!dryRun) db.prepare('INSERT OR REPLACE INTO follow_import_holds (source, follower_ref, target_ref, reason, seen_at) VALUES (?, ?, ?, ?, ?)').run(source, String(r.follower_ref), String(r.target_ref), reason, now);
+ if (!dryRun) await db.prepare('INSERT INTO follow_import_holds (source, follower_ref, target_ref, reason, seen_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (source, follower_ref, target_ref) DO UPDATE SET reason = excluded.reason, seen_at = excluded.seen_at').run(source, String(r.follower_ref), String(r.target_ref), reason, now);
continue;
continue;
}
}
- const prev = db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, 'channel', target);
+ const prev = await db.prepare('SELECT active FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, 'channel', target);
if (prev && prev.active) { out.unchanged++; continue; }
if (prev && prev.active) { out.unchanged++; continue; }
if (!dryRun) {
if (!dryRun) {
- setFollow(db, follower, 'channel', target, true, {
+ await setFollow(db, follower, 'channel', target, true, {
notifyEmail: r.notify_email == null ? true : !!r.notify_email, notifyPush: r.notify_push == null ? true : !!r.notify_push,
notifyEmail: r.notify_email == null ? true : !!r.notify_email, notifyPush: r.notify_push == null ? true : !!r.notify_push,
emit: false, source, at: r.created_at ? new Date(r.created_at).toISOString() : now,
emit: false, source, at: r.created_at ? new Date(r.created_at).toISOString() : now,
});
});
@@ -229,81 +205,81 @@ function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new
@@ -229,81 +205,81 @@ function importFollows(db, source, rows, subjectOf, { dryRun = false, now = new
out.imported++;
out.imported++;
}
}
};
};
- if (dryRun) run(); else db.transaction(run)();
+ if (dryRun) await run(); else await db.tx(run);
return out;
return out;
}
}
/** The signed-in person as a follower, or a FollowError. */
/** The signed-in person as a follower, or a FollowError. */
-function meOf(db, user) {
+async function meOf(db, user) {
if (!user || user.is_anon) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');
if (!user || user.is_anon) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');
- const sid = require('./subjects').ensureUserSubject(db, user);
+ const sid = await require('./subjects').ensureUserSubject(db, user);
if (!SUBJECT_RE.test(String(sid || ''))) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');
if (!SUBJECT_RE.test(String(sid || ''))) throw new FollowError(403, 'follows.guest', 'sign in with an account to follow');
return sid;
return sid;
}
}
/** The session user of a request, or null (never refuses: for public reads). */
/** The session user of a request, or null (never refuses: for public reads). */
-function viewerOf(req) {
+async function viewerOf(req) {
const h = req.headers.authorization || '';
const h = req.headers.authorization || '';
const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
if (!token) return null;
if (!token) return null;
try {
try {
- const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
+ const out = await require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
return out && out.user ? out.user : null;
return out && out.user ? out.user : null;
} catch { return null; }
} catch { return null; }
}
}
function routers({ requireAuth, followsGuard }) {
function routers({ requireAuth, followsGuard }) {
- const send = (req, res, fn) => {
+ const send = async (req, res, fn) => {
res.set('Cache-Control', 'private, no-store');
res.set('Cache-Control', 'private, no-store');
- try { return fn(req.app.locals.db); } catch (err) {
+ try { return await fn(req.app.locals.db); } catch (err) {
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Follows]', err.message);
console.error('[Follows]', err.message);
return http.sendProblem(res, 500, 'follows.failed', { detail: 'the follow could not be changed', ctx: req.ov });
return http.sendProblem(res, 500, 'follows.failed', { detail: 'the follow could not be changed', ctx: req.ov });
}
}
};
};
- const targetOrFail = (db, type, key) => {
- const u = findTarget(db, type, key);
+ const targetOrFail = async (db, type, key) => {
+ const u = await findTarget(db, type, key);
if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
if (!u || u.is_anon || !SUBJECT_RE.test(String(u.subject_id || ''))) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
return u;
return u;
};
};
const me = express.Router();
const me = express.Router();
me.use(http.middleware());
me.use(http.middleware());
- me.get('/', requireAuth, (req, res) => send(req, res, (db) => {
- const sid = meOf(db, req.user);
+ me.get('/', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const sid = await meOf(db, req.user);
const type = req.query.type ? String(req.query.type) : null;
const type = req.query.type ? String(req.query.type) : null;
if (type && !TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);
if (type && !TYPES.includes(type)) throw new FollowError(404, 'follows.unknown_type', `target type must be one of ${TYPES.join(', ')}`);
- res.json(list(db, { follower: sid, type, cursor: req.query.cursor, limit: req.query.limit }));
+ res.json(await list(db, { follower: sid, type, cursor: req.query.cursor, limit: req.query.limit }));
}));
}));
- me.put('/:type/:target', requireAuth, express.json({ limit: '4kb' }), (req, res) => send(req, res, (db) => {
- const sid = meOf(db, req.user);
- const t = targetOrFail(db, req.params.type, req.params.target);
+ me.put('/:type/:target', requireAuth, express.json({ limit: '4kb' }), async (req, res) => await send(req, res, async (db) => {
+ const sid = await meOf(db, req.user);
+ const t = await targetOrFail(db, req.params.type, req.params.target);
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
- const out = setFollow(db, sid, req.params.type, t.subject_id, true, {
+ const out = await setFollow(db, sid, req.params.type, t.subject_id, true, {
notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,
notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,
});
});
if (out.changed) kick(db);
if (out.changed) kick(db);
- res.status(out.changed && out.revision === 1 ? 201 : 200).json(status(db, req.params.type, t.subject_id, sid));
+ res.status(out.changed && out.revision === 1 ? 201 : 200).json(await status(db, req.params.type, t.subject_id, sid));
}));
}));
- me.delete('/:type/:target', requireAuth, (req, res) => send(req, res, (db) => {
- const sid = meOf(db, req.user);
+ me.delete('/:type/:target', requireAuth, async (req, res) => await send(req, res, async (db) => {
+ const sid = await meOf(db, req.user);
const key = String(req.params.target || '');
const key = String(req.params.target || '');
// A subject can be unfollowed even if its account is gone since.
// A subject can be unfollowed even if its account is gone since.
- const t = findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);
+ const t = await findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);
if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
- const out = setFollow(db, sid, req.params.type, t.subject_id, false);
+ const out = await setFollow(db, sid, req.params.type, t.subject_id, false);
if (out.changed) kick(db);
if (out.changed) kick(db);
- res.json(status(db, req.params.type, t.subject_id, sid));
+ res.json(await status(db, req.params.type, t.subject_id, sid));
}));
}));
const pub = express.Router();
const pub = express.Router();
pub.use(http.middleware());
pub.use(http.middleware());
- pub.get('/:type/:target', (req, res) => {
+ pub.get('/:type/:target', async (req, res) => {
try {
try {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const t = targetOrFail(db, req.params.type, req.params.target);
- const viewer = viewerOf(req);
- const body = status(db, req.params.type, t.subject_id, viewer && !viewer.is_anon ? viewer.subject_id : null);
+ const t = await targetOrFail(db, req.params.type, req.params.target);
+ const viewer = await viewerOf(req);
+ const body = await status(db, req.params.type, t.subject_id, viewer && !viewer.is_anon ? viewer.subject_id : null);
res.set('Cache-Control', viewer ? 'private, no-store' : 'public, max-age=30').set('Vary', 'Authorization, Cookie').json(body);
res.set('Cache-Control', viewer ? 'private, no-store' : 'public, max-age=30').set('Vary', 'Authorization, Cookie').json(body);
} catch (err) {
} catch (err) {
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
if (err instanceof FollowError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
@@ -311,13 +287,13 @@ function routers({ requireAuth, followsGuard }) {
@@ -311,13 +287,13 @@ function routers({ requireAuth, followsGuard }) {
}
}
});
});
// Who follows a target: its owner (signed in), or a service holding network.follows.read.
// Who follows a target: its owner (signed in), or a service holding network.follows.read.
- pub.get('/:type/:target/followers', (req, res, next) => {
+ pub.get('/:type/:target/followers', async (req, res, next) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
let t;
let t;
- try { t = targetOrFail(db, req.params.type, req.params.target); } catch (err) { return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov }); }
- const answer = () => send(req, res, () => res.json(list(db, { type: req.params.type, target: t.subject_id, cursor: req.query.cursor, limit: req.query.limit })));
- const viewer = viewerOf(req);
- if (viewer && viewer.subject_id === t.subject_id) return answer();
+ try { t = await targetOrFail(db, req.params.type, req.params.target); } catch (err) { return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov }); }
+ const answer = async () => await send(req, res, async () => res.json(await list(db, { type: req.params.type, target: t.subject_id, cursor: req.query.cursor, limit: req.query.limit })));
+ const viewer = await viewerOf(req);
+ if (viewer && viewer.subject_id === t.subject_id) return await answer();
if (viewer) return http.sendProblem(res, 403, 'follows.not_yours', { detail: 'only the channel owner sees who follows it', ctx: req.ov });
if (viewer) return http.sendProblem(res, 403, 'follows.not_yours', { detail: 'only the channel owner sees who follows it', ctx: req.ov });
return followsGuard(req, res, answer);
return followsGuard(req, res, answer);
});
});
@@ -326,32 +302,32 @@ function routers({ requireAuth, followsGuard }) {
@@ -326,32 +302,32 @@ function routers({ requireAuth, followsGuard }) {
// on a person's behalf (network.follow-write-request@1). The follower must be a Network account.
// on a person's behalf (network.follow-write-request@1). The follower must be a Network account.
const internal = express.Router();
const internal = express.Router();
internal.use(http.middleware());
internal.use(http.middleware());
- const followerOrFail = (db, subject) => {
- const u = SUBJECT_RE.test(String(subject || '')) ? personRow(db, 'subject_id = ?', subject) : null;
+ const followerOrFail = async (db, subject) => {
+ const u = SUBJECT_RE.test(String(subject || '')) ? await personRow(db, 'subject_id = ?', subject) : null;
if (!u || u.is_anon) throw new FollowError(404, 'follows.unknown_follower', 'the follower is not a Network account');
if (!u || u.is_anon) throw new FollowError(404, 'follows.unknown_follower', 'the follower is not a Network account');
return u.subject_id;
return u.subject_id;
};
};
- internal.put('/:type/:target', express.json({ limit: '4kb' }), (req, res) => send(req, res, (db) => {
+ internal.put('/:type/:target', express.json({ limit: '4kb' }), async (req, res) => await send(req, res, async (db) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
const v = validate('network.follow-write-request@1', b);
const v = validate('network.follow-write-request@1', b);
if (!v.valid) throw new FollowError(400, 'follows.invalid_request', 'the body does not match network.follow-write-request@1');
if (!v.valid) throw new FollowError(400, 'follows.invalid_request', 'the body does not match network.follow-write-request@1');
- const follower = followerOrFail(db, b.follower);
- const t = targetOrFail(db, req.params.type, req.params.target);
- const out = setFollow(db, follower, req.params.type, t.subject_id, true, {
+ const follower = await followerOrFail(db, b.follower);
+ const t = await targetOrFail(db, req.params.type, req.params.target);
+ const out = await setFollow(db, follower, req.params.type, t.subject_id, true, {
notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,
notifyEmail: typeof b.notify_email === 'boolean' ? b.notify_email : undefined, notifyPush: typeof b.notify_push === 'boolean' ? b.notify_push : undefined,
source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network',
source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network',
});
});
if (out.changed) kick(db);
if (out.changed) kick(db);
- res.status(out.changed && out.revision === 1 ? 201 : 200).json(status(db, req.params.type, t.subject_id, follower));
+ res.status(out.changed && out.revision === 1 ? 201 : 200).json(await status(db, req.params.type, t.subject_id, follower));
}));
}));
- internal.delete('/:type/:target', (req, res) => send(req, res, (db) => {
- const follower = followerOrFail(db, req.query.follower);
+ internal.delete('/:type/:target', async (req, res) => await send(req, res, async (db) => {
+ const follower = await followerOrFail(db, req.query.follower);
const key = String(req.params.target || '');
const key = String(req.params.target || '');
- const t = findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);
+ const t = await findTarget(db, req.params.type, key) || (SUBJECT_RE.test(key) ? { subject_id: key } : null);
if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
if (!t || !t.subject_id) throw new FollowError(404, 'follows.unknown_target', 'no such channel');
- const out = setFollow(db, follower, req.params.type, t.subject_id, false, { source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network' });
+ const out = await setFollow(db, follower, req.params.type, t.subject_id, false, { source: req.principal ? String(req.principal.sub).replace(/^svc:/, '') : 'network' });
if (out.changed) kick(db);
if (out.changed) kick(db);
- res.json(status(db, req.params.type, t.subject_id, follower));
+ res.json(await status(db, req.params.type, t.subject_id, follower));
}));
}));
return { me, pub, internal };
return { me, pub, internal };
@@ -363,20 +339,20 @@ function routers({ requireAuth, followsGuard }) {
@@ -363,20 +339,20 @@ function routers({ requireAuth, followsGuard }) {
* is dropped. No follow events: services repoint on network.subject.merged. Inside the merge's transaction.
* is dropped. No follow events: services repoint on network.subject.merged. Inside the merge's transaction.
* → { moved, dropped }
* → { moved, dropped }
*/
*/
-function onSubjectMerged(db, { from, into }) {
+async function onSubjectMerged(db, { from, into }) {
ensureSchema(db);
ensureSchema(db);
const out = { moved: 0, dropped: 0 };
const out = { moved: 0, dropped: 0 };
- const rows = db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? OR target_id = ?').all(from, from);
+ const rows = await db.prepare('SELECT * FROM user_follows WHERE follower_subject = ? OR target_id = ?').all(from, from);
for (const r of rows) {
for (const r of rows) {
const follower = r.follower_subject === from ? into : r.follower_subject;
const follower = r.follower_subject === from ? into : r.follower_subject;
const target = r.target_id === from ? into : r.target_id;
const target = r.target_id === from ? into : r.target_id;
const clash = follower === target
const clash = follower === target
- || db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);
+ || await db.prepare('SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').get(follower, r.target_type, target);
if (clash) {
if (clash) {
- db.prepare('DELETE FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(r.follower_subject, r.target_type, r.target_id);
+ await db.prepare('DELETE FROM user_follows WHERE follower_subject = ? AND target_type = ? AND target_id = ?').run(r.follower_subject, r.target_type, r.target_id);
out.dropped++;
out.dropped++;
} else {
} else {
- db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = CURRENT_TIMESTAMP WHERE follower_subject = ? AND target_type = ? AND target_id = ?')
+ await db.prepare('UPDATE user_follows SET follower_subject = ?, target_id = ?, updated_at = ov_now() WHERE follower_subject = ? AND target_type = ? AND target_id = ?')
.run(follower, target, r.follower_subject, r.target_type, r.target_id);
.run(follower, target, r.follower_subject, r.target_type, r.target_id);
out.moved++;
out.moved++;
}
}
@@ -30,26 +30,7 @@ class GrantError extends Error {
@@ -30,26 +30,7 @@ class GrantError extends Error {
constructor(status, message) { super(message); this.status = status; }
constructor(status, message) { super(message); this.status = status; }
}
}
-function ensureSchema(db) {
- const cols = new Set(db.prepare('PRAGMA table_info(principal_grants)').all().map((c) => c.name));
- if (!cols.has('expires_at')) db.exec('ALTER TABLE principal_grants ADD COLUMN expires_at DATETIME');
- if (!cols.has('reason')) db.exec('ALTER TABLE principal_grants ADD COLUMN reason TEXT');
- if (!cols.has('revoked_by')) db.exec('ALTER TABLE principal_grants ADD COLUMN revoked_by TEXT');
- db.exec(`CREATE TABLE IF NOT EXISTS principal_grant_changes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- client_id TEXT NOT NULL,
- capability TEXT NOT NULL,
- audience TEXT NOT NULL,
- change TEXT NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),
- namespaces TEXT NOT NULL DEFAULT '[]',
- expires_at TEXT,
- reason TEXT,
- actor TEXT,
- event_id TEXT,
- at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
- );
- CREATE INDEX IF NOT EXISTS idx_grant_changes_client ON principal_grant_changes(client_id, id);`);
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
const nowIso = () => new Date().toISOString();
const nowIso = () => new Date().toISOString();
const sqlNow = () => nowIso().replace('T', ' ').slice(0, 19);
const sqlNow = () => nowIso().replace('T', ' ').slice(0, 19);
@@ -60,7 +41,7 @@ function isoOrNull(v) {
@@ -60,7 +41,7 @@ function isoOrNull(v) {
if (Number.isNaN(d.getTime())) throw new GrantError(400, 'expires_at is not a date');
if (Number.isNaN(d.getTime())) throw new GrantError(400, 'expires_at is not a date');
return d.toISOString();
return d.toISOString();
}
}
-// principal_grants keeps SQLite's 'YYYY-MM-DD HH:MM:SS' (UTC) so it compares with CURRENT_TIMESTAMP.
+// principal_grants keeps SQLite's 'YYYY-MM-DD HH:MM:SS' (UTC) so it compares with ov_now().
const toSql = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : null);
const toSql = (iso) => (iso ? iso.replace('T', ' ').slice(0, 19) : null);
const fromSql = (v) => (v ? new Date(`${String(v).replace(' ', 'T')}Z`).toISOString() : null);
const fromSql = (v) => (v ? new Date(`${String(v).replace(' ', 'T')}Z`).toISOString() : null);
@@ -70,10 +51,10 @@ function stateOf(r, now = sqlNow()) {
@@ -70,10 +51,10 @@ function stateOf(r, now = sqlNow()) {
return 'active';
return 'active';
}
}
-function list(db, { client = null } = {}) {
+async function list(db, { client = null } = {}) {
const rows = client
const rows = client
- ? db.prepare('SELECT * FROM principal_grants WHERE client_id = ? ORDER BY client_id, capability, audience').all(String(client))
- : db.prepare('SELECT * FROM principal_grants ORDER BY client_id, capability, audience').all();
+ ? await db.prepare('SELECT * FROM principal_grants WHERE client_id = ? ORDER BY client_id, capability, audience').all(String(client))
+ : await db.prepare('SELECT * FROM principal_grants ORDER BY client_id, capability, audience').all();
const now = sqlNow();
const now = sqlNow();
return rows.map((r) => ({
return rows.map((r) => ({
client_id: r.client_id, capability: r.capability, audience: r.audience, namespaces: JSON.parse(r.namespaces || '[]'),
client_id: r.client_id, capability: r.capability, audience: r.audience, namespaces: JSON.parse(r.namespaces || '[]'),
@@ -82,16 +63,16 @@ function list(db, { client = null } = {}) {
@@ -82,16 +63,16 @@ function list(db, { client = null } = {}) {
}));
}));
}
}
-function changes(db, { client = null, limit = 100 } = {}) {
+async function changes(db, { client = null, limit = 100 } = {}) {
const n = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500);
const n = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 500);
const rows = client
const rows = client
- ? db.prepare('SELECT * FROM principal_grant_changes WHERE client_id = ? ORDER BY id DESC LIMIT ?').all(String(client), n)
- : db.prepare('SELECT * FROM principal_grant_changes ORDER BY id DESC LIMIT ?').all(n);
+ ? await db.prepare('SELECT * FROM principal_grant_changes WHERE client_id = ? ORDER BY id DESC LIMIT ?').all(String(client), n)
+ : await db.prepare('SELECT * FROM principal_grant_changes ORDER BY id DESC LIMIT ?').all(n);
return rows.map((r) => ({ ...r, namespaces: JSON.parse(r.namespaces || '[]') }));
return rows.map((r) => ({ ...r, namespaces: JSON.parse(r.namespaces || '[]') }));
}
}
/** The audit row and the event, inside the caller's transaction. */
/** The audit row and the event, inside the caller's transaction. */
-function record(db, { client_id, capability, audience, change, namespaces, expires_at, reason, actor }) {
+async function record(db, { client_id, capability, audience, change, namespaces, expires_at, reason, actor }) {
const ms = Date.now();
const ms = Date.now();
const env = {
const env = {
event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
@@ -103,19 +84,19 @@ function record(db, { client_id, capability, audience, change, namespaces, expir
@@ -103,19 +84,19 @@ function record(db, { client_id, capability, audience, change, namespaces, expir
const v = validate('events.event-envelope@1', env);
const v = validate('events.event-envelope@1', env);
const pv = validate(`${EVENT_TYPE}@1`, env.payload);
const pv = validate(`${EVENT_TYPE}@1`, env.payload);
if (!v.valid || !pv.valid) throw new Error(`grants: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
if (!v.valid || !pv.valid) throw new Error(`grants: bad event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
- eventRelay.writerFor(db).enqueue(env);
- db.prepare(`INSERT INTO principal_grant_changes (client_id, capability, audience, change, namespaces, expires_at, reason, actor, event_id)
+ await eventRelay.writerFor(db).enqueue(env);
+ await db.prepare(`INSERT INTO principal_grant_changes (client_id, capability, audience, change, namespaces, expires_at, reason, actor, event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(client_id, capability, audience, change, JSON.stringify(namespaces), expires_at || null, reason || null, actor || null, env.event_id);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(client_id, capability, audience, change, JSON.stringify(namespaces), expires_at || null, reason || null, actor || null, env.event_id);
}
}
function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
function kick(db) { const o = eventRelay.outboxFor(db); if (o) o.kick(); }
/** Check a request's grant target. → { client_id, capability, audience } */
/** Check a request's grant target. → { client_id, capability, audience } */
-function target(db, body) {
+async function target(db, body) {
const client_id = String(body.client_id || '');
const client_id = String(body.client_id || '');
const capability = String(body.capability || '');
const capability = String(body.capability || '');
if (!CLIENT_RE.test(client_id)) throw new GrantError(400, 'client_id is malformed');
if (!CLIENT_RE.test(client_id)) throw new GrantError(400, 'client_id is malformed');
- if (!db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ? AND is_first_party = 1').get(client_id)) throw new GrantError(404, `${client_id} is not a service principal`);
+ if (!await db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ? AND is_first_party = 1').get(client_id)) throw new GrantError(404, `${client_id} is not a service principal`);
if (!CAP_RE.test(capability)) throw new GrantError(400, 'capability is malformed');
if (!CAP_RE.test(capability)) throw new GrantError(400, 'capability is malformed');
const cap = capabilities.get(capability);
const cap = capabilities.get(capability);
if (!cap) throw new GrantError(400, `${capability} is not in openvibe-contracts`);
if (!cap) throw new GrantError(400, `${capability} is not in openvibe-contracts`);
@@ -124,8 +105,8 @@ function target(db, body) {
@@ -124,8 +105,8 @@ function target(db, body) {
return { client_id, capability, audience, status: cap.status };
return { client_id, capability, audience, status: cap.status };
}
}
-function grant(db, body, actor) {
- const t = target(db, body);
+async function grant(db, body, actor) {
+ const t = await target(db, body);
if (!GRANTABLE.has(t.status)) throw new GrantError(400, `${t.capability} is ${t.status}`);
if (!GRANTABLE.has(t.status)) throw new GrantError(400, `${t.capability} is ${t.status}`);
const reason = clean(body.reason, 500);
const reason = clean(body.reason, 500);
if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');
if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');
@@ -133,44 +114,44 @@ function grant(db, body, actor) {
@@ -133,44 +114,44 @@ function grant(db, body, actor) {
if (namespaces.some((n) => !/^[a-z][a-z0-9_.*-]{0,199}$/.test(n))) throw new GrantError(400, 'a namespace is malformed');
if (namespaces.some((n) => !/^[a-z][a-z0-9_.*-]{0,199}$/.test(n))) throw new GrantError(400, 'a namespace is malformed');
const expires_at = isoOrNull(body.expires_at);
const expires_at = isoOrNull(body.expires_at);
if (expires_at && expires_at <= nowIso()) throw new GrantError(400, 'expires_at is in the past');
if (expires_at && expires_at <= nowIso()) throw new GrantError(400, 'expires_at is in the past');
- return db.transaction(() => {
- const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);
+ return await db.tx(async () => {
+ const prev = await db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);
const wasActive = prev && stateOf(prev) === 'active';
const wasActive = prev && stateOf(prev) === 'active';
- db.prepare(`INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by, granted_at, expires_at, reason, revoked_at, revoked_by)
- VALUES (@client_id, @capability, @audience, @namespaces, @actor, CURRENT_TIMESTAMP, @expires, @reason, NULL, NULL)
+ await db.prepare(`INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by, granted_at, expires_at, reason, revoked_at, revoked_by)
+ VALUES (@client_id, @capability, @audience, @namespaces, @actor, ov_now(), @expires, @reason, NULL, NULL)
ON CONFLICT(client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by,
ON CONFLICT(client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by,
granted_at = excluded.granted_at, expires_at = excluded.expires_at, reason = excluded.reason, revoked_at = NULL, revoked_by = NULL`)
granted_at = excluded.granted_at, expires_at = excluded.expires_at, reason = excluded.reason, revoked_at = NULL, revoked_by = NULL`)
.run({ ...t, namespaces: JSON.stringify(namespaces), actor: actor || 'owner', expires: toSql(expires_at), reason });
.run({ ...t, namespaces: JSON.stringify(namespaces), actor: actor || 'owner', expires: toSql(expires_at), reason });
const change = wasActive ? 'updated' : 'granted';
const change = wasActive ? 'updated' : 'granted';
- record(db, { ...t, change, namespaces, expires_at, reason, actor });
+ await record(db, { ...t, change, namespaces, expires_at, reason, actor });
return { ...t, change, namespaces, expires_at };
return { ...t, change, namespaces, expires_at };
- })();
+ });
}
}
-function revoke(db, body, actor) {
- const t = target(db, body);
+async function revoke(db, body, actor) {
+ const t = await target(db, body);
const reason = clean(body.reason, 500);
const reason = clean(body.reason, 500);
if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');
if (!reason || reason.length < 3) throw new GrantError(400, 'a reason is required');
- return db.transaction(() => {
- const prev = db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);
+ return await db.tx(async () => {
+ const prev = await db.prepare('SELECT * FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?').get(t.client_id, t.capability, t.audience);
if (!prev || stateOf(prev) !== 'active') throw new GrantError(404, 'no active grant to revoke');
if (!prev || stateOf(prev) !== 'active') throw new GrantError(404, 'no active grant to revoke');
- db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP, revoked_by = ?, reason = ? WHERE client_id = ? AND capability = ? AND audience = ?")
+ await db.prepare("UPDATE principal_grants SET revoked_at = ov_now(), revoked_by = ?, reason = ? WHERE client_id = ? AND capability = ? AND audience = ?")
.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
.run(actor || 'owner', reason, t.client_id, t.capability, t.audience);
const namespaces = JSON.parse(prev.namespaces || '[]');
const namespaces = JSON.parse(prev.namespaces || '[]');
- record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
+ await record(db, { ...t, change: 'revoked', namespaces, expires_at: fromSql(prev.expires_at), reason, actor });
return { ...t, change: 'revoked' };
return { ...t, change: 'revoked' };
- })();
+ });
}
}
/** Record every grant whose expiry has passed, once. → how many */
/** Record every grant whose expiry has passed, once. → how many */
-function expireDue(db) {
- const due = db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP').all();
+async function expireDue(db) {
+ const due = await db.prepare('SELECT * FROM principal_grants WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ov_now()').all();
for (const g of due) {
for (const g of due) {
- db.transaction(() => {
- const n = db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
- .run(g.client_id, g.capability, g.audience).changes;
- if (n) record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
- })();
+ await db.tx(async () => {
+ const n = (await db.prepare("UPDATE principal_grants SET revoked_at = expires_at, revoked_by = 'expiry' WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL")
+ .run(g.client_id, g.capability, g.audience)).changes;
+ if (n) await record(db, { client_id: g.client_id, capability: g.capability, audience: g.audience, change: 'expired', namespaces: JSON.parse(g.namespaces || '[]'), expires_at: fromSql(g.expires_at), reason: g.reason || null, actor: null });
+ });
}
}
if (due.length) kick(db);
if (due.length) kick(db);
return due.length;
return due.length;
@@ -179,25 +160,25 @@ function expireDue(db) {
@@ -179,25 +160,25 @@ function expireDue(db) {
function router(db) {
function router(db) {
const r = express.Router();
const r = express.Router();
r.use((req, res, next) => (req.user && isOwner(req.user) ? next() : res.status(403).json({ ok: false, error: 'Only the owner manages service grants' })));
r.use((req, res, next) => (req.user && isOwner(req.user) ? next() : res.status(403).json({ ok: false, error: 'Only the owner manages service grants' })));
- const send = (res, fn) => {
- try { res.json({ ok: true, ...fn() }); } catch (err) {
+ const send = async (res, fn) => {
+ try { res.json({ ok: true, ...await fn() }); } catch (err) {
if (err instanceof GrantError) return res.status(err.status).json({ ok: false, error: err.message });
if (err instanceof GrantError) return res.status(err.status).json({ ok: false, error: err.message });
console.error('[Grants]', err.message);
console.error('[Grants]', err.message);
res.status(500).json({ ok: false, error: 'grant change failed' });
res.status(500).json({ ok: false, error: 'grant change failed' });
}
}
};
};
const actorOf = (req) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.user.subject_id || '')) ? req.user.subject_id : null);
const actorOf = (req) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(req.user.subject_id || '')) ? req.user.subject_id : null);
- r.get('/', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ grants: list(db, { client: req.query.client }) })); });
- r.get('/changes', (req, res) => { res.set('Cache-Control', 'private, no-store'); send(res, () => ({ changes: changes(db, { client: req.query.client, limit: req.query.limit }) })); });
- r.post('/', (req, res) => send(res, () => { const out = grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
- r.post('/revoke', (req, res) => send(res, () => { const out = revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
+ r.get('/', async (req, res) => { res.set('Cache-Control', 'private, no-store'); await send(res, async () => ({ grants: await list(db, { client: req.query.client }) })); });
+ r.get('/changes', async (req, res) => { res.set('Cache-Control', 'private, no-store'); await send(res, async () => ({ changes: await changes(db, { client: req.query.client, limit: req.query.limit }) })); });
+ r.post('/', async (req, res) => await send(res, async () => { const out = await grant(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
+ r.post('/revoke', async (req, res) => await send(res, async () => { const out = await revoke(db, req.body || {}, actorOf(req)); kick(db); return { grant: out }; }));
return r;
return r;
}
}
let timer = null;
let timer = null;
function start(db, { intervalMs = 10 * 60 * 1000 } = {}) {
function start(db, { intervalMs = 10 * 60 * 1000 } = {}) {
if (timer) return;
if (timer) return;
- const run = () => { try { expireDue(db); } catch (err) { console.warn('[Grants] expiry:', err.message); } };
+ const run = async () => { try { await expireDue(db); } catch (err) { console.warn('[Grants] expiry:', err.message); } };
timer = setInterval(run, intervalMs);
timer = setInterval(run, intervalMs);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
setImmediate(run);
setImmediate(run);
@@ -15,13 +15,13 @@ const router = express.Router();
@@ -15,13 +15,13 @@ const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
const MAX_ENTRIES = 1000;
const MAX_ENTRIES = 1000;
-router.get('/resolve', (req, res) => {
+router.get('/resolve', async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
const q = req.query;
const q = req.query;
if (!q.subject_id && !(q.system && q.id)) {
if (!q.subject_id && !(q.system && q.id)) {
return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, or system + id (+ type, default user)', ctx: req.ov });
return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'pass subject_id, or system + id (+ type, default user)', ctx: req.ov });
}
}
- const out = subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
+ const out = await subjects.resolve(db, { subject_id: q.subject_id, source_system: q.system, source_type: q.type || 'user', source_id: q.id });
if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
if (!out) return http.sendProblem(res, 404, 'identity.subject_not_found', { detail: 'no subject for that id', ctx: req.ov });
res.json(out);
res.json(out);
});
});
@@ -32,7 +32,7 @@ router.get('/resolve', (req, res) => {
@@ -32,7 +32,7 @@ router.get('/resolve', (req, res) => {
* -> { results: { <subject_id | id>: projection | null } }
* -> { results: { <subject_id | id>: projection | null } }
* Used by services that show authors (Community pastes) and by importers mapping legacy ids.
* Used by services that show authors (Community pastes) and by importers mapping legacy ids.
*/
*/
-router.post('/resolve-batch', express.json({ limit: '256kb' }), (req, res) => {
+router.post('/resolve-batch', express.json({ limit: '256kb' }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
const b = req.body || {};
const b = req.body || {};
const list = Array.isArray(b.subject_ids) ? b.subject_ids : Array.isArray(b.ids) ? b.ids : null;
const list = Array.isArray(b.subject_ids) ? b.subject_ids : Array.isArray(b.ids) ? b.ids : null;
@@ -43,13 +43,13 @@ router.post('/resolve-batch', express.json({ limit: '256kb' }), (req, res) => {
@@ -43,13 +43,13 @@ router.post('/resolve-batch', express.json({ limit: '256kb' }), (req, res) => {
for (const raw of list) {
for (const raw of list) {
const key = String(raw);
const key = String(raw);
results[key] = b.subject_ids
results[key] = b.subject_ids
- ? subjects.resolve(db, { subject_id: key })
- : subjects.resolve(db, { source_system: String(b.system), source_type: String(b.type || 'user'), source_id: key });
+ ? await subjects.resolve(db, { subject_id: key })
+ : await subjects.resolve(db, { source_system: String(b.system), source_type: String(b.type || 'user'), source_id: key });
}
}
res.json({ results });
res.json({ results });
});
});
-router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {
+router.post('/legacy-map', express.json({ limit: '512kb' }), async (req, res) => {
const entries = req.body && req.body.entries;
const entries = req.body && req.body.entries;
if (!Array.isArray(entries) || !entries.length) {
if (!Array.isArray(entries) || !entries.length) {
return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'entries must be a non-empty array', ctx: req.ov });
return http.sendProblem(res, 400, 'identity.bad_request', { detail: 'entries must be a non-empty array', ctx: req.ov });
@@ -57,7 +57,7 @@ router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {
@@ -57,7 +57,7 @@ router.post('/legacy-map', express.json({ limit: '512kb' }), (req, res) => {
if (entries.length > MAX_ENTRIES) {
if (entries.length > MAX_ENTRIES) {
return http.sendProblem(res, 413, 'identity.too_many_entries', { detail: `at most ${MAX_ENTRIES} entries per call`, ctx: req.ov });
return http.sendProblem(res, 413, 'identity.too_many_entries', { detail: `at most ${MAX_ENTRIES} entries per call`, ctx: req.ov });
}
}
- res.json(subjects.upsertLegacy(req.app.locals.db, entries));
+ res.json(await subjects.upsertLegacy(req.app.locals.db, entries));
});
});
module.exports = router;
module.exports = router;
@@ -28,41 +28,14 @@ class ModPrincipalError extends Error {
@@ -28,41 +28,14 @@ class ModPrincipalError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS mod_principals (
- mod_id TEXT PRIMARY KEY,
- owner TEXT NOT NULL,
- runtime TEXT NOT NULL,
- name TEXT,
- version TEXT,
- publisher TEXT,
- manifest TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
- revision INTEGER NOT NULL DEFAULT 1,
- created_at TEXT NOT NULL,
- created_by TEXT,
- updated_at TEXT NOT NULL,
- revoked_at TEXT,
- revoked_by TEXT
- );
- CREATE TABLE IF NOT EXISTS mod_principal_grants (
- mod_id TEXT NOT NULL,
- capability TEXT NOT NULL,
- state TEXT NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),
- changed_at TEXT NOT NULL,
- changed_by TEXT,
- PRIMARY KEY (mod_id, capability)
- );
- `);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
-function view(db, modId) {
- const p = db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId);
+async function view(db, modId) {
+ const p = await db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId);
if (!p) return null;
if (!p) return null;
- const grants = db.prepare('SELECT capability, state FROM mod_principal_grants WHERE mod_id = ? ORDER BY capability').all(modId);
+ const grants = await db.prepare('SELECT capability, state FROM mod_principal_grants WHERE mod_id = ? ORDER BY capability').all(modId);
const by = (s) => grants.filter((g) => g.state === s).map((g) => g.capability);
const by = (s) => grants.filter((g) => g.state === s).map((g) => g.capability);
const out = {
const out = {
principal: `mod:${p.mod_id}`, mod_id: p.mod_id, owner: p.owner, runtime: p.runtime, status: p.status,
principal: `mod:${p.mod_id}`, mod_id: p.mod_id, owner: p.owner, runtime: p.runtime, status: p.status,
@@ -74,7 +47,7 @@ function view(db, modId) {
@@ -74,7 +47,7 @@ function view(db, modId) {
return out;
return out;
}
}
-function announce(db, v, change, by, reason) {
+async function announce(db, v, change, by, reason) {
const at = v.updated_at;
const at = v.updated_at;
const payload = { mod_id: v.mod_id, owner: v.owner, status: v.status, approved: v.approved, pending: v.pending, revision: v.revision, change, by };
const payload = { mod_id: v.mod_id, owner: v.owner, status: v.status, approved: v.approved, pending: v.pending, revision: v.revision, change, by };
if (reason) payload.reason = String(reason).slice(0, 500);
if (reason) payload.reason = String(reason).slice(0, 500);
@@ -86,13 +59,13 @@ function announce(db, v, change, by, reason) {
@@ -86,13 +59,13 @@ function announce(db, v, change, by, reason) {
const ev = validate('events.event-envelope@1', env);
const ev = validate('events.event-envelope@1', env);
const pv = validate('network.mod.grants_changed@1', payload);
const pv = validate('network.mod.grants_changed@1', payload);
if (!ev.valid || !pv.valid) throw new Error(`mod-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
if (!ev.valid || !pv.valid) throw new Error(`mod-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
- eventRelay.writerFor(db).enqueue(env);
+ return eventRelay.writerFor(db).enqueue(env);
}
}
function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls */ } }
function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls */ } }
/** Register an install's principal (idempotent). → { status: 201|200, body: network.mod-principal@1 } */
/** Register an install's principal (idempotent). → { status: 201|200, body: network.mod-principal@1 } */
-function register(db, owner, body, { now = Date.now() } = {}) {
+async function register(db, owner, body, { now = Date.now() } = {}) {
ensureSchema(db);
ensureSchema(db);
const v = validate('network.mod-install-request@1', body);
const v = validate('network.mod-install-request@1', body);
if (!v.valid) throw new ModPrincipalError(400, 'mod.invalid_request', JSON.stringify(v.errors).slice(0, 300));
if (!v.valid) throw new ModPrincipalError(400, 'mod.invalid_request', JSON.stringify(v.errors).slice(0, 300));
@@ -103,65 +76,65 @@ function register(db, owner, body, { now = Date.now() } = {}) {
@@ -103,65 +76,65 @@ function register(db, owner, body, { now = Date.now() } = {}) {
const approve = [...new Set(body.approve || [])];
const approve = [...new Set(body.approve || [])];
const unrequested = approve.filter((c) => !requested.includes(c));
const unrequested = approve.filter((c) => !requested.includes(c));
if (unrequested.length) throw new ModPrincipalError(422, 'mod.not_requested', `not requested by the manifest: ${unrequested.join(', ')}`);
if (unrequested.length) throw new ModPrincipalError(422, 'mod.not_requested', `not requested by the manifest: ${unrequested.join(', ')}`);
- const existing = db.prepare('SELECT owner FROM mod_principals WHERE mod_id = ?').get(m.id);
+ const existing = await db.prepare('SELECT owner FROM mod_principals WHERE mod_id = ?').get(m.id);
if (existing) {
if (existing) {
if (existing.owner !== owner) throw new ModPrincipalError(409, 'mod.other_owner', 'that mod is registered by another runtime');
if (existing.owner !== owner) throw new ModPrincipalError(409, 'mod.other_owner', 'that mod is registered by another runtime');
- return { status: 200, body: view(db, m.id) };
+ return { status: 200, body: await view(db, m.id) };
}
}
const at = iso(now);
const at = iso(now);
- db.transaction(() => {
- db.prepare(`INSERT INTO mod_principals (mod_id, owner, runtime, name, version, publisher, manifest, created_at, created_by, updated_at)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO mod_principals (mod_id, owner, runtime, name, version, publisher, manifest, created_at, created_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(m.id, owner, m.runtime, m.name || null, m.version || null, m.publisher && m.publisher.id ? String(m.publisher.id).slice(0, 80) : null,
.run(m.id, owner, m.runtime, m.name || null, m.version || null, m.publisher && m.publisher.id ? String(m.publisher.id).slice(0, 80) : null,
JSON.stringify(m), at, body.actor ? String(body.actor).slice(0, 80) : owner, at);
JSON.stringify(m), at, body.actor ? String(body.actor).slice(0, 80) : owner, at);
const g = db.prepare('INSERT INTO mod_principal_grants (mod_id, capability, state, changed_at, changed_by) VALUES (?, ?, ?, ?, ?)');
const g = db.prepare('INSERT INTO mod_principal_grants (mod_id, capability, state, changed_at, changed_by) VALUES (?, ?, ?, ?, ?)');
- for (const c of requested) g.run(m.id, c, approve.includes(c) ? 'approved' : 'pending', at, body.actor || owner);
- announce(db, view(db, m.id), { action: 'register' }, 'runtime');
- })();
+ for (const c of requested) await g.run(m.id, c, approve.includes(c) ? 'approved' : 'pending', at, body.actor || owner);
+ await announce(db, await view(db, m.id), { action: 'register' }, 'runtime');
+ });
kick(db);
kick(db);
- return { status: 201, body: view(db, m.id) };
+ return { status: 201, body: await view(db, m.id) };
}
}
-function load(db, modId, owner) {
+async function load(db, modId, owner) {
ensureSchema(db);
ensureSchema(db);
- const p = MOD_RE.test(String(modId)) ? db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId) : null;
+ const p = MOD_RE.test(String(modId)) ? await db.prepare('SELECT * FROM mod_principals WHERE mod_id = ?').get(modId) : null;
if (!p) throw new ModPrincipalError(404, 'mod.not_found', 'no such mod principal');
if (!p) throw new ModPrincipalError(404, 'mod.not_found', 'no such mod principal');
if (owner && p.owner !== owner) throw new ModPrincipalError(403, 'mod.other_owner', 'that mod is registered by another runtime');
if (owner && p.owner !== owner) throw new ModPrincipalError(403, 'mod.other_owner', 'that mod is registered by another runtime');
return p;
return p;
}
}
/** Approve or revoke one capability. by: 'runtime' (owner given) or 'staff'. → network.mod-principal@1 */
/** Approve or revoke one capability. by: 'runtime' (owner given) or 'staff'. → network.mod-principal@1 */
-function change(db, modId, { capability, action, actor, reason }, { owner = null, by = 'runtime', now = Date.now() } = {}) {
+async function change(db, modId, { capability, action, actor, reason }, { owner = null, by = 'runtime', now = Date.now() } = {}) {
const v = validate('network.mod-grant-change@1', { capability, action, ...(actor ? { actor: String(actor).slice(0, 80) } : {}), ...(reason ? { reason: String(reason).slice(0, 500) } : {}) });
const v = validate('network.mod-grant-change@1', { capability, action, ...(actor ? { actor: String(actor).slice(0, 80) } : {}), ...(reason ? { reason: String(reason).slice(0, 500) } : {}) });
if (!v.valid || !CAP_RE.test(String(capability))) throw new ModPrincipalError(400, 'mod.invalid_request', 'capability and action (approve|revoke) are required');
if (!v.valid || !CAP_RE.test(String(capability))) throw new ModPrincipalError(400, 'mod.invalid_request', 'capability and action (approve|revoke) are required');
- const p = load(db, modId, owner);
+ const p = await load(db, modId, owner);
if (p.status === 'revoked') throw new ModPrincipalError(409, 'mod.revoked', 'a revoked install is never granted anything again');
if (p.status === 'revoked') throw new ModPrincipalError(409, 'mod.revoked', 'a revoked install is never granted anything again');
- const g = db.prepare('SELECT state FROM mod_principal_grants WHERE mod_id = ? AND capability = ?').get(modId, capability);
+ const g = await db.prepare('SELECT state FROM mod_principal_grants WHERE mod_id = ? AND capability = ?').get(modId, capability);
if (!g) throw new ModPrincipalError(422, 'mod.not_requested', 'the manifest does not request that capability');
if (!g) throw new ModPrincipalError(422, 'mod.not_requested', 'the manifest does not request that capability');
const to = action === 'approve' ? 'approved' : 'revoked';
const to = action === 'approve' ? 'approved' : 'revoked';
- if (g.state === to || (action === 'revoke' && g.state === 'pending')) return view(db, modId);
+ if (g.state === to || (action === 'revoke' && g.state === 'pending')) return await view(db, modId);
const at = iso(now);
const at = iso(now);
- db.transaction(() => {
- db.prepare('UPDATE mod_principal_grants SET state = ?, changed_at = ?, changed_by = ? WHERE mod_id = ? AND capability = ?').run(to, at, actor || by, modId, capability);
- db.prepare('UPDATE mod_principals SET revision = revision + 1, updated_at = ? WHERE mod_id = ?').run(at, modId);
- announce(db, view(db, modId), { action, capability }, by, reason);
- })();
+ await db.tx(async () => {
+ await db.prepare('UPDATE mod_principal_grants SET state = ?, changed_at = ?, changed_by = ? WHERE mod_id = ? AND capability = ?').run(to, at, actor || by, modId, capability);
+ await db.prepare('UPDATE mod_principals SET revision = revision + 1, updated_at = ? WHERE mod_id = ?').run(at, modId);
+ await announce(db, await view(db, modId), { action, capability }, by, reason);
+ });
kick(db);
kick(db);
- return view(db, modId);
+ return await view(db, modId);
}
}
/** End the install: every grant revoked, for good. → network.mod-principal@1 */
/** End the install: every grant revoked, for good. → network.mod-principal@1 */
-function revokeAll(db, modId, { actor, reason } = {}, { owner = null, by = 'runtime', now = Date.now() } = {}) {
- const p = load(db, modId, owner);
- if (p.status === 'revoked') return view(db, modId);
+async function revokeAll(db, modId, { actor, reason } = {}, { owner = null, by = 'runtime', now = Date.now() } = {}) {
+ const p = await load(db, modId, owner);
+ if (p.status === 'revoked') return await view(db, modId);
const at = iso(now);
const at = iso(now);
- db.transaction(() => {
- db.prepare("UPDATE mod_principal_grants SET state = 'revoked', changed_at = ?, changed_by = ? WHERE mod_id = ? AND state = 'approved'").run(at, actor || by, modId);
- db.prepare("UPDATE mod_principals SET status = 'revoked', revision = revision + 1, updated_at = ?, revoked_at = ?, revoked_by = ? WHERE mod_id = ?").run(at, at, actor || by, modId);
- announce(db, view(db, modId), { action: 'revoke_all' }, by, reason);
- })();
+ await db.tx(async () => {
+ await db.prepare("UPDATE mod_principal_grants SET state = 'revoked', changed_at = ?, changed_by = ? WHERE mod_id = ? AND state = 'approved'").run(at, actor || by, modId);
+ await db.prepare("UPDATE mod_principals SET status = 'revoked', revision = revision + 1, updated_at = ?, revoked_at = ?, revoked_by = ? WHERE mod_id = ?").run(at, at, actor || by, modId);
+ await announce(db, await view(db, modId), { action: 'revoke_all' }, by, reason);
+ });
kick(db);
kick(db);
- return view(db, modId);
+ return await view(db, modId);
}
}
function sendError(res, e) {
function sendError(res, e) {
@@ -175,15 +148,15 @@ function routers({ guard, requireAuth, staffClaims }) {
@@ -175,15 +148,15 @@ function routers({ guard, requireAuth, staffClaims }) {
const owner = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');
const owner = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');
const internal = express.Router();
const internal = express.Router();
internal.use(guard);
internal.use(guard);
- internal.get('/', (req, res) => {
+ internal.get('/', async (req, res) => {
const db = req.app.locals.db; ensureSchema(db);
const db = req.app.locals.db; ensureSchema(db);
- const rows = db.prepare('SELECT mod_id FROM mod_principals WHERE owner = ? ORDER BY created_at').all(owner(req));
- res.json({ mods: rows.map((r) => view(db, r.mod_id)) });
+ const rows = await db.prepare('SELECT mod_id FROM mod_principals WHERE owner = ? ORDER BY created_at').all(owner(req));
+ res.json({ mods: (await Promise.all(rows.map(async (r) => await view(db, r.mod_id)))) });
});
});
- internal.get('/:id', (req, res) => { try { load(req.app.locals.db, req.params.id, owner(req)); res.json(view(req.app.locals.db, req.params.id)); } catch (e) { sendError(res, e); } });
- internal.post('/', (req, res) => { try { const out = register(req.app.locals.db, owner(req), req.body || {}); res.status(out.status).json(out.body); } catch (e) { sendError(res, e); } });
- internal.post('/:id/grants', (req, res) => { try { res.json(change(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });
- internal.post('/:id/revoke', (req, res) => { try { res.json(revokeAll(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });
+ internal.get('/:id', async (req, res) => { try { await load(req.app.locals.db, req.params.id, owner(req)); res.json(await view(req.app.locals.db, req.params.id)); } catch (e) { sendError(res, e); } });
+ internal.post('/', async (req, res) => { try { const out = await register(req.app.locals.db, owner(req), req.body || {}); res.status(out.status).json(out.body); } catch (e) { sendError(res, e); } });
+ internal.post('/:id/grants', async (req, res) => { try { res.json(await change(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });
+ internal.post('/:id/revoke', async (req, res) => { try { res.json(await revokeAll(req.app.locals.db, req.params.id, req.body || {}, { owner: owner(req) })); } catch (e) { sendError(res, e); } });
const admin = express.Router();
const admin = express.Router();
admin.use(express.json({ limit: '8kb' }));
admin.use(express.json({ limit: '8kb' }));
@@ -193,25 +166,25 @@ function routers({ guard, requireAuth, staffClaims }) {
@@ -193,25 +166,25 @@ function routers({ guard, requireAuth, staffClaims }) {
if (reason.length < 10) { res.status(400).json({ error: 'mod.reason_required', detail: 'a written reason (10 characters or more)' }); return null; }
if (reason.length < 10) { res.status(400).json({ error: 'mod.reason_required', detail: 'a written reason (10 characters or more)' }); return null; }
return reason.slice(0, 500);
return reason.slice(0, 500);
};
};
- const audit = (req, action, detail) => req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));
- admin.get('/', requireAuth, staffOnly, (req, res) => {
+ const audit = async (req, action, detail) => await req.app.locals.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, action, JSON.stringify(detail));
+ admin.get('/', requireAuth, staffOnly, async (req, res) => {
const db = req.app.locals.db; ensureSchema(db);
const db = req.app.locals.db; ensureSchema(db);
- res.set('Cache-Control', 'private, no-store').json({ mods: db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all().map((r) => view(db, r.mod_id)) });
+ res.set('Cache-Control', 'private, no-store').json({ mods: (await Promise.all((await db.prepare('SELECT mod_id FROM mod_principals ORDER BY updated_at DESC LIMIT 500').all()).map(async (r) => await view(db, r.mod_id)))) });
});
});
- admin.post('/:id/grants', requireAuth, staffOnly, (req, res) => {
+ admin.post('/:id/grants', requireAuth, staffOnly, async (req, res) => {
const reason = reasoned(req, res); if (!reason) return;
const reason = reasoned(req, res); if (!reason) return;
const actor = req.user.subject_id || `user:${req.user.id}`;
const actor = req.user.subject_id || `user:${req.user.id}`;
try {
try {
- const out = change(req.app.locals.db, req.params.id, { capability: req.body.capability, action: req.body.action, actor, reason }, { by: 'staff' });
- audit(req, 'mod_grant_change', { mod_id: req.params.id, capability: req.body.capability, action: req.body.action, reason });
+ const out = await change(req.app.locals.db, req.params.id, { capability: req.body.capability, action: req.body.action, actor, reason }, { by: 'staff' });
+ await audit(req, 'mod_grant_change', { mod_id: req.params.id, capability: req.body.capability, action: req.body.action, reason });
res.json(out);
res.json(out);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
- admin.post('/:id/revoke', requireAuth, staffOnly, (req, res) => {
+ admin.post('/:id/revoke', requireAuth, staffOnly, async (req, res) => {
const reason = reasoned(req, res); if (!reason) return;
const reason = reasoned(req, res); if (!reason) return;
try {
try {
- const out = revokeAll(req.app.locals.db, req.params.id, { actor: req.user.subject_id || `user:${req.user.id}`, reason }, { by: 'staff' });
- audit(req, 'mod_revoke', { mod_id: req.params.id, reason });
+ const out = await revokeAll(req.app.locals.db, req.params.id, { actor: req.user.subject_id || `user:${req.user.id}`, reason }, { by: 'staff' });
+ await audit(req, 'mod_revoke', { mod_id: req.params.id, reason });
res.json(out);
res.json(out);
} catch (e) { sendError(res, e); }
} catch (e) { sendError(res, e); }
});
});
@@ -73,14 +73,12 @@ function buildEnvelope({ subjectId, namespace, namespaceOwner, schemaVersion, re
@@ -73,14 +73,12 @@ function buildEnvelope({ subjectId, namespace, namespaceOwner, schemaVersion, re
}
}
/** Create network_event_outbox if no relay has yet (boot), so a module write never has to. */
/** Create network_event_outbox if no relay has yet (boot), so a module write never has to. */
-function ensureSchema(db) {
- eventRelay.writerFor(db);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** Build and enqueue; call INSIDE the transaction that makes the change. Returns the envelope. */
/** Build and enqueue; call INSIDE the transaction that makes the change. Returns the envelope. */
-function record(db, change) {
+async function record(db, change) {
const env = buildEnvelope(change);
const env = buildEnvelope(change);
- eventRelay.writerFor(db).enqueue(env, { traceparent: change.ctx && change.ctx.traceparent });
+ await eventRelay.writerFor(db).enqueue(db, env, { traceparent: change.ctx && change.ctx.traceparent });
return env;
return env;
}
}
@@ -68,48 +68,7 @@ function ownerRetired(namespace) {
@@ -68,48 +68,7 @@ function ownerRetired(namespace) {
return !!(m && m.status === 'retired');
return !!(m && m.status === 'retired');
}
}
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS user_modules (
- subject_id TEXT NOT NULL,
- namespace TEXT NOT NULL,
- version INTEGER NOT NULL,
- revision INTEGER NOT NULL DEFAULT 0,
- data TEXT NOT NULL,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_by TEXT,
- PRIMARY KEY (subject_id, namespace)
- );
- -- The last revision issued per (subject, namespace), kept across deletes.
- CREATE TABLE IF NOT EXISTS user_module_revisions (
- subject_id TEXT NOT NULL,
- namespace TEXT NOT NULL,
- revision INTEGER NOT NULL,
- PRIMARY KEY (subject_id, namespace)
- );
- -- When Network first saw a namespace's owning service retired (onOwnerRemoved).
- CREATE TABLE IF NOT EXISTS user_module_retirements (
- namespace TEXT PRIMARY KEY,
- owner TEXT NOT NULL,
- retired_seen_at INTEGER NOT NULL
- );
- `);
- moduleEvents.ensureSchema(db);
- // An account cannot disappear, or change subject, while module rows remain: the code that deletes or
- // merges it must call onSubjectRemoved/onSubjectMerged (same transaction), which also emit the events.
- for (const table of ['users', 'anon_users']) {
- if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table)) continue;
- db.exec(`
- CREATE TRIGGER IF NOT EXISTS user_modules_guard_${table}_delete BEFORE DELETE ON ${table}
- WHEN OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'); END;
- CREATE TRIGGER IF NOT EXISTS user_modules_guard_${table}_rekey BEFORE UPDATE OF subject_id ON ${table}
- WHEN OLD.subject_id IS NOT NULL AND NEW.subject_id IS NOT OLD.subject_id
- AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id)
- BEGIN SELECT RAISE(ABORT, 'user_modules rows remain for this subject: call modules.onSubjectMerged first'); END;
- `);
- }
-}
+async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });
const subjectRef = (sid) => ({ type: sid.startsWith('gst_') ? 'guest' : 'user', id: sid });
@@ -125,16 +84,16 @@ function toRecord(row) {
@@ -125,16 +84,16 @@ function toRecord(row) {
data, updated_at: row.updated_at, updated_by: row.updated_by || undefined };
data, updated_at: row.updated_at, updated_by: row.updated_by || undefined };
}
}
-function read(db, subjectId, namespace) {
- const row = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+async function read(db, subjectId, namespace) {
+ const row = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
return row ? toRecord(row) : null;
return row ? toRecord(row) : null;
}
}
/** The next revision for (subject, namespace): one above both the current row and the last issued. */
/** The next revision for (subject, namespace): one above both the current row and the last issued. */
-function nextRevision(db, subjectId, namespace, current) {
- const hw = db.prepare('SELECT revision FROM user_module_revisions WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+async function nextRevision(db, subjectId, namespace, current) {
+ const hw = await db.prepare('SELECT revision FROM user_module_revisions WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
const next = Math.max(current || 0, hw ? hw.revision : 0) + 1;
const next = Math.max(current || 0, hw ? hw.revision : 0) + 1;
- db.prepare(`INSERT INTO user_module_revisions (subject_id, namespace, revision) VALUES (?, ?, ?)
+ await db.prepare(`INSERT INTO user_module_revisions (subject_id, namespace, revision) VALUES (?, ?, ?)
ON CONFLICT(subject_id, namespace) DO UPDATE SET revision = excluded.revision`).run(subjectId, namespace, next);
ON CONFLICT(subject_id, namespace) DO UPDATE SET revision = excluded.revision`).run(subjectId, namespace, next);
return next;
return next;
}
}
@@ -151,7 +110,7 @@ function actorOf(subjectId, writer) {
@@ -151,7 +110,7 @@ function actorOf(subjectId, writer) {
* the writer read (0 = "I expect no record"); undefined = unconditional (allowed for the owner service only).
* the writer read (0 = "I expect no record"); undefined = unconditional (allowed for the owner service only).
* Returns { status, record } or { status, code, detail, errors? }.
* Returns { status, record } or { status, code, detail, errors? }.
*/
*/
-function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx } = {}) {
+async function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx } = {}) {
const ns = modules.get(namespace);
const ns = modules.get(namespace);
if (!ns) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };
if (!ns) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };
if (!canWrite(namespace, writer)) return { status: 403, code: 'modules.write_denied', detail: `${writer && writer.type === 'user' ? 'users' : writer && writer.id} may not write ${namespace}` };
if (!canWrite(namespace, writer)) return { status: 403, code: 'modules.write_denied', detail: `${writer && writer.type === 'user' ? 'users' : writer && writer.id} may not write ${namespace}` };
@@ -160,19 +119,19 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }
@@ -160,19 +119,19 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }
const v = modules.validateData(namespace, data);
const v = modules.validateData(namespace, data);
if (!v.valid) return { status: 422, code: 'modules.invalid_data', detail: `does not match ${namespace} v${ns.version}`, errors: v.errors };
if (!v.valid) return { status: 422, code: 'modules.invalid_data', detail: `does not match ${namespace} v${ns.version}`, errors: v.errors };
const by = writer.type === 'user' ? `user:${subjectId}` : `svc:${writer.id}`;
const by = writer.type === 'user' ? `user:${subjectId}` : `svc:${writer.id}`;
- const out = db.transaction(() => {
- const cur = db.prepare('SELECT revision, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+ const out = await db.tx(async () => {
+ const cur = await db.prepare('SELECT revision, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
const have = cur ? cur.revision : 0;
const have = cur ? cur.revision : 0;
if (expectedRevision !== undefined && Number(expectedRevision) !== have) {
if (expectedRevision !== undefined && Number(expectedRevision) !== have) {
return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${have}, not ${expectedRevision}` };
return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${have}, not ${expectedRevision}` };
}
}
- const revision = nextRevision(db, subjectId, namespace, have);
- db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
+ const revision = await nextRevision(db, subjectId, namespace, have);
+ await db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)
+ VALUES (?, ?, ?, ?, ?, ov_now(), ?)
ON CONFLICT(subject_id, namespace) DO UPDATE SET version = excluded.version, revision = excluded.revision,
ON CONFLICT(subject_id, namespace) DO UPDATE SET version = excluded.version, revision = excluded.revision,
- data = excluded.data, updated_at = CURRENT_TIMESTAMP, updated_by = excluded.updated_by`)
+ data = excluded.data, updated_at = ov_now(), updated_by = excluded.updated_by`)
.run(subjectId, namespace, ns.version, revision, JSON.stringify(data), by);
.run(subjectId, namespace, ns.version, revision, JSON.stringify(data), by);
- const record = read(db, subjectId, namespace);
+ const record = await read(db, subjectId, namespace);
assertValid('modules.module-record@1', record);
assertValid('modules.module-record@1', record);
moduleEvents.record(db, {
moduleEvents.record(db, {
subjectId, namespace, namespaceOwner: ownerOf(namespace), schemaVersion: ns.version, revision,
subjectId, namespace, namespaceOwner: ownerOf(namespace), schemaVersion: ns.version, revision,
@@ -180,7 +139,7 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }
@@ -180,7 +139,7 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }
actor: actorOf(subjectId, writer), ctx,
actor: actorOf(subjectId, writer), ctx,
});
});
return { status: cur ? 200 : 201, record };
return { status: cur ? 200 : 201, record };
- })();
+ });
if (out.record) moduleEvents.kick(db);
if (out.record) moduleEvents.kick(db);
return out;
return out;
}
}
@@ -189,11 +148,11 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }
@@ -189,11 +148,11 @@ function write(db, subjectId, namespace, data, { writer, expectedRevision, ctx }
* Delete one record inside the caller's transaction (or its own) and emit its event.
* Delete one record inside the caller's transaction (or its own) and emit its event.
* Returns the revision of the delete, or 0 when there was no record.
* Returns the revision of the delete, or 0 when there was no record.
*/
*/
-function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx }) {
- const row = db.prepare('SELECT revision, version, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+async function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx }) {
+ const row = await db.prepare('SELECT revision, version, data FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
if (!row) return 0;
if (!row) return 0;
- db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjectId, namespace);
- const revision = nextRevision(db, subjectId, namespace, row.revision);
+ await db.prepare('DELETE FROM user_modules WHERE subject_id = ? AND namespace = ?').run(subjectId, namespace);
+ const revision = await nextRevision(db, subjectId, namespace, row.revision);
moduleEvents.record(db, {
moduleEvents.record(db, {
subjectId, namespace, namespaceOwner: ownerOf(namespace) || 'network', schemaVersion: row.version, revision,
subjectId, namespace, namespaceOwner: ownerOf(namespace) || 'network', schemaVersion: row.version, revision,
change: 'deleted', reason, before: JSON.parse(row.data), after: {}, actor, mergedInto, ctx,
change: 'deleted', reason, before: JSON.parse(row.data), after: {}, actor, mergedInto, ctx,
@@ -206,18 +165,18 @@ function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx })
@@ -206,18 +165,18 @@ function removeRow(db, subjectId, namespace, { actor, reason, mergedInto, ctx })
* only in the namespaces it owns. expectedRevision optional (412 if it moved).
* only in the namespaces it owns. expectedRevision optional (412 if it moved).
* Returns { status: 204, revision } | { status, code, detail }.
* Returns { status: 204, revision } | { status, code, detail }.
*/
*/
-function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}) {
+async function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}) {
if (!modules.get(namespace)) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };
if (!modules.get(namespace)) return { status: 404, code: 'modules.unknown_namespace', detail: `no namespace ${namespace}` };
if (writer.type === 'service' && writer.id !== ownerOf(namespace)) return { status: 403, code: 'modules.write_denied', detail: `${writer.id} may not delete ${namespace}` };
if (writer.type === 'service' && writer.id !== ownerOf(namespace)) return { status: 403, code: 'modules.write_denied', detail: `${writer.id} may not delete ${namespace}` };
- const out = db.transaction(() => {
- const cur = db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
+ const out = await db.tx(async () => {
+ const cur = await db.prepare('SELECT revision FROM user_modules WHERE subject_id = ? AND namespace = ?').get(subjectId, namespace);
if (!cur) return { status: 404, code: 'modules.not_found' };
if (!cur) return { status: 404, code: 'modules.not_found' };
if (expectedRevision !== undefined && Number(expectedRevision) !== cur.revision) {
if (expectedRevision !== undefined && Number(expectedRevision) !== cur.revision) {
return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${cur.revision}, not ${expectedRevision}` };
return { status: 412, code: 'modules.revision_conflict', detail: `revision is ${cur.revision}, not ${expectedRevision}` };
}
}
- const revision = removeRow(db, subjectId, namespace, { actor: actorOf(subjectId, writer), reason: writer.type === 'user' ? 'delete' : 'owner_delete', ctx });
+ const revision = await removeRow(db, subjectId, namespace, { actor: actorOf(subjectId, writer), reason: writer.type === 'user' ? 'delete' : 'owner_delete', ctx });
return { status: 204, revision };
return { status: 204, revision };
- })();
+ });
if (out.status === 204) moduleEvents.kick(db);
if (out.status === 204) moduleEvents.kick(db);
return out;
return out;
}
}
@@ -227,14 +186,14 @@ function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}
@@ -227,14 +186,14 @@ function remove(db, subjectId, namespace, { writer, expectedRevision, ctx } = {}
* reason subject_removed) each, and forget its revisions. Call inside the transaction that deletes the
* reason subject_removed) each, and forget its revisions. Call inside the transaction that deletes the
* account (the users/anon_users triggers refuse the delete otherwise). Returns how many were deleted.
* account (the users/anon_users triggers refuse the delete otherwise). Returns how many were deleted.
*/
*/
-function onSubjectRemoved(db, subjectId, { ctx } = {}) {
+async function onSubjectRemoved(db, subjectId, { ctx } = {}) {
if (!SUBJECT_RE.test(String(subjectId || ''))) throw new TypeError('onSubjectRemoved: a usr_/gst_ subject id is required');
if (!SUBJECT_RE.test(String(subjectId || ''))) throw new TypeError('onSubjectRemoved: a usr_/gst_ subject id is required');
- const n = db.transaction(() => {
- const rows = db.prepare('SELECT namespace FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(subjectId);
- for (const r of rows) removeRow(db, subjectId, r.namespace, { actor: { type: 'system', id: 'network' }, reason: 'subject_removed', ctx });
- db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(subjectId);
+ const n = await db.tx(async () => {
+ const rows = await db.prepare('SELECT namespace FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(subjectId);
+ for (const r of rows) await removeRow(db, subjectId, r.namespace, { actor: { type: 'system', id: 'network' }, reason: 'subject_removed', ctx });
+ await db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(subjectId);
return rows.length;
return rows.length;
- })();
+ });
if (n) moduleEvents.kick(db);
if (n) moduleEvents.kick(db);
return n;
return n;
}
}
@@ -247,18 +206,18 @@ function onSubjectRemoved(db, subjectId, { ctx } = {}) {
@@ -247,18 +206,18 @@ function onSubjectRemoved(db, subjectId, { ctx } = {}) {
* (reason subject_merged, merged_into). Moved and filled records say updated_by svc:network.
* (reason subject_merged, merged_into). Moved and filled records say updated_by svc:network.
* Call inside the merge's transaction, before `from`'s row goes. Returns { moved, filled, dropped }.
* Call inside the merge's transaction, before `from`'s row goes. Returns { moved, filled, dropped }.
*/
*/
-function onSubjectMerged(db, { from, into, ctx } = {}) {
+async function onSubjectMerged(db, { from, into, ctx } = {}) {
if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onSubjectMerged: from and into must be usr_/gst_ subject ids');
if (!SUBJECT_RE.test(String(from || '')) || !SUBJECT_RE.test(String(into || ''))) throw new TypeError('onSubjectMerged: from and into must be usr_/gst_ subject ids');
if (from === into) throw new TypeError('onSubjectMerged: from and into are the same subject');
if (from === into) throw new TypeError('onSubjectMerged: from and into are the same subject');
const system = { type: 'system', id: 'network' };
const system = { type: 'system', id: 'network' };
- const out = db.transaction(() => {
+ const out = await db.tx(async () => {
const res = { moved: 0, filled: 0, dropped: 0 };
const res = { moved: 0, filled: 0, dropped: 0 };
- for (const row of db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(from)) {
- const has = db.prepare('SELECT 1 FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
+ for (const row of await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(from)) {
+ const has = await db.prepare('SELECT 1 FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
if (!has) {
if (!has) {
- const revision = nextRevision(db, into, row.namespace, 0);
- db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 'svc:network')`).run(into, row.namespace, row.version, revision, row.data);
+ const revision = await nextRevision(db, into, row.namespace, 0);
+ await db.prepare(`INSERT INTO user_modules (subject_id, namespace, version, revision, data, updated_at, updated_by)
+ VALUES (?, ?, ?, ?, ?, ov_now(), 'svc:network')`).run(into, row.namespace, row.version, revision, row.data);
moduleEvents.record(db, {
moduleEvents.record(db, {
subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: row.version, revision,
subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: row.version, revision,
change: 'created', reason: 'subject_merged', before: {}, after: JSON.parse(row.data), actor: system, mergedFrom: from, ctx,
change: 'created', reason: 'subject_merged', before: {}, after: JSON.parse(row.data), actor: system, mergedFrom: from, ctx,
@@ -266,7 +225,7 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
@@ -266,7 +225,7 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
res.moved++;
res.moved++;
} else {
} else {
// The survivor's fields win; the other record fills only what it lacks (objects only).
// The survivor's fields win; the other record fills only what it lacks (objects only).
- const mine = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
+ const mine = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? AND namespace = ?').get(into, row.namespace);
let a = null; let b = null;
let a = null; let b = null;
try { a = JSON.parse(mine.data); b = JSON.parse(row.data); } catch { /* unreadable: keep the survivor's */ }
try { a = JSON.parse(mine.data); b = JSON.parse(row.data); } catch { /* unreadable: keep the survivor's */ }
const isObj = (x) => x && typeof x === 'object' && !Array.isArray(x);
const isObj = (x) => x && typeof x === 'object' && !Array.isArray(x);
@@ -274,8 +233,8 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
@@ -274,8 +233,8 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
if (gaps.length) {
if (gaps.length) {
const next = { ...a };
const next = { ...a };
for (const k of gaps) next[k] = b[k];
for (const k of gaps) next[k] = b[k];
- const revision = nextRevision(db, into, row.namespace, mine.revision);
- db.prepare("UPDATE user_modules SET data = ?, revision = ?, updated_at = CURRENT_TIMESTAMP, updated_by = 'svc:network' WHERE subject_id = ? AND namespace = ?")
+ const revision = await nextRevision(db, into, row.namespace, mine.revision);
+ await db.prepare("UPDATE user_modules SET data = ?, revision = ?, updated_at = ov_now(), updated_by = 'svc:network' WHERE subject_id = ? AND namespace = ?")
.run(JSON.stringify(next), revision, into, row.namespace);
.run(JSON.stringify(next), revision, into, row.namespace);
moduleEvents.record(db, {
moduleEvents.record(db, {
subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: mine.version, revision,
subjectId: into, namespace: row.namespace, namespaceOwner: ownerOf(row.namespace) || 'network', schemaVersion: mine.version, revision,
@@ -284,11 +243,11 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
@@ -284,11 +243,11 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
res.filled++;
res.filled++;
} else res.dropped++;
} else res.dropped++;
}
}
- removeRow(db, from, row.namespace, { actor: system, reason: 'subject_merged', mergedInto: into, ctx });
+ await removeRow(db, from, row.namespace, { actor: system, reason: 'subject_merged', mergedInto: into, ctx });
}
}
- db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(from);
+ await db.prepare('DELETE FROM user_module_revisions WHERE subject_id = ?').run(from);
return res;
return res;
- })();
+ });
if (out.moved || out.filled || out.dropped) moduleEvents.kick(db);
if (out.moved || out.filled || out.dropped) moduleEvents.kick(db);
return out;
return out;
}
}
@@ -298,23 +257,23 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
@@ -298,23 +257,23 @@ function onSubjectMerged(db, { from, into, ctx } = {}) {
* and once retentionDays have passed since, delete the namespace's records (reason owner_delete, actor
* and once retentionDays have passed since, delete the namespace's records (reason owner_delete, actor
* system:network). retain-readonly namespaces only become read-only (write()). Returns how many were deleted.
* system:network). retain-readonly namespaces only become read-only (write()). Returns how many were deleted.
*/
*/
-function sweepRetired(db, { now = Date.now(), ctx } = {}) {
+async function sweepRetired(db, { now = Date.now(), ctx } = {}) {
let deleted = 0;
let deleted = 0;
for (const ns of modules.namespaces) {
for (const ns of modules.namespaces) {
const owner = ownerOf(ns.namespace);
const owner = ownerOf(ns.namespace);
if (!ownerRetired(ns.namespace)) {
if (!ownerRetired(ns.namespace)) {
- db.prepare('DELETE FROM user_module_retirements WHERE namespace = ?').run(ns.namespace); // un-retired
+ await db.prepare('DELETE FROM user_module_retirements WHERE namespace = ?').run(ns.namespace); // un-retired
continue;
continue;
}
}
- db.prepare('INSERT OR IGNORE INTO user_module_retirements (namespace, owner, retired_seen_at) VALUES (?, ?, ?)').run(ns.namespace, owner, now);
+ await db.prepare('INSERT INTO user_module_retirements (namespace, owner, retired_seen_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(ns.namespace, owner, now);
if (ns.onOwnerRemoved !== 'delete-after-retention') continue;
if (ns.onOwnerRemoved !== 'delete-after-retention') continue;
- const seen = db.prepare('SELECT retired_seen_at FROM user_module_retirements WHERE namespace = ?').get(ns.namespace).retired_seen_at;
+ const seen = (await db.prepare('SELECT retired_seen_at FROM user_module_retirements WHERE namespace = ?').get(ns.namespace)).retired_seen_at;
if (now - seen < (ns.retentionDays || 0) * 86400000) continue;
if (now - seen < (ns.retentionDays || 0) * 86400000) continue;
- deleted += db.transaction(() => {
- const rows = db.prepare('SELECT subject_id FROM user_modules WHERE namespace = ?').all(ns.namespace);
- for (const r of rows) removeRow(db, r.subject_id, ns.namespace, { actor: { type: 'system', id: 'network' }, reason: 'owner_delete', ctx });
+ deleted += await db.tx(async () => {
+ const rows = await db.prepare('SELECT subject_id FROM user_modules WHERE namespace = ?').all(ns.namespace);
+ for (const r of rows) await removeRow(db, r.subject_id, ns.namespace, { actor: { type: 'system', id: 'network' }, reason: 'owner_delete', ctx });
return rows.length;
return rows.length;
- })();
+ });
}
}
if (deleted) moduleEvents.kick(db);
if (deleted) moduleEvents.kick(db);
return deleted;
return deleted;
@@ -334,42 +293,42 @@ function userRouter(requireAuth) {
@@ -334,42 +293,42 @@ function userRouter(requireAuth) {
const router = express.Router();
const router = express.Router();
router.use(http.middleware());
router.use(http.middleware());
- router.get('/:ns/public/:subject', (req, res) => {
+ router.get('/:ns/public/:subject', async (req, res) => {
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(res, { status: 404, code: 'modules.not_found' }, req);
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject)) return problem(res, { status: 404, code: 'modules.not_found' }, req);
- const rec = read(req.app.locals.db, req.params.subject, req.params.ns);
+ const rec = await read(req.app.locals.db, req.params.subject, req.params.ns);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
res.set('Cache-Control', 'public, max-age=60').json({ subject: rec.subject, namespace: rec.namespace, version: rec.version, data: modules.publicView(rec.namespace, rec.data) });
res.set('Cache-Control', 'public, max-age=60').json({ subject: rec.subject, namespace: rec.namespace, version: rec.version, data: modules.publicView(rec.namespace, rec.data) });
});
});
- router.get('/', requireAuth, (req, res) => {
+ router.get('/', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const sid = subjects.ensureUserSubject(db, req.user);
- const rows = db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);
+ const sid = await subjects.ensureUserSubject(db, req.user);
+ const rows = await db.prepare('SELECT * FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(sid);
res.set('Cache-Control', 'private, no-store').json({ subject: subjectRef(sid), modules: rows.map(toRecord),
res.set('Cache-Control', 'private, no-store').json({ subject: subjectRef(sid), modules: rows.map(toRecord),
namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: ownerOf(n.namespace), userWritable: n.writers.includes('user') && !ownerRetired(n.namespace), description: n.description })) });
namespaces: modules.namespaces.map(n => ({ namespace: n.namespace, owner: ownerOf(n.namespace), userWritable: n.writers.includes('user') && !ownerRetired(n.namespace), description: n.description })) });
});
});
- router.get('/:ns', requireAuth, (req, res) => {
+ router.get('/:ns', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
- const rec = read(db, subjects.ensureUserSubject(db, req.user), req.params.ns);
+ const rec = await read(db, await subjects.ensureUserSubject(db, req.user), req.params.ns);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found', detail: 'no record yet (write with If-Match: 0)' }, req);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found', detail: 'no record yet (write with If-Match: 0)' }, req);
withEtag(res, rec).json(rec);
withEtag(res, rec).json(rec);
});
});
- router.put('/:ns', requireAuth, (req, res) => {
+ router.put('/:ns', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
const rev = revisionHeader(req);
const rev = revisionHeader(req);
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision', detail: 'If-Match must be a revision number' }, req);
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision', detail: 'If-Match must be a revision number' }, req);
- const out = write(db, subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev, ctx: req.ov });
+ const out = await write(db, await subjects.ensureUserSubject(db, req.user), req.params.ns, req.body && req.body.data, { writer: { type: 'user' }, expectedRevision: rev, ctx: req.ov });
if (!out.record) return problem(res, out, req);
if (!out.record) return problem(res, out, req);
withEtag(res, out.record).status(out.status).json(out.record);
withEtag(res, out.record).status(out.status).json(out.record);
});
});
- router.delete('/:ns', requireAuth, (req, res) => {
+ router.delete('/:ns', requireAuth, async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const out = remove(db, subjects.ensureUserSubject(db, req.user), req.params.ns, { writer: { type: 'user' }, ctx: req.ov });
+ const out = await remove(db, await subjects.ensureUserSubject(db, req.user), req.params.ns, { writer: { type: 'user' }, ctx: req.ov });
if (out.status === 404 && out.code === 'modules.unknown_namespace') return problem(res, out, req);
if (out.status === 404 && out.code === 'modules.unknown_namespace') return problem(res, out, req);
res.status(out.status === 204 ? 204 : 404).end();
res.status(out.status === 204 ? 204 : 404).end();
});
});
@@ -379,14 +338,14 @@ function userRouter(requireAuth) {
@@ -379,14 +338,14 @@ function userRouter(requireAuth) {
/** Service routes; mounted under /internal (after requireInternalKey) with principal guards. */
/** Service routes; mounted under /internal (after requireInternalKey) with principal guards. */
function serviceRoutes(router, principals) {
function serviceRoutes(router, principals) {
const nsOf = (req) => req.params.ns;
const nsOf = (req) => req.params.ns;
- const resolveSubject = (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);
+ const resolveSubject = async (db, s) => (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(s) && await db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(s) ? s : null);
const serviceOf = (req) => String(req.principal.sub).replace(/^svc:/, '');
const serviceOf = (req) => String(req.principal.sub).replace(/^svc:/, '');
- router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf }), (req, res) => {
+ router.get('/modules/:ns/:subject', principals.guard('network.modules.read', { namespace: nsOf }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
if (!modules.get(req.params.ns)) return problem(res, { status: 404, code: 'modules.unknown_namespace' }, req);
- const sid = resolveSubject(db, req.params.subject);
- const rec = sid && read(db, sid, req.params.ns);
+ const sid = await resolveSubject(db, req.params.subject);
+ const rec = sid && await read(db, sid, req.params.ns);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
if (!rec) return problem(res, { status: 404, code: 'modules.not_found' }, req);
// Field-level read rules: the owner reads everything, another service what `readers` lists for it.
// Field-level read rules: the owner reads everything, another service what `readers` lists for it.
const svc = serviceOf(req);
const svc = serviceOf(req);
@@ -394,24 +353,24 @@ function serviceRoutes(router, principals) {
@@ -394,24 +353,24 @@ function serviceRoutes(router, principals) {
withEtag(res, rec).json(rec);
withEtag(res, rec).json(rec);
});
});
- router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), (req, res) => {
+ router.put('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const sid = resolveSubject(db, req.params.subject);
+ const sid = await resolveSubject(db, req.params.subject);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
const rev = revisionHeader(req);
const rev = revisionHeader(req);
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
- const out = write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
+ const out = await write(db, sid, req.params.ns, req.body && req.body.data, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
if (!out.record) return problem(res, out, req);
if (!out.record) return problem(res, out, req);
withEtag(res, out.record).status(out.status).json(out.record);
withEtag(res, out.record).status(out.status).json(out.record);
});
});
- router.delete('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), (req, res) => {
+ router.delete('/modules/:ns/:subject', principals.guard('network.modules.write', { namespace: nsOf }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const sid = resolveSubject(db, req.params.subject);
+ const sid = await resolveSubject(db, req.params.subject);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
if (!sid) return problem(res, { status: 404, code: 'identity.subject_not_found' }, req);
const rev = revisionHeader(req);
const rev = revisionHeader(req);
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
if (Number.isNaN(rev)) return problem(res, { status: 400, code: 'modules.bad_revision' }, req);
- const out = remove(db, sid, req.params.ns, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
+ const out = await remove(db, sid, req.params.ns, { writer: { type: 'service', id: serviceOf(req) }, expectedRevision: rev, ctx: req.ov });
if (out.status !== 204) return problem(res, out, req);
if (out.status !== 204) return problem(res, out, req);
res.set('Cache-Control', 'private, no-store').status(204).end();
res.set('Cache-Control', 'private, no-store').status(204).end();
});
});
@@ -262,56 +262,32 @@ const CHANGED_DEFAULT_NAMESPACES = [
@@ -262,56 +262,32 @@ const CHANGED_DEFAULT_NAMESPACES = [
['live', 'ai.run.read', 'openvibe.ai', ['live.*', 'network.site_copy'], ['live.*', 'network.site_copy', 'media.analyze']],
['live', 'ai.run.read', 'openvibe.ai', ['live.*', 'network.site_copy'], ['live.*', 'network.site_copy', 'media.analyze']],
];
];
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS principal_grants (
- client_id TEXT NOT NULL,
- capability TEXT NOT NULL,
- audience TEXT NOT NULL,
- namespaces TEXT NOT NULL DEFAULT '[]',
- granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- granted_by TEXT,
- revoked_at DATETIME,
- PRIMARY KEY (client_id, capability, audience)
- );
- CREATE TABLE IF NOT EXISTS principal_usage (
- principal TEXT NOT NULL,
- route TEXT NOT NULL,
- capability TEXT NOT NULL,
- auth TEXT NOT NULL,
- allowed INTEGER NOT NULL,
- code TEXT NOT NULL DEFAULT '',
- count INTEGER NOT NULL DEFAULT 0,
- first_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- last_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (principal, route, auth, allowed, code)
- );
- `);
- // ADR-012 rule 5: loyalty is not transferable between people, so nobody holds the transfer grant.
+async function ensureSchema(db) {
+ // adr-012 rule 5: loyalty is not transferable between people, so nobody holds the transfer grant.
for (const [client, cap, aud] of REVOKED_GRANTS) {
for (const [client, cap, aud] of REVOKED_GRANTS) {
- db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);
+ await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = ? AND audience = ? AND revoked_at IS NULL").run(client, cap, aud);
}
}
for (const [client, cap, aud, was, now] of CHANGED_DEFAULT_NAMESPACES) {
for (const [client, cap, aud, was, now] of CHANGED_DEFAULT_NAMESPACES) {
- db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = ?")
+ await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = ?")
.run(JSON.stringify(now), client, cap, aud, JSON.stringify(was));
.run(JSON.stringify(now), client, cap, aud, JSON.stringify(was));
}
}
- const seed = db.prepare("INSERT OR IGNORE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default')");
+ const seed = db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES (?, ?, ?, ?, 'default') ON CONFLICT DO NOTHING");
// A default grant that later gained namespaces fills them in on a row still seeded without any
// A default grant that later gained namespaces fills them in on a row still seeded without any
// (INSERT OR IGNORE alone would leave it at []); a row someone edited is left alone.
// (INSERT OR IGNORE alone would leave it at []); a row someone edited is left alone.
const fillNs = db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = '[]'");
const fillNs = db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = ? AND capability = ? AND audience = ? AND granted_by = 'default' AND namespaces = '[]'");
for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {
for (const [client, cap, aud, ns] of DEFAULT_GRANTS) {
- if (!db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) continue;
- seed.run(client, cap, aud, JSON.stringify(ns));
- if (ns.length) fillNs.run(JSON.stringify(ns), client, cap, aud);
+ if (!await db.prepare('SELECT 1 FROM oauth_clients WHERE client_id = ?').get(client)) continue;
+ await seed.run(client, cap, aud, JSON.stringify(ns));
+ if (ns.length) await fillNs.run(JSON.stringify(ns), client, cap, aud);
}
}
// Owner changes (expiry, reason, who revoked) and their audit trail (WS-D task 3).
// Owner changes (expiry, reason, who revoked) and their audit trail (WS-D task 3).
- require('./grants-admin').ensureSchema(db);
+ await require('./grants-admin').ensureSchema(db);
}
}
-function grantsFor(db, clientId, audience) {
+async function grantsFor(db, clientId, audience) {
// An expired grant stops counting at once; grants-admin.expireDue() records the expiry.
// An expired grant stops counting at once; grants-admin.expireDue() records the expiry.
- return db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY capability')
- .all(clientId, audience).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));
+ return (await db.prepare('SELECT capability, namespaces FROM principal_grants WHERE client_id = ? AND audience = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ov_now()) ORDER BY capability')
+ .all(clientId, audience)).map(r => ({ capability: r.capability, namespaces: JSON.parse(r.namespaces || '[]') }));
}
}
function sameSecret(a, b) {
function sameSecret(a, b) {
@@ -324,13 +300,13 @@ function sameSecret(a, b) {
@@ -324,13 +300,13 @@ function sameSecret(a, b) {
* grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).
* grant_type=client_credentials. Returns { status, body } (OAuth error shapes on failure).
* `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.
* `scope` (space-separated capability ids) narrows the token; omitted = every grant for the audience.
*/
*/
-function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {
- const client = db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
+async function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, issuer }) {
+ const client = await db.prepare('SELECT client_id, client_secret FROM oauth_clients WHERE client_id = ?').get(String(clientId || ''));
if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };
if (!client || !sameSecret(client.client_secret, clientSecret)) return { status: 401, body: { error: 'invalid_client', error_description: 'Invalid client credentials' } };
if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };
if (!/^[a-z][a-z0-9-]{1,39}$/.test(client.client_id)) return { status: 400, body: { error: 'unauthorized_client', error_description: 'client id is not a service principal' } };
const aud = String(audience || '').trim();
const aud = String(audience || '').trim();
if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };
if (!aud) return { status: 400, body: { error: 'invalid_request', error_description: 'audience is required' } };
- const grants = grantsFor(db, client.client_id, aud);
+ const grants = await grantsFor(db, client.client_id, aud);
const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
const chosen = wanted ? grants.filter(g => wanted.includes(g.capability)) : grants;
const chosen = wanted ? grants.filter(g => wanted.includes(g.capability)) : grants;
if (wanted && wanted.some(w => !chosen.find(g => g.capability === w))) {
if (wanted && wanted.some(w => !chosen.find(g => g.capability === w))) {
@@ -351,12 +327,12 @@ function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, i
@@ -351,12 +327,12 @@ function issueToken(db, { clientId, clientSecret, audience, scope, privateKey, i
/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */
/** Audit hook for requireCapability: one counter row per (principal, route, auth, outcome). */
function recordDecision(db) {
function recordDecision(db) {
const up = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES (?, ?, ?, ?, ?, ?, 1)
const up = db.prepare(`INSERT INTO principal_usage (principal, route, capability, auth, allowed, code, count) VALUES (?, ?, ?, ?, ?, ?, 1)
- ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = count + 1, last_at = CURRENT_TIMESTAMP`);
- return ({ req, capability, principal, allowed, code }) => {
+ ON CONFLICT(principal, route, auth, allowed, code) DO UPDATE SET count = principal_usage.count + 1, last_at = ov_now()`);
+ return async ({ req, capability, principal, allowed, code }) => {
const bearer = String(req.headers.authorization || '').startsWith('Bearer ');
const bearer = String(req.headers.authorization || '').startsWith('Bearer ');
const auth = bearer ? 'service-token' : 'none';
const auth = bearer ? 'service-token' : 'none';
const who = principal ? principal.sub : 'unknown';
const who = principal ? principal.sub : 'unknown';
- try { up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }
+ try { await up.run(who, `${req.method} ${req.baseUrl || ''}${req.route ? req.route.path : req.path}`, capability, auth, allowed ? 1 : 0, code || ''); } catch { /* best effort */ }
};
};
}
}
@@ -370,6 +346,18 @@ function guard(capability, { ownApp, namespace } = {}) {
@@ -370,6 +346,18 @@ function guard(capability, { ownApp, namespace } = {}) {
let check = null;
let check = null;
let record = null;
let record = null;
return function principalGuard(req, res, next) {
return function principalGuard(req, res, next) {
+ // The audit row for this request is written before its response leaves: the contracts' decision hooks
+ // are synchronous while the insert is async, so the first res.end waits for the request's audits.
+ const audits = [];
+ req._ovAudits = audits;
+ const end = res.end.bind(res);
+ let ended = false;
+ res.end = (...args) => {
+ if (ended) return end(...args);
+ ended = true;
+ Promise.allSettled(audits).then(() => end(...args));
+ return res;
+ };
if (!check) {
if (!check) {
record = recordDecision(req.app.locals.db);
record = recordDecision(req.app.locals.db);
check = serviceAuth.requireCapability(capability, {
check = serviceAuth.requireCapability(capability, {
@@ -378,7 +366,12 @@ function guard(capability, { ownApp, namespace } = {}) {
@@ -378,7 +366,12 @@ function guard(capability, { ownApp, namespace } = {}) {
audience: SELF_AUDIENCE,
audience: SELF_AUDIENCE,
namespace,
namespace,
// Denials are final here; an allow is recorded below, after the ownership check.
// Denials are final here; an allow is recorded below, after the ownership check.
- onDecision: (d) => { if (!d.allowed) { record(d); require('../observability').principalDenied(d); } },
+ onDecision: (d) => {
+ if (d.allowed) return;
+ const list = d.req && d.req._ovAudits;
+ if (list) list.push(record(d));
+ require('../observability').principalDenied(d);
+ },
});
});
}
}
check(req, res, () => {
check(req, res, () => {
@@ -390,7 +383,7 @@ function guard(capability, { ownApp, namespace } = {}) {
@@ -390,7 +383,7 @@ function guard(capability, { ownApp, namespace } = {}) {
const claims = devPolicy.unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());
const claims = devPolicy.unverifiedClaims(String(req.headers.authorization || '').slice(7).trim());
const env = devPolicy.environmentDecision(claims, { acceptSandbox: devPolicy.settings(req.app.locals.config).sandboxAudiences.has(SELF_AUDIENCE) });
const env = devPolicy.environmentDecision(claims, { acceptSandbox: devPolicy.settings(req.app.locals.config).sandboxAudiences.has(SELF_AUDIENCE) });
if (!env.ok) {
if (!env.ok) {
- record({ req, capability, principal, allowed: false, code: env.code });
+ audits.push(record({ req, capability, principal, allowed: false, code: env.code }));
require('../observability').principalDenied({ req, code: env.code });
require('../observability').principalDenied({ req, code: env.code });
return http.sendProblem(res, 401, env.code, { detail: env.reason, ctx: req.ov });
return http.sendProblem(res, 401, env.code, { detail: env.reason, ctx: req.ov });
}
}
@@ -399,12 +392,12 @@ function guard(capability, { ownApp, namespace } = {}) {
@@ -399,12 +392,12 @@ function guard(capability, { ownApp, namespace } = {}) {
const app = ownApp(req);
const app = ownApp(req);
const self = String(principal.sub).replace(/^svc:/, '');
const self = String(principal.sub).replace(/^svc:/, '');
if (app !== undefined && app !== self) {
if (app !== undefined && app !== self) {
- record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' });
+ audits.push(record({ req, capability, principal, allowed: false, code: 'capability.owner_denied' }));
require('../observability').principalDenied({ req, code: 'capability.owner_denied' });
require('../observability').principalDenied({ req, code: 'capability.owner_denied' });
return http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${principal.sub} may only act for app_id '${self}'` });
return http.sendProblem(res, 403, 'capability.owner_denied', { detail: `${principal.sub} may only act for app_id '${self}'` });
}
}
}
}
- record({ req, capability, principal, allowed: true, code: null });
+ audits.push(record({ req, capability, principal, allowed: true, code: null }));
next();
next();
});
});
};
};
@@ -22,30 +22,18 @@ const ROLES = ['user', 'streamer', 'global_mod', 'admin'];
@@ -22,30 +22,18 @@ const ROLES = ['user', 'streamer', 'global_mod', 'admin'];
const FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned'];
const FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'role', 'is_banned'];
const NAME = { is_banned: 'banned' };
const NAME = { is_banned: 'banned' };
-function ensureSchema(db) {
- const cols = new Set(db.prepare('PRAGMA table_info(users)').all().map((c) => c.name));
- const first = !cols.has('profile_revision');
- if (first) db.exec('ALTER TABLE users ADD COLUMN profile_revision INTEGER NOT NULL DEFAULT 0');
- const diff = FIELDS.map((f) => `(CASE WHEN OLD.${f} IS NOT NEW.${f} THEN '${NAME[f] || f} ' ELSE '' END)`).join(' || ');
- const any = FIELDS.map((f) => `OLD.${f} IS NOT NEW.${f}`).join(' OR ');
- db.exec(`
- CREATE TABLE IF NOT EXISTS user_profile_changes (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- changed TEXT NOT NULL,
- at INTEGER NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_user_profile_changes_user ON user_profile_changes(user_id);
- CREATE TRIGGER IF NOT EXISTS users_profile_changed AFTER UPDATE OF ${FIELDS.join(', ')} ON users
- WHEN COALESCE(NEW.is_anon, 0) = 0 AND (${any})
- BEGIN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, trim(${diff}), CAST(strftime('%s', 'now') AS INTEGER) * 1000); END;
- CREATE TRIGGER IF NOT EXISTS users_profile_created AFTER INSERT ON users
- WHEN COALESCE(NEW.is_anon, 0) = 0
- BEGIN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, 'created', CAST(strftime('%s', 'now') AS INTEGER) * 1000); END;
- `);
- // The first time: every existing account is announced once ('created'), so consumers can build their
- // projection of everyone, not only of people who change something later.
- if (first) db.prepare("INSERT INTO user_profile_changes (user_id, changed, at) SELECT id, 'created', ? FROM users WHERE COALESCE(is_anon, 0) = 0").run(Date.now());
+/**
+ * The table and the two users triggers are migrations/NNNN_*.sql (plan T2). What is left is the data
+ * work: an account nothing has recorded yet — one the import brought in, or one from before this module
+ * existed — is announced once ('created'), so consumers can build their projection of everyone, not only
+ * of people who change something later. Idempotent: once announced, the row exists and profile_revision
+ * has moved.
+ */
+async function ensureSchema(db) {
+ await db.prepare(`INSERT INTO user_profile_changes (user_id, changed, at)
+ SELECT u.id, 'created', ? FROM users u
+ WHERE COALESCE(u.is_anon, 0) = 0 AND COALESCE(u.profile_revision, 0) = 0
+ AND NOT EXISTS (SELECT 1 FROM user_profile_changes c WHERE c.user_id = u.id)`).run(Date.now());
eventRelay.writerFor(db);
eventRelay.writerFor(db);
}
}
@@ -60,25 +48,25 @@ function payloadOf(u, revision, changed) {
@@ -60,25 +48,25 @@ function payloadOf(u, revision, changed) {
}
}
/** Turn recorded changes into events. → how many events were queued */
/** Turn recorded changes into events. → how many events were queued */
-function drain(db, { limit = 500 } = {}) {
- const rows = db.prepare('SELECT id, user_id, changed FROM user_profile_changes ORDER BY id LIMIT ?').all(limit);
+async function drain(db, { limit = 500 } = {}) {
+ const rows = await db.prepare('SELECT id, user_id, changed FROM user_profile_changes ORDER BY id LIMIT ?').all(limit);
if (!rows.length) return 0;
if (!rows.length) return 0;
const byUser = new Map();
const byUser = new Map();
for (const r of rows) { if (!byUser.has(r.user_id)) byUser.set(r.user_id, []); byUser.get(r.user_id).push(r); }
for (const r of rows) { if (!byUser.has(r.user_id)) byUser.set(r.user_id, []); byUser.get(r.user_id).push(r); }
let queued = 0;
let queued = 0;
for (const [userId, list] of byUser) {
for (const [userId, list] of byUser) {
- db.transaction(() => {
+ await db.tx(async () => {
const del = db.prepare('DELETE FROM user_profile_changes WHERE id = ?');
const del = db.prepare('DELETE FROM user_profile_changes WHERE id = ?');
- const u = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
+ const u = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
// A deleted, guest or not-yet-subject account: nothing to announce (a subject assignment is an update
// A deleted, guest or not-yet-subject account: nothing to announce (a subject assignment is an update
// of subject_id, which the created event waits for below).
// of subject_id, which the created event waits for below).
if (!u || u.is_anon || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(u.subject_id || ''))) {
if (!u || u.is_anon || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(u.subject_id || ''))) {
- if (!u || u.is_anon) for (const r of list) del.run(r.id);
+ if (!u || u.is_anon) for (const r of list) await del.run(r.id);
return;
return;
}
}
const changed = [...new Set(list.flatMap((r) => r.changed.split(' ').filter(Boolean)))];
const changed = [...new Set(list.flatMap((r) => r.changed.split(' ').filter(Boolean)))];
const revision = (Number(u.profile_revision) || 0) + 1;
const revision = (Number(u.profile_revision) || 0) + 1;
- db.prepare('UPDATE users SET profile_revision = ? WHERE id = ?').run(revision, u.id);
+ await db.prepare('UPDATE users SET profile_revision = ? WHERE id = ?').run(revision, u.id);
const ms = Date.now();
const ms = Date.now();
const env = {
const env = {
event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
event_id: ids.newId('event', ms), event_type: EVENT_TYPE, version: 1, source: 'network',
@@ -88,20 +76,20 @@ function drain(db, { limit = 500 } = {}) {
@@ -88,20 +76,20 @@ function drain(db, { limit = 500 } = {}) {
const v = validate('events.event-envelope@1', env);
const v = validate('events.event-envelope@1', env);
const pv = validate('network.user.updated@1', env.payload);
const pv = validate('network.user.updated@1', env.payload);
if (!v.valid || !pv.valid) throw new Error(`profile-events: bad event for user ${u.id}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
if (!v.valid || !pv.valid) throw new Error(`profile-events: bad event for user ${u.id}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
- eventRelay.writerFor(db).enqueue(env);
- for (const r of list) del.run(r.id);
+ await eventRelay.writerFor(db).enqueue(env);
+ for (const r of list) await del.run(r.id);
queued++;
queued++;
- })();
+ });
}
}
if (queued) { const live = eventRelay.outboxFor(db); if (live) live.kick(); }
if (queued) { const live = eventRelay.outboxFor(db); if (live) live.kick(); }
return queued;
return queued;
}
}
let timer = null;
let timer = null;
-function start(db, { intervalMs = 3000, log = console } = {}) {
+async function start(db, { intervalMs = 3000, log = console } = {}) {
if (timer) return;
if (timer) return;
- ensureSchema(db);
- timer = setInterval(() => { try { drain(db); } catch (err) { log.warn && log.warn('[Profile events]', err.message); } }, intervalMs);
+ await ensureSchema(db);
+ timer = setInterval(async () => { try { await drain(db); } catch (err) { log.warn && log.warn('[Profile events]', err.message); } }, intervalMs);
if (timer.unref) timer.unref();
if (timer.unref) timer.unref();
}
}
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -14,46 +14,30 @@ const { ids, validate } = require('openvibe-contracts');
@@ -14,46 +14,30 @@ const { ids, validate } = require('openvibe-contracts');
const SELF = 'network';
const SELF = 'network';
-/** SQLite CURRENT_TIMESTAMP ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */
+/** SQLite ov_now() ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */
function createdMs(value) {
function createdMs(value) {
const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;
const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;
return Number.isFinite(t) ? t : Date.now();
return Number.isFinite(t) ? t : Date.now();
}
}
-function ensureSchema(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS identity_legacy_map (
- source_system TEXT NOT NULL,
- source_type TEXT NOT NULL,
- source_id TEXT NOT NULL,
- subject_id TEXT NOT NULL,
- first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- verified_at DATETIME,
- metadata TEXT,
- PRIMARY KEY (source_system, source_type, source_id)
- );
- CREATE INDEX IF NOT EXISTS idx_identity_legacy_subject ON identity_legacy_map(subject_id);
- CREATE UNIQUE INDEX IF NOT EXISTS idx_users_subject_id ON users(subject_id);
- CREATE UNIQUE INDEX IF NOT EXISTS idx_anon_users_subject_id ON anon_users(subject_id);
- `);
- const filled = backfill(db);
- const seeded = seedLegacyMap(db);
+async function ensureSchema(db) {
+ const filled = await backfill(db);
+ const seeded = await seedLegacyMap(db);
if (filled.users || filled.guests || seeded) {
if (filled.users || filled.guests || seeded) {
console.log(`[Identity] subject ids: ${filled.users} users, ${filled.guests} guests assigned; ${seeded} legacy map rows seeded`);
console.log(`[Identity] subject ids: ${filled.users} users, ${filled.guests} guests assigned; ${seeded} legacy map rows seeded`);
}
}
}
}
/** Give every row without a subject id one. Idempotent; runs at boot. */
/** Give every row without a subject id one. Idempotent; runs at boot. */
-function backfill(db) {
+async function backfill(db) {
const out = { users: 0, guests: 0 };
const out = { users: 0, guests: 0 };
- const run = db.transaction(() => {
+ await db.tx(async () => {
for (const [table, kind, key, born] of [['users', 'user', 'users', 'created_at'], ['anon_users', 'guest', 'guests', 'first_seen']]) {
for (const [table, kind, key, born] of [['users', 'user', 'users', 'created_at'], ['anon_users', 'guest', 'guests', 'first_seen']]) {
- const rows = db.prepare(`SELECT id, ${born} AS created_at FROM ${table} WHERE subject_id IS NULL`).all();
+ const rows = await db.prepare(`SELECT id, ${born} AS created_at FROM ${table} WHERE subject_id IS NULL`).all();
const set = db.prepare(`UPDATE ${table} SET subject_id = ? WHERE id = ? AND subject_id IS NULL`);
const set = db.prepare(`UPDATE ${table} SET subject_id = ? WHERE id = ? AND subject_id IS NULL`);
- for (const r of rows) out[key] += set.run(ids.newId(kind, createdMs(r.created_at)), r.id).changes;
+ for (const r of rows) out[key] += (await set.run(ids.newId(kind, createdMs(r.created_at)), r.id)).changes;
}
}
});
});
- run();
return out;
return out;
}
}
@@ -62,40 +46,40 @@ function backfill(db) {
@@ -62,40 +46,40 @@ function backfill(db) {
* accounts migrated from an older platform (users.legacy_source/legacy_id), and site-local ids
* accounts migrated from an older platform (users.legacy_source/legacy_id), and site-local ids
* reported through /internal/link-account.
* reported through /internal/link-account.
*/
*/
-function seedLegacyMap(db) {
- const ins = db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`);
+async function seedLegacyMap(db) {
+ const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
+ VALUES (?, ?, ?, ?, ov_now()) ON CONFLICT DO NOTHING`);
let n = 0;
let n = 0;
- db.transaction(() => {
- for (const u of db.prepare('SELECT id, subject_id, legacy_source, legacy_id FROM users WHERE subject_id IS NOT NULL').all()) {
- n += ins.run(SELF, 'user', String(u.id), u.subject_id).changes;
- if (u.legacy_source && u.legacy_id != null) n += ins.run(String(u.legacy_source), 'user', String(u.legacy_id), u.subject_id).changes;
+ await db.tx(async () => {
+ for (const u of await db.prepare('SELECT id, subject_id, legacy_source, legacy_id FROM users WHERE subject_id IS NOT NULL').all()) {
+ n += (await ins.run(SELF, 'user', String(u.id), u.subject_id)).changes;
+ if (u.legacy_source && u.legacy_id != null) n += (await ins.run(String(u.legacy_source), 'user', String(u.legacy_id), u.subject_id)).changes;
}
}
- for (const g of db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {
- n += ins.run(SELF, 'anon_user', String(g.id), g.subject_id).changes;
+ for (const g of await db.prepare('SELECT id, subject_id FROM anon_users WHERE subject_id IS NOT NULL').all()) {
+ n += (await ins.run(SELF, 'anon_user', String(g.id), g.subject_id)).changes;
}
}
// Sites that report their own local id through /internal/link-account (Live does). Rows written by
// Sites that report their own local id through /internal/link-account (Live does). Rows written by
// the OAuth exchange hold 'network:<id>' instead and say nothing about the site's ids.
// the OAuth exchange hold 'network:<id>' instead and say nothing about the site's ids.
- for (const l of db.prepare(`SELECT la.service, la.service_user_id, u.subject_id FROM linked_accounts la JOIN users u ON u.id = la.user_id
+ for (const l of await db.prepare(`SELECT la.service, la.service_user_id, u.subject_id FROM linked_accounts la JOIN users u ON u.id = la.user_id
WHERE u.subject_id IS NOT NULL AND la.service_user_id NOT LIKE 'network:%'`).all()) {
WHERE u.subject_id IS NOT NULL AND la.service_user_id NOT LIKE 'network:%'`).all()) {
- if (/^[a-z][a-z0-9-]{1,39}$/.test(l.service)) n += ins.run(l.service, 'user', String(l.service_user_id), l.subject_id).changes;
+ if (/^[a-z][a-z0-9-]{1,39}$/.test(l.service)) n += (await ins.run(l.service, 'user', String(l.service_user_id), l.subject_id)).changes;
}
}
- })();
+ });
return n;
return n;
}
}
/** Subject id for a users row, assigning one if an insert path skipped it. */
/** Subject id for a users row, assigning one if an insert path skipped it. */
-function ensureUserSubject(db, user) {
+async function ensureUserSubject(db, user) {
if (!user) return null;
if (!user) return null;
if (user.subject_id) return user.subject_id;
if (user.subject_id) return user.subject_id;
- const row = db.prepare('SELECT subject_id, created_at FROM users WHERE id = ?').get(user.id);
+ const row = await db.prepare('SELECT subject_id, created_at FROM users WHERE id = ?').get(user.id);
if (!row) return null;
if (!row) return null;
if (!row.subject_id) {
if (!row.subject_id) {
const sid = ids.newId('user', createdMs(row.created_at));
const sid = ids.newId('user', createdMs(row.created_at));
- db.prepare('UPDATE users SET subject_id = ? WHERE id = ? AND subject_id IS NULL').run(sid, user.id);
- db.prepare(`INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
- VALUES (?, 'user', ?, ?, CURRENT_TIMESTAMP)`).run(SELF, String(user.id), sid);
- row.subject_id = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id).subject_id;
+ await db.prepare('UPDATE users SET subject_id = ? WHERE id = ? AND subject_id IS NULL').run(sid, user.id);
+ await db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at)
+ VALUES (?, 'user', ?, ?, ov_now()) ON CONFLICT DO NOTHING`).run(SELF, String(user.id), sid);
+ row.subject_id = (await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id)).subject_id;
}
}
user.subject_id = row.subject_id;
user.subject_id = row.subject_id;
return row.subject_id;
return row.subject_id;
@@ -106,10 +90,10 @@ const newUserSubjectId = () => ids.newId('user');
@@ -106,10 +90,10 @@ const newUserSubjectId = () => ids.newId('user');
const newGuestSubjectId = () => ids.newId('guest');
const newGuestSubjectId = () => ids.newId('guest');
/** Public projection of a subject. Never includes email, role internals or secrets. */
/** Public projection of a subject. Never includes email, role internals or secrets. */
-function projection(db, subjectId) {
+async function projection(db, subjectId) {
if (typeof subjectId !== 'string') return null;
if (typeof subjectId !== 'string') return null;
if (subjectId.startsWith('usr_')) {
if (subjectId.startsWith('usr_')) {
- const u = db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);
+ const u = await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);
if (!u) return null;
if (!u) return null;
// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.
// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.
if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };
if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };
@@ -117,7 +101,7 @@ function projection(db, subjectId) {
@@ -117,7 +101,7 @@ function projection(db, subjectId) {
display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
}
}
if (subjectId.startsWith('gst_')) {
if (subjectId.startsWith('gst_')) {
- const g = db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);
+ const g = await db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);
if (!g) return null;
if (!g) return null;
return { subject: { type: 'guest', id: g.subject_id }, network_anon_id: g.id, username: `anon${g.anon_number}`,
return { subject: { type: 'guest', id: g.subject_id }, network_anon_id: g.id, username: `anon${g.anon_number}`,
display_name: g.display_name || `anon${g.anon_number}`, avatar_url: null, banned: false };
display_name: g.display_name || `anon${g.anon_number}`, avatar_url: null, banned: false };
@@ -126,24 +110,24 @@ function projection(db, subjectId) {
@@ -126,24 +110,24 @@ function projection(db, subjectId) {
}
}
/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */
/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */
-function resolve(db, { subject_id, source_system, source_type, source_id }) {
+async function resolve(db, { subject_id, source_system, source_type, source_id }) {
let sid = subject_id;
let sid = subject_id;
if (!sid) {
if (!sid) {
if (source_system === SELF && source_type === 'user') {
if (source_system === SELF && source_type === 'user') {
- const u = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?').get(Number(source_id));
- sid = u ? ensureUserSubject(db, u) : null;
+ const u = await db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?').get(Number(source_id));
+ sid = u ? await ensureUserSubject(db, u) : null;
} else {
} else {
- const row = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
+ const row = await db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
.get(String(source_system), String(source_type || 'user'), String(source_id));
.get(String(source_system), String(source_type || 'user'), String(source_id));
sid = row && row.subject_id;
sid = row && row.subject_id;
}
}
}
}
// A subject merged into another (ADR-029) resolves to the survivor, saying where it came from.
// A subject merged into another (ADR-029) resolves to the survivor, saying where it came from.
- const survivor = sid ? require('./account-merge').survivorOf(db, sid) : sid;
- const p = survivor ? projection(db, survivor) : null;
+ const survivor = sid ? await require('./account-merge').survivorOf(db, sid) : sid;
+ const p = survivor ? await projection(db, survivor) : null;
if (!p) return null;
if (!p) return null;
if (survivor !== sid) p.merged_from = sid;
if (survivor !== sid) p.merged_from = sid;
- p.legacy_ids = db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')
+ p.legacy_ids = await db.prepare('SELECT source_system, source_type, source_id FROM identity_legacy_map WHERE subject_id = ? ORDER BY source_system, source_type')
.all(p.subject.id);
.all(p.subject.id);
return p;
return p;
}
}
@@ -154,35 +138,35 @@ function resolve(db, { subject_id, source_system, source_type, source_id }) {
@@ -154,35 +138,35 @@ function resolve(db, { subject_id, source_system, source_type, source_id }) {
* are refused: Network is the authority for its own ids.
* are refused: Network is the authority for its own ids.
* Returns { inserted, unchanged, conflicts: [...], rejected: [...] }.
* Returns { inserted, unchanged, conflicts: [...], rejected: [...] }.
*/
*/
-function upsertLegacy(db, entries) {
+async function upsertLegacy(db, entries) {
const out = { inserted: 0, unchanged: 0, conflicts: [], rejected: [] };
const out = { inserted: 0, unchanged: 0, conflicts: [], rejected: [] };
const get = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?');
const get = db.prepare('SELECT subject_id FROM identity_legacy_map WHERE source_system = ? AND source_type = ? AND source_id = ?');
const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at, metadata)
const ins = db.prepare(`INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id, verified_at, metadata)
- VALUES (?, ?, ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?)`);
+ VALUES (?, ?, ?, ?, CASE WHEN ? THEN ov_now() END, ?)`);
const byNetworkId = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?');
const byNetworkId = db.prepare('SELECT id, subject_id, created_at FROM users WHERE id = ?');
- db.transaction(() => {
- entries.forEach((e, i) => {
+ await db.tx(async () => {
+ for (const [i, e] of entries.entries()) {
let subjectId = e.subject_id;
let subjectId = e.subject_id;
if (!subjectId && e.network_user_id != null) {
if (!subjectId && e.network_user_id != null) {
- const u = byNetworkId.get(Number(e.network_user_id));
- subjectId = u ? ensureUserSubject(db, u) : null;
+ const u = await byNetworkId.get(Number(e.network_user_id));
+ subjectId = u ? await ensureUserSubject(db, u) : null;
}
}
const row = { subject_id: subjectId, source_system: e.source_system, source_type: e.source_type || 'user', source_id: e.source_id == null ? '' : String(e.source_id) };
const row = { subject_id: subjectId, source_system: e.source_system, source_type: e.source_type || 'user', source_id: e.source_id == null ? '' : String(e.source_id) };
const v = validate('identity.legacy-identity-map@1', row);
const v = validate('identity.legacy-identity-map@1', row);
- if (!subjectId || !v.valid || row.source_system === SELF || !projection(db, subjectId)) {
+ if (!subjectId || !v.valid || row.source_system === SELF || !await projection(db, subjectId)) {
out.rejected.push({ index: i, reason: !subjectId ? 'unknown network account' : row.source_system === SELF ? 'network ids are not writable' : !v.valid ? v.errors.map(x => `${x.path} ${x.message}`).join('; ') : 'unknown subject' });
out.rejected.push({ index: i, reason: !subjectId ? 'unknown network account' : row.source_system === SELF ? 'network ids are not writable' : !v.valid ? v.errors.map(x => `${x.path} ${x.message}`).join('; ') : 'unknown subject' });
- return;
+ continue;
}
}
- const existing = get.get(row.source_system, row.source_type, row.source_id);
+ const existing = await get.get(row.source_system, row.source_type, row.source_id);
if (existing) {
if (existing) {
if (existing.subject_id === subjectId) out.unchanged++;
if (existing.subject_id === subjectId) out.unchanged++;
else out.conflicts.push({ index: i, source: `${row.source_system}:${row.source_type}:${row.source_id}`, mapped_to: existing.subject_id, requested: subjectId });
else out.conflicts.push({ index: i, source: `${row.source_system}:${row.source_type}:${row.source_id}`, mapped_to: existing.subject_id, requested: subjectId });
- return;
+ continue;
}
}
- ins.run(row.source_system, row.source_type, row.source_id, subjectId, e.verified ? 1 : 0, e.metadata ? JSON.stringify(e.metadata) : null);
+ await ins.run(row.source_system, row.source_type, row.source_id, subjectId, e.verified ? 1 : 0, e.metadata ? JSON.stringify(e.metadata) : null);
out.inserted++;
out.inserted++;
- });
- })();
+ }
+ });
return out;
return out;
}
}
@@ -16,35 +16,24 @@ const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;
@@ -16,35 +16,24 @@ const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;
const RESERVE_DAYS = 180;
const RESERVE_DAYS = 180;
const SYSTEM_NAMES = new Set(['live', 'system', 'openvibelivebot']);
const SYSTEM_NAMES = new Set(['live', 'system', 'openvibelivebot']);
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS username_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user_id INTEGER NOT NULL,
- old_username TEXT NOT NULL,
- new_username TEXT NOT NULL,
- changed_by INTEGER,
- changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
- CREATE INDEX IF NOT EXISTS idx_username_history_old ON username_history(old_username COLLATE NOCASE);
- CREATE INDEX IF NOT EXISTS idx_username_history_user ON username_history(user_id);`);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** Whether `name` is an old username someone other than `exceptUserId` gave up within RESERVE_DAYS. */
/** Whether `name` is an old username someone other than `exceptUserId` gave up within RESERVE_DAYS. */
-function isReserved(db, name, exceptUserId = null) {
+async function isReserved(db, name, exceptUserId = null) {
ensureSchema(db);
ensureSchema(db);
- const row = db.prepare(`SELECT user_id FROM username_history WHERE old_username = ? COLLATE NOCASE
+ const row = await db.prepare(`SELECT user_id FROM username_history WHERE lower(old_username) = lower(?)
AND changed_at > datetime('now', ?) ORDER BY id DESC LIMIT 1`).get(String(name), `-${RESERVE_DAYS} days`);
AND changed_at > datetime('now', ?) ORDER BY id DESC LIMIT 1`).get(String(name), `-${RESERVE_DAYS} days`);
return Boolean(row && row.user_id !== exceptUserId);
return Boolean(row && row.user_id !== exceptUserId);
}
}
/** Why `name` cannot be used, or null. */
/** Why `name` cannot be used, or null. */
-function problemWith(db, name, userId = null) {
+async function problemWith(db, name, userId = null) {
if (!NAME_RE.test(name)) return 'Usernames are 3-24 letters, numbers and underscores';
if (!NAME_RE.test(name)) return 'Usernames are 3-24 letters, numbers and underscores';
if (/^anon/i.test(name)) return 'Usernames cannot start with "anon"';
if (/^anon/i.test(name)) return 'Usernames cannot start with "anon"';
if (SYSTEM_NAMES.has(name.toLowerCase())) return 'That name is reserved by the system';
if (SYSTEM_NAMES.has(name.toLowerCase())) return 'That name is reserved by the system';
- const taken = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(name);
+ const taken = await db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(name);
if (taken && taken.id !== userId) return 'That username is taken';
if (taken && taken.id !== userId) return 'That username is taken';
- if (isReserved(db, name, userId)) return `That name belonged to someone else in the last ${RESERVE_DAYS} days`;
+ if (await isReserved(db, name, userId)) return `That name belonged to someone else in the last ${RESERVE_DAYS} days`;
return null;
return null;
}
}
@@ -52,30 +41,30 @@ function problemWith(db, name, userId = null) {
@@ -52,30 +41,30 @@ function problemWith(db, name, userId = null) {
* Rename a user. A display name that was just the old username (any capitalisation) follows it.
* Rename a user. A display name that was just the old username (any capitalisation) follows it.
* @returns {{ from: string, to: string }}
* @returns {{ from: string, to: string }}
*/
*/
-function rename(db, userId, newName, { actorId = null } = {}) {
+async function rename(db, userId, newName, { actorId = null } = {}) {
ensureSchema(db);
ensureSchema(db);
const to = String(newName || '').trim();
const to = String(newName || '').trim();
- return db.transaction(() => {
- const user = db.prepare('SELECT id, username, display_name FROM users WHERE id = ?').get(userId);
+ return await db.tx(async () => {
+ const user = await db.prepare('SELECT id, username, display_name FROM users WHERE id = ?').get(userId);
if (!user) throw Object.assign(new Error('No such user'), { status: 404 });
if (!user) throw Object.assign(new Error('No such user'), { status: 404 });
if (to === user.username) throw Object.assign(new Error('That is already the username'), { status: 400 });
if (to === user.username) throw Object.assign(new Error('That is already the username'), { status: 400 });
- const problem = problemWith(db, to, user.id);
+ const problem = await problemWith(db, to, user.id);
if (problem) throw Object.assign(new Error(problem), { status: 409 });
if (problem) throw Object.assign(new Error(problem), { status: 409 });
- db.prepare('INSERT INTO username_history (user_id, old_username, new_username, changed_by) VALUES (?, ?, ?, ?)').run(user.id, user.username, to, actorId);
+ await db.prepare('INSERT INTO username_history (user_id, old_username, new_username, changed_by) VALUES (?, ?, ?, ?)').run(user.id, user.username, to, actorId);
const followDisplay = !user.display_name || user.display_name.toLowerCase() === user.username.toLowerCase();
const followDisplay = !user.display_name || user.display_name.toLowerCase() === user.username.toLowerCase();
- db.prepare(`UPDATE users SET username = ?${followDisplay ? ', display_name = ?' : ''}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`)
+ await db.prepare(`UPDATE users SET username = ?${followDisplay ? ', display_name = ?' : ''}, updated_at = ov_now() WHERE id = ?`)
.run(...(followDisplay ? [to, to, user.id] : [to, user.id]));
.run(...(followDisplay ? [to, to, user.id] : [to, user.id]));
return { from: user.username, to };
return { from: user.username, to };
- })();
+ });
}
}
/** The current username for an old one, or null (never for a name that is someone's current name). */
/** The current username for an old one, or null (never for a name that is someone's current name). */
-function renamedTo(db, oldName) {
+async function renamedTo(db, oldName) {
ensureSchema(db);
ensureSchema(db);
if (!NAME_RE.test(String(oldName || ''))) return null;
if (!NAME_RE.test(String(oldName || ''))) return null;
- if (db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?)').get(oldName)) return null;
- const row = db.prepare(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id
- WHERE h.old_username = ? COLLATE NOCASE ORDER BY h.id DESC LIMIT 1`).get(oldName);
+ if (await db.prepare('SELECT 1 FROM users WHERE LOWER(username) = LOWER(?)').get(oldName)) return null;
+ const row = await db.prepare(`SELECT u.username FROM username_history h JOIN users u ON u.id = h.user_id
+ WHERE lower(h.old_username) = lower(?) ORDER BY h.id DESC LIMIT 1`).get(oldName);
return row ? row.username : null;
return row ? row.username : null;
}
}
@@ -83,26 +72,26 @@ function renamedTo(db, oldName) {
@@ -83,26 +72,26 @@ function renamedTo(db, oldName) {
* The public record for a name: { current, network_id, renamed, previous_names } when it is someone's
* The public record for a name: { current, network_id, renamed, previous_names } when it is someone's
* current name or one they were renamed away from; null otherwise.
* current name or one they were renamed away from; null otherwise.
*/
*/
-function lookup(db, name) {
+async function lookup(db, name) {
ensureSchema(db);
ensureSchema(db);
if (!NAME_RE.test(String(name || ''))) return null;
if (!NAME_RE.test(String(name || ''))) return null;
- let user = db.prepare('SELECT id, username FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0').get(name);
+ let user = await db.prepare('SELECT id, username FROM users WHERE LOWER(username) = LOWER(?) AND COALESCE(is_banned, 0) = 0').get(name);
let renamed = false;
let renamed = false;
if (!user) {
if (!user) {
- const h = db.prepare('SELECT user_id FROM username_history WHERE old_username = ? COLLATE NOCASE ORDER BY id DESC LIMIT 1').get(name);
+ const h = await db.prepare('SELECT user_id FROM username_history WHERE lower(old_username) = lower(?) ORDER BY id DESC LIMIT 1').get(name);
if (!h) return null;
if (!h) return null;
- user = db.prepare('SELECT id, username FROM users WHERE id = ? AND COALESCE(is_banned, 0) = 0').get(h.user_id);
+ user = await db.prepare('SELECT id, username FROM users WHERE id = ? AND COALESCE(is_banned, 0) = 0').get(h.user_id);
if (!user) return null;
if (!user) return null;
renamed = true;
renamed = true;
}
}
- const previous = db.prepare('SELECT old_username FROM username_history WHERE user_id = ? ORDER BY id DESC').all(user.id).map(r => r.old_username);
+ const previous = (await db.prepare('SELECT old_username FROM username_history WHERE user_id = ? ORDER BY id DESC').all(user.id)).map(r => r.old_username);
return { current: user.username, network_id: user.id, renamed, previous_names: previous };
return { current: user.username, network_id: user.id, renamed, previous_names: previous };
}
}
/** A user's past usernames, newest first. */
/** A user's past usernames, newest first. */
-function historyOf(db, userId) {
+async function historyOf(db, userId) {
ensureSchema(db);
ensureSchema(db);
- return db.prepare('SELECT old_username, new_username, changed_at FROM username_history WHERE user_id = ? ORDER BY id DESC').all(userId);
+ return await db.prepare('SELECT old_username, new_username, changed_at FROM username_history WHERE user_id = ? ORDER BY id DESC').all(userId);
}
}
module.exports = { ensureSchema, isReserved, problemWith, rename, renamedTo, lookup, historyOf, RESERVE_DAYS, NAME_RE };
module.exports = { ensureSchema, isReserved, problemWith, rename, renamedTo, lookup, historyOf, RESERVE_DAYS, NAME_RE };
@@ -32,6 +32,9 @@ const createDeployRoutes = require('./deploy/routes');
@@ -32,6 +32,9 @@ const createDeployRoutes = require('./deploy/routes');
const createCoinsRoutes = require('./coins/routes');
const createCoinsRoutes = require('./coins/routes');
const { signToken } = require('./auth/routes');
const { signToken } = require('./auth/routes');
+// The boot runs asynchronously: opening PostgreSQL (migrations, seeds) is async (plan T2, ADR-035).
+const ready = (async () => {
+
const app = express();
const app = express();
// What this server is running (ADR-016, registry.release-manifest@1); open tabs poll it through
// What this server is running (ADR-016, registry.release-manifest@1); open tabs poll it through
@@ -50,8 +53,8 @@ function getRequestHost(req) {
@@ -50,8 +53,8 @@ function getRequestHost(req) {
return String(req.headers.host || '').split(':')[0].toLowerCase();
return String(req.headers.host || '').split(':')[0].toLowerCase();
}
}
-function ensureAdminUser(db, config) {
- const adminExists = db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();
+async function ensureAdminUser(db, config) {
+ const adminExists = await db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();
if (adminExists) return;
if (adminExists) return;
const username = config.admin.username || (config.nodeEnv !== 'production' ? 'admin' : null);
const username = config.admin.username || (config.nodeEnv !== 'production' ? 'admin' : null);
const password = config.admin.password || (config.nodeEnv !== 'production' ? 'admin' : null);
const password = config.admin.password || (config.nodeEnv !== 'production' ? 'admin' : null);
@@ -60,10 +63,10 @@ function ensureAdminUser(db, config) {
@@ -60,10 +63,10 @@ function ensureAdminUser(db, config) {
return;
return;
}
}
const passwordHash = bcrypt.hashSync(password, 10);
const passwordHash = bcrypt.hashSync(password, 10);
- db.prepare(`
+ await db.prepare(`
INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)
INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)
VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)
VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)
- ON CONFLICT(username) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash
+ ON CONFLICT (lower(username)) DO UPDATE SET role = 'admin', password_hash = excluded.password_hash
`).run(username, null, passwordHash, username, require('./identity/subjects').newUserSubjectId());
`).run(username, null, passwordHash, username, require('./identity/subjects').newUserSubjectId());
console.log(`[Setup] Admin user created or elevated: ${username}`);
console.log(`[Setup] Admin user created or elevated: ${username}`);
}
}
@@ -292,11 +295,11 @@ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
@@ -292,11 +295,11 @@ app.use('/api/', rateLimit({ windowMs: 60_000, max: 120 }));
app.use('/api/auth/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessfulRequests: true }));
app.use('/api/auth/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessfulRequests: true }));
// ── Database ─────────────────────────────────────────────────
// ── Database ─────────────────────────────────────────────────
-const db = initDb(config.db.path);
-urlRegistry.initializeUrlRegistry(db);
-urlRegistry.seedBootstrapRegistry(db, process.env, config.bootstrapProfile);
-ensureAdminUser(db, config);
-const resolvedRegistry = urlRegistry.getResolvedRegistry(db, process.env);
+const db = await initDb();
+await urlRegistry.initializeUrlRegistry(db);
+await urlRegistry.seedBootstrapRegistry(db, process.env, config.bootstrapProfile);
+await ensureAdminUser(db, config);
+const resolvedRegistry = await urlRegistry.getResolvedRegistry(db, process.env);
// Apply resolved network registry values to runtime config
// Apply resolved network registry values to runtime config
if (resolvedRegistry.OV_NETWORK_URL?.value) {
if (resolvedRegistry.OV_NETWORK_URL?.value) {
@@ -321,11 +324,11 @@ app.locals.urlRegistry = resolvedRegistry;
@@ -321,11 +324,11 @@ app.locals.urlRegistry = resolvedRegistry;
config._registry = resolvedRegistry;
config._registry = resolvedRegistry;
// ── Analytics Tracking (ADR-021) ──────────────────────────────
// ── Analytics Tracking (ADR-021) ──────────────────────────────
-// openvibe-shared/analytics; same tables in network.db, on a connection of the tracker's own
-// (server/analytics/network.js). Raw rows: route template, rotating session id, user-agent class,
-// referer origin; never an IP or a user id. A Sec-GPC: 1 / DNT: 1 request is not recorded. Pruned after
-// 30 days by the analytics-prune job below; rollups are kept.
-const analytics = networkAnalytics.openAnalytics(config.db.path);
+// openvibe-shared/analytics on PostgreSQL (server/analytics/network.js; migrations/0002_analytics.sql).
+// Raw rows: route template, rotating session id, user-agent class, referer origin; never an IP or a
+// user id. A Sec-GPC: 1 / DNT: 1 request is not recorded. Pruned after 30 days by the tracker itself;
+// rollups are kept.
+const analytics = networkAnalytics.openAnalytics(db);
app.locals.analytics = analytics;
app.locals.analytics = analytics;
app.use(analytics.middleware());
app.use(analytics.middleware());
@@ -362,11 +365,19 @@ app.locals.selfToken = selfToken;
@@ -362,11 +365,19 @@ app.locals.selfToken = selfToken;
app.locals.publicKey = publicKey;
app.locals.publicKey = publicKey;
app.locals.config = config;
app.locals.config = config;
+// Shared counters on Valkey when VALKEY_URL is set (ADR-035, plan T2): every process and host counts one
+// actor together. Unset — or unreachable — the limits fall back to this process's own counters (never 500).
+const valkey = config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix }) : null;
+app.locals.valkey = valkey;
+
// Per-actor limits on API writes, by the person whose session makes them (server/auth/actor-limits.js; WS-R task 4).
// Per-actor limits on API writes, by the person whose session makes them (server/auth/actor-limits.js; WS-R task 4).
-app.use('/api/', require('./auth/actor-limits').createNetworkActorLimits({ publicKey, issuer: config.jwt.issuer, registry: observability.registry }));
+app.use('/api/', require('./auth/actor-limits').createNetworkActorLimits({
+ publicKey, issuer: config.jwt.issuer, registry: observability.registry,
+ store: valkey ? require('openvibe-sdk/limits').createValkeyLimitStore(valkey) : null,
+}));
// Provider secrets: environment first, database fallback (server/secrets.js). Names and sources only.
// Provider secrets: environment first, database fallback (server/secrets.js). Names and sources only.
-console.log(`[Secrets] ${require('./secrets').summary(db)}`);
+console.log(`[Secrets] ${await require('./secrets').summary(db)}`);
// ── Initialize Services ──────────────────────────────────────
// ── Initialize Services ──────────────────────────────────────
const notificationService = new NotificationService(db);
const notificationService = new NotificationService(db);
@@ -381,7 +392,7 @@ const liveFollowers = privateKey.includes('BEGIN')
@@ -381,7 +392,7 @@ const liveFollowers = privateKey.includes('BEGIN')
const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);
const moderationAudit = require('./admin/moderation-audit').createModerationAudit(db);
// Developer projects' usage (WS-N task 4): the services' hourly rollups, per project and day.
// Developer projects' usage (WS-N task 4): the services' hourly rollups, per project and day.
const projectUsage = require('./developer/usage').createProjectUsage(db);
const projectUsage = require('./developer/usage').createProjectUsage(db);
-eventsConsumer = require('./notifications/events-consumer').createEventsConsumer({
+eventsConsumer = await require('./notifications/events-consumer').createEventsConsumer({
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
db, notifications: notificationService, secrets: config.eventsWebhookSecrets,
liveFollowers, discord: () => app.locals.discordService || null, moderationAudit, projectUsage,
liveFollowers, discord: () => app.locals.discordService || null, moderationAudit, projectUsage,
followsAuthority: process.env.FOLLOWS_AUTHORITY === 'network' ? 'network' : 'live', // ADR-030 step 4
followsAuthority: process.env.FOLLOWS_AUTHORITY === 'network' ? 'network' : 'live', // ADR-030 step 4
@@ -404,8 +415,8 @@ app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router())
@@ -404,8 +415,8 @@ app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router())
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
// Username history (WS-B task 6): who holds a name now, so sites redirect /@old → /@new and pick up a
// new name before they have seen it. Banned accounts and unknown names are 404.
// new name before they have seen it. Banned accounts and unknown names are 404.
-app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), (req, res) => {
- const rec = require('./identity/usernames').lookup(db, req.params.name);
+app.get('/api/v1/users/names/:name', rateLimit({ windowMs: 60_000, max: 240 }), async (req, res) => {
+ const rec = await require('./identity/usernames').lookup(db, req.params.name);
res.set('Cache-Control', 'public, max-age=120');
res.set('Cache-Control', 'public, max-age=120');
if (!rec) return res.status(404).json({ error: 'not_found' });
if (!rec) return res.status(404).json({ error: 'not_found' });
res.json(rec);
res.json(rec);
@@ -420,7 +431,7 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
@@ -420,7 +431,7 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
// (staff.identity.merge) for account recovery, with a reason.
// (staff.identity.merge) for account recovery, with a reason.
{
{
const accountMerge = require('./identity/account-merge');
const accountMerge = require('./identity/account-merge');
- accountMerge.ensureSchema(db);
+ await accountMerge.ensureSchema(db);
const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });
const mergeRouters = accountMerge.routers({ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims });
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
app.use('/api/admin/account-merges', mergeRouters.admin);
app.use('/api/admin/account-merges', mergeRouters.admin);
@@ -437,12 +448,12 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
@@ -437,12 +448,12 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
}
}
// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;
// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;
// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is
// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is
-// outstanding. Archives live next to the database for 7 days.
-const ACCOUNT_EXPORT_DIR = path.join(path.dirname(path.resolve(config.db.path)), 'account-exports');
-const notifyAccountData = (userId, n) => notificationService.create({ user_id: userId, type: 'GENERIC', category: 'system', service: 'network', url: 'https://openvibe.network/my#accounts', ...n });
+// outstanding. Archives live under the data directory for 7 days.
+const ACCOUNT_EXPORT_DIR = path.join(path.resolve(config.dataDir), 'account-exports');
+const notifyAccountData = async (userId, n) => await notificationService.create({ user_id: userId, type: 'GENERIC', category: 'system', service: 'network', url: 'https://openvibe.network/my#accounts', ...n });
{
{
const accountData = require('./identity/account-data');
const accountData = require('./identity/account-data');
- accountData.ensureSchema(db);
+ await accountData.ensureSchema(db);
const guard = require('./identity/principals').guard;
const guard = require('./identity/principals').guard;
const dataRouters = accountData.routers({
const dataRouters = accountData.routers({
requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
@@ -490,13 +501,13 @@ const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issu
@@ -490,13 +501,13 @@ const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issu
// Production drift: each running service's deployed commit against its repository's main (WS-S task 7).
// Production drift: each running service's deployed commit against its repository's main (WS-S task 7).
const deployDrift = require('./registry/deploy-drift').createDeployDrift({
const deployDrift = require('./registry/deploy-drift').createDeployDrift({
services: () => ecosystem.releases().services.filter((r) => r.release).map((r) => ({ id: r.id, release: r.release, repository: ((require('openvibe-contracts').services.manifests.find((m) => m.id === r.id)) || {}).repository })),
services: () => ecosystem.releases().services.filter((r) => r.release).map((r) => ({ id: r.id, release: r.release, repository: ((require('openvibe-contracts').services.manifests.find((m) => m.id === r.id)) || {}).repository })),
- token: () => process.env.GITHUB_TOKEN || require('./integrations/github').tokenOf(db) || '',
+ token: async () => process.env.GITHUB_TOKEN || await require('./integrations/github').tokenOf(db) || '',
});
});
deployDrift.start();
deployDrift.start();
app.use(ecosystem.router());
app.use(ecosystem.router());
ecosystem.start();
ecosystem.start();
// The released libraries' latest published tags (not the versions Network installs) for the registry.
// The released libraries' latest published tags (not the versions Network installs) for the registry.
-const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) });
+const libraryTags = require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: async () => await require('./integrations/github').tokenOf(db) });
libraryTags.start();
libraryTags.start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Creator analytics (WS-E task 6): from live.stream.ended, counts only; the full figures for the creator or
// Creator analytics (WS-E task 6): from live.stream.ended, counts only; the full figures for the creator or
@@ -565,7 +576,7 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
@@ -565,7 +576,7 @@ app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
app.use('/api/v1/projects', rateLimit({ windowMs: 60_000, max: 60 }), require('./developer/routes').router());
// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when
// Their network.app.* / credential / grant events go to OpenVibe.Events through an outbox when
// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.
// OV_EVENTS_INTERNAL_URL is set (server/developer/event-relay.js); off otherwise.
-require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });
+await require('./developer/event-relay').startRelay(db, { eventsUrl: config.eventsInternalUrl, privateKey, issuer: config.jwt.issuer });
// network.user.updated (WS-B task 2): profile, role and ban changes, recorded by triggers on users and relayed
// network.user.updated (WS-B task 2): profile, role and ban changes, recorded by triggers on users and relayed
// through the same outbox (server/identity/profile-events.js).
// through the same outbox (server/identity/profile-events.js).
require('./identity/profile-events').start(db);
require('./identity/profile-events').start(db);
@@ -576,11 +587,11 @@ app.use('/api/notifications', createNotificationRoutes(db, notificationService,
@@ -576,11 +587,11 @@ app.use('/api/notifications', createNotificationRoutes(db, notificationService,
// Push Notifications API
// Push Notifications API
const pushService = require('./push/push-service');
const pushService = require('./push/push-service');
-pushService.initVapid(db);
+await pushService.initVapid(db);
app.use('/api/push', requireAuth, require('./push/routes'));
app.use('/api/push', requireAuth, require('./push/routes'));
// Cross-site history (what the account touched anywhere on the network)
// Cross-site history (what the account touched anywhere on the network)
-app.use('/api/history', rateLimit({ windowMs: 60_000, max: 60 }), require('./history/routes').createHistoryRoutes(db, requireAuth));
+app.use('/api/history', rateLimit({ windowMs: 60_000, max: 60 }), await require('./history/routes').createHistoryRoutes(db, requireAuth));
// "Sign in everywhere" chain targets (public: the fanout page reads them before hopping)
// "Sign in everywhere" chain targets (public: the fanout page reads them before hopping)
app.get('/api/sso/targets', (req, res) => {
app.get('/api/sso/targets', (req, res) => {
@@ -615,7 +626,7 @@ app.locals.avatarService = avatarService;
@@ -615,7 +626,7 @@ app.locals.avatarService = avatarService;
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
-const frameService = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });
+const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
@@ -624,7 +635,7 @@ frameService.start();
@@ -624,7 +635,7 @@ frameService.start();
ecosystem.setRanking(() => frameService.ranking());
ecosystem.setRanking(() => frameService.ranking());
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
-const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
+const toolDomains = await require('./domains/routes').createDomainRoutes(db, requireAuth);
app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.publicRouter);
app.use('/api/domains', rateLimit({ windowMs: 60_000, max: 120 }), toolDomains.publicRouter);
app.use('/api/admin/domains', toolDomains.adminRouter);
app.use('/api/admin/domains', toolDomains.adminRouter);
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
@@ -675,7 +686,7 @@ app.use('/api/admin/streamer', requireAuth, (req, res, next) => {
@@ -675,7 +686,7 @@ app.use('/api/admin/streamer', requireAuth, (req, res, next) => {
}
}
next();
next();
}, async (req, res) => {
}, async (req, res) => {
- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');
+ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/admin${req.url}`, 'Streamer proxy error');
});
});
// Proxy /api/mod/* for moderator routes
// Proxy /api/mod/* for moderator routes
@@ -685,7 +696,7 @@ app.use('/api/admin/streamer-mod', requireAuth, (req, res, next) => {
@@ -685,7 +696,7 @@ app.use('/api/admin/streamer-mod', requireAuth, (req, res, next) => {
}
}
next();
next();
}, async (req, res) => {
}, async (req, res) => {
- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');
+ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/mod${req.url}`, 'Mod proxy error');
});
});
app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {
app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {
@@ -694,7 +705,7 @@ app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {
@@ -694,7 +705,7 @@ app.use('/api/admin/streamer-tts', requireAuth, (req, res, next) => {
}
}
next();
next();
}, async (req, res) => {
}, async (req, res) => {
- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');
+ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/tts${req.url}`, 'TTS proxy error');
});
});
app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {
app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {
@@ -703,7 +714,7 @@ app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {
@@ -703,7 +714,7 @@ app.use('/api/admin/streamer-funds', requireAuth, (req, res, next) => {
}
}
next();
next();
}, async (req, res) => {
}, async (req, res) => {
- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');
+ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/funds${req.url}`, 'Funds proxy error');
});
});
app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {
app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {
@@ -712,7 +723,7 @@ app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {
@@ -712,7 +723,7 @@ app.use('/api/admin/streamer-pastes', requireAuth, (req, res, next) => {
}
}
next();
next();
}, async (req, res) => {
}, async (req, res) => {
- return proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');
+ return await proxyJsonRequest(req, res, `${OPENVIBELIVE_INTERNAL}/api/pastes${req.url}`, 'Pastes proxy error');
});
});
// Internal API (server-to-server, capability-scoped service tokens only)
// Internal API (server-to-server, capability-scoped service tokens only)
@@ -845,7 +856,6 @@ app.use(require('./not-found').notFound);
@@ -845,7 +856,6 @@ app.use(require('./not-found').notFound);
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
const timers = []; // the periodic maintenance below, cleared on stop
const timers = []; // the periodic maintenance below, cleared on stop
-let analyticsPrune = null;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.
// The home page renders the Tools catalog it already holds: fetch it now, not on the first visit.
require('./domains/catalog').refresh().catch(() => {});
require('./domains/catalog').refresh().catch(() => {});
@@ -864,34 +874,33 @@ const server = app.listen(config.port, config.host, () => {
@@ -864,34 +874,33 @@ const server = app.listen(config.port, config.host, () => {
// Process email queue every 2 minutes
// Process email queue every 2 minutes
timers.push(setInterval(() => emailService.processQueue(notificationService), 2 * 60 * 1000));
timers.push(setInterval(() => emailService.processQueue(notificationService), 2 * 60 * 1000));
- // Raw analytics retention (ADR-021), job analytics-prune: events older than 30 days go, in
- // bounded batches; hourly/daily rollups stay. First run 5 minutes after boot, then every 24 h.
- analyticsPrune = networkAnalytics.schedulePrune(analytics);
+ // Raw analytics retention (ADR-021) is scheduled by the PostgreSQL tracker itself: events older
+ // than 30 days go, in bounded batches; hourly/daily rollups stay.
// User modules of a retired owning service (onOwnerRemoved, server/identity/modules.js): writes stop at
// User modules of a retired owning service (onOwnerRemoved, server/identity/modules.js): writes stop at
// once; delete-after-retention records go retentionDays after Network first saw the retirement.
// once; delete-after-retention records go retentionDays after Network first saw the retirement.
- const sweepModules = () => {
+ const sweepModules = async () => {
try {
try {
- const n = require('./identity/modules').sweepRetired(db);
+ const n = await require('./identity/modules').sweepRetired(db);
if (n) console.log(`[Modules] Deleted ${n} record(s) of retired namespace owners`);
if (n) console.log(`[Modules] Deleted ${n} record(s) of retired namespace owners`);
} catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }
} catch (e) { console.warn('[Modules] retired-owner sweep:', e.message); }
};
};
timers.push(setTimeout(sweepModules, 5 * 60 * 1000), setInterval(sweepModules, 24 * 60 * 60 * 1000));
timers.push(setTimeout(sweepModules, 5 * 60 * 1000), setInterval(sweepModules, 24 * 60 * 60 * 1000));
// Account merges older than 30 days keep only the alias facts (ADR-029).
// Account merges older than 30 days keep only the alias facts (ADR-029).
- const reduceMerges = () => { try { const n = require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };
+ const reduceMerges = async () => { try { const n = await require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };
timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));
timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));
// Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).
// Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).
- const sweepAccountData = () => {
+ const sweepAccountData = async () => {
try {
try {
- const n = require('./identity/account-data').sweep(db, { dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData });
+ const n = await require('./identity/account-data').sweep(db, { dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData });
if (n.built || n.expired || n.deleted) console.log(`[AccountData] sweep: ${JSON.stringify(n)}`);
if (n.built || n.expired || n.deleted) console.log(`[AccountData] sweep: ${JSON.stringify(n)}`);
} catch (e) { console.warn('[AccountData] sweep failed:', e.message); }
} catch (e) { console.warn('[AccountData] sweep failed:', e.message); }
};
};
timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));
timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));
// Clean expired sessions daily
// Clean expired sessions daily
- timers.push(setInterval(() => {
- const cleaned = db.prepare("DELETE FROM user_sessions WHERE expires_at < datetime('now') OR is_active = 0").run().changes;
+ timers.push(setInterval(async () => {
+ const cleaned = (await db.prepare("DELETE FROM user_sessions WHERE expires_at < datetime('now') OR is_active = 0").run()).changes;
if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);
if (cleaned > 0) console.log(`[Sessions] Cleaned ${cleaned} expired sessions`);
}, 24 * 60 * 60 * 1000));
}, 24 * 60 * 60 * 1000));
});
});
@@ -901,14 +910,14 @@ const server = app.listen(config.port, config.host, () => {
@@ -901,14 +910,14 @@ const server = app.listen(config.port, config.host, () => {
// pollers, the frame refreshes, the profile-event and grant-expiry timers stop (nothing new starts); the
// pollers, the frame refreshes, the profile-event and grant-expiry timers stop (nothing new starts); the
// server stops taking connections, closes idle keep-alive ones and lets requests in flight finish (8 s at
// server stops taking connections, closes idle keep-alive ones and lets requests in flight finish (8 s at
// most, Connection: close); then the developer/profile/grant event relay finishes its send in progress
// most, Connection: close); then the developer/profile/grant event relay finishes its send in progress
-// (unsent rows stay in the outbox), analytics flush and close, network.db closes, and the process exits 0,
-// within 10 s (well inside the unit's stop timeout). The email queue resumes on the next start.
+// (unsent rows stay in the outbox), analytics flush and close, the database and the Valkey connection
+// close, and the process exits 0, within 10 s (well inside the unit's stop timeout). The email queue
+// resumes on the next start.
const { within } = require('./graceful');
const { within } = require('./graceful');
require('./graceful').gracefulStop({
require('./graceful').gracefulStop({
name: 'Network', server, drainMs: 8000, deadlineMs: 10000,
name: 'Network', server, drainMs: 8000, deadlineMs: 10000,
stop: [
stop: [
() => { for (const t of timers) { clearTimeout(t); clearInterval(t); } },
() => { for (const t of timers) { clearTimeout(t); clearInterval(t); } },
- () => { if (analyticsPrune) analyticsPrune.stop(); },
() => ecosystem.stop(),
() => ecosystem.stop(),
() => deployDrift.stop(),
() => deployDrift.stop(),
() => libraryTags.stop(),
() => libraryTags.stop(),
@@ -918,7 +927,17 @@ require('./graceful').gracefulStop({
@@ -918,7 +927,17 @@ require('./graceful').gracefulStop({
],
],
close: [
close: [
() => within(1500, require('./developer/event-relay').stopRelay(db)),
() => within(1500, require('./developer/event-relay').stopRelay(db)),
- () => { analytics.destroy(); analytics.db.close(); },
+ () => analytics.destroy().catch(() => {}),
() => db.close(),
() => db.close(),
+ () => valkey && valkey.close().catch(() => {}),
],
],
});
});
+
+return { app, server };
+})();
+ready.catch((err) => {
+ console.error('[Network] failed to start:', err);
+ process.exit(1);
+});
+
+module.exports = { ready };
@@ -20,14 +20,14 @@ const KEY = 'github_token';
@@ -20,14 +20,14 @@ const KEY = 'github_token';
// Classic (ghp_/gho_/ghs_/ghu_/ghr_), fine-grained (github_pat_) and legacy 40-hex tokens.
// Classic (ghp_/gho_/ghs_/ghu_/ghr_), fine-grained (github_pat_) and legacy 40-hex tokens.
const TOKEN_RE = /^(gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{40,255}|[0-9a-f]{40})$/;
const TOKEN_RE = /^(gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{40,255}|[0-9a-f]{40})$/;
-function tokenOf(db) {
- const v = db.getSetting ? db.getSetting(KEY) : null;
+async function tokenOf(db) {
+ const v = db.getSetting ? await db.getSetting(KEY) : null;
return v && String(v).trim() ? String(v).trim() : null;
return v && String(v).trim() ? String(v).trim() : null;
}
}
-function status(db) {
- const t = tokenOf(db);
- return { source: secrets.source(db, KEY), env: secrets.envName(KEY), set: Boolean(t), last4: t ? t.slice(-4) : null,
+async function status(db) {
+ const t = await tokenOf(db);
+ return { source: await secrets.source(db, KEY), env: secrets.envName(KEY), set: Boolean(t), last4: t ? t.slice(-4) : null,
used_by: ['the registry library versions (Network)', 'the network changelog and Patch notes (OpenVibe.Blog)'] };
used_by: ['the registry library versions (Network)', 'the network changelog and Patch notes (OpenVibe.Blog)'] };
}
}
@@ -47,23 +47,23 @@ async function test(token, fetchImpl = globalThis.fetch) {
@@ -47,23 +47,23 @@ async function test(token, fetchImpl = globalThis.fetch) {
function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
const r = express.Router();
const r = express.Router();
r.use(requireOwner);
r.use(requireOwner);
- r.get('/', (req, res) => res.json({ ok: true, github: status(db) }));
- r.put('/', express.json({ limit: '4kb' }), (req, res) => {
- if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
+ r.get('/', async (req, res) => res.json({ ok: true, github: await status(db) }));
+ r.put('/', express.json({ limit: '4kb' }), async (req, res) => {
+ if (await secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
const token = String((req.body && req.body.token) || '').trim();
const token = String((req.body && req.body.token) || '').trim();
if (!TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'That does not look like a GitHub token (ghp_…, github_pat_…)' });
if (!TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'That does not look like a GitHub token (ghp_…, github_pat_…)' });
- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(KEY, token, 'secret');
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));
- res.json({ ok: true, github: status(db) });
+ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(KEY, token, 'secret');
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));
+ res.json({ ok: true, github: await status(db) });
});
});
- r.delete('/', (req, res) => {
- if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
- db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);
- db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));
- res.json({ ok: true, github: status(db) });
+ r.delete('/', async (req, res) => {
+ if (await secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
+ await db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);
+ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));
+ res.json({ ok: true, github: await status(db) });
});
});
r.post('/test', async (req, res) => {
r.post('/test', async (req, res) => {
- try { res.json({ ok: true, test: await test(tokenOf(db), fetchImpl) }); }
+ try { res.json({ ok: true, test: await test(await tokenOf(db), fetchImpl) }); }
catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }
catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }
});
});
return r;
return r;
@@ -71,11 +71,11 @@ function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
@@ -71,11 +71,11 @@ function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
/** GET /internal/integrations/github-token for a service holding network.integration.github.read. */
/** GET /internal/integrations/github-token for a service holding network.integration.github.read. */
function internalHandler(db) {
function internalHandler(db) {
- return (req, res) => {
- const t = tokenOf(db);
+ return async (req, res) => {
+ const t = await tokenOf(db);
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
if (!t) return res.status(404).json({ error: 'not_configured', detail: 'No GitHub token is set (admin → Settings → GitHub, or GITHUB_TOKEN)' });
if (!t) return res.status(404).json({ error: 'not_configured', detail: 'No GitHub token is set (admin → Settings → GitHub, or GITHUB_TOKEN)' });
- res.json({ token: t, source: secrets.source(db, KEY) });
+ res.json({ token: t, source: await secrets.source(db, KEY) });
};
};
}
}
@@ -56,27 +56,27 @@ router.use('/identity', require('../identity/internal-routes'));
@@ -56,27 +56,27 @@ router.use('/identity', require('../identity/internal-routes'));
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// the service reports the link here.
// the service reports the link here.
// A service token may only report links for its own service (svc:live -> service 'live').
// A service token may only report links for its own service (svc:live -> service 'live').
-router.post('/link-account', principals.guard('identity.subject.resolve', { ownApp: forService }), (req, res) => {
+router.post('/link-account', principals.guard('identity.subject.resolve', { ownApp: forService }), async (req, res) => {
const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;
const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;
if (!user_id || !service || !service_user_id) {
if (!user_id || !service || !service_user_id) {
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
}
}
- const db = getDb(req);
- db.prepare(`
+ const db = await getDb(req);
+ await db.prepare(`
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at)
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+ VALUES (?, ?, ?, ?, ov_now())
ON CONFLICT(user_id, service) DO UPDATE SET
ON CONFLICT(user_id, service) DO UPDATE SET
service_user_id = ?,
service_user_id = ?,
service_username = ?,
service_username = ?,
- linked_at = CURRENT_TIMESTAMP
+ linked_at = ov_now()
`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
// The site told us its own id for this account: record it against the canonical subject too, so
// The site told us its own id for this account: record it against the canonical subject too, so
// other services can resolve "<service> user N" without asking that site (Wave 1 legacy map).
// other services can resolve "<service> user N" without asking that site (Wave 1 legacy map).
if (!String(service_user_id).startsWith('network:')) {
if (!String(service_user_id).startsWith('network:')) {
try {
try {
- const r = require('../identity/subjects').upsertLegacy(db, [{ network_user_id: user_id, source_system: String(service), source_type: 'user', source_id: String(service_user_id), verified: true }]);
+ const r = await require('../identity/subjects').upsertLegacy(db, [{ network_user_id: user_id, source_system: String(service), source_type: 'user', source_id: String(service_user_id), verified: true }]);
if (r.conflicts.length) console.warn(`[Identity] ${service} user ${service_user_id} is already mapped to another subject; not repointed`);
if (r.conflicts.length) console.warn(`[Identity] ${service} user ${service_user_id} is already mapped to another subject; not repointed`);
} catch (err) { console.warn('[Identity] legacy map from link-account failed:', err.message); }
} catch (err) { console.warn('[Identity] legacy map from link-account failed:', err.message); }
}
}
@@ -85,13 +85,13 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA
@@ -85,13 +85,13 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA
// so every other site shows the same face. Never overwrites a picture or name the user set here, and only
// so every other site shows the same face. Never overwrites a picture or name the user set here, and only
// accepts https URLs on our own sites.
// accepts https URLs on our own sites.
try {
try {
- const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);
+ const me = await db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);
if (me) {
if (me) {
const svc = req.app.locals.avatarService;
const svc = req.app.locals.avatarService;
if (svc && avatar_url && !me.avatar_url) svc.fromSite({ user_id, avatar_url, origin: service });
if (svc && avatar_url && !me.avatar_url) svc.fromSite({ user_id, avatar_url, origin: service });
const name = String(display_name || '').trim().slice(0, 60);
const name = String(display_name || '').trim().slice(0, 60);
// Same rule the profile form enforces: a display name only re-cases the username.
// Same rule the profile form enforces: a display name only re-cases the username.
- if (name && name !== me.display_name && name.toLowerCase() === String(me.username || '').toLowerCase()) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);
+ if (name && name !== me.display_name && name.toLowerCase() === String(me.username || '').toLowerCase()) await db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);
}
}
} catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }
} catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }
@@ -99,21 +99,21 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA
@@ -99,21 +99,21 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA
});
});
// ── A site changed someone's avatar (Live's avatar picker) ───
// ── A site changed someone's avatar (Live's avatar picker) ───
-router.post('/user-avatar', principals.guard('network.avatar.write'), (req, res) => {
+router.post('/user-avatar', principals.guard('network.avatar.write'), async (req, res) => {
const svc = req.app.locals.avatarService;
const svc = req.app.locals.avatarService;
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
- const r = svc.fromSite(req.body || {});
+ const r = await svc.fromSite(req.body || {});
res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
});
});
// Service URLs only (no secrets). Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the
// Service URLs only (no secrets). Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the
// owner's, not the services'.
// owner's, not the services'.
-router.get('/url-registry/resolved', principals.guard('network.registry.read'), (req, res) => {
+router.get('/url-registry/resolved', principals.guard('network.registry.read'), async (req, res) => {
try {
try {
- const db = getDb(req);
+ const db = await getDb(req);
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { isSensitiveSettingKey } = require('../auth/owner-guard');
const { isSensitiveSettingKey } = require('../auth/owner-guard');
- const resolved = Object.fromEntries(Object.entries(urlRegistry.getResolvedRegistry(db, process.env))
+ const resolved = Object.fromEntries(Object.entries(await urlRegistry.getResolvedRegistry(db, process.env))
.filter(([key]) => !((URL_DEFINITIONS[key] || {}).type === 'secret' || isSensitiveSettingKey(key))));
.filter(([key]) => !((URL_DEFINITIONS[key] || {}).type === 'secret' || isSensitiveSettingKey(key))));
res.json({ ok: true, registry: resolved });
res.json({ ok: true, registry: resolved });
} catch (err) {
} catch (err) {
@@ -142,31 +142,31 @@ function handleWalletError(res, err) {
@@ -142,31 +142,31 @@ function handleWalletError(res, err) {
// so the answer is held for a minute.
// so the answer is held for a minute.
let _coinStats = { at: 0, data: null };
let _coinStats = { at: 0, data: null };
// Guarded by the ledger capability its caller (Live's home hero) holds. TODO(contracts): network.coins.read.
// Guarded by the ledger capability its caller (Live's home hero) holds. TODO(contracts): network.coins.read.
-router.get('/coins/stats', principals.guard('network.coins.read'), (req, res) => {
+router.get('/coins/stats', principals.guard('network.coins.read'), async (req, res) => {
try {
try {
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
if (_coinStats.data && Date.now() - _coinStats.at < 60_000) return res.json(_coinStats.data);
- const db = getDb(req);
- const one = (sql) => { try { return db.prepare(sql).get()?.n || 0; } catch { return 0; } };
+ const db = await getDb(req);
+ const one = async (sql) => { try { const row = await db.prepare(sql).get(); return Number(row?.n ?? 0); } catch { return 0; } };
const data = {
const data = {
- earned: one('SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0'),
- spent: one('SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0'),
- circulating: one('SELECT COALESCE(SUM(balance), 0) AS n FROM wallets'),
- holders: one('SELECT COUNT(*) AS n FROM wallets WHERE balance > 0'),
- transactions: one('SELECT COUNT(*) AS n FROM coin_transactions'),
+ earned: await one('SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0'),
+ spent: await one('SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0'),
+ circulating: await one('SELECT COALESCE(SUM(balance), 0) AS n FROM wallets'),
+ holders: await one('SELECT COUNT(*) AS n FROM wallets WHERE balance > 0'),
+ transactions: await one('SELECT COUNT(*) AS n FROM coin_transactions'),
// Rolling windows so the display can say whether the economy is speeding up. Same
// Rolling windows so the display can say whether the economy is speeding up. Same
// shape the Live stats use: this seven days, and the seven before it.
// shape the Live stats use: this seven days, and the seven before it.
recent: {
recent: {
earned: {
earned: {
- w: one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-7 days')"),
- pw: one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),
+ w: await one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-7 days')"),
+ pw: await one("SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),
},
},
spent: {
spent: {
- w: one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-7 days')"),
- pw: one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),
+ w: await one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-7 days')"),
+ pw: await one("SELECT COALESCE(-SUM(delta), 0) AS n FROM coin_transactions WHERE delta < 0 AND created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),
},
},
holders: {
holders: {
- w: one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-7 days')"),
- pw: one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),
+ w: await one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-7 days')"),
+ pw: await one("SELECT COUNT(DISTINCT user_id) AS n FROM coin_transactions WHERE created_at >= datetime('now','-14 days') AND created_at < datetime('now','-7 days')"),
},
},
},
},
};
};
@@ -183,10 +183,10 @@ router.get('/coins/stats', principals.guard('network.coins.read'), (req, res) =>
@@ -183,10 +183,10 @@ router.get('/coins/stats', principals.guard('network.coins.read'), (req, res) =>
// User modules for services (token only; server/identity/modules.js).
// User modules for services (token only; server/identity/modules.js).
require('../identity/modules').serviceRoutes(router, principals);
require('../identity/modules').serviceRoutes(router, principals);
-router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), (req, res) => {
+router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp: forApp }), async (req, res) => {
try {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
- const result = wallet.credit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
+ const result = await wallet.credit(await getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
res.json({ balance: result.balance });
res.json({ balance: result.balance });
} catch (err) {
} catch (err) {
handleWalletError(res, err);
handleWalletError(res, err);
@@ -195,10 +195,10 @@ router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp:
@@ -195,10 +195,10 @@ router.post('/coins/credit', principals.guard('network.coins.credit', { ownApp:
// ── POST /internal/coins/debit ───────────────────────────────
// ── POST /internal/coins/debit ───────────────────────────────
// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }
// Same body → { balance }; insufficient funds → 409 { error: 'insufficient_funds', balance }
-router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), (req, res) => {
+router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: forApp }), async (req, res) => {
try {
try {
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
- const result = wallet.debit(getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
+ const result = await wallet.debit(await getDb(req), { user_id, app_id, amount, reason, ref, idempotency_key });
res.json({ balance: result.balance });
res.json({ balance: result.balance });
} catch (err) {
} catch (err) {
handleWalletError(res, err);
handleWalletError(res, err);
@@ -208,10 +208,10 @@ router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: fo
@@ -208,10 +208,10 @@ router.post('/coins/debit', principals.guard('network.coins.debit', { ownApp: fo
// ── POST /internal/coins/transfer ────────────────────────────
// ── POST /internal/coins/transfer ────────────────────────────
// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }
// Body: { from_user_id, to_user_id, app_id, amount, reason, ref?, idempotency_key }
// → { from_balance, to_balance } (atomic)
// → { from_balance, to_balance } (atomic)
-router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), (req, res) => {
+router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownApp: forApp }), async (req, res) => {
try {
try {
const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
const { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key } = req.body || {};
- const result = wallet.transfer(getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });
+ const result = await wallet.transfer(await getDb(req), { from_user_id, to_user_id, app_id, amount, reason, ref, idempotency_key });
res.json({ from_balance: result.from_balance, to_balance: result.to_balance });
res.json({ from_balance: result.from_balance, to_balance: result.to_balance });
} catch (err) {
} catch (err) {
handleWalletError(res, err);
handleWalletError(res, err);
@@ -241,7 +241,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
@@ -241,7 +241,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
// email AND Discord. `force:true` (admin/manual) bypasses the cooldown, not the cap.
// email AND Discord. `force:true` (admin/manual) bypasses the cooldown, not the cap.
// The same window is claimed by the live.stream.started consumer (../notifications/stream-live.js).
// The same window is claimed by the live.stream.started consumer (../notifications/stream-live.js).
{
{
- const claim = streamLive.claimAnnouncement(getDb(req), { streamerKey: streamer.network_id || streamer.id || streamer.username, streamId: stream.id, force: !!req.body.force });
+ const claim = await streamLive.claimAnnouncement(await getDb(req), { streamerKey: streamer.network_id || streamer.id || streamer.username, streamId: stream.id, force: !!req.body.force });
if (claim.skipped && claim.reason === 'daily-cap') {
if (claim.skipped && claim.reason === 'daily-cap') {
console.log(`[StreamLive] ${streamer.username}: daily cap (${claim.cap}) reached — not announcing`);
console.log(`[StreamLive] ${streamer.username}: daily cap (${claim.cap}) reached — not announcing`);
return res.json({ ok: true, ...claim });
return res.json({ ok: true, ...claim });
@@ -265,7 +265,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
@@ -265,7 +265,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
// ── Push Notifications to Followers ──────────────────────
// ── Push Notifications to Followers ──────────────────────
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (notifService) {
if (notifService) {
- const db = getDb(req);
+ const db = await getDb(req);
const notifData = streamLive.streamLiveNotification({
const notifData = streamLive.streamLiveNotification({
username: streamer.username, displayName: streamer.display_name, avatarUrl: streamer.avatar_url,
username: streamer.username, displayName: streamer.display_name, avatarUrl: streamer.avatar_url,
senderId: streamer.id || null, stream, url: streamLive.channelUrl(streamer.username),
senderId: streamer.id || null, stream, url: streamLive.channelUrl(streamer.username),
@@ -280,26 +280,26 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
@@ -280,26 +280,26 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
: [];
: [];
if (!Array.isArray(follower_network_ids)) {
if (!Array.isArray(follower_network_ids)) {
// Legacy caller: fall back to Network's graph, resolving the streamer's NETWORK id first.
// Legacy caller: fall back to Network's graph, resolving the streamer's NETWORK id first.
- const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
- if (link) followerIds = db.prepare('SELECT follower_id FROM follows WHERE followed_id = ?').all(link.user_id).map(r => r.follower_id);
+ const link = streamer.id ? await db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
+ if (link) followerIds = (await db.prepare('SELECT follower_id FROM follows WHERE followed_id = ?').all(link.user_id)).map(r => r.follower_id);
}
}
// sender_id must be the streamer's NETWORK id for dedupe + "who is this" lookups.
// sender_id must be the streamer's NETWORK id for dedupe + "who is this" lookups.
try {
try {
- const link = streamer.id ? db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
+ const link = streamer.id ? await db.prepare("SELECT user_id FROM linked_accounts WHERE service = 'live' AND service_user_id = ?").get(String(streamer.id)) : null;
if (link) notifData.sender_id = link.user_id;
if (link) notifData.sender_id = link.user_id;
// A Live id is not a Network person: no block check against whoever has that Network id.
// A Live id is not a Network person: no block check against whoever has that Network id.
else notifData.actor_subject = null;
else notifData.actor_subject = null;
} catch { notifData.actor_subject = null; /* keep Live id */ }
} catch { notifData.actor_subject = null; /* keep Live id */ }
// Find users who opted into "all live" notifications
// Find users who opted into "all live" notifications
- const allLiveUserIds = streamLive.allLiveSubscribers(db);
+ const allLiveUserIds = await streamLive.allLiveSubscribers(db);
// Merge and deduplicate
// Merge and deduplicate
const targetIds = [...new Set([...followerIds, ...allLiveUserIds])];
const targetIds = [...new Set([...followerIds, ...allLiveUserIds])];
if (targetIds.length > 0) {
if (targetIds.length > 0) {
try {
try {
- const created = notifService.createBulk(targetIds, notifData);
+ const created = await notifService.createBulk(targetIds, notifData);
results.notifications = { sent: created.length, total: targetIds.length };
results.notifications = { sent: created.length, total: targetIds.length };
} catch (err) {
} catch (err) {
results.notifications = { error: err.message };
results.notifications = { error: err.message };
@@ -321,7 +321,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
@@ -321,7 +321,7 @@ router.post('/events/stream-live', principals.guard('network.notifications.push'
// ── Push Single Notification ─────────────────────────────────
// ── Push Single Notification ─────────────────────────────────
// POST /internal/notifications/push
// POST /internal/notifications/push
// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
-router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {
+router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
@@ -329,7 +329,7 @@ router.post('/notifications/push', principals.guard('network.notifications.push'
@@ -329,7 +329,7 @@ router.post('/notifications/push', principals.guard('network.notifications.push'
if (!user_id) return res.status(400).json({ error: 'user_id required' });
if (!user_id) return res.status(400).json({ error: 'user_id required' });
try {
try {
- const notification = notifService.create({ user_id, ...data });
+ const notification = await notifService.create({ user_id, ...data });
if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });
if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });
res.json({ ok: true, notification });
res.json({ ok: true, notification });
} catch (err) {
} catch (err) {
@@ -341,7 +341,7 @@ router.post('/notifications/push', principals.guard('network.notifications.push'
@@ -341,7 +341,7 @@ router.post('/notifications/push', principals.guard('network.notifications.push'
// ── Push Bulk Notifications ──────────────────────────────────
// ── Push Bulk Notifications ──────────────────────────────────
// POST /internal/notifications/push-bulk
// POST /internal/notifications/push-bulk
// Body: { user_ids: [], type, title, message, ... }
// Body: { user_ids: [], type, title, message, ... }
-router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), (req, res) => {
+router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
@@ -354,7 +354,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
@@ -354,7 +354,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
}
}
try {
try {
- const results = notifService.createBulk(user_ids, data);
+ const results = await notifService.createBulk(user_ids, data);
res.json({ ok: true, sent: results.length, total: user_ids.length });
res.json({ ok: true, sent: results.length, total: user_ids.length });
} catch (err) {
} catch (err) {
console.error('[Internal] Bulk notification push error:', err);
console.error('[Internal] Bulk notification push error:', err);
@@ -366,7 +366,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
@@ -366,7 +366,7 @@ router.post('/notifications/push-bulk', principals.guard('network.notifications.
// POST /internal/operator/alerts — network.operator-alerts-request@1 → network.operator-alerts-result@1.
// POST /internal/operator/alerts — network.operator-alerts-request@1 → network.operator-alerts-result@1.
// Host's relay (ovhost alerts relay) sends the complete set of alerts firing now; server/operator/alerts.js
// Host's relay (ovhost alerts relay) sends the complete set of alerts firing now; server/operator/alerts.js
// pages the owner when one opens, once a day while it stays open, and when it resolves. Token only.
// pages the owner when one opens, once a day while it stays open, and when it resolves. Token only.
-router.post('/operator/alerts', principals.guard('network.operator.alert'), (req, res) => {
+router.post('/operator/alerts', principals.guard('network.operator.alert'), async (req, res) => {
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const v = contracts.validate('network.operator-alerts-request@1', req.body);
const v = contracts.validate('network.operator-alerts-request@1', req.body);
if (!v.valid) return res.status(400).json({ error: 'The body does not match network.operator-alerts-request@1', details: (v.errors || []).slice(0, 5) });
if (!v.valid) return res.status(400).json({ error: 'The body does not match network.operator-alerts-request@1', details: (v.errors || []).slice(0, 5) });
@@ -374,7 +374,7 @@ router.post('/operator/alerts', principals.guard('network.operator.alert'), (req
@@ -374,7 +374,7 @@ router.post('/operator/alerts', principals.guard('network.operator.alert'), (req
const notify = notifService ? (userId, n) => notifService.create({ user_id: userId, ...n }) : null;
const notify = notifService ? (userId, n) => notifService.create({ user_id: userId, ...n }) : null;
const revise = notifService ? (userId, id, fields) => notifService.revise(id, userId, fields) : null;
const revise = notifService ? (userId, id, fields) => notifService.revise(id, userId, fields) : null;
try {
try {
- res.json(require('../operator/alerts').receive(getDb(req), req.body, { notify, revise }));
+ res.json(await require('../operator/alerts').receive(await getDb(req), req.body, { notify, revise }));
} catch (err) {
} catch (err) {
console.error('[Internal] Operator alerts error:', err.message);
console.error('[Internal] Operator alerts error:', err.message);
res.status(500).json({ error: 'Operator alerts failed' });
res.status(500).json({ error: 'Operator alerts failed' });
@@ -409,25 +409,25 @@ router.post('/notifications/mark-read', principals.guard('network.notifications.
@@ -409,25 +409,25 @@ router.post('/notifications/mark-read', principals.guard('network.notifications.
// Body: { ip }
// Body: { ip }
// Called by first-party services to get or create a unified
// Called by first-party services to get or create a unified
// anon identity for a given IP address. Single source of truth.
// anon identity for a given IP address. Single source of truth.
-router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req, res) => {
+router.post('/resolve-anon', principals.guard('identity.subject.resolve'), async (req, res) => {
const { ip } = req.body;
const { ip } = req.body;
if (!ip) return res.status(400).json({ error: 'ip required' });
if (!ip) return res.status(400).json({ error: 'ip required' });
- const db = getDb(req);
+ const db = await getDb(req);
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
try {
try {
// Check if this IP already has an anon
// Check if this IP already has an anon
- const byIpLog = db.prepare(`
+ const byIpLog = await db.prepare(`
SELECT a.* FROM anon_users a
SELECT a.* FROM anon_users a
INNER JOIN anon_ip_log l ON l.anon_id = a.id
INNER JOIN anon_ip_log l ON l.anon_id = a.id
WHERE l.ip = ?
WHERE l.ip = ?
ORDER BY a.id ASC LIMIT 1
ORDER BY a.id ASC LIMIT 1
`).get(ip);
`).get(ip);
if (byIpLog) {
if (byIpLog) {
- db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(byIpLog.id);
- db.prepare(`
- UPDATE anon_ip_log SET last_seen = CURRENT_TIMESTAMP
+ await db.prepare('UPDATE anon_users SET last_seen = ov_now() WHERE id = ?').run(byIpLog.id);
+ await db.prepare(`
+ UPDATE anon_ip_log SET last_seen = ov_now()
WHERE anon_id = ? AND ip = ?
WHERE anon_id = ? AND ip = ?
`).run(byIpLog.id, ip);
`).run(byIpLog.id, ip);
return res.json({
return res.json({
@@ -440,12 +440,12 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,
@@ -440,12 +440,12 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,
}
}
// Check by creating IP
// Check by creating IP
- const byCreatingIp = db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);
+ const byCreatingIp = await db.prepare('SELECT * FROM anon_users WHERE ip = ? ORDER BY id ASC LIMIT 1').get(ip);
if (byCreatingIp) {
if (byCreatingIp) {
- db.prepare('UPDATE anon_users SET last_seen = CURRENT_TIMESTAMP WHERE id = ?').run(byCreatingIp.id);
+ await db.prepare('UPDATE anon_users SET last_seen = ov_now() WHERE id = ?').run(byCreatingIp.id);
// Ensure IP log entry exists
// Ensure IP log entry exists
try {
try {
- db.prepare('INSERT OR IGNORE INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(byCreatingIp.id, ip);
+ await db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES(?, ?) ON CONFLICT DO NOTHING').run(byCreatingIp.id, ip);
} catch { /* ok */ }
} catch { /* ok */ }
return res.json({
return res.json({
anon_number: byCreatingIp.anon_number,
anon_number: byCreatingIp.anon_number,
@@ -457,17 +457,17 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,
@@ -457,17 +457,17 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,
}
}
// Create new anon
// Create new anon
- const maxNum = db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get().max || 0;
+ const maxNum = (await db.prepare('SELECT MAX(anon_number) as max FROM anon_users').get()).max || 0;
const anonNumber = maxNum + 1;
const anonNumber = maxNum + 1;
const sessionToken = uuidv4();
const sessionToken = uuidv4();
- const result = db.prepare(
- 'INSERT INTO anon_users (anon_number, session_token, ip, subject_id) VALUES (?, ?, ?, ?)'
+ const result = await db.prepare(
+ 'INSERT INTO anon_users (anon_number, session_token, ip, subject_id) VALUES (?, ?, ?, ?) RETURNING id'
).run(anonNumber, sessionToken, ip, require('../identity/subjects').newGuestSubjectId());
).run(anonNumber, sessionToken, ip, require('../identity/subjects').newGuestSubjectId());
// Log IP
// Log IP
try {
try {
- db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(result.lastInsertRowid, ip);
+ await db.prepare('INSERT INTO anon_ip_log (anon_id, ip) VALUES (?, ?)').run(result.lastInsertRowid, ip);
} catch { /* ok */ }
} catch { /* ok */ }
console.log(`[Internal] New unified anon #${anonNumber} for IP ${ip}`);
console.log(`[Internal] New unified anon #${anonNumber} for IP ${ip}`);
@@ -69,7 +69,7 @@ class EmailService {
@@ -69,7 +69,7 @@ class EmailService {
* One-time migration: copy legacy email settings to the new email_* keys if they exist
* One-time migration: copy legacy email settings to the new email_* keys if they exist
* and the new keys haven't been set yet.
* and the new keys haven't been set yet.
*/
*/
- _migrateFromSES() {
+ async _migrateFromSES() {
const migrations = [
const migrations = [
['ses_enabled', 'email_enabled', 'boolean'],
['ses_enabled', 'email_enabled', 'boolean'],
['ses_from_email', 'email_from_address', 'string'],
['ses_from_email', 'email_from_address', 'string'],
@@ -78,20 +78,20 @@ class EmailService {
@@ -78,20 +78,20 @@ class EmailService {
['ses_from_email_openvibegames', 'email_from_openvibegames', 'string'],
['ses_from_email_openvibegames', 'email_from_openvibegames', 'string'],
['ses_from_email_openvibenetwork', 'email_from_openvibenetwork', 'string'],
['ses_from_email_openvibenetwork', 'email_from_openvibenetwork', 'string'],
];
];
- const setSetting = this.db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
+ const setSetting = this.db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT DO NOTHING');
for (const [oldKey, newKey, type] of migrations) {
for (const [oldKey, newKey, type] of migrations) {
- const oldVal = this.db.getSetting(oldKey);
+ const oldVal = await this.db.getSetting(oldKey);
if (oldVal != null && oldVal !== '') {
if (oldVal != null && oldVal !== '') {
- setSetting.run(newKey, String(oldVal), type);
+ await setSetting.run(newKey, String(oldVal), type);
}
}
}
}
}
}
- _loadConfig() {
- this._enabled = this.db.getSetting('email_enabled') === true;
- this._apiKey = this.db.getSetting('resend_api_key') || null;
- this._fromEmail = this.db.getSetting('email_from_address') || '[email protected]';
- this._fromName = this.db.getSetting('email_from_name') || 'OpenVibe';
+ async _loadConfig() {
+ this._enabled = await this.db.getSetting('email_enabled') === true;
+ this._apiKey = await this.db.getSetting('resend_api_key') || null;
+ this._fromEmail = await this.db.getSetting('email_from_address') || '[email protected]';
+ this._fromName = await this.db.getSetting('email_from_name') || 'OpenVibe';
if (this._enabled && this._apiKey) {
if (this._enabled && this._apiKey) {
console.log(`[Email] Resend initialized (from: ${this._fromName} <${this._fromEmail}>)`);
console.log(`[Email] Resend initialized (from: ${this._fromName} <${this._fromEmail}>)`);
@@ -113,10 +113,10 @@ class EmailService {
@@ -113,10 +113,10 @@ class EmailService {
* Resolve the from-address for a given service.
* Resolve the from-address for a given service.
* Falls back to the global from-email if no per-service override.
* Falls back to the global from-email if no per-service override.
*/
*/
- getFromEmail(service) {
+ async getFromEmail(service) {
if (service) {
if (service) {
const key = `email_from_${service.replace(/[^a-z0-9]/gi, '').toLowerCase()}`;
const key = `email_from_${service.replace(/[^a-z0-9]/gi, '').toLowerCase()}`;
- const override = this.db.getSetting(key);
+ const override = await this.db.getSetting(key);
if (override) return override;
if (override) return override;
}
}
return this._fromEmail;
return this._fromEmail;
@@ -126,9 +126,9 @@ class EmailService {
@@ -126,9 +126,9 @@ class EmailService {
* Diagnose why the email service isn't ready.
* Diagnose why the email service isn't ready.
* Returns a user-friendly error string, or null if ready.
* Returns a user-friendly error string, or null if ready.
*/
*/
- diagnose() {
+ async diagnose() {
if (!this._enabled) {
if (!this._enabled) {
- const dbEnabled = this.db.getSetting('email_enabled');
+ const dbEnabled = await this.db.getSetting('email_enabled');
if (dbEnabled !== true) return 'Email is disabled — enable the toggle in admin panel and save';
if (dbEnabled !== true) return 'Email is disabled — enable the toggle in admin panel and save';
return 'Email service failed to initialize after being enabled — check server logs';
return 'Email service failed to initialize after being enabled — check server logs';
}
}
@@ -264,7 +264,7 @@ class EmailService {
@@ -264,7 +264,7 @@ class EmailService {
async processQueue(notifService) {
async processQueue(notifService) {
if (!this.isEnabled) return;
if (!this.isEnabled) return;
- const pending = notifService.getPendingEmails();
+ const pending = await notifService.getPendingEmails();
if (pending.length === 0) return;
if (pending.length === 0) return;
console.log(`[Email] Processing ${pending.length} pending email(s)...`);
console.log(`[Email] Processing ${pending.length} pending email(s)...`);
@@ -272,13 +272,13 @@ class EmailService {
@@ -272,13 +272,13 @@ class EmailService {
let skipped = 0;
let skipped = 0;
for (const notif of pending) {
for (const notif of pending) {
if (!notifService.shouldEmail(notif)) {
if (!notifService.shouldEmail(notif)) {
- notifService.markEmailed(notif.id);
+ await notifService.markEmailed(notif.id);
continue;
continue;
}
}
const why = notifService.emailGuard ? notifService.emailGuard(notif) : null;
const why = notifService.emailGuard ? notifService.emailGuard(notif) : null;
if (why) {
if (why) {
// Caps: leave the row un-emailed only if the cap may lift (it won't for stale/dup).
// Caps: leave the row un-emailed only if the cap may lift (it won't for stale/dup).
- if (why === 'stale' || why === 'dup') notifService.markEmailed(notif.id);
+ if (why === 'stale' || why === 'dup') await notifService.markEmailed(notif.id);
skipped++;
skipped++;
continue;
continue;
}
}
@@ -291,7 +291,7 @@ class EmailService {
@@ -291,7 +291,7 @@ class EmailService {
subject,
subject,
notification: notif,
notification: notif,
});
});
- if (sent) notifService.markEmailed(notif.id);
+ if (sent) await notifService.markEmailed(notif.id);
}
}
if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);
if (skipped) console.log(`[Email] ${skipped} notification email(s) held back by guards (stale/dup/caps)`);
}
}
@@ -412,7 +412,7 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
@@ -412,7 +412,7 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
].join('\n');
].join('\n');
}
}
- _escapeHtml(value) {
+ async _escapeHtml(value) {
return String(value ?? '')
return String(value ?? '')
.replace(/&/g, '&')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/</g, '<')
@@ -424,8 +424,8 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
@@ -424,8 +424,8 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
/**
/**
* Get current email service configuration status (for admin panel).
* Get current email service configuration status (for admin panel).
*/
*/
- getStatus() {
- const apiKey = this.db.getSetting('resend_api_key') || '';
+ async getStatus() {
+ const apiKey = await this.db.getSetting('resend_api_key') || '';
const issue = this.diagnose();
const issue = this.diagnose();
const secrets = require('../secrets');
const secrets = require('../secrets');
return {
return {
@@ -435,14 +435,14 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
@@ -435,14 +435,14 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
provider: 'resend',
provider: 'resend',
from_email: this._fromEmail,
from_email: this._fromEmail,
from_name: this._fromName,
from_name: this._fromName,
- from_email_openvibelive: this.db.getSetting('email_from_openvibelive') || '',
- from_email_openvibegames: this.db.getSetting('email_from_openvibegames') || '',
- from_email_openvibenetwork: this.db.getSetting('email_from_openvibenetwork') || '',
+ from_email_openvibelive: await this.db.getSetting('email_from_openvibelive') || '',
+ from_email_openvibegames: await this.db.getSetting('email_from_openvibegames') || '',
+ from_email_openvibenetwork: await this.db.getSetting('email_from_openvibenetwork') || '',
hasApiKey: !!apiKey,
hasApiKey: !!apiKey,
// Masked API key for display (show first 6 chars + last 4)
// Masked API key for display (show first 6 chars + last 4)
api_key: apiKey ? apiKey.slice(0, 6) + '••••' + apiKey.slice(-4) : '',
api_key: apiKey ? apiKey.slice(0, 6) + '••••' + apiKey.slice(-4) : '',
// Where the key comes from: env (RESEND_API_KEY, the admin UI cannot change it), database, unset.
// Where the key comes from: env (RESEND_API_KEY, the admin UI cannot change it), database, unset.
- api_key_source: secrets.source(this.db, 'resend_api_key'),
+ api_key_source: await secrets.source(this.db, 'resend_api_key'),
api_key_env: secrets.envName('resend_api_key'),
api_key_env: secrets.envName('resend_api_key'),
};
};
}
}
@@ -476,9 +476,9 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
@@ -476,9 +476,9 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
return true;
return true;
}
}
- _recordDelivery({ emailType, recipient, subject, status, errorMessage = null, userId = null, notificationId = null, metadata = null }) {
+ async _recordDelivery({ emailType, recipient, subject, status, errorMessage = null, userId = null, notificationId = null, metadata = null }) {
try {
try {
- this._logDelivery.run(
+ await this._logDelivery.run(
emailType,
emailType,
recipient,
recipient,
subject || null,
subject || null,
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
* Each event becomes one inbox notification for its person through NotificationService.create(),
* Each event becomes one inbox notification for its person through NotificationService.create(),
* which applies their preferences: a muted category ('service') creates nothing, and email follows
* which applies their preferences: a muted category ('service') creates nothing, and email follows
* the same per-category choice (shouldEmail). Exactly once: the openvibe-sdk inbox claims
* the same per-category choice (shouldEmail). Exactly once: the openvibe-sdk inbox claims
- * (consumer, event_id) in the same SQLite transaction as the notification, so a redelivery does
+ * (consumer, event_id) in the same PostgreSQL transaction as the notification, so a redelivery does
* nothing and a failure rolls both back (Events retries).
* nothing and a failure rolls both back (Events retries).
*
*
* Signature: v2 only (openvibe-sdk parseDelivery with requireV2) — HMAC over "<t>.<raw body>" with t
* Signature: v2 only (openvibe-sdk parseDelivery with requireV2) — HMAC over "<t>.<raw body>" with t
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
*/
*/
const express = require('express');
const express = require('express');
const { http, ids } = require('openvibe-contracts');
const { http, ids } = require('openvibe-contracts');
-const { parseDelivery, createInbox } = require('openvibe-sdk/events');
+const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { siteForHost } = require('../frame/sites');
const { siteForHost } = require('../frame/sites');
const streamLive = require('./stream-live');
const streamLive = require('./stream-live');
const { LiveFollowersError } = require('./live-followers');
const { LiveFollowersError } = require('./live-followers');
@@ -162,10 +162,9 @@ function liveStarted(event, { now, maxAgeMs }) {
@@ -162,10 +162,9 @@ function liveStarted(event, { now, maxAgeMs }) {
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {() => ({ sendLiveAlert(streamer: object, stream: object): Promise<object> }|null)} [o.discord]
* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()
* @param {{ record(event: object): string }} [o.projectUsage] ../developer/usage.js createProjectUsage()
*/
*/
-function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, followsAuthority = 'live', discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
+async function createEventsConsumer({ db, notifications, secrets, liveFollowers = null, followsAuthority = 'live', discord = () => null, moderationAudit = null, projectUsage = null, liveStartedMaxAgeMs = LIVE_STARTED_MAX_AGE_MS, now = () => Date.now(), log = console }) {
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
const keys = Array.isArray(secrets) ? secrets.filter((s) => typeof s === 'string' && s.length >= 32) : secretsFrom(secrets);
- const inbox = createInbox(db, { table: INBOX_TABLE, now });
- inbox.ensureSchema();
+ const inbox = createPgInbox(db, { table: INBOX_TABLE, now });
const userBySubject = db.prepare('SELECT id FROM users WHERE subject_id = ?');
const userBySubject = db.prepare('SELECT id FROM users WHERE subject_id = ?');
const streamerBySubject = db.prepare('SELECT id, username, display_name, avatar_url FROM users WHERE subject_id = ?');
const streamerBySubject = db.prepare('SELECT id, username, display_name, avatar_url FROM users WHERE subject_id = ?');
const userById = db.prepare('SELECT id FROM users WHERE id = ?');
const userById = db.prepare('SELECT id FROM users WHERE id = ?');
@@ -178,11 +177,11 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -178,11 +177,11 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
async 'live.stream.started'(event) {
async 'live.stream.started'(event) {
const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });
const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });
if (typeof v === 'string') return { skip: v };
if (typeof v === 'string') return { skip: v };
- if (!streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce
+ if (!await streamerBySubject.get(v.subject)) return { skip: 'ignored:channel' }; // not a Network account: nobody to announce
// ADR-030 step 4: with FOLLOWS_AUTHORITY=network the followers come from Network's own graph
// ADR-030 step 4: with FOLLOWS_AUTHORITY=network the followers come from Network's own graph
// (server/identity/follows.js), not from Live. Rollback: unset it.
// (server/identity/follows.js), not from Live. Rollback: unset it.
if (followsAuthority === 'network') {
if (followsAuthority === 'network') {
- const rows = db.prepare("SELECT follower_subject FROM user_follows WHERE target_type = 'channel' AND target_id = ? AND active = 1").all(v.subject);
+ const rows = await db.prepare("SELECT follower_subject FROM user_follows WHERE target_type = 'channel' AND target_id = ? AND active = 1").all(v.subject);
return { channelSubject: v.subject, followers: rows.map((r) => ({ subject: r.follower_subject })) };
return { channelSubject: v.subject, followers: rows.map((r) => ({ subject: r.follower_subject })) };
}
}
if (!liveFollowers) throw new LiveFollowersError('no Live followers client (RS256 signing key and OV_LIVE_INTERNAL_URL needed)');
if (!liveFollowers) throw new LiveFollowersError('no Live followers client (RS256 signing key and OV_LIVE_INTERNAL_URL needed)');
@@ -191,32 +190,32 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -191,32 +190,32 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
};
};
/** live.stream.started inside the inbox transaction: the announcement window, then one notification per person. */
/** live.stream.started inside the inbox transaction: the announcement window, then one notification per person. */
- function liveStreamStarted(event, prep) {
+ async function liveStreamStarted(event, prep) {
if (prep && prep.skip) return prep.skip;
if (prep && prep.skip) return prep.skip;
const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });
const v = liveStarted(event, { now: now(), maxAgeMs: liveStartedMaxAgeMs });
if (typeof v === 'string') return v;
if (typeof v === 'string') return v;
if (!prep || prep.missing) return 'ignored:stream';
if (!prep || prep.missing) return 'ignored:stream';
if (prep.channelSubject !== v.subject) return 'ignored:channel-mismatch'; // that stream is not this channel's
if (prep.channelSubject !== v.subject) return 'ignored:channel-mismatch'; // that stream is not this channel's
- const streamer = streamerBySubject.get(v.subject);
+ const streamer = await streamerBySubject.get(v.subject);
if (!streamer) return 'ignored:channel';
if (!streamer) return 'ignored:channel';
- const claim = streamLive.claimAnnouncement(db, { streamerKey: streamer.id, streamId: v.streamId, now: now() });
+ const claim = await streamLive.claimAnnouncement(db, { streamerKey: streamer.id, streamId: v.streamId, now: now() });
if (claim.skipped) return { outcome: `skipped:${claim.reason}`, detail: { next_allowed_at: claim.next_allowed_at || null } };
if (claim.skipped) return { outcome: `skipped:${claim.reason}`, detail: { next_allowed_at: claim.next_allowed_at || null } };
const targets = new Set();
const targets = new Set();
let unresolved = 0;
let unresolved = 0;
for (const f of prep.followers || []) {
for (const f of prep.followers || []) {
let uid = null;
let uid = null;
- if (f.subject && ids.isSubjectId('user', f.subject)) { const u = userBySubject.get(f.subject); if (u) uid = u.id; }
- if (uid == null && f.network_user_id) { const u = userById.get(f.network_user_id); if (u) uid = u.id; }
+ if (f.subject && ids.isSubjectId('user', f.subject)) { const u = await userBySubject.get(f.subject); if (u) uid = u.id; }
+ if (uid == null && f.network_user_id) { const u = await userById.get(f.network_user_id); if (u) uid = u.id; }
if (uid == null) { unresolved++; continue; }
if (uid == null) { unresolved++; continue; }
targets.add(uid);
targets.add(uid);
}
}
- for (const uid of streamLive.allLiveSubscribers(db)) targets.add(uid);
+ for (const uid of await streamLive.allLiveSubscribers(db)) targets.add(uid);
targets.delete(streamer.id); // never tell streamers about themselves
targets.delete(streamer.id); // never tell streamers about themselves
const notification = streamLive.streamLiveNotification({
const notification = streamLive.streamLiveNotification({
username: v.username, displayName: v.displayName, avatarUrl: streamer.avatar_url, senderId: streamer.id,
username: v.username, displayName: v.displayName, avatarUrl: streamer.avatar_url, senderId: streamer.id,
stream: { id: v.streamId, title: v.title, protocol: v.protocol, event_id: event.event_id }, url: v.url,
stream: { id: v.streamId, title: v.title, protocol: v.protocol, event_id: event.event_id }, url: v.url,
});
});
- const created = targets.size ? notifications.createBulk([...targets], notification) : [];
+ const created = targets.size ? await notifications.createBulk([...targets], notification) : [];
const detail = { followers: (prep.followers || []).length, unresolved, targets: targets.size, notified: created.length, ...(prep.truncated ? { truncated: true } : {}) };
const detail = { followers: (prep.followers || []).length, unresolved, targets: targets.size, notified: created.length, ...(prep.truncated ? { truncated: true } : {}) };
const after = () => {
const after = () => {
const d = discord && discord();
const d = discord && discord();
@@ -229,9 +228,9 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -229,9 +228,9 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
}
}
/** Apply one envelope (with what PREPARE fetched for it). Returns { duplicate, outcome, detail }. Throws only on a storage failure. */
/** Apply one envelope (with what PREPARE fetched for it). Returns { duplicate, outcome, detail }. Throws only on a storage failure. */
- function apply(event, prep) {
+ async function apply(event, prep) {
let after = null;
let after = null;
- const r = inbox.once(CONSUMER, event.event_id, () => {
+ const r = await inbox.once(CONSUMER, event.event_id, async () => {
// Staff actions go to the moderation audit log (ADR-022), never to anyone's inbox.
// Staff actions go to the moderation audit log (ADR-022), never to anyone's inbox.
if (AUDIT_TOPICS.includes(event.event_type)) return moderationAudit ? moderationAudit.record(event) : 'ignored:audit_off';
if (AUDIT_TOPICS.includes(event.event_type)) return moderationAudit ? moderationAudit.record(event) : 'ignored:audit_off';
// Developer projects' usage rollups go to their dashboards (WS-N task 4), never to an inbox.
// Developer projects' usage rollups go to their dashboards (WS-N task 4), never to an inbox.
@@ -239,7 +238,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -239,7 +238,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
// Creator analytics (WS-E task 6): each ended stream's totals, counts only.
// Creator analytics (WS-E task 6): each ended stream's totals, counts only.
if (event.event_type === 'live.stream.ended') return require('../analytics/creators').record(db, event);
if (event.event_type === 'live.stream.ended') return require('../analytics/creators').record(db, event);
if (event.event_type === 'live.stream.started') {
if (event.event_type === 'live.stream.started') {
- const out = liveStreamStarted(event, prep);
+ const out = await liveStreamStarted(event, prep);
if (typeof out === 'string') return out;
if (typeof out === 'string') return out;
after = out.after || null;
after = out.after || null;
return { outcome: out.outcome, detail: out.detail };
return { outcome: out.outcome, detail: out.detail };
@@ -249,10 +248,10 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -249,10 +248,10 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
const out = handler(event);
const out = handler(event);
if (typeof out === 'string') return out;
if (typeof out === 'string') return out;
if (!ids.isSubjectId('user', out.subject)) return 'ignored:recipient';
if (!ids.isSubjectId('user', out.subject)) return 'ignored:recipient';
- const user = userBySubject.get(out.subject);
+ const user = await userBySubject.get(out.subject);
if (!user) return 'ignored:recipient';
if (!user) return 'ignored:recipient';
// create() returns null when the person turned the category off.
// create() returns null when the person turned the category off.
- return notifications.create({ ...out.notification, user_id: user.id }) ? 'notified' : 'suppressed:preference';
+ return await notifications.create({ ...out.notification, user_id: user.id }) ? 'notified' : 'suppressed:preference';
});
});
if (r.duplicate) return { duplicate: true, outcome: null };
if (r.duplicate) return { duplicate: true, outcome: null };
// Side effects outside the database run only after the commit, once per event.
// Side effects outside the database run only after the commit, once per event.
@@ -275,7 +274,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -275,7 +274,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
let prep;
let prep;
if (Object.prototype.hasOwnProperty.call(PREPARE, event.event_type)) {
if (Object.prototype.hasOwnProperty.call(PREPARE, event.event_type)) {
// Already handled: answer without asking anyone anything.
// Already handled: answer without asking anyone anything.
- if (inbox.seen(CONSUMER, event.event_id)) return res.status(200).json({ event_id: event.event_id, duplicate: true, outcome: null });
+ if (await inbox.seen(CONSUMER, event.event_id)) return res.status(200).json({ event_id: event.event_id, duplicate: true, outcome: null });
try {
try {
prep = await PREPARE[event.event_type](event);
prep = await PREPARE[event.event_type](event);
} catch (err) {
} catch (err) {
@@ -286,7 +285,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
@@ -286,7 +285,7 @@ function createEventsConsumer({ db, notifications, secrets, liveFollowers = null
}
}
let out;
let out;
try {
try {
- out = apply(event, prep);
+ out = await apply(event, prep);
} catch (err) {
} catch (err) {
// Not acknowledged: the inbox claim rolled back with the notification, and Events retries.
// Not acknowledged: the inbox claim rolled back with the notification, and Events retries.
log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
log.error(`[Events consumer] ${event.event_id} (${event.event_type}) failed:`, err.message);
@@ -12,14 +12,14 @@
@@ -12,14 +12,14 @@
const { channelUrl } = require('./stream-live');
const { channelUrl } = require('./stream-live');
function followNotifier(notifications) {
function followNotifier(notifications) {
- return (db, { follower, type, target }) => {
+ return async (db, { follower, type, target }) => {
if (type !== 'channel') return null;
if (type !== 'channel') return null;
const who = db.prepare('SELECT id, username, display_name, avatar_url, is_anon FROM users WHERE subject_id = ?');
const who = db.prepare('SELECT id, username, display_name, avatar_url, is_anon FROM users WHERE subject_id = ?');
- const f = who.get(follower);
- const t = who.get(target);
+ const f = await who.get(follower);
+ const t = await who.get(target);
if (!f || !t || t.is_anon) return null;
if (!f || !t || t.is_anon) return null;
const name = f.display_name || f.username || 'Someone';
const name = f.display_name || f.username || 'Someone';
- return notifications.create({
+ return await notifications.create({
user_id: t.id, type: 'FOLLOW', title: 'New Follower', message: `${name} followed you`,
user_id: t.id, type: 'FOLLOW', title: 'New Follower', message: `${name} followed you`,
url: f.username ? channelUrl(f.username) : 'https://openvibe.live/', service: 'live',
url: f.username ? channelUrl(f.username) : 'https://openvibe.live/', service: 'live',
sender_id: f.id, sender_name: name, sender_avatar: f.avatar_url || null, actor_subject: follower,
sender_id: f.id, sender_name: name, sender_avatar: f.avatar_url || null, actor_subject: follower,
@@ -156,7 +156,8 @@ class NotificationService {
@@ -156,7 +156,8 @@ class NotificationService {
* Create a notification. Returns the created notification object.
* Create a notification. Returns the created notification object.
* @param {Object} data - { user_id, type, title, message, sender_id, sender_name, sender_avatar, service, url, rich_content, expires_at }
* @param {Object} data - { user_id, type, title, message, sender_id, sender_name, sender_avatar, service, url, rich_content, expires_at }
*/
*/
- create(data) {
+ async create(data) {
+ const db = this.db;
const typeDef = TYPES[data.type] || {};
const typeDef = TYPES[data.type] || {};
const id = uuidv4();
const id = uuidv4();
const category = data.category || typeDef.category || 'system';
const category = data.category || typeDef.category || 'system';
@@ -165,15 +166,15 @@ class NotificationService {
@@ -165,15 +166,15 @@ class NotificationService {
const title = data.title || typeDef.title || 'Notification';
const title = data.title || typeDef.title || 'Notification';
// Check user's preferences — skip if disabled
// Check user's preferences — skip if disabled
- const pref = this._getPrefByCategory.get(data.user_id, category);
+ const pref = await this._getPrefByCategory.get(data.user_id, category);
if (pref && !pref.enabled) return null;
if (pref && !pref.enabled) return null;
// Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a
// Nothing from a person the recipient blocked (platform blocks, WS-E task 5). A block never hides a
// staff action: moderation, system and admin notices are always created.
// staff action: moderation, system and admin notices are always created.
- if (!BLOCK_EXEMPT_CATEGORIES.has(category) && this.fromBlockedActor(data)) return null;
+ if (!BLOCK_EXEMPT_CATEGORIES.has(category) && await this.fromBlockedActor(data)) return null;
// Go-live and follow dedupe (see _recentFromSender): one an hour per sender, so following again after an
// Go-live and follow dedupe (see _recentFromSender): one an hour per sender, so following again after an
// unfollow does not notify again.
// unfollow does not notify again.
if ((data.type === 'STREAM_LIVE' || data.type === 'FOLLOW') && data.sender_id != null) {
if ((data.type === 'STREAM_LIVE' || data.type === 'FOLLOW') && data.sender_id != null) {
- try { if (this._recentFromSender.get(data.user_id, data.type, data.sender_id)) return null; } catch { /* */ }
+ try { if (await this._recentFromSender.get(data.user_id, data.type, data.sender_id)) return null; } catch { /* */ }
}
}
const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
const richContent = data.rich_content ? JSON.stringify(data.rich_content) : null;
@@ -181,17 +182,17 @@ class NotificationService {
@@ -181,17 +182,17 @@ class NotificationService {
// The notification and its network.notification.created event: one transaction.
// The notification and its network.notification.created event: one transaction.
let announced = false;
let announced = false;
- this.db.transaction(() => {
- this._insertNotif.run(
+ await db.tx(async () => {
+ await this._insertNotif.run(
id, data.user_id, data.type || 'GENERIC', category, priority,
id, data.user_id, data.type || 'GENERIC', category, priority,
title, data.message || null, icon,
title, data.message || null, icon,
data.sender_id || null, data.sender_name || null, data.sender_avatar || null,
data.sender_id || null, data.sender_name || null, data.sender_avatar || null,
data.service || null, data.url || null, richContent,
data.service || null, data.url || null, richContent,
data.expires_at || null,
data.expires_at || null,
);
);
- announced = this._announce({ id, userId: data.user_id, type: data.type, category, priority, service: data.service, createdAt });
- })();
- // Wake the relay (it reads after the outermost transaction commits: better-sqlite3 is synchronous).
+ announced = await this._announce({ id, userId: data.user_id, type: data.type, category, priority, service: data.service, createdAt });
+ });
+ // Wake the relay after the commit (the outbox row committed with the notification).
if (announced) { const live = eventRelay.outboxFor(this.db); if (live) live.kick(); }
if (announced) { const live = eventRelay.outboxFor(this.db); if (live) live.kick(); }
// Fire browser push notification (async, non-blocking); `silent` keeps it to the bell.
// Fire browser push notification (async, non-blocking); `silent` keeps it to the bell.
@@ -215,8 +216,8 @@ class NotificationService {
@@ -215,8 +216,8 @@ class NotificationService {
* given one) gets none; an envelope that would not match its contract is skipped with a warning rather
* given one) gets none; an envelope that would not match its contract is skipped with a warning rather
* than losing the notification (the badge's polling still finds it).
* than losing the notification (the badge's polling still finds it).
*/
*/
- _announce({ id, userId, type, category, priority, service, createdAt }) {
- const r = this._recipient.get(userId);
+ async _announce({ id, userId, type, category, priority, service, createdAt }) {
+ const r = await this._recipient.get(userId);
if (!r || r.is_anon || !SUBJECT_RE.test(String(r.subject_id || ''))) return false;
if (!r || r.is_anon || !SUBJECT_RE.test(String(r.subject_id || ''))) return false;
const payload = {
const payload = {
notification_id: String(id),
notification_id: String(id),
@@ -225,7 +226,7 @@ class NotificationService {
@@ -225,7 +226,7 @@ class NotificationService {
priority: PRIORITIES.has(priority) ? priority : 'normal',
priority: PRIORITIES.has(priority) ? priority : 'normal',
service: /^[a-z][a-z0-9-]{1,39}$/.test(String(service || '')) ? String(service) : null,
service: /^[a-z][a-z0-9-]{1,39}$/.test(String(service || '')) ? String(service) : null,
created_at: createdAt,
created_at: createdAt,
- unread_count: this._unreadCount.get(userId)?.count || 0,
+ unread_count: (await this._unreadCount.get(userId))?.count || 0,
};
};
const ms = Date.parse(createdAt) || Date.now();
const ms = Date.parse(createdAt) || Date.now();
const env = {
const env = {
@@ -239,7 +240,7 @@ class NotificationService {
@@ -239,7 +240,7 @@ class NotificationService {
console.warn(`[Notifications] ${NOTIFICATION_EVENT} not queued for notification ${id}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 200)}`);
console.warn(`[Notifications] ${NOTIFICATION_EVENT} not queued for notification ${id}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 200)}`);
return false;
return false;
}
}
- eventRelay.writerFor(this.db).enqueue(env);
+ await eventRelay.writerFor(this.db).enqueue(this.db, env);
return true;
return true;
}
}
@@ -247,35 +248,35 @@ class NotificationService {
@@ -247,35 +248,35 @@ class NotificationService {
* Whether the recipient (data.user_id) blocked the notification's actor: data.actor_subject (a usr_
* Whether the recipient (data.user_id) blocked the notification's actor: data.actor_subject (a usr_
* subject; null = no known person) when given, else data.sender_id as a Network user id.
* subject; null = no known person) when given, else data.sender_id as a Network user id.
*/
*/
- fromBlockedActor(data) {
+ async fromBlockedActor(data) {
try {
try {
if (Object.prototype.hasOwnProperty.call(data, 'actor_subject')) {
if (Object.prototype.hasOwnProperty.call(data, 'actor_subject')) {
- return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(data.actor_subject || '')) && !!this._blockedActorSubject.get(data.user_id, data.actor_subject);
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(data.actor_subject || '')) && !!await this._blockedActorSubject.get(data.user_id, data.actor_subject);
}
}
const sender = Number(data.sender_id);
const sender = Number(data.sender_id);
- return Number.isInteger(sender) && sender > 0 && Number(data.user_id) !== sender && !!this._blockedActorId.get(data.user_id, sender);
+ return Number.isInteger(sender) && sender > 0 && Number(data.user_id) !== sender && !!await this._blockedActorId.get(data.user_id, sender);
} catch { return false; }
} catch { return false; }
}
}
/**
/**
* Bulk-create notifications for multiple users (e.g., broadcast).
* Bulk-create notifications for multiple users (e.g., broadcast).
*/
*/
- createBulk(userIds, data) {
+ async createBulk(userIds, data) {
+ const db = this.db;
const results = [];
const results = [];
- const tx = this.db.transaction(() => {
+ await db.tx(async () => {
for (const uid of userIds) {
for (const uid of userIds) {
- const notif = this.create({ ...data, user_id: uid });
+ const notif = await this.create({ ...data, user_id: uid });
if (notif) results.push(notif);
if (notif) results.push(notif);
}
}
});
});
- tx();
return results;
return results;
}
}
// ─── Read ──────────────────────────────────────────────────
// ─── Read ──────────────────────────────────────────────────
- getById(id) {
- const n = this._getById.get(id);
+ async getById(id) {
+ const n = await this._getById.get(id);
if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);
if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);
return n;
return n;
}
}
@@ -287,7 +288,7 @@ class NotificationService {
@@ -287,7 +288,7 @@ class NotificationService {
* - since: ISO timestamp, only rows created after it (toasts)
* - since: ISO timestamp, only rows created after it (toasts)
* Fetches limit+1 so the client knows whether another page exists.
* Fetches limit+1 so the client knows whether another page exists.
*/
*/
- getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {
+ async getForUser(userId, { limit = 50, offset = 0, category = null, unreadOnly = false, q = null, since = null, type = null } = {}) {
const where = ['user_id = ?', 'is_dismissed = 0'];
const where = ['user_id = ?', 'is_dismissed = 0'];
const params = [userId];
const params = [userId];
if (category) { where.push('category = ?'); params.push(category); }
if (category) { where.push('category = ?'); params.push(category); }
@@ -299,11 +300,11 @@ class NotificationService {
@@ -299,11 +300,11 @@ class NotificationService {
where.push("(title LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\' OR sender_name LIKE ? ESCAPE '\\')");
where.push("(title LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\' OR sender_name LIKE ? ESCAPE '\\')");
params.push(like, like, like);
params.push(like, like, like);
}
}
- const sql = `SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`;
- const rows = this.db.prepare(sql).all(...params, limit + 1, offset);
+ const sql = `SELECT * FROM notifications WHERE ${where.join(' AND ')} ORDER BY created_at DESC, seq DESC LIMIT ? OFFSET ?`;
+ const rows = await this.db.prepare(sql).all(...params, limit + 1, offset);
const has_more = rows.length > limit;
const has_more = rows.length > limit;
if (has_more) rows.length = limit;
if (has_more) rows.length = limit;
- const total = this.db.prepare(`SELECT COUNT(*) AS c FROM notifications WHERE ${where.join(' AND ')}`).get(...params)?.c || 0;
+ const total = (await this.db.prepare(`SELECT COUNT(*) AS c FROM notifications WHERE ${where.join(' AND ')}`).get(...params))?.c || 0;
const notifications = rows.map(n => {
const notifications = rows.map(n => {
// Defensive: a single corrupt row must never 500 the whole dropdown.
// Defensive: a single corrupt row must never 500 the whole dropdown.
if (n.rich_content) {
if (n.rich_content) {
@@ -315,25 +316,26 @@ class NotificationService {
@@ -315,25 +316,26 @@ class NotificationService {
return { notifications, has_more, total };
return { notifications, has_more, total };
}
}
- markReadMany(ids, userId) {
+ async markReadMany(ids, userId) {
+ const db = this.db;
if (!Array.isArray(ids) || !ids.length) return 0;
if (!Array.isArray(ids) || !ids.length) return 0;
const stmt = this.db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND is_read = 0');
const stmt = this.db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND is_read = 0');
let n = 0;
let n = 0;
- const tx = this.db.transaction(() => { for (const id of ids.slice(0, 500)) n += stmt.run(String(id), userId).changes; });
- tx();
+ const tx = db.txFn(async () => { for (const id of ids.slice(0, 500)) n += (await stmt.run(String(id), userId)).changes; });
+ await tx();
return n;
return n;
}
}
- getUnreadCount(userId) {
- return this._unreadCount.get(userId)?.count || 0;
+ async getUnreadCount(userId) {
+ return (await this._unreadCount.get(userId))?.count || 0;
}
}
- getUnreadByCategory(userId) {
- return this._unreadCountByCategory.all(userId);
+ async getUnreadByCategory(userId) {
+ return await this._unreadCountByCategory.all(userId);
}
}
- getNewest(userId) {
- const n = this._newestForUser.get(userId);
+ async getNewest(userId) {
+ const n = await this._newestForUser.get(userId);
if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);
if (n && n.rich_content) n.rich_content = JSON.parse(n.rich_content);
return n;
return n;
}
}
@@ -341,51 +343,51 @@ class NotificationService {
@@ -341,51 +343,51 @@ class NotificationService {
// ─── Update ────────────────────────────────────────────────
// ─── Update ────────────────────────────────────────────────
/** Edit one of a user's notifications in place (an operator alert that resolves or fires again). */
/** Edit one of a user's notifications in place (an operator alert that resolves or fires again). */
- revise(id, userId, { title, message, icon, is_read: isRead } = {}) {
+ async revise(id, userId, { title, message, icon, is_read: isRead } = {}) {
const sets = []; const args = [];
const sets = []; const args = [];
if (title != null) { sets.push('title = ?'); args.push(String(title).slice(0, 200)); }
if (title != null) { sets.push('title = ?'); args.push(String(title).slice(0, 200)); }
if (message != null) { sets.push('message = ?'); args.push(String(message)); }
if (message != null) { sets.push('message = ?'); args.push(String(message)); }
if (icon != null) { sets.push('icon = ?'); args.push(String(icon)); }
if (icon != null) { sets.push('icon = ?'); args.push(String(icon)); }
if (isRead != null) { sets.push('is_read = ?'); args.push(isRead ? 1 : 0); }
if (isRead != null) { sets.push('is_read = ?'); args.push(isRead ? 1 : 0); }
if (!sets.length) return false;
if (!sets.length) return false;
- const r = this.db.prepare(`UPDATE notifications SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...args, id, userId);
+ const r = await this.db.prepare(`UPDATE notifications SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...args, id, userId);
return r.changes > 0;
return r.changes > 0;
}
}
- markRead(id, userId) {
- return this._markRead.run(id, userId).changes > 0;
+ async markRead(id, userId) {
+ return (await this._markRead.run(id, userId)).changes > 0;
}
}
- markAllRead(userId, category = null) {
- if (category) return this._markReadByCategory.run(userId, category).changes;
- return this._markAllRead.run(userId).changes;
+ async markAllRead(userId, category = null) {
+ if (category) return (await this._markReadByCategory.run(userId, category)).changes;
+ return (await this._markAllRead.run(userId)).changes;
}
}
- dismiss(id, userId) {
- return this._dismiss.run(id, userId).changes > 0;
+ async dismiss(id, userId) {
+ return (await this._dismiss.run(id, userId)).changes > 0;
}
}
- dismissAll(userId) {
- return this._dismissAll.run(userId).changes;
+ async dismissAll(userId) {
+ return (await this._dismissAll.run(userId)).changes;
}
}
- markReadByType(userId, type, urlPattern = null) {
- if (urlPattern) return this._markReadByTypeAndUrl.run(userId, type, urlPattern).changes;
- return this._markReadByType.run(userId, type).changes;
+ async markReadByType(userId, type, urlPattern = null) {
+ if (urlPattern) return (await this._markReadByTypeAndUrl.run(userId, type, urlPattern)).changes;
+ return (await this._markReadByType.run(userId, type)).changes;
}
}
- markEmailed(id) {
- return this._markEmailed.run(id).changes > 0;
+ async markEmailed(id) {
+ return (await this._markEmailed.run(id)).changes > 0;
}
}
// ─── Preferences ──────────────────────────────────────────
// ─── Preferences ──────────────────────────────────────────
- getPreferences(userId) {
- return this._getPrefs.all(userId);
+ async getPreferences(userId) {
+ return await this._getPrefs.all(userId);
}
}
- setPreference(userId, category, updates = {}) {
- const current = this._getPrefByCategory.get(userId, category) || {
+ async setPreference(userId, category, updates = {}) {
+ const current = await this._getPrefByCategory.get(userId, category) || {
enabled: 1,
enabled: 1,
sound: 1,
sound: 1,
toasts: 1,
toasts: 1,
@@ -397,7 +399,7 @@ class NotificationService {
@@ -397,7 +399,7 @@ class NotificationService {
toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
toasts: updates.toasts !== undefined ? (updates.toasts ? 1 : 0) : current.toasts,
email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,
email: updates.email !== undefined ? (updates.email === null ? null : (updates.email ? 1 : 0)) : current.email,
};
};
- this._upsertPref.run(
+ await this._upsertPref.run(
userId,
userId,
category,
category,
next.enabled,
next.enabled,
@@ -411,15 +413,15 @@ class NotificationService {
@@ -411,15 +413,15 @@ class NotificationService {
);
);
}
}
- resetPreference(userId, category) {
- this._deletePrefs.run(userId, category);
+ async resetPreference(userId, category) {
+ await this._deletePrefs.run(userId, category);
}
}
// ─── Email Queue ──────────────────────────────────────────
// ─── Email Queue ──────────────────────────────────────────
/**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */
/**\n * Get notifications that should be emailed.\n * Now checks ALL un-emailed notifications (not just critical),\n * because shouldEmail() will filter by user preference.\n */
- getPendingEmails() {
- return this._getPendingEmails.all();
+ async getPendingEmails() {
+ return await this._getPendingEmails.all();
}
}
/**
/**
@@ -428,8 +430,8 @@ class NotificationService {
@@ -428,8 +430,8 @@ class NotificationService {
* Built-in: CRITICAL + system/moderation/admin always email.
* Built-in: CRITICAL + system/moderation/admin always email.
* User opt-in: any category the user explicitly enabled email for.
* User opt-in: any category the user explicitly enabled email for.
*/
*/
- shouldEmail(notification) {
- const pref = this._getPrefByCategory.get(notification.user_id, notification.category);
+ async shouldEmail(notification) {
+ const pref = await this._getPrefByCategory.get(notification.user_id, notification.category);
// If user disabled the whole category, no email
// If user disabled the whole category, no email
if (pref && !pref.enabled) return false;
if (pref && !pref.enabled) return false;
// LOW priority never emails
// LOW priority never emails
@@ -454,37 +456,37 @@ class NotificationService {
@@ -454,37 +456,37 @@ class NotificationService {
* - user cap / global cap: site_settings email_user_daily_cap (default 30),
* - user cap / global cap: site_settings email_user_daily_cap (default 30),
* email_daily_cap (default 2000)
* email_daily_cap (default 2000)
*/
*/
- emailGuard(notification) {
+ async emailGuard(notification) {
try {
try {
if (notification.type === 'STREAM_LIVE') {
if (notification.type === 'STREAM_LIVE') {
const ageMs = Date.now() - new Date(String(notification.created_at).replace(' ', 'T') + 'Z').getTime();
const ageMs = Date.now() - new Date(String(notification.created_at).replace(' ', 'T') + 'Z').getTime();
if (ageMs > 2 * 3600 * 1000) return 'stale';
if (ageMs > 2 * 3600 * 1000) return 'stale';
- if (notification.sender_id != null && this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';
+ if (notification.sender_id != null && await this._recentEmailFromSender.get(notification.user_id, 'STREAM_LIVE', notification.sender_id)) return 'dup';
}
}
- const userCap = parseInt(this.db.getSetting?.('email_user_daily_cap'), 10) || 30;
- if ((this._emailsSentToUserToday.get(notification.user_id)?.c || 0) >= userCap) return 'user-cap';
- const globalCap = parseInt(this.db.getSetting?.('email_daily_cap'), 10) || 2000;
- if ((this._emailsSentToday.get()?.c || 0) >= globalCap) return 'global-cap';
+ const userCap = parseInt(await this.db.getSetting?.('email_user_daily_cap'), 10) || 30;
+ if (((await this._emailsSentToUserToday.get(notification.user_id))?.c || 0) >= userCap) return 'user-cap';
+ const globalCap = parseInt(await this.db.getSetting?.('email_daily_cap'), 10) || 2000;
+ if (((await this._emailsSentToday.get())?.c || 0) >= globalCap) return 'global-cap';
} catch { /* guards are best-effort */ }
} catch { /* guards are best-effort */ }
return null;
return null;
}
}
// ─── Cleanup ──────────────────────────────────────────────
// ─── Cleanup ──────────────────────────────────────────────
- cleanExpired() {
- return this._deleteExpired.run().changes;
+ async cleanExpired() {
+ return (await this._deleteExpired.run()).changes;
}
}
- cleanOld(days = 90) {
- return this._deleteOld.run(`-${days} days`).changes;
+ async cleanOld(days = 90) {
+ return (await this._deleteOld.run(`-${days} days`)).changes;
}
}
/**
/**
* Run periodic maintenance (call from setInterval in main server).
* Run periodic maintenance (call from setInterval in main server).
*/
*/
- maintenance() {
+ async maintenance() {
const expired = this.cleanExpired();
const expired = this.cleanExpired();
- const maxAge = this.db.getSetting?.('notification_max_age_days') || 90;
+ const maxAge = await this.db.getSetting?.('notification_max_age_days') || 90;
const old = this.cleanOld(maxAge);
const old = this.cleanOld(maxAge);
if (expired + old > 0) {
if (expired + old > 0) {
console.log(`[Notifications] Cleaned ${expired} expired + ${old} old notifications`);
console.log(`[Notifications] Cleaned ${expired} expired + ${old} old notifications`);
@@ -23,9 +23,9 @@ function verifySvix(secret, headers, rawBody) {
@@ -23,9 +23,9 @@ function verifySvix(secret, headers, rawBody) {
module.exports = function resendWebhook() {
module.exports = function resendWebhook() {
const router = express.Router();
const router = express.Router();
- router.post('/resend', express.raw({ type: '*/*', limit: '256kb' }), (req, res) => {
+ router.post('/resend', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const secret = db.getSetting('resend_webhook_secret');
+ const secret = await db.getSetting('resend_webhook_secret');
const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body || '');
const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body || '');
if (!secret) { console.warn('[Resend webhook] received but resend_webhook_secret is not configured — ignored'); return res.status(503).json({ ok: false }); }
if (!secret) { console.warn('[Resend webhook] received but resend_webhook_secret is not configured — ignored'); return res.status(503).json({ ok: false }); }
if (!verifySvix(secret, req.headers, raw)) return res.status(401).json({ ok: false, error: 'bad signature' });
if (!verifySvix(secret, req.headers, raw)) return res.status(401).json({ ok: false, error: 'bad signature' });
@@ -35,11 +35,11 @@ module.exports = function resendWebhook() {
@@ -35,11 +35,11 @@ module.exports = function resendWebhook() {
if (!to.length) return res.json({ ok: true });
if (!to.length) return res.json({ ok: true });
if (type === 'email.bounced' || type === 'email.complained') {
if (type === 'email.bounced' || type === 'email.complained') {
const reason = type === 'email.complained' ? 'complaint' : (evt?.data?.bounce?.message || evt?.data?.bounce?.type || 'bounce');
const reason = type === 'email.complained' ? 'complaint' : (evt?.data?.bounce?.message || evt?.data?.bounce?.type || 'bounce');
- const stmt = db.prepare("UPDATE users SET email_bounced_at = CURRENT_TIMESTAMP, email_bounce_reason = ?, email_verified = CASE WHEN ? = 'complaint' THEN email_verified ELSE 0 END WHERE LOWER(email) = ?");
+ const stmt = db.prepare("UPDATE users SET email_bounced_at = ov_now(), email_bounce_reason = ?, email_verified = CASE WHEN ? = 'complaint' THEN email_verified ELSE 0 END WHERE LOWER(email) = ?");
let n = 0;
let n = 0;
- for (const addr of to) n += stmt.run(String(reason).slice(0, 200), type === 'email.complained' ? 'complaint' : 'bounce', addr).changes;
+ for (const addr of to) n += (await stmt.run(String(reason).slice(0, 200), type === 'email.complained' ? 'complaint' : 'bounce', addr)).changes;
console.warn(`[Resend webhook] ${type} for ${to.join(',')} — ${n} account(s) suppressed`);
console.warn(`[Resend webhook] ${type} for ${to.join(',')} — ${n} account(s) suppressed`);
- try { db.prepare("INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, metadata) VALUES (?, ?, ?, 'failed', ?, ?)").run(`webhook:${type}`, to[0], null, String(reason).slice(0, 200), JSON.stringify({ event: type })); } catch { /* */ }
+ try { await db.prepare("INSERT INTO email_delivery_log (email_type, recipient, subject, status, error_message, metadata) VALUES (?, ?, ?, 'failed', ?, ?)").run(`webhook:${type}`, to[0], null, String(reason).slice(0, 200), JSON.stringify({ event: type })); } catch { /* */ }
}
}
res.json({ ok: true });
res.json({ ok: true });
});
});
@@ -13,7 +13,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -13,7 +13,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── GET /api/notifications ────────────────────────────
// ─── GET /api/notifications ────────────────────────────
// Query params: limit, offset, category, unread_only
// Query params: limit, offset, category, unread_only
- router.get('/', requireAuth, (req, res) => {
+ router.get('/', requireAuth, async (req, res) => {
try {
try {
const userId = req.user.id;
const userId = req.user.id;
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
@@ -25,7 +25,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -25,7 +25,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
const q = req.query.q ? String(req.query.q).trim() : null;
const q = req.query.q ? String(req.query.q).trim() : null;
const since = req.query.since ? String(req.query.since) : null;
const since = req.query.since ? String(req.query.since) : null;
- const out = notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });
+ const out = await notificationService.getForUser(userId, { limit, offset, category, unreadOnly, q, since, type });
res.json({ ok: true, ...out, limit, offset });
res.json({ ok: true, ...out, limit, offset });
} catch (err) {
} catch (err) {
console.error('[Notifications] GET / error:', err);
console.error('[Notifications] GET / error:', err);
@@ -34,9 +34,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -34,9 +34,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/unread-count ───────────────
// ─── GET /api/notifications/unread-count ───────────────
- router.get('/unread-count', requireAuth, (req, res) => {
+ router.get('/unread-count', requireAuth, async (req, res) => {
try {
try {
- const count = notificationService.getUnreadCount(req.user.id);
+ const count = await notificationService.getUnreadCount(req.user.id);
res.json({ ok: true, count });
res.json({ ok: true, count });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to get count' });
res.status(500).json({ ok: false, error: 'Failed to get count' });
@@ -44,9 +44,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -44,9 +44,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/unread-by-category ─────────
// ─── GET /api/notifications/unread-by-category ─────────
- router.get('/unread-by-category', requireAuth, (req, res) => {
+ router.get('/unread-by-category', requireAuth, async (req, res) => {
try {
try {
- const categories = notificationService.getUnreadByCategory(req.user.id);
+ const categories = await notificationService.getUnreadByCategory(req.user.id);
res.json({ ok: true, categories });
res.json({ ok: true, categories });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to get counts' });
res.status(500).json({ ok: false, error: 'Failed to get counts' });
@@ -54,9 +54,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -54,9 +54,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/newest ─────────────────────
// ─── GET /api/notifications/newest ─────────────────────
- router.get('/newest', requireAuth, (req, res) => {
+ router.get('/newest', requireAuth, async (req, res) => {
try {
try {
- const newest = notificationService.getNewest(req.user.id);
+ const newest = await notificationService.getNewest(req.user.id);
res.json({ ok: true, notification: newest || null });
res.json({ ok: true, notification: newest || null });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to get newest' });
res.status(500).json({ ok: false, error: 'Failed to get newest' });
@@ -64,10 +64,10 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -64,10 +64,10 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── POST /api/notifications/:id/read ──────────────────
// ─── POST /api/notifications/:id/read ──────────────────
- router.post('/:id/read', requireAuth, (req, res) => {
+ router.post('/:id/read', requireAuth, async (req, res) => {
try {
try {
- const ok = notificationService.markRead(req.params.id, req.user.id);
- res.json({ ok, unread: notificationService.getUnreadCount(req.user.id) });
+ const ok = await notificationService.markRead(req.params.id, req.user.id);
+ res.json({ ok, unread: await notificationService.getUnreadCount(req.user.id) });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to mark read' });
res.status(500).json({ ok: false, error: 'Failed to mark read' });
}
}
@@ -75,21 +75,21 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -75,21 +75,21 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── POST /api/notifications/read-batch ────────────────
// ─── POST /api/notifications/read-batch ────────────────
// Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.
// Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.
- router.post('/read-batch', requireAuth, (req, res) => {
+ router.post('/read-batch', requireAuth, async (req, res) => {
try {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
- const changed = notificationService.markReadMany(ids, req.user.id);
- res.json({ ok: true, changed, unread: notificationService.getUnreadCount(req.user.id) });
+ const changed = await notificationService.markReadMany(ids, req.user.id);
+ res.json({ ok: true, changed, unread: await notificationService.getUnreadCount(req.user.id) });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to mark read' });
res.status(500).json({ ok: false, error: 'Failed to mark read' });
}
}
});
});
// ─── POST /api/notifications/read-all ──────────────────
// ─── POST /api/notifications/read-all ──────────────────
- router.post('/read-all', requireAuth, (req, res) => {
+ router.post('/read-all', requireAuth, async (req, res) => {
try {
try {
const { category } = req.body || {};
const { category } = req.body || {};
- const changed = notificationService.markAllRead(req.user.id, category || null);
+ const changed = await notificationService.markAllRead(req.user.id, category || null);
res.json({ ok: true, changed });
res.json({ ok: true, changed });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to mark all read' });
res.status(500).json({ ok: false, error: 'Failed to mark all read' });
@@ -97,9 +97,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -97,9 +97,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── POST /api/notifications/:id/dismiss ───────────────
// ─── POST /api/notifications/:id/dismiss ───────────────
- router.post('/:id/dismiss', requireAuth, (req, res) => {
+ router.post('/:id/dismiss', requireAuth, async (req, res) => {
try {
try {
- const ok = notificationService.dismiss(req.params.id, req.user.id);
+ const ok = await notificationService.dismiss(req.params.id, req.user.id);
res.json({ ok });
res.json({ ok });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to dismiss' });
res.status(500).json({ ok: false, error: 'Failed to dismiss' });
@@ -108,9 +108,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -108,9 +108,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── DELETE /api/notifications ─────────────────────────
// ─── DELETE /api/notifications ─────────────────────────
// Dismiss all
// Dismiss all
- router.delete('/', requireAuth, (req, res) => {
+ router.delete('/', requireAuth, async (req, res) => {
try {
try {
- const changed = notificationService.dismissAll(req.user.id);
+ const changed = await notificationService.dismissAll(req.user.id);
res.json({ ok: true, changed });
res.json({ ok: true, changed });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to dismiss all' });
res.status(500).json({ ok: false, error: 'Failed to dismiss all' });
@@ -118,9 +118,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -118,9 +118,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/preferences ────────────────
// ─── GET /api/notifications/preferences ────────────────
- router.get('/preferences', requireAuth, (req, res) => {
+ router.get('/preferences', requireAuth, async (req, res) => {
try {
try {
- const prefs = notificationService.getPreferences(req.user.id);
+ const prefs = await notificationService.getPreferences(req.user.id);
res.json({ ok: true, preferences: prefs });
res.json({ ok: true, preferences: prefs });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to get preferences' });
res.status(500).json({ ok: false, error: 'Failed to get preferences' });
@@ -129,11 +129,11 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -129,11 +129,11 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── PUT /api/notifications/preferences ────────────────
// ─── PUT /api/notifications/preferences ────────────────
// Body: { category, enabled, sound, toasts, email }
// Body: { category, enabled, sound, toasts, email }
- router.put('/preferences', requireAuth, (req, res) => {
+ router.put('/preferences', requireAuth, async (req, res) => {
try {
try {
const { category, enabled, sound, toasts, email } = req.body;
const { category, enabled, sound, toasts, email } = req.body;
if (!category) return res.status(400).json({ ok: false, error: 'Category required' });
if (!category) return res.status(400).json({ ok: false, error: 'Category required' });
- notificationService.setPreference(req.user.id, category, {
+ await notificationService.setPreference(req.user.id, category, {
enabled,
enabled,
sound,
sound,
toasts,
toasts,
@@ -146,9 +146,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -146,9 +146,9 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── DELETE /api/notifications/preferences/:category ───
// ─── DELETE /api/notifications/preferences/:category ───
- router.delete('/preferences/:category', requireAuth, (req, res) => {
+ router.delete('/preferences/:category', requireAuth, async (req, res) => {
try {
try {
- notificationService.resetPreference(req.user.id, req.params.category);
+ await notificationService.resetPreference(req.user.id, req.params.category);
res.json({ ok: true });
res.json({ ok: true });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: 'Failed to reset preference' });
res.status(500).json({ ok: false, error: 'Failed to reset preference' });
@@ -27,40 +27,36 @@ function canonicalChannelUrl(url, username) {
@@ -27,40 +27,36 @@ function canonicalChannelUrl(url, username) {
}
}
/** One fan-out per streamer per `stream_live_cooldown_min` (60) and at most `stream_live_daily_cap` (8) per 24h. */
/** One fan-out per streamer per `stream_live_cooldown_min` (60) and at most `stream_live_daily_cap` (8) per 24h. */
-function ensureAnnouncements(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS stream_live_announcements (
- id INTEGER PRIMARY KEY AUTOINCREMENT, streamer_key TEXT NOT NULL, stream_id TEXT, sent_at DATETIME DEFAULT CURRENT_TIMESTAMP);
- CREATE INDEX IF NOT EXISTS idx_sla_key ON stream_live_announcements(streamer_key, sent_at DESC)`);
-}
+function ensureAnnouncements(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
-function setting(db, key) {
- if (typeof db.getSetting === 'function') return db.getSetting(key);
- try { const r = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return r ? r.value : null; } catch { return null; }
+async function setting(db, key) {
+ if (typeof db.getSetting === 'function') return await db.getSetting(key);
+ try { const r = await db.prepare('SELECT value FROM settings WHERE key = ?').get(key); return r ? r.value : null; } catch { return null; }
}
}
/**
/**
* Claim the announcement for a streamer, or say why not. `force` (admin/manual) bypasses the
* Claim the announcement for a streamer, or say why not. `force` (admin/manual) bypasses the
* cooldown, never the daily cap. Returns { ok: true } or { skipped: true, reason, ... }.
* cooldown, never the daily cap. Returns { ok: true } or { skipped: true, reason, ... }.
*/
*/
-function claimAnnouncement(db, { streamerKey, streamId, force = false, now = Date.now() }) {
+async function claimAnnouncement(db, { streamerKey, streamId, force = false, now = Date.now() }) {
try { ensureAnnouncements(db); } catch { /* exists */ }
try { ensureAnnouncements(db); } catch { /* exists */ }
const key = String(streamerKey).toLowerCase();
const key = String(streamerKey).toLowerCase();
- const cooldownMin = Math.max(1, parseInt(setting(db, 'stream_live_cooldown_min'), 10) || 60);
- const dailyCap = Math.max(1, parseInt(setting(db, 'stream_live_daily_cap'), 10) || 8);
- const last = db.prepare('SELECT sent_at FROM stream_live_announcements WHERE streamer_key = ? ORDER BY sent_at DESC, id DESC LIMIT 1').get(key);
- const today = db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE streamer_key = ? AND sent_at > datetime('now','-1 day')").get(key)?.c || 0;
+ const cooldownMin = Math.max(1, parseInt(await setting(db, 'stream_live_cooldown_min'), 10) || 60);
+ const dailyCap = Math.max(1, parseInt(await setting(db, 'stream_live_daily_cap'), 10) || 8);
+ const last = await db.prepare('SELECT sent_at FROM stream_live_announcements WHERE streamer_key = ? ORDER BY sent_at DESC, id DESC LIMIT 1').get(key);
+ const today = (await db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE streamer_key = ? AND sent_at > datetime('now','-1 day')").get(key))?.c || 0;
const lastMs = last ? new Date(String(last.sent_at).replace(' ', 'T') + 'Z').getTime() : 0;
const lastMs = last ? new Date(String(last.sent_at).replace(' ', 'T') + 'Z').getTime() : 0;
const sinceMin = last ? (now - lastMs) / 60000 : Infinity;
const sinceMin = last ? (now - lastMs) / 60000 : Infinity;
if (today >= dailyCap) return { skipped: true, reason: 'daily-cap', announced_today: today, cap: dailyCap };
if (today >= dailyCap) return { skipped: true, reason: 'daily-cap', announced_today: today, cap: dailyCap };
if (!force && sinceMin < cooldownMin) return { skipped: true, reason: 'cooldown', next_allowed_at: new Date(lastMs + cooldownMin * 60000).toISOString() };
if (!force && sinceMin < cooldownMin) return { skipped: true, reason: 'cooldown', next_allowed_at: new Date(lastMs + cooldownMin * 60000).toISOString() };
- db.prepare('INSERT INTO stream_live_announcements (streamer_key, stream_id) VALUES (?, ?)').run(key, streamId != null ? String(streamId) : null);
- db.prepare("DELETE FROM stream_live_announcements WHERE sent_at < datetime('now','-7 days')").run();
+ await db.prepare('INSERT INTO stream_live_announcements (streamer_key, stream_id) VALUES (?, ?)').run(key, streamId != null ? String(streamId) : null);
+ await db.prepare("DELETE FROM stream_live_announcements WHERE sent_at < datetime('now','-7 days')").run();
return { ok: true };
return { ok: true };
}
}
/** People who asked for every go-live on the network (preference category stream_live_all). */
/** People who asked for every go-live on the network (preference category stream_live_all). */
-function allLiveSubscribers(db) {
- return db.prepare("SELECT user_id FROM notification_preferences WHERE category = 'stream_live_all' AND enabled = 1").all().map(r => r.user_id);
+async function allLiveSubscribers(db) {
+ return (await db.prepare("SELECT user_id FROM notification_preferences WHERE category = 'stream_live_all' AND enabled = 1").all()).map(r => r.user_id);
}
}
/** The STREAM_LIVE notification (category 'stream' from openvibe-shared, so the person's mute and email choice apply). */
/** The STREAM_LIVE notification (category 'stream' from openvibe-shared, so the person's mute and email choice apply). */
@@ -60,7 +60,7 @@ function principalDenied({ req, code }) {
@@ -60,7 +60,7 @@ function principalDenied({ req, code }) {
*/
*/
function createNetworkReadiness({ db, getKeys, release, ecosystem = null, discordService = null, production = process.env.NODE_ENV === 'production', pollMs = 60000 }) {
function createNetworkReadiness({ db, getKeys, release, ecosystem = null, discordService = null, production = process.env.NODE_ENV === 'production', pollMs = 60000 }) {
const checks = [
const checks = [
- { name: 'db', required: true, check: () => { const r = db.prepare('SELECT COUNT(*) AS n FROM oauth_clients').get(); return { detail: { oauth_clients: r.n } }; } },
+ { name: 'db', required: true, check: async () => { const r = await db.prepare('SELECT COUNT(*) AS n FROM oauth_clients').get(); return { detail: { oauth_clients: r.n } }; } },
{
{
name: 'signing_key', required: production, cacheMs: 60000,
name: 'signing_key', required: production, cacheMs: 60000,
check: () => {
check: () => {
@@ -27,34 +27,13 @@ const FLAP_MS = 12 * 3600 * 1000;
@@ -27,34 +27,13 @@ const FLAP_MS = 12 * 3600 * 1000;
const KEEP_RESOLVED_MS = 30 * 24 * 3600 * 1000;
const KEEP_RESOLVED_MS = 30 * 24 * 3600 * 1000;
const STATUS_URL = 'https://openvibe.network/status';
const STATUS_URL = 'https://openvibe.network/status';
-function ensureTables(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS operator_alerts (
- source TEXT NOT NULL,
- fingerprint TEXT NOT NULL,
- name TEXT NOT NULL,
- severity TEXT NOT NULL,
- summary TEXT NOT NULL,
- description TEXT,
- service TEXT,
- state TEXT NOT NULL CHECK (state IN ('firing', 'resolved')),
- started_at TEXT NOT NULL,
- opened_at INTEGER NOT NULL,
- last_seen_at INTEGER NOT NULL,
- notified_at INTEGER,
- resolved_at INTEGER,
- PRIMARY KEY (source, fingerprint)
- )`);
- // The notifications of the current episode ([[userId, notificationId], …]) and how often it fired again.
- for (const col of ['notices TEXT', 'reopened INTEGER NOT NULL DEFAULT 0']) {
- try { db.exec(`ALTER TABLE operator_alerts ADD COLUMN ${col}`); } catch { /* already there */ }
- }
-}
+function ensureTables(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
-function operatorUserIds(db, env = process.env) {
+async function operatorUserIds(db, env = process.env) {
const names = new Set([(env.OWNER_USERNAME || 'goosely').toLowerCase()]);
const names = new Set([(env.OWNER_USERNAME || 'goosely').toLowerCase()]);
for (const n of String(env.OPERATOR_ALERT_USERNAMES || '').split(',')) if (n.trim()) names.add(n.trim().toLowerCase());
for (const n of String(env.OPERATOR_ALERT_USERNAMES || '').split(',')) if (n.trim()) names.add(n.trim().toLowerCase());
const q = db.prepare('SELECT id FROM users WHERE lower(username) = ?');
const q = db.prepare('SELECT id FROM users WHERE lower(username) = ?');
- return [...names].map((n) => q.get(n)).filter(Boolean).map((u) => u.id);
+ return (await Promise.all([...names].map(async (n) => await q.get(n)))).filter(Boolean).map((u) => u.id);
}
}
function message(a) {
function message(a) {
@@ -68,11 +47,11 @@ const ago = (ms) => (ms < 3600e3 ? `${Math.max(1, Math.round(ms / 60e3))} min` :
@@ -68,11 +47,11 @@ const ago = (ms) => (ms < 3600e3 ? `${Math.max(1, Math.round(ms / 60e3))} min` :
* notify(userId, notification) creates one notification (the notification service's create(); `silent` skips
* notify(userId, notification) creates one notification (the notification service's create(); `silent` skips
* web push); revise(userId, notificationId, fields) edits one in place (title, message, icon, is_read).
* web push); revise(userId, notificationId, fields) edits one in place (title, message, icon, is_read).
*/
*/
-function receive(db, body, { notify, revise = null, now = Date.now(), env = process.env } = {}) {
+async function receive(db, body, { notify, revise = null, now = Date.now(), env = process.env } = {}) {
ensureTables(db);
ensureTables(db);
const source = body.source;
const source = body.source;
const current = new Map(body.alerts.map((a) => [a.fingerprint, a]));
const current = new Map(body.alerts.map((a) => [a.fingerprint, a]));
- const rows = db.prepare('SELECT * FROM operator_alerts WHERE source = ?').all(source);
+ const rows = await db.prepare('SELECT * FROM operator_alerts WHERE source = ?').all(source);
const byFp = new Map(rows.map((r) => [r.fingerprint, r]));
const byFp = new Map(rows.map((r) => [r.fingerprint, r]));
const work = [];
const work = [];
const out = { ok: true, firing: current.size, opened: 0, reminded: 0, resolved: 0, notified: 0 };
const out = { ok: true, firing: current.size, opened: 0, reminded: 0, resolved: 0, notified: 0 };
@@ -85,7 +64,7 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
@@ -85,7 +64,7 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
opened_at = CASE WHEN operator_alerts.state = 'resolved' AND @reopen = 0 THEN @now ELSE operator_alerts.opened_at END`);
opened_at = CASE WHEN operator_alerts.state = 'resolved' AND @reopen = 0 THEN @now ELSE operator_alerts.opened_at END`);
const resolve = db.prepare("UPDATE operator_alerts SET state = 'resolved', resolved_at = ? WHERE source = ? AND fingerprint = ?");
const resolve = db.prepare("UPDATE operator_alerts SET state = 'resolved', resolved_at = ? WHERE source = ? AND fingerprint = ?");
- db.transaction(() => {
+ await db.tx(async () => {
for (const a of current.values()) {
for (const a of current.values()) {
const prev = byFp.get(a.fingerprint);
const prev = byFp.get(a.fingerprint);
let kind = null;
let kind = null;
@@ -97,23 +76,23 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
@@ -97,23 +76,23 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
out.reminded += 1;
out.reminded += 1;
}
}
const paged = kind === 'opened' || kind === 'reminded';
const paged = kind === 'opened' || kind === 'reminded';
- upsert.run({ source, fingerprint: a.fingerprint, name: a.name, severity: a.severity, summary: a.summary, description: a.description || null,
+ await upsert.run({ source, fingerprint: a.fingerprint, name: a.name, severity: a.severity, summary: a.summary, description: a.description || null,
service: a.service || null, started_at: a.started_at, now, reopen: kind === 'reopened' ? 1 : 0,
service: a.service || null, started_at: a.started_at, now, reopen: kind === 'reopened' ? 1 : 0,
notified_at: paged ? now : (prev && prev.notified_at) || null });
notified_at: paged ? now : (prev && prev.notified_at) || null });
if (kind) work.push({ kind, a, prev });
if (kind) work.push({ kind, a, prev });
}
}
for (const r of rows) {
for (const r of rows) {
if (r.state !== 'firing' || current.has(r.fingerprint)) continue;
if (r.state !== 'firing' || current.has(r.fingerprint)) continue;
- resolve.run(now, source, r.fingerprint);
+ await resolve.run(now, source, r.fingerprint);
out.resolved += 1;
out.resolved += 1;
work.push({ kind: 'resolved', a: r, prev: r });
work.push({ kind: 'resolved', a: r, prev: r });
}
}
- db.prepare("DELETE FROM operator_alerts WHERE state = 'resolved' AND resolved_at < ?").run(now - KEEP_RESOLVED_MS);
- })();
+ await db.prepare("DELETE FROM operator_alerts WHERE state = 'resolved' AND resolved_at < ?").run(now - KEEP_RESOLVED_MS);
+ });
const notices = (row) => { try { return JSON.parse((row && row.notices) || '[]'); } catch { return []; } };
const notices = (row) => { try { return JSON.parse((row && row.notices) || '[]'); } catch { return []; } };
const saveNotices = db.prepare('UPDATE operator_alerts SET notices = ? WHERE source = ? AND fingerprint = ?');
const saveNotices = db.prepare('UPDATE operator_alerts SET notices = ? WHERE source = ? AND fingerprint = ?');
- const users = work.length ? operatorUserIds(db, env) : [];
+ const users = work.length ? await operatorUserIds(db, env) : [];
for (const { kind, a, prev } of work) {
for (const { kind, a, prev } of work) {
if (a.severity === 'info') continue;
if (a.severity === 'info') continue;
const critical = a.severity === 'critical';
const critical = a.severity === 'critical';
@@ -133,7 +112,7 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
@@ -133,7 +112,7 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
if (created && created.id) kept.push([uid, created.id]);
if (created && created.id) kept.push([uid, created.id]);
} catch (e) { console.warn('[operator-alerts] notify failed:', e.message); }
} catch (e) { console.warn('[operator-alerts] notify failed:', e.message); }
}
}
- saveNotices.run(JSON.stringify(kept), source, a.fingerprint);
+ await saveNotices.run(JSON.stringify(kept), source, a.fingerprint);
continue;
continue;
}
}
if (typeof revise !== 'function') continue;
if (typeof revise !== 'function') continue;
@@ -148,9 +127,9 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
@@ -148,9 +127,9 @@ function receive(db, body, { notify, revise = null, now = Date.now(), env = proc
}
}
/** Firing and recently resolved alerts, newest first (the admin view and /status). */
/** Firing and recently resolved alerts, newest first (the admin view and /status). */
-function list(db, { limit = 100 } = {}) {
+async function list(db, { limit = 100 } = {}) {
ensureTables(db);
ensureTables(db);
- return db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);
+ return await db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);
}
}
module.exports = { ensureTables, receive, list, operatorUserIds, REMIND_MS, FLAP_MS };
module.exports = { ensureTables, receive, list, operatorUserIds, REMIND_MS, FLAP_MS };
@@ -90,11 +90,11 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
@@ -90,11 +90,11 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
}
}
/** The one place an avatar changes. `origin` names who asked ('network', 'live'), so we never echo a push back. */
/** The one place an avatar changes. `origin` names who asked ('network', 'live'), so we never echo a push back. */
- function apply(userId, username, url, origin) {
- const before = (getUrl.get(userId) || {}).avatar_url || null;
+ async function apply(userId, username, url, origin) {
+ const before = (await getUrl.get(userId) || {}).avatar_url || null;
if (before === url) return false;
if (before === url) return false;
- setUrl.run(url, userId);
- try { db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(userId, 'avatar_change', JSON.stringify({ from: before, to: url, origin }), null); } catch { /* audit is best effort */ }
+ await setUrl.run(url, userId);
+ try { await db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(userId, 'avatar_change', JSON.stringify({ from: before, to: url, origin }), null); } catch { /* audit is best effort */ }
if (origin !== 'live') pushToSites(userId, username, url);
if (origin !== 'live') pushToSites(userId, username, url);
return true;
return true;
}
}
@@ -108,15 +108,15 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
@@ -108,15 +108,15 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
if (!got.ok) return res.status(got.status || 422).json({ error: got.error });
if (!got.ok) return res.status(got.status || 422).json({ error: got.error });
n.url = got.url; n.paste = got.slug;
n.url = got.url; n.paste = got.slug;
} else if (n.url) { const v = await verifyImage(n.url); if (!v.ok) return res.status(422).json({ error: v.error }); }
} else if (n.url) { const v = await verifyImage(n.url); if (!v.ok) return res.status(422).json({ error: v.error }); }
- apply(req.user.id, req.user.username, n.url, 'network');
+ await apply(req.user.id, req.user.username, n.url, 'network');
res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
});
});
- api.delete('/', requireAuth, (req, res) => { apply(req.user.id, req.user.username, null, 'network'); res.json({ ok: true, avatar_url: null }); });
+ api.delete('/', requireAuth, async (req, res) => { await apply(req.user.id, req.user.username, null, 'network'); res.json({ ok: true, avatar_url: null }); });
const pub = express.Router();
const pub = express.Router();
- pub.get('/:username', (req, res) => {
+ pub.get('/:username', async (req, res) => {
const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
- const row = byName.get(name);
+ const row = await byName.get(name);
res.set({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400', 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });
res.set({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400', 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });
const ok = row && row.avatar_url && !!normalizeAvatar(row.avatar_url).url;
const ok = row && row.avatar_url && !!normalizeAvatar(row.avatar_url).url;
if (ok) return res.redirect(302, row.avatar_url);
if (ok) return res.redirect(302, row.avatar_url);
@@ -124,11 +124,11 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
@@ -124,11 +124,11 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
});
});
/** Live changed someone's picture: adopt it (same rules, no verification round trip for our own media host). */
/** Live changed someone's picture: adopt it (same rules, no verification round trip for our own media host). */
- function fromSite(body) {
+ async function fromSite(body) {
const id = parseInt(body && body.user_id, 10); if (!id) return { status: 400, error: 'user_id required' };
const id = parseInt(body && body.user_id, 10); if (!id) return { status: 400, error: 'user_id required' };
const n = normalizeAvatar(body.avatar_url); if (n.error || n.ingest) return { status: 422, error: n.error || 'Sites report openvibe.media pictures only' };
const n = normalizeAvatar(body.avatar_url); if (n.error || n.ingest) return { status: 422, error: n.error || 'Sites report openvibe.media pictures only' };
- const u = db.prepare('SELECT username FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };
- return { status: 200, changed: apply(id, u.username, n.url, String(body.origin || 'live')) };
+ const u = await db.prepare('SELECT username FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };
+ return { status: 200, changed: await apply(id, u.username, n.url, String(body.origin || 'live')) };
}
}
return { api, pub, fromSite, apply, log };
return { api, pub, fromSite, apply, log };
@@ -41,13 +41,13 @@ function publicFromPrivate(privateKey) {
@@ -41,13 +41,13 @@ function publicFromPrivate(privateKey) {
return ecdh.getPublicKey().toString('base64url');
return ecdh.getPublicKey().toString('base64url');
}
}
-function initVapid(db) {
+async function initVapid(db) {
_db = db;
_db = db;
if (!webpush) return;
if (!webpush) return;
- const upsert = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
+ const upsert = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');
// Both are environment-first (VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY, server/secrets.js), else site_settings.
// Both are environment-first (VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY, server/secrets.js), else site_settings.
- let publicKey = db.getSetting('vapid_public_key');
- let privateKey = db.getSetting('vapid_private_key');
+ let publicKey = await db.getSetting('vapid_public_key');
+ let privateKey = await db.getSetting('vapid_private_key');
// The public key is derived from the private one, so a private key alone (or a stale public key
// The public key is derived from the private one, so a private key alone (or a stale public key
// beside it) still gives a matching pair; a new pair is made only when there is no private key.
// beside it) still gives a matching pair; a new pair is made only when there is no private key.
@@ -64,13 +64,13 @@ function initVapid(db) {
@@ -64,13 +64,13 @@ function initVapid(db) {
const keys = webpush.generateVAPIDKeys();
const keys = webpush.generateVAPIDKeys();
publicKey = keys.publicKey;
publicKey = keys.publicKey;
privateKey = keys.privateKey;
privateKey = keys.privateKey;
- upsert.run('vapid_public_key', publicKey, 'secret');
- upsert.run('vapid_private_key', privateKey, 'secret');
+ await upsert.run('vapid_public_key', publicKey, 'secret');
+ await upsert.run('vapid_private_key', privateKey, 'secret');
_publicKey = publicKey;
_publicKey = publicKey;
console.log('[push] Generated new VAPID keypair');
console.log('[push] Generated new VAPID keypair');
}
}
- const contactEmail = db.getSetting('vapid_contact_email') || 'mailto:[email protected]';
+ const contactEmail = await db.getSetting('vapid_contact_email') || 'mailto:[email protected]';
webpush.setVapidDetails(contactEmail, publicKey, privateKey);
webpush.setVapidDetails(contactEmail, publicKey, privateKey);
console.log('[push] VAPID initialized');
console.log('[push] VAPID initialized');
}
}
@@ -78,40 +78,41 @@ function initVapid(db) {
@@ -78,40 +78,41 @@ function initVapid(db) {
/**
/**
* Get the public VAPID key for client subscription.
* Get the public VAPID key for client subscription.
*/
*/
-function getPublicKey() {
- return _publicKey || _db?.getSetting('vapid_public_key') || null;
+async function getPublicKey() {
+ return _publicKey || await _db?.getSetting('vapid_public_key') || null;
}
}
/**
/**
* Save a push subscription for a user.
* Save a push subscription for a user.
*/
*/
-function subscribe(userId, subscription) {
+async function subscribe(userId, subscription) {
if (!_db) throw new Error('Push service not initialized');
if (!_db) throw new Error('Push service not initialized');
if (!subscription?.endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
if (!subscription?.endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
throw new Error('Invalid push subscription');
throw new Error('Invalid push subscription');
}
}
if (String(subscription.endpoint).length > 1000 || !endpointAllowed(subscription.endpoint)) throw new Error('Invalid push subscription endpoint');
if (String(subscription.endpoint).length > 1000 || !endpointAllowed(subscription.endpoint)) throw new Error('Invalid push subscription endpoint');
const stmt = _db.prepare(`
const stmt = _db.prepare(`
- INSERT OR REPLACE INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth, user_agent, created_at)
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth, user_agent, created_at)
+ VALUES (?, ?, ?, ?, ?, ov_now())
+ ON CONFLICT (endpoint) DO UPDATE SET user_id = excluded.user_id, keys_p256dh = excluded.keys_p256dh, keys_auth = excluded.keys_auth, user_agent = excluded.user_agent, created_at = excluded.created_at
`);
`);
- stmt.run(userId, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, subscription.userAgent || null);
+ await stmt.run(userId, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, subscription.userAgent || null);
}
}
/**
/**
* Remove a push subscription.
* Remove a push subscription.
*/
*/
-function unsubscribe(userId, endpoint) {
+async function unsubscribe(userId, endpoint) {
if (!_db) return;
if (!_db) return;
- _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(userId, endpoint);
+ await _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(userId, endpoint);
}
}
/**
/**
* Remove all subscriptions for a user.
* Remove all subscriptions for a user.
*/
*/
-function unsubscribeAll(userId) {
+async function unsubscribeAll(userId) {
if (!_db) return;
if (!_db) return;
- _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
+ await _db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
}
}
/**
/**
@@ -122,7 +123,7 @@ function unsubscribeAll(userId) {
@@ -122,7 +123,7 @@ function unsubscribeAll(userId) {
async function sendPush(userId, payload) {
async function sendPush(userId, payload) {
if (!webpush || !_db) return;
if (!webpush || !_db) return;
- const subs = _db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
+ const subs = await _db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
if (!subs.length) return;
if (!subs.length) return;
const pushPayload = JSON.stringify({
const pushPayload = JSON.stringify({
@@ -151,7 +152,7 @@ async function sendPush(userId, payload) {
@@ -151,7 +152,7 @@ async function sendPush(userId, payload) {
// Cleanup stale subscriptions
// Cleanup stale subscriptions
if (stale.length) {
if (stale.length) {
const placeholders = stale.map(() => '?').join(',');
const placeholders = stale.map(() => '?').join(',');
- _db.prepare(`DELETE FROM push_subscriptions WHERE id IN (${placeholders})`).run(...stale);
+ await _db.prepare(`DELETE FROM push_subscriptions WHERE id IN (${placeholders})`).run(...stale);
}
}
}
}
@@ -161,7 +162,7 @@ async function sendPush(userId, payload) {
@@ -161,7 +162,7 @@ async function sendPush(userId, payload) {
async function sendPushBulk(userIds, payload) {
async function sendPushBulk(userIds, payload) {
if (!webpush || !userIds.length) return;
if (!webpush || !userIds.length) return;
// Fire all in parallel, don't block
// Fire all in parallel, don't block
- await Promise.allSettled(userIds.map(uid => sendPush(uid, payload)));
+ await Promise.allSettled(userIds.map(async uid => await sendPush(uid, payload)));
}
}
module.exports = { initVapid, getPublicKey, subscribe, unsubscribe, unsubscribeAll, sendPush, sendPushBulk, endpointAllowed };
module.exports = { initVapid, getPublicKey, subscribe, unsubscribe, unsubscribeAll, sendPush, sendPushBulk, endpointAllowed };
@@ -7,18 +7,18 @@ const pushService = require('./push-service');
@@ -7,18 +7,18 @@ const pushService = require('./push-service');
// All routes require auth (middleware applied by parent)
// All routes require auth (middleware applied by parent)
/** GET /api/push/vapid-key — return the public VAPID key for client subscription */
/** GET /api/push/vapid-key — return the public VAPID key for client subscription */
-router.get('/vapid-key', (req, res) => {
- const key = pushService.getPublicKey();
+router.get('/vapid-key', async (req, res) => {
+ const key = await pushService.getPublicKey();
if (!key) return res.status(503).json({ error: 'Push notifications not configured' });
if (!key) return res.status(503).json({ error: 'Push notifications not configured' });
res.json({ publicKey: key });
res.json({ publicKey: key });
});
});
/** POST /api/push/subscribe — save a PushSubscription */
/** POST /api/push/subscribe — save a PushSubscription */
-router.post('/subscribe', (req, res) => {
+router.post('/subscribe', async (req, res) => {
try {
try {
const { subscription } = req.body;
const { subscription } = req.body;
if (!subscription) return res.status(400).json({ error: 'Missing subscription' });
if (!subscription) return res.status(400).json({ error: 'Missing subscription' });
- pushService.subscribe(req.user.id, subscription);
+ await pushService.subscribe(req.user.id, subscription);
res.json({ success: true });
res.json({ success: true });
} catch (err) {
} catch (err) {
res.status(400).json({ error: err.message });
res.status(400).json({ error: err.message });
@@ -26,17 +26,17 @@ router.post('/subscribe', (req, res) => {
@@ -26,17 +26,17 @@ router.post('/subscribe', (req, res) => {
});
});
/** POST /api/push/unsubscribe — remove a PushSubscription */
/** POST /api/push/unsubscribe — remove a PushSubscription */
-router.post('/unsubscribe', (req, res) => {
+router.post('/unsubscribe', async (req, res) => {
const { endpoint } = req.body;
const { endpoint } = req.body;
if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
- pushService.unsubscribe(req.user.id, endpoint);
+ await pushService.unsubscribe(req.user.id, endpoint);
res.json({ success: true });
res.json({ success: true });
});
});
/** GET /api/push/status — check subscription + live notification preferences */
/** GET /api/push/status — check subscription + live notification preferences */
-router.get('/status', (req, res) => {
+router.get('/status', async (req, res) => {
const db = req.app.locals.db;
const db = req.app.locals.db;
- const subs = db.prepare('SELECT id, endpoint, created_at FROM push_subscriptions WHERE user_id = ?').all(req.user.id);
+ const subs = await db.prepare('SELECT id, endpoint, created_at FROM push_subscriptions WHERE user_id = ?').all(req.user.id);
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
const streamPref = notifService
const streamPref = notifService
? notifService._getPrefByCategory.get(req.user.id, 'stream') || { enabled: 1, sound: 1, toasts: 1 }
? notifService._getPrefByCategory.get(req.user.id, 'stream') || { enabled: 1, sound: 1, toasts: 1 }
@@ -57,13 +57,13 @@ router.get('/status', (req, res) => {
@@ -57,13 +57,13 @@ router.get('/status', (req, res) => {
});
});
/** PUT /api/push/live-preferences — toggle live notification mode */
/** PUT /api/push/live-preferences — toggle live notification mode */
-router.put('/live-preferences', (req, res) => {
+router.put('/live-preferences', async (req, res) => {
const { stream_live_all } = req.body;
const { stream_live_all } = req.body;
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (stream_live_all !== undefined) {
if (stream_live_all !== undefined) {
- notifService.setPreference(req.user.id, 'stream_live_all', {
+ await notifService.setPreference(req.user.id, 'stream_live_all', {
enabled: !!stream_live_all,
enabled: !!stream_live_all,
sound: 1,
sound: 1,
toasts: 1,
toasts: 1,
@@ -9,42 +9,32 @@
@@ -9,42 +9,32 @@
const express = require('express');
const express = require('express');
const ensured = new WeakSet();
const ensured = new WeakSet();
-function ensureSchema(db) {
- if (ensured.has(db)) return;
- ensured.add(db);
- db.exec(`CREATE TABLE IF NOT EXISTS platform_nodes (
- id TEXT PRIMARY KEY,
- source TEXT NOT NULL,
- doc TEXT NOT NULL,
- status TEXT NOT NULL,
- reported_at TEXT NOT NULL
- )`);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** Apply a report: upsert every node; mark this source's other nodes down. → { nodes, marked_down } */
/** Apply a report: upsert every node; mark this source's other nodes down. → { nodes, marked_down } */
-function report(db, { source, nodes }, now = new Date().toISOString()) {
+async function report(db, { source, nodes }, now = new Date().toISOString()) {
ensureSchema(db);
ensureSchema(db);
const upsert = db.prepare(`INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES (?, ?, ?, ?, ?)
const upsert = db.prepare(`INSERT INTO platform_nodes (id, source, doc, status, reported_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET source = excluded.source, doc = excluded.doc, status = excluded.status, reported_at = excluded.reported_at`);
ON CONFLICT(id) DO UPDATE SET source = excluded.source, doc = excluded.doc, status = excluded.status, reported_at = excluded.reported_at`);
let marked = 0;
let marked = 0;
- db.transaction(() => {
- for (const n of nodes) upsert.run(n.id, source, JSON.stringify(n), n.health.status, now);
+ await db.tx(async () => {
+ for (const n of nodes) await upsert.run(n.id, source, JSON.stringify(n), n.health.status, now);
const ids = new Set(nodes.map((n) => n.id));
const ids = new Set(nodes.map((n) => n.id));
- for (const row of db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {
+ for (const row of await db.prepare('SELECT id, doc FROM platform_nodes WHERE source = ? AND status <> ?').all(source, 'down')) {
if (ids.has(row.id)) continue;
if (ids.has(row.id)) continue;
const doc = JSON.parse(row.doc);
const doc = JSON.parse(row.doc);
doc.health = { status: 'down', checked_at: now };
doc.health = { status: 'down', checked_at: now };
doc.updated_at = now;
doc.updated_at = now;
- db.prepare('UPDATE platform_nodes SET doc = ?, status = ? WHERE id = ?').run(JSON.stringify(doc), 'down', row.id);
+ await db.prepare('UPDATE platform_nodes SET doc = ?, status = ? WHERE id = ?').run(JSON.stringify(doc), 'down', row.id);
marked++;
marked++;
}
}
- })();
+ });
return { nodes: nodes.length, marked_down: marked };
return { nodes: nodes.length, marked_down: marked };
}
}
-function list(db, { role = null, region = null } = {}) {
+async function list(db, { role = null, region = null } = {}) {
ensureSchema(db);
ensureSchema(db);
- return db.prepare('SELECT doc FROM platform_nodes ORDER BY id').all().map((r) => JSON.parse(r.doc))
+ return (await db.prepare('SELECT doc FROM platform_nodes ORDER BY id').all()).map((r) => JSON.parse(r.doc))
.filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
.filter((n) => (!role || n.roles.includes(role)) && (!region || n.location.region === region));
}
}
@@ -52,22 +42,22 @@ function routers({ guard }) {
@@ -52,22 +42,22 @@ function routers({ guard }) {
const pub = express.Router();
const pub = express.Router();
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
.set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
- pub.get('/', (req, res) => {
+ pub.get('/', async (req, res) => {
const role = typeof req.query.role === 'string' ? req.query.role : null;
const role = typeof req.query.role === 'string' ? req.query.role : null;
const region = typeof req.query.region === 'string' ? req.query.region : null;
const region = typeof req.query.region === 'string' ? req.query.region : null;
- open(res, 60).json({ nodes: list(req.app.locals.db, { role, region }), generated_at: new Date().toISOString() });
+ open(res, 60).json({ nodes: await list(req.app.locals.db, { role, region }), generated_at: new Date().toISOString() });
});
});
// A beacon a browser times to estimate its distance from a node (openvibe-sdk/geo). Answered by the node that
// A beacon a browser times to estimate its distance from a node (openvibe-sdk/geo). Answered by the node that
// serves it; a report names another URL for nodes whose traffic does not come through here.
// serves it; a report names another URL for nodes whose traffic does not come through here.
pub.get('/:id/beacon', (req, res) => open(res, 0).status(204).end());
pub.get('/:id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
const internal = express.Router();
- internal.post('/report', guard, express.json({ limit: '256kb' }), (req, res) => {
+ internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
const v = validate('network.node-report-request@1', req.body);
const v = validate('network.node-report-request@1', req.body);
if (!v.valid) return res.status(400).json({ error: 'The body does not match network.node-report-request@1', details: (v.errors || []).slice(0, 5) });
if (!v.valid) return res.status(400).json({ error: 'The body does not match network.node-report-request@1', details: (v.errors || []).slice(0, 5) });
- const out = report(req.app.locals.db, req.body);
+ const out = await report(req.app.locals.db, req.body);
res.set('X-Nodes-Marked-Down', String(out.marked_down))
res.set('X-Nodes-Marked-Down', String(out.marked_down))
- .json({ nodes: list(req.app.locals.db), generated_at: new Date().toISOString() });
+ .json({ nodes: await list(req.app.locals.db), generated_at: new Date().toISOString() });
});
});
return { pub, internal };
return { pub, internal };
}
}
@@ -1,56 +1,39 @@
@@ -1,56 +1,39 @@
'use strict';
'use strict';
-
-const path = require('path');
+// ═══════════════════════════════════════════════════════════════
+// reset-db — development/owner convenience: throw away the local database and let the next boot recreate
+// the schema. Under PostgreSQL there is no file to remove: in production (DATABASE_URL set) this refuses
+// and tells the operator what to run instead; without it (the embedded PGlite database of a dev/test
+// process) the PGlite directory is moved aside, so the next start migrates a fresh one.
+// ═══════════════════════════════════════════════════════════════
const fs = require('fs');
const fs = require('fs');
-const dotenv = require('dotenv');
-
-function loadEnv() {
- const envPath = path.resolve(process.cwd(), '.env');
- if (fs.existsSync(envPath)) {
- const parsed = dotenv.config({ path: envPath });
- if (parsed.error) {
- throw parsed.error;
- }
- return parsed.parsed || {};
- }
- return {};
-}
+const path = require('path');
+const config = require('./config');
-function resolveDatabasePath(env) {
- if (env.DB_PATH) {
- return path.resolve(process.cwd(), env.DB_PATH);
+async function main({ log = console, cfg = config } = {}) {
+ if (cfg.db.url) {
+ log.error('reset-db: the database is PostgreSQL (DATABASE_URL is set); there is no file to remove.');
+ log.error("Drop and recreate the schema as the owner instead, e.g. psql \"$DATABASE_DIRECT_URL\" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' — or point DATABASE_URL at a scratch database and re-run scripts/migrate-to-postgres.js.");
+ return 1;
}
}
- return path.resolve(__dirname, '..', 'data', 'network.db');
-}
-
-function main() {
- const env = loadEnv();
- const dbPath = resolveDatabasePath(env);
- if (!fs.existsSync(dbPath)) {
- console.log(`No database file found at ${dbPath}. Nothing to reset.`);
- process.exit(0);
+ const dir = path.resolve(process.cwd(), cfg.db.pgliteDir);
+ if (!fs.existsSync(dir)) {
+ log.log(`No embedded database at ${dir}. Nothing to reset.`);
+ return 0;
}
}
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
- const backupPath = `${dbPath}.backup.${timestamp}`;
-
+ const backup = `${dir}.backup.${new Date().toISOString().replace(/[:.]/g, '-')}`;
try {
try {
- fs.copyFileSync(dbPath, backupPath);
- fs.unlinkSync(dbPath);
- for (const suffix of ['-wal', '-shm']) {
- const journal = `${dbPath}${suffix}`;
- if (fs.existsSync(journal)) {
- fs.unlinkSync(journal);
- }
- }
- console.log(`Backed up existing database to ${backupPath}`);
- console.log(`Database reset complete. Restart the openvibe-network service to recreate the schema.`);
- process.exit(0);
+ fs.renameSync(dir, backup);
+ log.log(`Moved the embedded database to ${backup}`);
+ log.log('Reset complete. Restart the openvibe-network service to recreate the schema (migrations run at boot).');
+ return 0;
} catch (err) {
} catch (err) {
- console.error(`Failed to reset database: ${err.message}`);
- process.exit(1);
+ log.error(`Failed to reset database: ${err.message}`);
+ return 1;
}
}
}
}
-main();
+if (require.main === module) main().then((code) => process.exit(code), (err) => { console.error(`Failed to reset database: ${err.message}`); process.exit(1); });
+
+module.exports = { main };
@@ -52,27 +52,27 @@ const isManaged = (key) => BY_KEY.has(key);
@@ -52,27 +52,27 @@ const isManaged = (key) => BY_KEY.has(key);
const isSecret = (key) => SECRETS.some(s => s.key === key);
const isSecret = (key) => SECRETS.some(s => s.key === key);
const envName = (key) => (BY_KEY.get(key) || {}).env || null;
const envName = (key) => (BY_KEY.get(key) || {}).env || null;
-function dbValue(db, key) {
- try { const r = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key); return r && r.value ? String(r.value) : ''; } catch { return ''; }
+async function dbValue(db, key) {
+ try { const r = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key); return r && r.value ? String(r.value) : ''; } catch { return ''; }
}
}
/** 'env' | 'database' | 'unset' for a managed key. */
/** 'env' | 'database' | 'unset' for a managed key. */
-function source(db, key, env = process.env) {
+async function source(db, key, env = process.env) {
if (fromEnv(key, env) !== null) return 'env';
if (fromEnv(key, env) !== null) return 'env';
- return dbValue(db, key) ? 'database' : 'unset';
+ return (await dbValue(db, key)) ? 'database' : 'unset';
}
}
/** Where each secret comes from, by name only: [{ key, env, source, database_copy, use }]. Never values. */
/** Where each secret comes from, by name only: [{ key, env, source, database_copy, use }]. Never values. */
-function report(db, env = process.env) {
- return [...SECRETS, ...COMPANIONS].map(s => ({
- key: s.key, env: s.env, secret: isSecret(s.key), source: source(db, s.key, env),
- database_copy: !!dbValue(db, s.key), use: s.use,
- }));
+async function report(db, env = process.env) {
+ return await Promise.all([...SECRETS, ...COMPANIONS].map(async s => ({
+ key: s.key, env: s.env, secret: isSecret(s.key), source: await source(db, s.key, env),
+ database_copy: !!(await dbValue(db, s.key)), use: s.use,
+ })));
}
}
/** One boot line: "resend_api_key=env discord_bot_token=database ..." (names and sources only). */
/** One boot line: "resend_api_key=env discord_bot_token=database ..." (names and sources only). */
-function summary(db, env = process.env) {
- return report(db, env).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');
+async function summary(db, env = process.env) {
+ return (await report(db, env)).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');
}
}
module.exports = { SECRETS, COMPANIONS, UNUSED, fromEnv, isManaged, isSecret, envName, source, report, summary };
module.exports = { SECRETS, COMPANIONS, UNUSED, fromEnv, isManaged, isSecret, envName, source, report, summary };
@@ -26,8 +26,8 @@ function redactResolvedRegistry(resolved) {
@@ -26,8 +26,8 @@ function redactResolvedRegistry(resolved) {
function createSetupRoutes(db, config) {
function createSetupRoutes(db, config) {
const router = express.Router();
const router = express.Router();
- function hasAdminUser() {
- return !!db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();
+ async function hasAdminUser() {
+ return !!await db.prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1").get();
}
}
function isValidSetupToken(req) {
function isValidSetupToken(req) {
@@ -35,8 +35,8 @@ function createSetupRoutes(db, config) {
@@ -35,8 +35,8 @@ function createSetupRoutes(db, config) {
return config.setupToken && token && token === config.setupToken;
return config.setupToken && token && token === config.setupToken;
}
}
- function requireSetupMode(req, res, next) {
- if (!hasAdminUser()) return next();
+ async function requireSetupMode(req, res, next) {
+ if (!await hasAdminUser()) return next();
if (isValidSetupToken(req)) return next();
if (isValidSetupToken(req)) return next();
return res.status(403).json({ ok: false, error: 'Setup locked. Provide a valid setup token or log in as an admin.' });
return res.status(403).json({ ok: false, error: 'Setup locked. Provide a valid setup token or log in as an admin.' });
}
}
@@ -72,12 +72,12 @@ function createSetupRoutes(db, config) {
@@ -72,12 +72,12 @@ function createSetupRoutes(db, config) {
return required.filter(key => !resolvedRegistry[key]?.value).map(key => `${key} is missing or invalid.`);
return required.filter(key => !resolvedRegistry[key]?.value).map(key => `${key} is missing or invalid.`);
}
}
- function buildSetupStatus() {
- const resolvedRegistry = urlRegistry.getResolvedRegistry(db, process.env);
+ async function buildSetupStatus() {
+ const resolvedRegistry = await urlRegistry.getResolvedRegistry(db, process.env);
return {
return {
- adminExists: hasAdminUser(),
+ adminExists: await hasAdminUser(),
setupTokenConfigured: Boolean(config.setupToken),
setupTokenConfigured: Boolean(config.setupToken),
- registrySeeded: urlRegistry.isRegistrySeeded(db),
+ registrySeeded: await urlRegistry.isRegistrySeeded(db),
bootstrapProfile: config.bootstrapProfile,
bootstrapProfile: config.bootstrapProfile,
warnings: getSetupWarnings(resolvedRegistry),
warnings: getSetupWarnings(resolvedRegistry),
issues: getSetupIssues(resolvedRegistry),
issues: getSetupIssues(resolvedRegistry),
@@ -85,39 +85,39 @@ function createSetupRoutes(db, config) {
@@ -85,39 +85,39 @@ function createSetupRoutes(db, config) {
};
};
}
}
- router.get('/status', (req, res) => {
+ router.get('/status', async (req, res) => {
try {
try {
- res.json({ ok: true, status: buildSetupStatus() });
+ res.json({ ok: true, status: await buildSetupStatus() });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
}
}
});
});
- router.post('/bootstrap', requireSetupMode, (req, res) => {
+ router.post('/bootstrap', requireSetupMode, async (req, res) => {
try {
try {
const profile = String(req.body.profile || config.bootstrapProfile || 'local-dev');
const profile = String(req.body.profile || config.bootstrapProfile || 'local-dev');
const validProfiles = ['local-dev', 'single-node-prod'];
const validProfiles = ['local-dev', 'single-node-prod'];
if (!validProfiles.includes(profile)) {
if (!validProfiles.includes(profile)) {
return res.status(400).json({ ok: false, error: 'Invalid bootstrap profile' });
return res.status(400).json({ ok: false, error: 'Invalid bootstrap profile' });
}
}
- urlRegistry.seedBootstrapRegistry(db, process.env, profile);
- const status = buildSetupStatus();
+ await urlRegistry.seedBootstrapRegistry(db, process.env, profile);
+ const status = await buildSetupStatus();
return res.json({ ok: true, message: 'Bootstrap completed', status });
return res.json({ ok: true, message: 'Bootstrap completed', status });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
}
}
});
});
- router.post('/admin', requireSetupMode, (req, res) => {
+ router.post('/admin', requireSetupMode, async (req, res) => {
try {
try {
const { username, password } = req.body;
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password are required' });
if (!username || !password) return res.status(400).json({ ok: false, error: 'username and password are required' });
- if (hasAdminUser() && !isValidSetupToken(req)) {
+ if (await hasAdminUser() && !isValidSetupToken(req)) {
return res.status(403).json({ ok: false, error: 'Admin account already exists' });
return res.status(403).json({ ok: false, error: 'Admin account already exists' });
}
}
const normalizedUsername = String(username).trim().toLowerCase();
const normalizedUsername = String(username).trim().toLowerCase();
const passwordHash = bcrypt.hashSync(String(password), 10);
const passwordHash = bcrypt.hashSync(String(password), 10);
- db.prepare(`
+ await db.prepare(`
INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)
INSERT INTO users (username, email, password_hash, display_name, role, profile_color, subject_id)
VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)
VALUES (?, ?, ?, ?, 'admin', '#8b5cf6', ?)
ON CONFLICT(username) DO UPDATE SET
ON CONFLICT(username) DO UPDATE SET
@@ -146,7 +146,7 @@ function createSetupRoutes(db, config) {
@@ -146,7 +146,7 @@ function createSetupRoutes(db, config) {
tools_subdomain_base: 'TOOLS_SUBDOMAIN_BASE',
tools_subdomain_base: 'TOOLS_SUBDOMAIN_BASE',
extra_origins: 'ALLOWED_EXTRA_ORIGINS',
extra_origins: 'ALLOWED_EXTRA_ORIGINS',
};
};
- router.post('/identity', requireSetupMode, (req, res) => {
+ router.post('/identity', requireSetupMode, async (req, res) => {
try {
try {
const updated = {};
const updated = {};
for (const [bodyKey, registryKey] of Object.entries(IDENTITY_FIELD_MAP)) {
for (const [bodyKey, registryKey] of Object.entries(IDENTITY_FIELD_MAP)) {
@@ -174,7 +174,7 @@ function createSetupRoutes(db, config) {
@@ -174,7 +174,7 @@ function createSetupRoutes(db, config) {
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
continue;
continue;
}
}
- const entry = urlRegistry.setRegistryEntry(db, registryKey, value, null);
+ const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, null);
updated[registryKey] = entry.value;
updated[registryKey] = entry.value;
}
}
return res.json({ ok: true, updated, message: 'Identity config saved' });
return res.json({ ok: true, updated, message: 'Identity config saved' });
@@ -187,7 +187,7 @@ function createSetupRoutes(db, config) {
@@ -187,7 +187,7 @@ function createSetupRoutes(db, config) {
// POST /api/setup/urls — configure network URLs during first-time setup.
// POST /api/setup/urls — configure network URLs during first-time setup.
// Writes the provided values into the url_registry with source='admin'.
// Writes the provided values into the url_registry with source='admin'.
// Only keys that exist in URL_DEFINITIONS are accepted.
// Only keys that exist in URL_DEFINITIONS are accepted.
- router.post('/urls', requireSetupMode, (req, res) => {
+ router.post('/urls', requireSetupMode, async (req, res) => {
try {
try {
const allowedKeys = new Set(Object.keys(URL_DEFINITIONS));
const allowedKeys = new Set(Object.keys(URL_DEFINITIONS));
const updated = {};
const updated = {};
@@ -198,13 +198,13 @@ function createSetupRoutes(db, config) {
@@ -198,13 +198,13 @@ function createSetupRoutes(db, config) {
continue;
continue;
}
}
try {
try {
- const entry = urlRegistry.setRegistryEntry(db, key, value, null);
+ const entry = await urlRegistry.setRegistryEntry(db, key, value, null);
updated[key] = entry.value;
updated[key] = entry.value;
} catch (err) {
} catch (err) {
rejected[key] = err.message;
rejected[key] = err.message;
}
}
}
}
- const status = buildSetupStatus();
+ const status = await buildSetupStatus();
return res.json({ ok: true, updated, rejected, status });
return res.json({ ok: true, updated, rejected, status });
} catch (err) {
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
res.status(500).json({ ok: false, error: err.message });
@@ -233,7 +233,7 @@ function createSetupRoutes(db, config) {
@@ -233,7 +233,7 @@ function createSetupRoutes(db, config) {
service_map: 'DEPLOY_SERVICE_MAP',
service_map: 'DEPLOY_SERVICE_MAP',
};
};
- router.post('/deploy', requireSetupMode, (req, res) => {
+ router.post('/deploy', requireSetupMode, async (req, res) => {
try {
try {
const updated = {};
const updated = {};
const errors = {};
const errors = {};
@@ -280,7 +280,7 @@ function createSetupRoutes(db, config) {
@@ -280,7 +280,7 @@ function createSetupRoutes(db, config) {
if (value === undefined || value === null) continue;
if (value === undefined || value === null) continue;
try {
try {
- const entry = urlRegistry.setRegistryEntry(db, registryKey, value, null);
+ const entry = await urlRegistry.setRegistryEntry(db, registryKey, value, null);
updated[registryKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
updated[registryKey] = registryKey === 'DEPLOY_CLOUDFLARE_TOKEN' ? '(saved)' : entry.value;
} catch (err) {
} catch (err) {
errors[bodyKey] = err.message;
errors[bodyKey] = err.message;
@@ -303,9 +303,9 @@ function createSetupRoutes(db, config) {
@@ -303,9 +303,9 @@ function createSetupRoutes(db, config) {
});
});
// GET /api/setup/deploy-status — check deploy prerequisites and current config
// GET /api/setup/deploy-status — check deploy prerequisites and current config
- router.get('/deploy-status', (req, res) => {
+ router.get('/deploy-status', async (req, res) => {
try {
try {
- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
+ const resolved = await urlRegistry.getResolvedRegistry(db, process.env);
const prereqs = certManager.checkPrerequisites();
const prereqs = certManager.checkPrerequisites();
res.json({
res.json({
@@ -3,19 +3,18 @@
@@ -3,19 +3,18 @@
/**
/**
* Provision or rotate a service principal's client credentials (roadmap Wave 1, ADR-003).
* Provision or rotate a service principal's client credentials (roadmap Wave 1, ADR-003).
*
*
- * sudo node server/setup/service-principal.js create <id> --env-file /etc/openvibe/<id>.env [--db data/network.db]
+ * sudo node server/setup/service-principal.js create <id> --env-file /etc/openvibe/<id>.env
* sudo node server/setup/service-principal.js rotate <id> --env-file /etc/openvibe/<id>.env
* sudo node server/setup/service-principal.js rotate <id> --env-file /etc/openvibe/<id>.env
* node server/setup/service-principal.js list
* node server/setup/service-principal.js list
*
*
* A service principal is an OAuth client with no redirect URIs (it can only use client_credentials).
* A service principal is an OAuth client with no redirect URIs (it can only use client_credentials).
* The secret is written straight into the service's env file as OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET
* The secret is written straight into the service's env file as OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET
* (file mode 0600) and is never printed. Grants are separate (principal_grants); this only makes the
* (file mode 0600) and is never printed. Grants are separate (principal_grants); this only makes the
- * identity exist.
+ * identity exist. The database is DATABASE_URL (PostgreSQL); there is no SQLite path (plan T2).
*/
*/
const fs = require('fs');
const fs = require('fs');
-const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
-const Database = require('better-sqlite3');
+const { initDb } = require('../db/database');
const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
const ID_RE = /^[a-z][a-z0-9-]{1,39}$/;
@@ -29,14 +28,13 @@ function upsertEnv(file, pairs) {
@@ -29,14 +28,13 @@ function upsertEnv(file, pairs) {
fs.chmodSync(file, 0o600);
fs.chmodSync(file, 0o600);
}
}
-function main(argv) {
+async function main(argv) {
const [cmd, id] = argv;
const [cmd, id] = argv;
const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : null; };
- const dbPath = path.resolve(opt('db') || process.env.DB_PATH || path.join(__dirname, '..', '..', 'data', 'network.db'));
- const db = new Database(dbPath, { fileMustExist: true });
- try {
+ const db = await initDb();
+ {
if (cmd === 'list') {
if (cmd === 'list') {
- for (const r of db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {
+ for (const r of await db.prepare("SELECT client_id, name, redirect_uris FROM oauth_clients ORDER BY client_id").all()) {
const kind = JSON.parse(r.redirect_uris || '[]').length ? 'site' : 'service';
const kind = JSON.parse(r.redirect_uris || '[]').length ? 'site' : 'service';
console.log(`${r.client_id}\t${kind}\t${r.name}`);
console.log(`${r.client_id}\t${kind}\t${r.name}`);
}
}
@@ -49,20 +47,20 @@ function main(argv) {
@@ -49,20 +47,20 @@ function main(argv) {
const envFile = opt('env-file');
const envFile = opt('env-file');
if (!envFile) { console.error('--env-file is required (the secret is written there, never printed)'); return 2; }
if (!envFile) { console.error('--env-file is required (the secret is written there, never printed)'); return 2; }
const secret = crypto.randomBytes(32).toString('base64url');
const secret = crypto.randomBytes(32).toString('base64url');
- const existing = db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
+ const existing = await db.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(id);
if (cmd === 'create') {
if (cmd === 'create') {
if (existing) { console.error(`${id} already exists; use rotate`); return 1; }
if (existing) { console.error(`${id} already exists; use rotate`); return 1; }
- db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)")
+ await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, '[]', 1)")
.run(id, secret, `OpenVibe.${id} (service)`);
.run(id, secret, `OpenVibe.${id} (service)`);
} else {
} else {
if (!existing) { console.error(`${id} does not exist; use create`); return 1; }
if (!existing) { console.error(`${id} does not exist; use create`); return 1; }
- db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, id);
+ await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, id);
}
}
upsertEnv(envFile, { OV_OAUTH_CLIENT_ID: id, OV_OAUTH_CLIENT_SECRET: secret });
upsertEnv(envFile, { OV_OAUTH_CLIENT_ID: id, OV_OAUTH_CLIENT_SECRET: secret });
console.log(`${cmd === 'create' ? 'created' : 'rotated'} service principal ${id}; credentials written to ${envFile} (0600). Restart the service to pick them up.`);
console.log(`${cmd === 'create' ? 'created' : 'rotated'} service principal ${id}; credentials written to ${envFile} (0600). Restart the service to pick them up.`);
return 0;
return 0;
- } finally { db.close(); }
+ }
}
}
-if (require.main === module) process.exitCode = main(process.argv.slice(2));
+if (require.main === module) main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; });
module.exports = { main, upsertEnv };
module.exports = { main, upsertEnv };
@@ -26,51 +26,29 @@ class IncidentError extends Error {
@@ -26,51 +26,29 @@ class IncidentError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
-function ensureSchema(db) {
- db.exec(`CREATE TABLE IF NOT EXISTS status_incidents (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL CHECK (kind IN ('incident', 'maintenance')),
- title TEXT NOT NULL,
- severity TEXT CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),
- state TEXT NOT NULL,
- services TEXT NOT NULL DEFAULT '[]',
- starts_at TEXT NOT NULL,
- ends_at TEXT,
- created_by TEXT NOT NULL,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
- );
- CREATE TABLE IF NOT EXISTS status_incident_updates (
- incident_id TEXT NOT NULL REFERENCES status_incidents(id),
- at TEXT NOT NULL,
- state TEXT NOT NULL,
- message TEXT NOT NULL,
- author TEXT NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_status_incident_updates ON status_incident_updates(incident_id, at);`);
-}
+function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
const statesOf = (kind) => (kind === 'maintenance' ? MAINTENANCE_STATES : INCIDENT_STATES);
const statesOf = (kind) => (kind === 'maintenance' ? MAINTENANCE_STATES : INCIDENT_STATES);
-function present(db, row) {
+async function present(db, row) {
if (!row) return null;
if (!row) return null;
const out = {
const out = {
id: row.id, kind: row.kind, title: row.title, ...(row.severity ? { severity: row.severity } : {}), state: row.state,
id: row.id, kind: row.kind, title: row.title, ...(row.severity ? { severity: row.severity } : {}), state: row.state,
services: JSON.parse(row.services || '[]'), starts_at: row.starts_at, ends_at: row.ends_at || null,
services: JSON.parse(row.services || '[]'), starts_at: row.starts_at, ends_at: row.ends_at || null,
- updates: db.prepare('SELECT at, state, message FROM status_incident_updates WHERE incident_id = ? ORDER BY at, rowid').all(row.id),
+ updates: await db.prepare('SELECT at, state, message FROM status_incident_updates WHERE incident_id = ? ORDER BY at, seq').all(row.id),
created_at: row.created_at, updated_at: row.updated_at,
created_at: row.created_at, updated_at: row.updated_at,
};
};
return out;
return out;
}
}
-function get(db, id) { ensureSchema(db); return present(db, db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id))); }
+async function get(db, id) { ensureSchema(db); return await present(db, await db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id))); }
/** Active: open incidents, and maintenance windows not completed. Recent: closed within 30 days. */
/** Active: open incidents, and maintenance windows not completed. Recent: closed within 30 days. */
-function list(db, { now = Date.now() } = {}) {
+async function list(db, { now = Date.now() } = {}) {
ensureSchema(db);
ensureSchema(db);
const since = new Date(now - RECENT_DAYS * 86400000).toISOString();
const since = new Date(now - RECENT_DAYS * 86400000).toISOString();
- const active = db.prepare("SELECT * FROM status_incidents WHERE state NOT IN ('resolved', 'completed') ORDER BY starts_at DESC").all().map((r) => present(db, r));
- const recent = db.prepare("SELECT * FROM status_incidents WHERE state IN ('resolved', 'completed') AND updated_at >= ? ORDER BY updated_at DESC LIMIT 50").all(since).map((r) => present(db, r));
+ const active = (await Promise.all((await db.prepare("SELECT * FROM status_incidents WHERE state NOT IN ('resolved', 'completed') ORDER BY starts_at DESC").all()).map(async (r) => await present(db, r))));
+ const recent = (await Promise.all((await db.prepare("SELECT * FROM status_incidents WHERE state IN ('resolved', 'completed') AND updated_at >= ? ORDER BY updated_at DESC LIMIT 50").all(since)).map(async (r) => await present(db, r))));
return { active, recent };
return { active, recent };
}
}
@@ -80,7 +58,7 @@ function check(schema, body) {
@@ -80,7 +58,7 @@ function check(schema, body) {
}
}
/** Open an incident or maintenance window (body: network.status-incident-request@1, the open form). */
/** Open an incident or maintenance window (body: network.status-incident-request@1, the open form). */
-function open(db, body, author, { now = new Date().toISOString() } = {}) {
+async function open(db, body, author, { now = new Date().toISOString() } = {}) {
ensureSchema(db);
ensureSchema(db);
check('network.status-incident-request@1', body);
check('network.status-incident-request@1', body);
if (!body.kind) throw new IncidentError(400, 'status.invalid_request', 'kind, title, services and message open an incident');
if (!body.kind) throw new IncidentError(400, 'status.invalid_request', 'kind, title, services and message open an incident');
@@ -92,30 +70,30 @@ function open(db, body, author, { now = new Date().toISOString() } = {}) {
@@ -92,30 +70,30 @@ function open(db, body, author, { now = new Date().toISOString() } = {}) {
const startsAt = body.starts_at ? new Date(body.starts_at).toISOString() : now;
const startsAt = body.starts_at ? new Date(body.starts_at).toISOString() : now;
const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : null;
const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : null;
if (endsAt && endsAt < startsAt) throw new IncidentError(400, 'status.invalid_window', 'ends_at is before starts_at');
if (endsAt && endsAt < startsAt) throw new IncidentError(400, 'status.invalid_window', 'ends_at is before starts_at');
- db.transaction(() => {
- db.prepare(`INSERT INTO status_incidents (id, kind, title, severity, state, services, starts_at, ends_at, created_by, created_at, updated_at)
+ await db.tx(async () => {
+ await db.prepare(`INSERT INTO status_incidents (id, kind, title, severity, state, services, starts_at, ends_at, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, kind, body.title.trim(), kind === 'incident' ? (body.severity || 'minor') : null, state,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, kind, body.title.trim(), kind === 'incident' ? (body.severity || 'minor') : null, state,
JSON.stringify([...new Set(body.services)]), startsAt, endsAt, author, now, now);
JSON.stringify([...new Set(body.services)]), startsAt, endsAt, author, now, now);
- db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(id, now, state, body.message.trim(), author);
- })();
- return get(db, id);
+ await db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(id, now, state, body.message.trim(), author);
+ });
+ return await get(db, id);
}
}
/** Add an update (body: the update form): a new state and message; ends_at for a window. Closed ones refuse. */
/** Add an update (body: the update form): a new state and message; ends_at for a window. Closed ones refuse. */
-function update(db, id, body, author, { now = new Date().toISOString() } = {}) {
+async function update(db, id, body, author, { now = new Date().toISOString() } = {}) {
ensureSchema(db);
ensureSchema(db);
check('network.status-incident-request@1', body);
check('network.status-incident-request@1', body);
if (!body.state || body.kind) throw new IncidentError(400, 'status.invalid_request', 'an update is { state, message, ends_at? }');
if (!body.state || body.kind) throw new IncidentError(400, 'status.invalid_request', 'an update is { state, message, ends_at? }');
- const row = db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id));
+ const row = await db.prepare('SELECT * FROM status_incidents WHERE id = ?').get(String(id));
if (!row) throw new IncidentError(404, 'status.incident_not_found', 'no such incident');
if (!row) throw new IncidentError(404, 'status.incident_not_found', 'no such incident');
if (CLOSED.has(row.state)) throw new IncidentError(409, 'status.incident_closed', `this ${row.kind} is ${row.state}`);
if (CLOSED.has(row.state)) throw new IncidentError(409, 'status.incident_closed', `this ${row.kind} is ${row.state}`);
if (!statesOf(row.kind).includes(body.state)) throw new IncidentError(400, 'status.invalid_state', `a ${row.kind} is one of ${statesOf(row.kind).join(', ')}`);
if (!statesOf(row.kind).includes(body.state)) throw new IncidentError(400, 'status.invalid_state', `a ${row.kind} is one of ${statesOf(row.kind).join(', ')}`);
const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : (CLOSED.has(body.state) ? now : row.ends_at);
const endsAt = body.ends_at ? new Date(body.ends_at).toISOString() : (CLOSED.has(body.state) ? now : row.ends_at);
- db.transaction(() => {
- db.prepare('UPDATE status_incidents SET state = ?, ends_at = ?, updated_at = ? WHERE id = ?').run(body.state, endsAt, now, row.id);
- db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(row.id, now, body.state, body.message.trim(), author);
- })();
- return get(db, row.id);
+ await db.tx(async () => {
+ await db.prepare('UPDATE status_incidents SET state = ?, ends_at = ?, updated_at = ? WHERE id = ?').run(body.state, endsAt, now, row.id);
+ await db.prepare('INSERT INTO status_incident_updates (incident_id, at, state, message, author) VALUES (?, ?, ?, ?, ?)').run(row.id, now, body.state, body.message.trim(), author);
+ });
+ return await get(db, row.id);
}
}
/**
/**
@@ -125,15 +103,15 @@ function update(db, id, body, author, { now = new Date().toISOString() } = {}) {
@@ -125,15 +103,15 @@ function update(db, id, body, author, { now = new Date().toISOString() } = {}) {
function router({ requireAuth, incidentGuard }) {
function router({ requireAuth, incidentGuard }) {
const r = express.Router();
const r = express.Router();
r.use(http.middleware());
r.use(http.middleware());
- r.get('/', (req, res) => {
- res.set('Cache-Control', 'public, max-age=30').json(list(req.app.locals.db));
+ r.get('/', async (req, res) => {
+ res.set('Cache-Control', 'public, max-age=30').json(await list(req.app.locals.db));
});
});
- const writer = (req, res, next) => {
+ const writer = async (req, res, next) => {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
const token = h.startsWith('Bearer ') ? h.slice(7) : (req.cookies && req.cookies.ov_token);
let user = null;
let user = null;
if (token) {
if (token) {
- try { const out = require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config }); user = out && out.user; } catch { user = null; }
+ try { const out = await require('../auth/session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config }); user = out && out.user; } catch { user = null; }
}
}
if (user) {
if (user) {
if (user.role !== 'admin') return http.sendProblem(res, 403, 'status.staff_only', { detail: 'only staff admins post incidents', ctx: req.ov });
if (user.role !== 'admin') return http.sendProblem(res, 403, 'status.staff_only', { detail: 'only staff admins post incidents', ctx: req.ov });
@@ -142,16 +120,16 @@ function router({ requireAuth, incidentGuard }) {
@@ -142,16 +120,16 @@ function router({ requireAuth, incidentGuard }) {
}
}
return incidentGuard(req, res, () => { req.statusAuthor = req.principal ? String(req.principal.sub) : 'service'; next(); });
return incidentGuard(req, res, () => { req.statusAuthor = req.principal ? String(req.principal.sub) : 'service'; next(); });
};
};
- const send = (res, req, fn, status = 200) => {
+ const send = async (res, req, fn, status = 200) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
- try { res.status(status).json(fn()); } catch (err) {
+ try { res.status(status).json(await fn()); } catch (err) {
if (err instanceof IncidentError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
if (err instanceof IncidentError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
console.error('[Status incidents]', err.message);
console.error('[Status incidents]', err.message);
return http.sendProblem(res, 500, 'status.failed', { detail: 'the incident could not be saved', ctx: req.ov });
return http.sendProblem(res, 500, 'status.failed', { detail: 'the incident could not be saved', ctx: req.ov });
}
}
};
};
- r.post('/', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => open(req.app.locals.db, req.body || {}, req.statusAuthor), 201));
- r.post('/:id/updates', express.json({ limit: '16kb' }), writer, (req, res) => send(res, req, () => update(req.app.locals.db, req.params.id, req.body || {}, req.statusAuthor)));
+ r.post('/', express.json({ limit: '16kb' }), writer, async (req, res) => await send(res, req, async () => await open(req.app.locals.db, req.body || {}, req.statusAuthor), 201));
+ r.post('/:id/updates', express.json({ limit: '16kb' }), writer, async (req, res) => await send(res, req, async () => await update(req.app.locals.db, req.params.id, req.body || {}, req.statusAuthor)));
return r;
return r;
}
}
@@ -259,11 +259,11 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {
@@ -259,11 +259,11 @@ function createStatusRoutes({ ecosystem, now = () => new Date() }) {
r.get('/api/v1/status/slo', (_req, res) => {
r.get('/api/v1/status/slo', (_req, res) => {
res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(loadSlo());
res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(loadSlo());
});
});
- r.get('/status', (_req, res) => {
+ r.get('/status', async (_req, res) => {
const slo = { ...loadSlo(), pollSeconds: ecosystem.pollMs / 1000 };
const slo = { ...loadSlo(), pollSeconds: ecosystem.pollMs / 1000 };
res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-cache, max-age=0').set('X-Robots-Tag', 'noindex, nofollow');
res.set('Content-Type', 'text/html; charset=utf-8').set('Cache-Control', 'no-cache, max-age=0').set('X-Robots-Tag', 'noindex, nofollow');
let incidents = null;
let incidents = null;
- try { incidents = require('./incidents').list(_req.app.locals.db); } catch { incidents = null; }
+ try { incidents = await require('./incidents').list(_req.app.locals.db); } catch { incidents = null; }
res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null, incidents));
res.send(renderPage(rows(ecosystem), slo, now().toISOString(), loadDevPath(), loadDevPath(TOOLSJOB_FILE), typeof ecosystem.releaseHealthSince === 'function' ? ecosystem.releaseHealthSince() : null, incidents));
});
});
return r;
return r;
@@ -16,13 +16,7 @@ const router = express.Router();
@@ -16,13 +16,7 @@ const router = express.Router();
const EXPORT_FORMAT = 'openvibe-theme@1';
const EXPORT_FORMAT = 'openvibe-theme@1';
const MAX_PENDING = 5;
const MAX_PENDING = 5;
let reviewReady = false;
let reviewReady = false;
-function ensureReview(db) {
- if (reviewReady) return;
- for (const col of ["review_status TEXT NOT NULL DEFAULT 'approved'", 'reviewed_by INTEGER', 'reviewed_at DATETIME', 'review_note TEXT']) {
- try { db.exec(`ALTER TABLE themes ADD COLUMN ${col}`); } catch { /* already there */ }
- }
- reviewReady = true;
-}
+function ensureReview(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
const parseTheme = (t) => {
const parseTheme = (t) => {
if (!t) return t;
if (!t) return t;
try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }
try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }
@@ -35,14 +29,14 @@ const visibleTo = (t, userId) => t && ((t.is_public === 1 && t.review_status ===
@@ -35,14 +29,14 @@ const visibleTo = (t, userId) => t && ((t.is_public === 1 && t.review_status ===
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
function getConfig(req) { return req.app.locals.config; }
-function optionalAuth(req, res, next) {
+async function optionalAuth(req, res, next) {
// Sliding sessions (server/auth/session.js): renewable tokens count, and get renewed.
// Sliding sessions (server/auth/session.js): renewable tokens count, and get renewed.
const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
if (!token) return next();
if (!token) return next();
try {
try {
const { verifySession, COOKIE } = require('../auth/session');
const { verifySession, COOKIE } = require('../auth/session');
- const out = verifySession(token, { db: getDb(req), publicKey: req.app.locals.publicKey, config: req.app.locals.config });
+ const out = await verifySession(token, { db: await getDb(req), publicKey: req.app.locals.publicKey, config: req.app.locals.config });
if (out.error) return next();
if (out.error) return next();
// Routes here read both req.user.sub (JWT shape) and req.user.id (row shape) — give them both.
// Routes here read both req.user.sub (JWT shape) and req.user.id (row shape) — give them both.
req.user = { ...out.decoded, ...out.user, sub: out.user.id, id: out.user.id }; req.token = token;
req.user = { ...out.decoded, ...out.user, sub: out.user.id, id: out.user.id }; req.token = token;
@@ -53,16 +47,16 @@ function optionalAuth(req, res, next) {
@@ -53,16 +47,16 @@ function optionalAuth(req, res, next) {
next();
next();
}
}
-function requireAuth(req, res, next) {
- optionalAuth(req, res, () => {
+async function requireAuth(req, res, next) {
+ await optionalAuth(req, res, () => {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
next();
next();
});
});
}
}
// ── List Themes ──────────────────────────────────────────────
// ── List Themes ──────────────────────────────────────────────
-router.get('/', (req, res) => {
- const db = getDb(req);
+router.get('/', async (req, res) => {
+ const db = await getDb(req);
ensureReview(db);
ensureReview(db);
const { mode, search, sort, limit } = req.query;
const { mode, search, sort, limit } = req.query;
@@ -83,7 +77,7 @@ router.get('/', (req, res) => {
@@ -83,7 +77,7 @@ router.get('/', (req, res) => {
sql += ` LIMIT ?`;
sql += ` LIMIT ?`;
params.push(Math.min(parseInt(limit, 10) || 100, 500));
params.push(Math.min(parseInt(limit, 10) || 100, 500));
- const themes = db.prepare(sql).all(...params);
+ const themes = await db.prepare(sql).all(...params);
for (const t of themes) {
for (const t of themes) {
try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }
try { t.variables = JSON.parse(t.variables); } catch { t.variables = {}; }
try { t.preview_colors = JSON.parse(t.preview_colors); } catch { t.preview_colors = null; }
try { t.preview_colors = JSON.parse(t.preview_colors); } catch { t.preview_colors = null; }
@@ -99,16 +93,16 @@ router.get('/me/active', (req, res, next) => {
@@ -99,16 +93,16 @@ router.get('/me/active', (req, res, next) => {
const hasAuth = /^Bearer\s+\S/.test(req.headers.authorization || '') || /(?:^|;\s*)ov_token=/.test(req.headers.cookie || '');
const hasAuth = /^Bearer\s+\S/.test(req.headers.authorization || '') || /(?:^|;\s*)ov_token=/.test(req.headers.cookie || '');
if (!hasAuth) return res.set('Cache-Control', 'no-store').json({ theme_id: null, guest: true });
if (!hasAuth) return res.set('Cache-Control', 'no-store').json({ theme_id: null, guest: true });
next();
next();
-}, requireAuth, (req, res) => {
- const db = getDb(req);
+}, requireAuth, async (req, res) => {
+ const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
- const prefs = db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
+ const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });
if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });
let custom = null;
let custom = null;
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
let display = null;
let display = null;
- try { const d = db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }
+ try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }
res.json({ theme_id: prefs.theme_id, custom_variables: custom, display });
res.json({ theme_id: prefs.theme_id, custom_variables: custom, display });
});
});
@@ -120,38 +114,38 @@ function cleanDisplay(d) {
@@ -120,38 +114,38 @@ function cleanDisplay(d) {
for (const k of Object.keys(DISPLAY)) if (DISPLAY[k].includes(String(d[k]))) out[k] = String(d[k]);
for (const k of Object.keys(DISPLAY)) if (DISPLAY[k].includes(String(d[k]))) out[k] = String(d[k]);
return Object.keys(out).length ? out : null;
return Object.keys(out).length ? out : null;
}
}
-router.put('/me/display', requireAuth, (req, res) => {
- const db = getDb(req);
+router.put('/me/display', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
const display = cleanDisplay(req.body) || {};
const display = cleanDisplay(req.body) || {};
- db.prepare(`INSERT INTO user_preferences (user_id, display_prefs, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
- ON CONFLICT(user_id) DO UPDATE SET display_prefs = excluded.display_prefs, updated_at = CURRENT_TIMESTAMP`).run(userId, JSON.stringify(display));
+ await db.prepare(`INSERT INTO user_preferences (user_id, display_prefs, updated_at) VALUES (?, ?, ov_now())
+ ON CONFLICT(user_id) DO UPDATE SET display_prefs = excluded.display_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(display));
res.json({ ok: true, display });
res.json({ ok: true, display });
});
});
// ── Your submissions, with their review status ────────────────
// ── Your submissions, with their review status ────────────────
-router.get('/me/submissions', requireAuth, (req, res) => {
- const db = getDb(req);
+router.get('/me/submissions', requireAuth, async (req, res) => {
+ const db = await getDb(req);
ensureReview(db);
ensureReview(db);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
- const themes = db.prepare('SELECT id, name, slug, description, mode, variables, tags, review_status, review_note, reviewed_at, created_at FROM themes WHERE author_id = ? AND is_builtin = 0 ORDER BY created_at DESC LIMIT 50').all(userId).map(parseTheme);
+ const themes = (await db.prepare('SELECT id, name, slug, description, mode, variables, tags, review_status, review_note, reviewed_at, created_at FROM themes WHERE author_id = ? AND is_builtin = 0 ORDER BY created_at DESC LIMIT 50').all(userId)).map(parseTheme);
res.set('Cache-Control', 'private, no-store').json({ themes });
res.set('Cache-Control', 'private, no-store').json({ themes });
});
});
// ── Get Theme by ID or Slug (approved themes; your own at any status) ──
// ── Get Theme by ID or Slug (approved themes; your own at any status) ──
-router.get('/:idOrSlug', optionalAuth, (req, res) => {
- const db = getDb(req);
+router.get('/:idOrSlug', optionalAuth, async (req, res) => {
+ const db = await getDb(req);
ensureReview(db);
ensureReview(db);
- const theme = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
+ const theme = await db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
if (!visibleTo(theme, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });
if (!visibleTo(theme, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });
res.json({ theme: parseTheme(theme) });
res.json({ theme: parseTheme(theme) });
});
});
// ── Export a theme as a file (import it elsewhere, or submit an edited copy) ──
// ── Export a theme as a file (import it elsewhere, or submit an edited copy) ──
-router.get('/:idOrSlug/export', optionalAuth, (req, res) => {
- const db = getDb(req);
+router.get('/:idOrSlug/export', optionalAuth, async (req, res) => {
+ const db = await getDb(req);
ensureReview(db);
ensureReview(db);
- const t = db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
+ const t = await db.prepare('SELECT * FROM themes WHERE id = ? OR slug = ?').get(req.params.idOrSlug, req.params.idOrSlug);
if (!visibleTo(t, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });
if (!visibleTo(t, req.user && (req.user.sub || req.user.id))) return res.status(404).json({ error: 'Theme not found' });
parseTheme(t);
parseTheme(t);
const file = { format: EXPORT_FORMAT, name: t.name, slug: t.slug, description: t.description || '', mode: t.mode, variables: t.variables, tags: t.tags };
const file = { format: EXPORT_FORMAT, name: t.name, slug: t.slug, description: t.description || '', mode: t.mode, variables: t.variables, tags: t.tags };
@@ -159,14 +153,14 @@ router.get('/:idOrSlug/export', optionalAuth, (req, res) => {
@@ -159,14 +153,14 @@ router.get('/:idOrSlug/export', optionalAuth, (req, res) => {
});
});
// ── Set User's Active Theme ──────────────────────────────────
// ── Set User's Active Theme ──────────────────────────────────
-router.put('/me', requireAuth, (req, res) => {
- const db = getDb(req);
+router.put('/me', requireAuth, async (req, res) => {
+ const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
const { theme_id, custom_variables } = req.body;
const { theme_id, custom_variables } = req.body;
ensureReview(db);
ensureReview(db);
if (theme_id) {
if (theme_id) {
- const theme = db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);
+ const theme = await db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);
if (!visibleTo(theme, userId)) return res.status(404).json({ error: 'Theme not found' });
if (!visibleTo(theme, userId)) return res.status(404).json({ error: 'Theme not found' });
}
}
if (custom_variables) {
if (custom_variables) {
@@ -175,13 +169,13 @@ router.put('/me', requireAuth, (req, res) => {
@@ -175,13 +169,13 @@ router.put('/me', requireAuth, (req, res) => {
req.body.custom_variables = v.variables;
req.body.custom_variables = v.variables;
}
}
- db.prepare(`
+ await db.prepare(`
INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)
INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)
- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ VALUES (?, ?, ?, ov_now())
ON CONFLICT(user_id) DO UPDATE SET
ON CONFLICT(user_id) DO UPDATE SET
- theme_id = COALESCE(?, theme_id),
+ theme_id = COALESCE(?, user_preferences.theme_id),
custom_theme_variables = ?,
custom_theme_variables = ?,
- updated_at = CURRENT_TIMESTAMP
+ updated_at = ov_now()
`).run(
`).run(
userId,
userId,
theme_id || 'vibe',
theme_id || 'vibe',
@@ -194,7 +188,7 @@ router.put('/me', requireAuth, (req, res) => {
@@ -194,7 +188,7 @@ router.put('/me', requireAuth, (req, res) => {
});
});
// ── Submit a community theme (pending review) ─────────────────
// ── Submit a community theme (pending review) ─────────────────
-function submitTheme(db, userId, body) {
+async function submitTheme(db, userId, body) {
ensureReview(db);
ensureReview(db);
const { name, slug, description, mode, variables, tags } = body || {};
const { name, slug, description, mode, variables, tags } = body || {};
if (!name || !slug || !variables) return [400, { error: 'name, slug, and variables required' }];
if (!name || !slug || !variables) return [400, { error: 'name, slug, and variables required' }];
@@ -204,52 +198,52 @@ function submitTheme(db, userId, body) {
@@ -204,52 +198,52 @@ function submitTheme(db, userId, body) {
const v = cleanVariables(variables);
const v = cleanVariables(variables);
if (!v.ok) return [422, { error: 'Theme rejected', code: 'theme.invalid_variables', errors: v.errors }];
if (!v.ok) return [422, { error: 'Theme rejected', code: 'theme.invalid_variables', errors: v.errors }];
const cleanTags = Array.isArray(tags) ? tags.filter((t) => typeof t === 'string' && /^[a-z0-9-]{1,24}$/.test(t)).slice(0, 8) : [];
const cleanTags = Array.isArray(tags) ? tags.filter((t) => typeof t === 'string' && /^[a-z0-9-]{1,24}$/.test(t)).slice(0, 8) : [];
- if (db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug)) return [409, { error: 'A theme with that slug already exists' }];
- const pending = db.prepare("SELECT COUNT(*) AS n FROM themes WHERE author_id = ? AND review_status = 'pending'").get(userId).n;
+ if (await db.prepare('SELECT id FROM themes WHERE slug = ?').get(slug)) return [409, { error: 'A theme with that slug already exists' }];
+ const pending = (await db.prepare("SELECT COUNT(*) AS n FROM themes WHERE author_id = ? AND review_status = 'pending'").get(userId)).n;
if (pending >= MAX_PENDING) return [429, { error: `You have ${pending} themes waiting for review; wait for those first`, code: 'theme.too_many_pending' }];
if (pending >= MAX_PENDING) return [429, { error: `You have ${pending} themes waiting for review; wait for those first`, code: 'theme.too_many_pending' }];
const id = `community-${slug}`;
const id = `community-${slug}`;
- db.prepare(`INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags, review_status)
+ await db.prepare(`INSERT INTO themes (id, name, slug, author_id, description, mode, variables, is_builtin, is_public, tags, review_status)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, 'pending')`)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, 'pending')`)
.run(id, name.trim(), slug, userId, String(description || '').slice(0, 300), mode || 'dark', JSON.stringify(v.variables), JSON.stringify(cleanTags));
.run(id, name.trim(), slug, userId, String(description || '').slice(0, 300), mode || 'dark', JSON.stringify(v.variables), JSON.stringify(cleanTags));
- return [201, { theme: parseTheme(db.prepare('SELECT * FROM themes WHERE id = ?').get(id)), review: 'pending', note: 'Submitted themes are reviewed before anyone else sees them. You can use yours right away.' }];
+ return [201, { theme: parseTheme(await db.prepare('SELECT * FROM themes WHERE id = ?').get(id)), review: 'pending', note: 'Submitted themes are reviewed before anyone else sees them. You can use yours right away.' }];
}
}
-router.post('/', requireAuth, (req, res) => {
- const [status, body] = submitTheme(getDb(req), req.user.sub || req.user.id, req.body);
+router.post('/', requireAuth, async (req, res) => {
+ const [status, body] = await submitTheme(await getDb(req), req.user.sub || req.user.id, req.body);
res.status(status).json(body);
res.status(status).json(body);
});
});
// ── Import a theme file (openvibe-theme@1, as /:id/export writes it): a submission like any other ──
// ── Import a theme file (openvibe-theme@1, as /:id/export writes it): a submission like any other ──
-router.post('/import', requireAuth, (req, res) => {
+router.post('/import', requireAuth, async (req, res) => {
const file = req.body || {};
const file = req.body || {};
if (file.format !== EXPORT_FORMAT) return res.status(400).json({ error: `Not an ${EXPORT_FORMAT} file`, code: 'theme.bad_format' });
if (file.format !== EXPORT_FORMAT) return res.status(400).json({ error: `Not an ${EXPORT_FORMAT} file`, code: 'theme.bad_format' });
- const [status, body] = submitTheme(getDb(req), req.user.sub || req.user.id, file);
+ const [status, body] = await submitTheme(await getDb(req), req.user.sub || req.user.id, file);
res.status(status).json(body);
res.status(status).json(body);
});
});
// ── Review queue (admins; mounted at /api/admin/themes behind requireAuth + requireAdmin) ──
// ── Review queue (admins; mounted at /api/admin/themes behind requireAuth + requireAdmin) ──
function reviewRouter() {
function reviewRouter() {
const r = express.Router();
const r = express.Router();
- r.get('/', (req, res) => {
- const db = getDb(req);
+ r.get('/', async (req, res) => {
+ const db = await getDb(req);
ensureReview(db);
ensureReview(db);
const status = ['pending', 'approved', 'rejected'].includes(req.query.status) ? req.query.status : 'pending';
const status = ['pending', 'approved', 'rejected'].includes(req.query.status) ? req.query.status : 'pending';
- const themes = db.prepare(`SELECT t.id, t.name, t.slug, t.description, t.mode, t.variables, t.tags, t.review_status, t.review_note, t.reviewed_at, t.created_at, u.username AS author
- FROM themes t LEFT JOIN users u ON u.id = t.author_id WHERE t.is_builtin = 0 AND t.review_status = ? ORDER BY t.created_at ASC LIMIT 200`).all(status).map(parseTheme);
+ const themes = (await db.prepare(`SELECT t.id, t.name, t.slug, t.description, t.mode, t.variables, t.tags, t.review_status, t.review_note, t.reviewed_at, t.created_at, u.username AS author
+ FROM themes t LEFT JOIN users u ON u.id = t.author_id WHERE t.is_builtin = 0 AND t.review_status = ? ORDER BY t.created_at ASC LIMIT 200`).all(status)).map(parseTheme);
res.set('Cache-Control', 'private, no-store').json({ status, themes });
res.set('Cache-Control', 'private, no-store').json({ status, themes });
});
});
- r.post('/:id/review', (req, res) => {
- const db = getDb(req);
+ r.post('/:id/review', async (req, res) => {
+ const db = await getDb(req);
ensureReview(db);
ensureReview(db);
const decision = req.body && req.body.decision;
const decision = req.body && req.body.decision;
if (!['approve', 'reject'].includes(decision)) return res.status(400).json({ error: 'decision is approve or reject' });
if (!['approve', 'reject'].includes(decision)) return res.status(400).json({ error: 'decision is approve or reject' });
const note = String((req.body && req.body.note) || '').slice(0, 300) || null;
const note = String((req.body && req.body.note) || '').slice(0, 300) || null;
if (decision === 'reject' && !note) return res.status(400).json({ error: 'Say why it is rejected (note)', code: 'theme.note_required' });
if (decision === 'reject' && !note) return res.status(400).json({ error: 'Say why it is rejected (note)', code: 'theme.note_required' });
- const t = db.prepare('SELECT id, is_builtin FROM themes WHERE id = ?').get(req.params.id);
+ const t = await db.prepare('SELECT id, is_builtin FROM themes WHERE id = ?').get(req.params.id);
if (!t || t.is_builtin) return res.status(404).json({ error: 'No such community theme' });
if (!t || t.is_builtin) return res.status(404).json({ error: 'No such community theme' });
- db.prepare("UPDATE themes SET review_status = ?, is_public = ?, reviewed_by = ?, reviewed_at = CURRENT_TIMESTAMP, review_note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ await db.prepare("UPDATE themes SET review_status = ?, is_public = ?, reviewed_by = ?, reviewed_at = ov_now(), review_note = ?, updated_at = ov_now() WHERE id = ?")
.run(decision === 'approve' ? 'approved' : 'rejected', decision === 'approve' ? 1 : 0, req.user.id || req.user.sub, note, t.id);
.run(decision === 'approve' ? 'approved' : 'rejected', decision === 'approve' ? 1 : 0, req.user.id || req.user.sub, note, t.id);
- res.json({ theme: parseTheme(db.prepare('SELECT * FROM themes WHERE id = ?').get(t.id)) });
+ res.json({ theme: parseTheme(await db.prepare('SELECT * FROM themes WHERE id = ?').get(t.id)) });
});
});
return r;
return r;
}
}
@@ -2,30 +2,14 @@
@@ -2,30 +2,14 @@
const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('openvibe-shared/url-resolver');
const { URL_DEFINITIONS, normalizeValue, resolveRegistryValues } = require('openvibe-shared/url-resolver');
-function initializeUrlRegistry(db) {
- db.exec(`
- CREATE TABLE IF NOT EXISTS url_registry (
- key TEXT PRIMARY KEY,
- label TEXT NOT NULL,
- category TEXT NOT NULL,
- service TEXT NOT NULL,
- scope TEXT NOT NULL,
- type TEXT NOT NULL,
- value TEXT,
- description TEXT,
- source TEXT NOT NULL DEFAULT 'admin',
- updated_by INTEGER,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
- `);
-}
+function initializeUrlRegistry(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
-function getRegistryRows(db) {
- return db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry ORDER BY category, service, key').all();
+async function getRegistryRows(db) {
+ return await db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry ORDER BY category, service, key').all();
}
}
-function getRegistryRow(db, key) {
- return db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry WHERE key = ?').get(key);
+async function getRegistryRow(db, key) {
+ return await db.prepare('SELECT key, label, category, service, scope, type, value, description, source, updated_by, updated_at FROM url_registry WHERE key = ?').get(key);
}
}
function encodeRegistryValue(value, type) {
function encodeRegistryValue(value, type) {
@@ -58,14 +42,14 @@ function decodeRegistryValue(value, type) {
@@ -58,14 +42,14 @@ function decodeRegistryValue(value, type) {
}
}
}
}
-function getAllRegistryEntries(db) {
- const rows = db.prepare('SELECT key, value, source, updated_by, updated_at FROM url_registry').all();
+async function getAllRegistryEntries(db) {
+ const rows = await db.prepare('SELECT key, value, source, updated_by, updated_at FROM url_registry').all();
const rowMap = rows.reduce((map, row) => {
const rowMap = rows.reduce((map, row) => {
map[row.key] = row;
map[row.key] = row;
return map;
return map;
}, {});
}, {});
- const warnings = getRegistryWarnings(db);
+ const warnings = await getRegistryWarnings(db);
return Object.values(URL_DEFINITIONS).map(def => {
return Object.values(URL_DEFINITIONS).map(def => {
const row = rowMap[def.key];
const row = rowMap[def.key];
@@ -88,9 +72,9 @@ function getAllRegistryEntries(db) {
@@ -88,9 +72,9 @@ function getAllRegistryEntries(db) {
});
});
}
}
-function getRegistryWarnings(db, env = process.env) {
+async function getRegistryWarnings(db, env = process.env) {
const warnings = {};
const warnings = {};
- const resolved = getResolvedRegistry(db, env);
+ const resolved = await getResolvedRegistry(db, env);
try {
try {
const liveHost = resolved.OV_LIVE_URL.value ? new URL(resolved.OV_LIVE_URL.value).hostname : null;
const liveHost = resolved.OV_LIVE_URL.value ? new URL(resolved.OV_LIVE_URL.value).hostname : null;
const whipHost = resolved.WHIP_PUBLIC_URL.value ? new URL(resolved.WHIP_PUBLIC_URL.value).hostname : null;
const whipHost = resolved.WHIP_PUBLIC_URL.value ? new URL(resolved.WHIP_PUBLIC_URL.value).hostname : null;
@@ -104,8 +88,8 @@ function getRegistryWarnings(db, env = process.env) {
@@ -104,8 +88,8 @@ function getRegistryWarnings(db, env = process.env) {
return warnings;
return warnings;
}
}
-function loadRegistryValuesBySource(db, source) {
- const rows = db.prepare("SELECT key, value FROM url_registry WHERE source = ? AND value IS NOT NULL AND value != ''").all(source);
+async function loadRegistryValuesBySource(db, source) {
+ const rows = await db.prepare("SELECT key, value FROM url_registry WHERE source = ? AND value IS NOT NULL AND value != ''").all(source);
return rows.reduce((map, row) => {
return rows.reduce((map, row) => {
const def = URL_DEFINITIONS[row.key];
const def = URL_DEFINITIONS[row.key];
map[row.key] = def ? decodeRegistryValue(row.value, def.type) : row.value;
map[row.key] = def ? decodeRegistryValue(row.value, def.type) : row.value;
@@ -113,12 +97,12 @@ function loadRegistryValuesBySource(db, source) {
@@ -113,12 +97,12 @@ function loadRegistryValuesBySource(db, source) {
}, {});
}, {});
}
}
-function loadOverrides(db) {
- return loadRegistryValuesBySource(db, 'admin');
+async function loadOverrides(db) {
+ return await loadRegistryValuesBySource(db, 'admin');
}
}
-function loadBootstrapValues(db) {
- return loadRegistryValuesBySource(db, 'bootstrap');
+async function loadBootstrapValues(db) {
+ return await loadRegistryValuesBySource(db, 'bootstrap');
}
}
function formatEntry(row) {
function formatEntry(row) {
@@ -139,13 +123,13 @@ function formatEntry(row) {
@@ -139,13 +123,13 @@ function formatEntry(row) {
};
};
}
}
-function getResolvedRegistry(db, env = process.env) {
- const overrides = loadOverrides(db);
- const bootstrap = loadBootstrapValues(db);
+async function getResolvedRegistry(db, env = process.env) {
+ const overrides = await loadOverrides(db);
+ const bootstrap = await loadBootstrapValues(db);
return resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
return resolveRegistryValues(env, overrides, bootstrap, URL_DEFINITIONS);
}
}
-function setRegistryEntry(db, key, value, updatedBy = null) {
+async function setRegistryEntry(db, key, value, updatedBy = null) {
if (!URL_DEFINITIONS[key]) {
if (!URL_DEFINITIONS[key]) {
throw new Error(`Unknown URL registry key: ${key}`);
throw new Error(`Unknown URL registry key: ${key}`);
}
}
@@ -154,28 +138,28 @@ function setRegistryEntry(db, key, value, updatedBy = null) {
@@ -154,28 +138,28 @@ function setRegistryEntry(db, key, value, updatedBy = null) {
if (encoded == null) {
if (encoded == null) {
throw new Error(`Invalid value for ${key}`);
throw new Error(`Invalid value for ${key}`);
}
}
- db.prepare(`
+ await db.prepare(`
INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_by, updated_at)
INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_by, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'admin', ?, CURRENT_TIMESTAMP)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'admin', ?, ov_now())
ON CONFLICT(key) DO UPDATE SET
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
value = excluded.value,
source = 'admin',
source = 'admin',
updated_by = excluded.updated_by,
updated_by = excluded.updated_by,
updated_at = excluded.updated_at
updated_at = excluded.updated_at
`).run(entry.key, entry.label, entry.category, entry.service, entry.scope, entry.type, encoded, entry.description, updatedBy);
`).run(entry.key, entry.label, entry.category, entry.service, entry.scope, entry.type, encoded, entry.description, updatedBy);
- return formatEntry(getRegistryRow(db, key));
+ return formatEntry(await getRegistryRow(db, key));
}
}
-function resetRegistryEntry(db, key) {
+async function resetRegistryEntry(db, key) {
if (!URL_DEFINITIONS[key]) {
if (!URL_DEFINITIONS[key]) {
throw new Error(`Unknown URL registry key: ${key}`);
throw new Error(`Unknown URL registry key: ${key}`);
}
}
- db.prepare('DELETE FROM url_registry WHERE key = ?').run(key);
+ await db.prepare('DELETE FROM url_registry WHERE key = ?').run(key);
return URL_DEFINITIONS[key];
return URL_DEFINITIONS[key];
}
}
-function isRegistrySeeded(db) {
- const rowCount = db.prepare('SELECT COUNT(*) AS cnt FROM url_registry').get().cnt;
+async function isRegistrySeeded(db) {
+ const rowCount = (await db.prepare('SELECT COUNT(*) AS cnt FROM url_registry').get()).cnt;
return rowCount > 0;
return rowCount > 0;
}
}
@@ -195,24 +179,22 @@ function buildBootstrapOverrides(env = {}, profile = 'local-dev') {
@@ -195,24 +179,22 @@ function buildBootstrapOverrides(env = {}, profile = 'local-dev') {
return bootstrap;
return bootstrap;
}
}
-function seedBootstrapRegistry(db, env = process.env, profile = 'local-dev') {
+async function seedBootstrapRegistry(db, env = process.env, profile = 'local-dev') {
const bootstrap = buildBootstrapOverrides(env, profile);
const bootstrap = buildBootstrapOverrides(env, profile);
- const insert = db.prepare(`
- INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bootstrap', CURRENT_TIMESTAMP)
- ON CONFLICT(key) DO NOTHING
- `);
- const exists = db.prepare('SELECT 1 FROM url_registry WHERE key = ? LIMIT 1');
-
- const tx = db.transaction(() => {
+ await db.tx(async (t) => {
+ const insert = t.prepare(`
+ INSERT INTO url_registry (key, label, category, service, scope, type, value, description, source, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bootstrap', ov_now())
+ ON CONFLICT(key) DO NOTHING
+ `);
+ const exists = t.prepare('SELECT 1 FROM url_registry WHERE key = ? LIMIT 1');
for (const [key, def] of Object.entries(URL_DEFINITIONS)) {
for (const [key, def] of Object.entries(URL_DEFINITIONS)) {
- if (exists.get(def.key)) continue;
+ if (await exists.get(def.key)) continue;
const encoded = encodeRegistryValue(bootstrap[key], def.type);
const encoded = encodeRegistryValue(bootstrap[key], def.type);
if (encoded == null) continue;
if (encoded == null) continue;
- insert.run(def.key, def.label, def.category, def.service, def.scope, def.type, encoded, def.description || '');
+ await insert.run(def.key, def.label, def.category, def.service, def.scope, def.type, encoded, def.description || '');
}
}
});
});
- tx();
}
}
module.exports = {
module.exports = {
@@ -19,42 +19,43 @@ const cookieParser = require('cookie-parser');
@@ -19,42 +19,43 @@ const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const bcrypt = require('bcryptjs');
const { validate, serviceAuth } = require('openvibe-contracts');
const { validate, serviceAuth } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const wallet = require('../server/coins/wallet');
const wallet = require('../server/coins/wallet');
const accountMerge = require('../server/identity/account-merge');
const accountMerge = require('../server/identity/account-merge');
const accountData = require('../server/identity/account-data');
const accountData = require('../server/identity/account-data');
const zip = require('../server/utils/zip');
const zip = require('../server/utils/zip');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-account-data-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-account-data-'));
const exportDir = path.join(dir, 'account-exports');
const exportDir = path.join(dir, 'account-exports');
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
// The five services that keep data about people are registered clients (production has them all).
// The five services that keep data about people are registered clients (production has them all).
-for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);
+for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
-principals.ensureSchema(db);
+await principals.ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
-accountMerge.ensureSchema(db);
-accountData.ensureSchema(db);
+await accountMerge.ensureSchema(db);
+await accountData.ensureSchema(db);
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
process.env.OWNER_USERNAME = 'boss';
process.env.OWNER_USERNAME = 'boss';
const hash = bcrypt.hashSync('secret12', 4);
const hash = bcrypt.hashSync('secret12', 4);
-const mk = (name, role = 'user') => {
- const id = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(name, `${name}@example.com`, hash, role).lastInsertRowid;
- subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+const mk = async (name, role = 'user') => {
+ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?) RETURNING id').run(name, `${name}@example.com`, hash, role)).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
};
};
const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',
const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',
...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
// A service token as Network issues it: `cap` holds only what principal_grants gives that service.
// A service token as Network issues it: `cap` holds only what principal_grants gives that service.
const svc = (name, cap = ['network.account.export.contribute', 'network.account.deletion.confirm']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'],
const svc = (name, cap = ['network.account.export.contribute', 'network.account.deletion.confirm']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'],
cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
-const events = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === type);
+const events = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === type);
const authRoutes = require('../server/auth/routes');
const authRoutes = require('../server/auth/routes');
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
@@ -79,26 +80,26 @@ const server = http.createServer(app);
@@ -79,26 +80,26 @@ const server = http.createServer(app);
.then((r) => { try { r.body = JSON.parse(r.buf.toString('utf8')); } catch { r.body = {}; } return r; });
.then((r) => { try { r.body = JSON.parse(r.buf.toString('utf8')); } catch { r.body = {}; } return r; });
const settle = () => new Promise((r) => setImmediate(r));
const settle = () => new Promise((r) => setImmediate(r));
try {
try {
- const dana = mk('dana'); const eve = mk('eve'); const x = mk('xavier'); const boss = mk('boss', 'admin');
- db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-dana')").run(dana.id);
- db.prepare("INSERT INTO user_sessions (user_id, session_token, device_name, is_active, expires_at) VALUES (?, 'sess-secret-dana', 'phone', 1, datetime('now', '+1 day'))").run(dana.id);
- db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-secret-dana', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(dana.id);
- wallet.credit(db, { user_id: dana.id, app_id: 'live', amount: 40, reason: 'chat', idempotency_key: 'd-1' });
- require('../server/identity/modules').write(db, dana.subject_id, 'live.profile', { is_streamer: true, followers: 2 }, { writer: { type: 'service', id: 'live' } });
+ const dana = await mk('dana'); const eve = await mk('eve'); const x = await mk('xavier'); const boss = await mk('boss', 'admin');
+ await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-dana')").run(dana.id);
+ await db.prepare("INSERT INTO user_sessions (user_id, session_token, device_name, is_active, expires_at) VALUES (?, 'sess-secret-dana', 'phone', 1, datetime('now', '+1 day'))").run(dana.id);
+ await db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-secret-dana', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(dana.id);
+ await wallet.credit(db, { user_id: dana.id, app_id: 'live', amount: 40, reason: 'chat', idempotency_key: 'd-1' });
+ await require('../server/identity/modules').write(db, dana.subject_id, 'live.profile', { is_streamer: true, followers: 2 }, { writer: { type: 'service', id: 'live' } });
const follows = require('../server/identity/follows');
const follows = require('../server/identity/follows');
- follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });
- follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });
- db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);
+ await follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });
+ await follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });
+ await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);
// ── Export ──
// ── Export ──
- const expected = accountData.expectedServices(db, 'network.account.export.contribute');
+ const expected = await accountData.expectedServices(db, 'network.account.export.contribute');
assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');
assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');
let r = await call('POST', '/api/v1/account/export', tok(dana));
let r = await call('POST', '/api/v1/account/export', tok(dana));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));
assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));
const exp = r.body.export_id;
const exp = r.body.export_id;
assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), expected.map((s) => `${s}:waiting`));
assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), expected.map((s) => `${s}:waiting`));
- const req = events('network.account.export_requested');
+ const req = await events('network.account.export_requested');
assert.strictEqual(req.length, 1);
assert.strictEqual(req.length, 1);
assert.deepStrictEqual([req[0].payload.export_id, req[0].payload.subject, req[0].visibility], [exp, dana.subject_id, 'internal']);
assert.deepStrictEqual([req[0].payload.export_id, req[0].payload.subject, req[0].visibility], [exp, dana.subject_id, 'internal']);
r = await call('POST', '/api/v1/account/export', tok(dana));
r = await call('POST', '/api/v1/account/export', tok(dana));
@@ -122,13 +123,13 @@ const server = http.createServer(app);
@@ -122,13 +123,13 @@ const server = http.createServer(app);
r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [{ message: 'hello' }] }]));
r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [{ message: 'hello' }] }]));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
await settle();
await settle();
- assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp).status, 'pending', 'still waiting for three services');
+ assert.strictEqual((await db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp)).status, 'pending', 'still waiting for three services');
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
assert.deepStrictEqual([r.status, r.body.error], [409, 'export.pending']);
assert.deepStrictEqual([r.status, r.body.error], [409, 'export.pending']);
// The deadline passes: the archive is built without the silent services, and says so.
// The deadline passes: the archive is built without the silent services, and says so.
- const deadline = Date.parse(db.prepare('SELECT deadline FROM account_exports WHERE id = ?').get(exp).deadline);
- assert.deepStrictEqual(accountData.sweep(db, { dir: exportDir, now: deadline + 1000, notify: (u, n) => notified.push([u, n.title]) }), { built: 1, expired: 0, deleted: 0 });
+ const deadline = Date.parse((await db.prepare('SELECT deadline FROM account_exports WHERE id = ?').get(exp)).deadline);
+ assert.deepStrictEqual(await accountData.sweep(db, { dir: exportDir, now: deadline + 1000, notify: (u, n) => notified.push([u, n.title]) }), { built: 1, expired: 0, deleted: 0 });
r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));
r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));
assert.strictEqual(r.body.status, 'partial');
assert.strictEqual(r.body.status, 'partial');
assert.ok(validate('network.account-export@1', r.body).valid);
assert.ok(validate('network.account-export@1', r.body).valid);
@@ -157,17 +158,17 @@ const server = http.createServer(app);
@@ -157,17 +158,17 @@ const server = http.createServer(app);
assert.ok(Number(r.headers.get('retry-after')) > 0);
assert.ok(Number(r.headers.get('retry-after')) > 0);
// Every expected service answers: ready at once.
// Every expected service answers: ready at once.
- for (const s of ['community', 'games', 'media']) db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);
+ for (const s of ['community', 'games', 'media']) await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);
r = await call('POST', '/api/v1/account/export', tok(eve));
r = await call('POST', '/api/v1/account/export', tok(eve));
const exp2 = r.body.export_id;
const exp2 = r.body.export_id;
await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));
await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));
await call('POST', `/internal/account-exports/${exp2}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));
await call('POST', `/internal/account-exports/${exp2}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));
await settle(); await settle();
await settle(); await settle();
- assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp2).status, 'ready');
+ assert.strictEqual((await db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp2)).status, 'ready');
// After 7 days the archive is gone.
// After 7 days the archive is gone.
- const expires = Date.parse(db.prepare('SELECT expires_at FROM account_exports WHERE id = ?').get(exp).expires_at);
- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: expires + 1000 }).expired, 2);
+ const expires = Date.parse((await db.prepare('SELECT expires_at FROM account_exports WHERE id = ?').get(exp)).expires_at);
+ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: expires + 1000 })).expired, 2);
assert.ok(!fs.existsSync(path.join(exportDir, `${exp}.zip`)));
assert.ok(!fs.existsSync(path.join(exportDir, `${exp}.zip`)));
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
assert.deepStrictEqual([r.status, r.body.error], [410, 'export.expired']);
assert.deepStrictEqual([r.status, r.body.error], [410, 'export.expired']);
@@ -192,37 +193,37 @@ const server = http.createServer(app);
@@ -192,37 +193,37 @@ const server = http.createServer(app);
r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });
r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });
const del = r.body.deletion_id;
const del = r.body.deletion_id;
const due = Date.parse(r.body.delete_after);
const due = Date.parse(r.body.delete_after);
- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due - 60000 }).deleted, 0, 'nothing before the date');
- assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id).username, 'dana');
+ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due - 60000 })).deleted, 0, 'nothing before the date');
+ assert.strictEqual((await db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id)).username, 'dana');
// dana had folded an older account in (ADR-029): its subject goes with her.
// dana had folded an older account in (ADR-029): its subject goes with her.
- const old = mk('dana_old');
- accountMerge.merge(db, old, db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
+ const old = await mk('dana_old');
+ await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
const oldToken = tok(dana);
const oldToken = tok(dana);
- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 1000 }).deleted, 1);
- const gone = db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
+ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
+ const gone = await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
assert.match(gone.username, /^deleted-/);
assert.match(gone.username, /^deleted-/);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
- assert.match(db.prepare('SELECT username FROM users WHERE id = ?').get(old.id).username, /^deleted-/, 'the merged-in account too');
+ assert.match((await db.prepare('SELECT username FROM users WHERE id = ?').get(old.id)).username, /^deleted-/, 'the merged-in account too');
for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id']]) {
for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id']]) {
- assert.strictEqual(db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id).n, 0, `${t} erased`);
+ assert.strictEqual((await db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id)).n, 0, `${t} erased`);
}
}
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id).n, 0);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id).n, 0, 'follows both ways');
- assert.strictEqual(wallet.getBalance ? wallet.getBalance(db, dana.id) : db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id).balance, 0);
- assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id).n, 1, 'one closing entry; the ledger stays');
- assert.ok(mk('dana'), 'the username is released');
- const p = subjects.resolve(db, { subject_id: dana.subject_id });
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id)).n, 0);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id)).n, 0, 'follows both ways');
+ assert.strictEqual(wallet.getBalance ? await wallet.getBalance(db, dana.id) : (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id)).balance, 0);
+ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id)).n, 1, 'one closing entry; the ledger stays');
+ assert.ok(await mk('dana'), 'the username is released');
+ const p = await subjects.resolve(db, { subject_id: dana.subject_id });
assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);
assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);
- assert.strictEqual(subjects.resolve(db, { subject_id: old.subject_id }).deleted, true, 'the alias resolves as deleted too');
+ assert.strictEqual((await subjects.resolve(db, { subject_id: old.subject_id })).deleted, true, 'the alias resolves as deleted too');
r = await call('GET', '/api/v1/account/deletion', oldToken);
r = await call('GET', '/api/v1/account/deletion', oldToken);
assert.strictEqual(r.status, 401, 'old tokens are refused');
assert.strictEqual(r.status, 401, 'old tokens are refused');
- const ev = events('network.account.deleted');
+ const ev = await events('network.account.deleted');
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.ok(validate('network.account.deleted@1', ev[0].payload).valid);
assert.ok(validate('network.account.deleted@1', ev[0].payload).valid);
assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
- assert.ok(events('network.user.token_valid_after').some((e) => e.payload.reason === 'account_deleted'));
- assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 5000 }).deleted, 0, 'once');
+ assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));
+ assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');
// Services confirm; staff see what is outstanding.
// Services confirm; staff see what is outstanding.
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
@@ -241,15 +242,31 @@ const server = http.createServer(app);
@@ -241,15 +242,31 @@ const server = http.createServer(app);
// Staff cancel a scheduled deletion only with a reason.
// Staff cancel a scheduled deletion only with a reason.
await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });
await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });
- const eveDel = db.prepare("SELECT id FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(eve.id).id;
+ const eveDel = (await db.prepare("SELECT id FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(eve.id)).id;
r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'no' });
r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'no' });
assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.reason_required']);
assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.reason_required']);
r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'the person wrote to support and changed their mind' });
r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'the person wrote to support and changed their mind' });
assert.strictEqual(r.body.status, 'cancelled');
assert.strictEqual(r.body.status, 'cancelled');
- assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'account_deletion_cancelled'").get());
+ assert.ok(await db.prepare("SELECT 1 FROM audit_log WHERE action = 'account_deletion_cancelled'").get());
+
+ // ── Concurrent double-spend of one wallet: exactly one debit wins (decision 1, plan T2) ──
+ // On PostgreSQL under READ COMMITTED the balance check moves into the conditional UPDATE, so two
+ // spends of one wallet race safely; on SQLite the single writer hid this.
+ const spender = await mk('spender');
+ await wallet.credit(db, { user_id: spender.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'sp-1' });
+ const spends = await Promise.allSettled([
+ wallet.debit(db, { user_id: spender.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'sp-2' }),
+ wallet.debit(db, { user_id: spender.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'sp-3' }),
+ ]);
+ assert.strictEqual(spends.filter((s) => s.status === 'fulfilled').length, 1, 'one of two concurrent spends of one wallet wins');
+ assert.strictEqual(await wallet.getBalance(db, spender.id), 0, 'the wallet was spent once, never twice');
+ const losers = spends.filter((s) => s.status === 'rejected');
+ assert.strictEqual(losers.length, 1);
+ assert.strictEqual(losers[0].reason.status, 409, 'the loser is insufficient_funds, not a crash');
} finally {
} finally {
server.close();
server.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log('account export and deletion: all checks passed');
console.log('account export and deletion: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -22,33 +22,34 @@ const cookieParser = require('cookie-parser');
@@ -22,33 +22,34 @@ const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const bcrypt = require('bcryptjs');
const { validate, ids } = require('openvibe-contracts');
const { validate, ids } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const wallet = require('../server/coins/wallet');
const wallet = require('../server/coins/wallet');
const accountMerge = require('../server/identity/account-merge');
const accountMerge = require('../server/identity/account-merge');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-merge-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-merge-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-require('../server/identity/principals').ensureSchema(db);
+await require('../server/identity/principals').ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
-accountMerge.ensureSchema(db);
+await accountMerge.ensureSchema(db);
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
process.env.OWNER_USERNAME = 'boss';
process.env.OWNER_USERNAME = 'boss';
const hash = bcrypt.hashSync('secret12', 4);
const hash = bcrypt.hashSync('secret12', 4);
-const mk = (name, role = 'user') => {
- const id = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)').run(name, hash, role).lastInsertRowid;
- subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+const mk = async (name, role = 'user') => {
+ const id = (await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(name, hash, role)).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
};
};
// A session token from 10 s ago (the revocation cutoff has second precision); authAgo: its sign-in time, or null for none.
// A session token from 10 s ago (the revocation cutoff has second precision); authAgo: its sign-in time, or null for none.
const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',
const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',
...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
-const events = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === type);
+const events = async (type) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === type);
const authRoutes = require('../server/auth/routes');
const authRoutes = require('../server/auth/routes');
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
@@ -67,29 +68,29 @@ const server = http.createServer(app);
@@ -67,29 +68,29 @@ const server = http.createServer(app);
const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
.then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
.then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
try {
try {
- const carol = mk('carol'); const carol2 = mk('carol_old');
- const x = mk('xavier'); const y = mk('yolanda'); const z = mk('zed');
+ const carol = await mk('carol'); const carol2 = await mk('carol_old');
+ const x = await mk('xavier'); const y = await mk('yolanda'); const z = await mk('zed');
// What the folded-in account has.
// What the folded-in account has.
- db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-carol'), (?, 'google', 'g-carol-old'), (?, 'discord', 'd-carol-old')").run(carol.id, carol2.id, carol2.id);
- db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-old', 1, datetime('now', '+1 day'))").run(carol2.id);
- db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-old', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(carol2.id);
- db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES ('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ?, 'bot', datetime('now'), ?)").run(carol2.subject_id, carol2.subject_id);
- wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });
- wallet.credit(db, { user_id: carol2.id, app_id: 'live', amount: 150, reason: 'chat', idempotency_key: 'c-2' });
+ await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-carol'), (?, 'google', 'g-carol-old'), (?, 'discord', 'd-carol-old')").run(carol.id, carol2.id, carol2.id);
+ await db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-old', 1, datetime('now', '+1 day'))").run(carol2.id);
+ await db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-old', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(carol2.id);
+ await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by) VALUES ('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ?, 'bot', datetime('now'), ?)").run(carol2.subject_id, carol2.subject_id);
+ await wallet.credit(db, { user_id: carol.id, app_id: 'live', amount: 50, reason: 'chat', idempotency_key: 'c-1' });
+ await wallet.credit(db, { user_id: carol2.id, app_id: 'live', amount: 150, reason: 'chat', idempotency_key: 'c-2' });
const modules = require('../server/identity/modules');
const modules = require('../server/identity/modules');
const live = { type: 'service', id: 'live' };
const live = { type: 'service', id: 'live' };
- const w = (s, n, d) => { const o = modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };
- w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });
- w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });
- w(carol2.subject_id, 'live.stats', { streams_30d: 4 });
+ const w = async (s, n, d) => { const o = await modules.write(db, s, n, d, { writer: live }); assert.ok(!o.status || o.status < 300, JSON.stringify(o)); };
+ await w(carol.subject_id, 'live.profile', { is_streamer: true, followers: 12 });
+ await w(carol2.subject_id, 'live.profile', { is_streamer: false, followers: 3, channel_url: 'https://openvibe.live/@carol_old' });
+ await w(carol2.subject_id, 'live.stats', { streams_30d: 4 });
const follows = require('../server/identity/follows');
const follows = require('../server/identity/follows');
- follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });
- follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already
- follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });
- follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel
- follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself
- db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);
- db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);
+ await follows.setFollow(db, carol.subject_id, 'channel', x.subject_id, true, { emit: false });
+ await follows.setFollow(db, carol2.subject_id, 'channel', x.subject_id, true, { emit: false }); // clash: carol follows x already
+ await follows.setFollow(db, carol2.subject_id, 'channel', y.subject_id, true, { emit: false });
+ await follows.setFollow(db, z.subject_id, 'channel', carol2.subject_id, true, { emit: false }); // z follows the folded-in channel
+ await follows.setFollow(db, carol2.subject_id, 'channel', carol.subject_id, true, { emit: false }); // would become following oneself
+ await db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en'), (?, 'pt')").run(carol.id, carol2.id);
+ await db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) VALUES ('live', 'user', '70', ?)").run(carol2.subject_id);
const oldCarol2 = tok(carol2);
const oldCarol2 = tok(carol2);
const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
const sameSecond = jwt.sign({ sub: carol2.id, id: carol2.id, subject_id: carol2.subject_id, username: carol2.username, role: 'user', auth_time: Math.floor(Date.now() / 1000) }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
@@ -106,7 +107,7 @@ const server = http.createServer(app);
@@ -106,7 +107,7 @@ const server = http.createServer(app);
assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired']);
assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired']);
r = await call('POST', '/api/v1/account/merge', tok(carol), { intent });
r = await call('POST', '/api/v1/account/merge', tok(carol), { intent });
assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.same_account']);
assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.same_account']);
- const expired = accountMerge.createIntent(db, carol, { now: Date.now() - 11 * 60 * 1000 });
+ const expired = await accountMerge.createIntent(db, carol, { now: Date.now() - 11 * 60 * 1000 });
r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: expired.intent });
r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent: expired.intent });
assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired'], 'an intent lives 10 minutes');
assert.deepStrictEqual([r.status, r.body.error], [410, 'merge.intent_expired'], 'an intent lives 10 minutes');
@@ -119,39 +120,39 @@ const server = http.createServer(app);
@@ -119,39 +120,39 @@ const server = http.createServer(app);
assert.strictEqual(Date.parse(r.body.split_until) - Date.parse(r.body.merged_at), 30 * 86400000);
assert.strictEqual(Date.parse(r.body.split_until) - Date.parse(r.body.merged_at), 30 * 86400000);
// Resolution: either subject, and the old legacy id, answer the survivor.
// Resolution: either subject, and the old legacy id, answer the survivor.
- let res = subjects.resolve(db, { subject_id: carol2.subject_id });
+ let res = await subjects.resolve(db, { subject_id: carol2.subject_id });
assert.deepStrictEqual([res.subject.id, res.merged_from, res.username], [carol.subject_id, carol2.subject_id, 'carol']);
assert.deepStrictEqual([res.subject.id, res.merged_from, res.username], [carol.subject_id, carol2.subject_id, 'carol']);
- res = subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '70' });
+ res = await subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '70' });
assert.deepStrictEqual([res.subject.id, res.merged_from], [carol.subject_id, carol2.subject_id], 'the legacy map is not repointed, the alias answers');
assert.deepStrictEqual([res.subject.id, res.merged_from], [carol.subject_id, carol2.subject_id], 'the legacy map is not repointed, the alias answers');
- assert.strictEqual(db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get().subject_id, carol2.subject_id);
- assert.strictEqual(subjects.resolve(db, { subject_id: carol.subject_id }).merged_from, undefined);
+ assert.strictEqual((await db.prepare("SELECT subject_id FROM identity_legacy_map WHERE source_id = '70'").get()).subject_id, carol2.subject_id);
+ assert.strictEqual((await subjects.resolve(db, { subject_id: carol.subject_id })).merged_from, undefined);
// Coins: summed, one ledger entry per side.
// Coins: summed, one ledger entry per side.
- assert.deepStrictEqual([wallet.getBalance(db, carol.id), wallet.getBalance(db, carol2.id)], [200, 0]);
- const ledger = db.prepare("SELECT user_id, delta FROM coin_transactions WHERE reason = 'account_merge' ORDER BY id").all();
+ assert.deepStrictEqual([await wallet.getBalance(db, carol.id), await wallet.getBalance(db, carol2.id)], [200, 0]);
+ const ledger = await db.prepare("SELECT user_id, delta FROM coin_transactions WHERE reason = 'account_merge' ORDER BY id").all();
assert.deepStrictEqual(ledger.map((l) => [l.user_id, l.delta]), [[carol2.id, -150], [carol.id, 150]]);
assert.deepStrictEqual(ledger.map((l) => [l.user_id, l.delta]), [[carol2.id, -150], [carol.id, 150]]);
// Modules: the survivor's fields win; the other fills only what it lacks; a record only it had moves.
// Modules: the survivor's fields win; the other fills only what it lacks; a record only it had moves.
- assert.deepStrictEqual(modules.read(db, carol.subject_id, 'live.profile').data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });
- assert.deepStrictEqual(modules.read(db, carol.subject_id, 'live.stats').data, { streams_30d: 4 });
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id).n, 0);
- const modEv = events('network.module.updated').filter((e) => e.payload.reason === 'subject_merged');
+ assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.profile')).data, { is_streamer: true, followers: 12, channel_url: 'https://openvibe.live/@carol_old' });
+ assert.deepStrictEqual((await modules.read(db, carol.subject_id, 'live.stats')).data, { streams_30d: 4 });
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(carol2.subject_id)).n, 0);
+ const modEv = (await events('network.module.updated')).filter((e) => e.payload.reason === 'subject_merged');
assert.ok(modEv.some((e) => e.payload.merged_from && e.payload.change === 'updated' && e.payload.keys.includes('channel_url')), 'the filled record is announced');
assert.ok(modEv.some((e) => e.payload.merged_from && e.payload.change === 'updated' && e.payload.keys.includes('channel_url')), 'the filled record is announced');
// Follows: moved, the clash and the self-follow dropped, the folded channel's follower now follows the survivor.
// Follows: moved, the clash and the self-follow dropped, the folded channel's follower now follows the survivor.
- const f = db.prepare('SELECT follower_subject AS a, target_id AS b FROM user_follows ORDER BY a, b').all().map((x2) => `${x2.a === carol.subject_id ? 'carol' : x2.a === z.subject_id ? 'zed' : x2.a}>${x2.b === x.subject_id ? 'x' : x2.b === y.subject_id ? 'y' : x2.b === carol.subject_id ? 'carol' : x2.b}`).sort();
+ const f = (await db.prepare('SELECT follower_subject AS a, target_id AS b FROM user_follows ORDER BY a, b').all()).map((x2) => `${x2.a === carol.subject_id ? 'carol' : x2.a === z.subject_id ? 'zed' : x2.a}>${x2.b === x.subject_id ? 'x' : x2.b === y.subject_id ? 'y' : x2.b === carol.subject_id ? 'carol' : x2.b}`).sort();
assert.deepStrictEqual(f, ['carol>x', 'carol>y', 'zed>carol']);
assert.deepStrictEqual(f, ['carol>x', 'carol>y', 'zed>carol']);
// Providers: google clashed and stays on the folded-in account (signing in with it lands on the survivor); discord moved.
// Providers: google clashed and stays on the folded-in account (signing in with it lands on the survivor); discord moved.
- assert.deepStrictEqual(db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id).map((l) => l.service), ['discord', 'google']);
- assert.strictEqual(db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get().user_id, carol2.id);
- assert.strictEqual(db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get().user_id, carol.id);
- assert.strictEqual(db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get().owner_subject, carol.subject_id);
- assert.deepStrictEqual(db.prepare('SELECT user_id, language FROM user_preferences ORDER BY user_id').all().map((p) => [p.user_id, p.language]), [[carol.id, 'en']], 'the survivor keeps its preferences');
+ assert.deepStrictEqual((await db.prepare('SELECT service FROM linked_accounts WHERE user_id = ? ORDER BY service').all(carol.id)).map((l) => l.service), ['discord', 'google']);
+ assert.strictEqual((await db.prepare("SELECT user_id FROM linked_accounts WHERE service_user_id = 'g-carol-old'").get()).user_id, carol2.id);
+ assert.strictEqual((await db.prepare("SELECT user_id FROM user_sessions WHERE session_token = 'sess-old'").get()).user_id, carol.id);
+ assert.strictEqual((await db.prepare("SELECT owner_subject FROM dev_projects WHERE id = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'").get()).owner_subject, carol.subject_id);
+ assert.deepStrictEqual((await db.prepare('SELECT user_id, language FROM user_preferences ORDER BY user_id').all()).map((p) => [p.user_id, p.language]), [[carol.id, 'en']], 'the survivor keeps its preferences');
// Events: network.subject.merged, and the folded-in account's tokens end (account_merged).
// Events: network.subject.merged, and the folded-in account's tokens end (account_merged).
- const merged = events('network.subject.merged');
+ const merged = await events('network.subject.merged');
assert.strictEqual(merged.length, 1);
assert.strictEqual(merged.length, 1);
assert.ok(validate('events.event-envelope@1', merged[0]).valid && validate('network.subject.merged@1', merged[0].payload).valid);
assert.ok(validate('events.event-envelope@1', merged[0]).valid && validate('network.subject.merged@1', merged[0].payload).valid);
assert.deepStrictEqual([merged[0].payload.from, merged[0].payload.into, merged[0].payload.initiated_by, merged[0].visibility], [carol2.subject_id, carol.subject_id, 'person', 'internal']);
assert.deepStrictEqual([merged[0].payload.from, merged[0].payload.into, merged[0].payload.initiated_by, merged[0].visibility], [carol2.subject_id, carol.subject_id, 'person', 'internal']);
- const revoked = events('network.user.token_valid_after').filter((e) => e.subject.id === carol2.subject_id);
+ const revoked = (await events('network.user.token_valid_after')).filter((e) => e.subject.id === carol2.subject_id);
assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);
assert.deepStrictEqual(revoked.map((e) => e.payload.reason), ['account_merged']);
assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");
assert.strictEqual((await call('GET', '/api/auth/me', oldCarol2)).status, 401, "the folded-in account's old token is refused");
assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');
assert.strictEqual((await call('GET', '/api/auth/me', sameSecond)).status, 401, 'even one minted in the second of the merge (strict cutoff; merged accounts have no tokens)');
@@ -165,25 +166,25 @@ const server = http.createServer(app);
@@ -165,25 +166,25 @@ const server = http.createServer(app);
// (401); the survivor lists the merge instead.
// (401); the survivor lists the merge instead.
r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });
r = await call('POST', '/api/v1/account/merge', tok(carol2), { intent });
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
- const again = accountMerge.merge(db, db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), carol);
+ const again = await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), carol);
assert.deepStrictEqual([again.replayed, again.merge_id], [true, merged[0].payload.merge_id]);
assert.deepStrictEqual([again.replayed, again.merge_id], [true, merged[0].payload.merge_id]);
r = await call('GET', '/api/v1/account/merges', tok(carol));
r = await call('GET', '/api/v1/account/merges', tok(carol));
assert.deepStrictEqual(r.body.merges.map((m) => m.merge_id), [merged[0].payload.merge_id]);
assert.deepStrictEqual(r.body.merges.map((m) => m.merge_id), [merged[0].payload.merge_id]);
- assert.strictEqual(wallet.getBalance(db, carol.id), 200);
- assert.strictEqual(events('network.subject.merged').length, 1);
- assert.throws(() => accountMerge.merge(db, db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');
+ assert.strictEqual(await wallet.getBalance(db, carol.id), 200);
+ assert.strictEqual((await events('network.subject.merged')).length, 1);
+ await assert.rejects(async () => await accountMerge.merge(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(carol2.id), x), (e) => e.code === 'merge.already_merged');
// ── Staff (account recovery) ──
// ── Staff (account recovery) ──
- const admin = mk('adminy', 'admin'); const boss = mk('boss', 'admin');
- const d1 = mk('dora'); const d2 = mk('dora_lost');
+ const admin = await mk('adminy', 'admin'); const boss = await mk('boss', 'admin');
+ const d1 = await mk('dora'); const d2 = await mk('dora_lost');
r = await call('POST', '/api/admin/account-merges', tok(admin), { from: 'dora_lost', into: 'dora', reason: 'lost her password, verified by email' });
r = await call('POST', '/api/admin/account-merges', tok(admin), { from: 'dora_lost', into: 'dora', reason: 'lost her password, verified by email' });
assert.strictEqual(r.status, 403, 'an admin is not the owner');
assert.strictEqual(r.status, 403, 'an admin is not the owner');
r = await call('POST', '/api/admin/account-merges', tok(boss), { from: 'dora_lost', into: 'dora', reason: 'short' });
r = await call('POST', '/api/admin/account-merges', tok(boss), { from: 'dora_lost', into: 'dora', reason: 'short' });
assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.reason_required']);
assert.deepStrictEqual([r.status, r.body.error], [400, 'merge.reason_required']);
r = await call('POST', '/api/admin/account-merges', tok(boss), { from: d2.subject_id, into: 'dora', reason: 'lost her password, verified by email' });
r = await call('POST', '/api/admin/account-merges', tok(boss), { from: d2.subject_id, into: 'dora', reason: 'lost her password, verified by email' });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.strictEqual(events('network.subject.merged').pop().payload.initiated_by, 'staff');
- const audit = db.prepare("SELECT details FROM audit_log WHERE action = 'account_merge'").all().map((a) => JSON.parse(a.details));
+ assert.strictEqual((await events('network.subject.merged')).pop().payload.initiated_by, 'staff');
+ const audit = (await db.prepare("SELECT details FROM audit_log WHERE action = 'account_merge'").all()).map((a) => JSON.parse(a.details));
assert.deepStrictEqual([audit.length, audit[0].from, audit[0].reason], [1, d2.subject_id, 'lost her password, verified by email']);
assert.deepStrictEqual([audit.length, audit[0].from, audit[0].reason], [1, d2.subject_id, 'lost her password, verified by email']);
r = await call('GET', '/api/admin/account-merges', tok(boss));
r = await call('GET', '/api/admin/account-merges', tok(boss));
assert.strictEqual(r.body.merges.length, 2);
assert.strictEqual(r.body.merges.length, 2);
@@ -191,9 +192,9 @@ const server = http.createServer(app);
@@ -191,9 +192,9 @@ const server = http.createServer(app);
assert.ok(d1);
assert.ok(d1);
// ── 30 days later: only the alias facts remain ──
// ── 30 days later: only the alias facts remain ──
- assert.strictEqual(accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get().n, 0);
- assert.strictEqual(subjects.resolve(db, { subject_id: carol2.subject_id }).subject.id, carol.subject_id, 'the alias still answers');
+ assert.strictEqual(await accountMerge.reduceExpired(db, { now: Date.now() + 31 * 86400000 }), 2);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM account_merges WHERE pre_state IS NOT NULL').get()).n, 0);
+ assert.strictEqual((await subjects.resolve(db, { subject_id: carol2.subject_id })).subject.id, carol.subject_id, 'the alias still answers');
// auth_time: a renewal keeps the original sign-in time, a sign-in sets it.
// auth_time: a renewal keeps the original sign-in time, a sign-in sets it.
const renewed = jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: { auth_time: 1234 } }));
const renewed = jwt.decode(authRoutes.signToken(carol, keys.privateKey, config, { renew: { auth_time: 1234 } }));
@@ -207,3 +208,4 @@ const server = http.createServer(app);
@@ -207,3 +208,4 @@ const server = http.createServer(app);
}
}
console.log('account merge: all checks passed');
console.log('account merge: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -58,5 +58,27 @@ console.warn = () => {};
@@ -58,5 +58,27 @@ console.warn = () => {};
} finally {
} finally {
server.close();
server.close();
}
}
+
+ // ── A shared store that is down (Valkey unreachable) degrades to this process, never a 500 (decision 5) ──
+ let storeHits = 0;
+ const downApp = express();
+ downApp.use('/api/', createNetworkActorLimits({
+ env: { NETWORK_LIMITS_MINUTE: '2' }, publicKey: keys.publicKey, issuer: ISS, now: () => t,
+ store: { hit: async () => { storeHits++; throw new Error('valkey unreachable'); } },
+ }));
+ downApp.all('/api/*', (req, res) => res.json({ ok: true }));
+ const downServer = http.createServer(downApp);
+ await new Promise((r) => downServer.listen(0, '127.0.0.1', r));
+ const downBase = `http://127.0.0.1:${downServer.address().port}`;
+ try {
+ const dave = session(9, 'usr_01JAB2C3D4E5F6G7H8J9K0MNP9');
+ const hit = () => fetch(`${downBase}/api/v1/projects`, { method: 'POST', headers: { authorization: `Bearer ${dave}` } }).then((r) => r.status);
+ assert.strictEqual(await hit(), 200, 'a request with the store down is served, not a 500');
+ assert.strictEqual(await hit(), 200);
+ assert.strictEqual(await hit(), 429, 'the in-process counters still enforce the limit');
+ assert.ok(storeHits >= 3, 'the shared store was tried first, every time');
+ } finally {
+ downServer.close();
+ }
console.log('actor limits: all checks passed');
console.log('actor limits: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -10,25 +10,26 @@ const os = require('os');
@@ -10,25 +10,26 @@ const os = require('os');
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-admin-role-events-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-admin-role-events-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
const profile = require('../server/identity/profile-events');
const profile = require('../server/identity/profile-events');
-profile.ensureSchema(db);
-profile.drain(db);
-db.prepare('DELETE FROM network_event_outbox').run();
+await profile.ensureSchema(db);
+await profile.drain(db);
+await db.prepare('DELETE FROM network_event_outbox').run();
delete process.env.OWNER_USERNAME; // the default owner, 'goosely'
delete process.env.OWNER_USERNAME; // the default owner, 'goosely'
const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const MIA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const MIA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'goosely', 'x', ?, 'admin')").run(OWNER);
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (2, 'mia', 'x', ?, 'user')").run(MIA);
-profile.drain(db);
-db.prepare('DELETE FROM network_event_outbox').run();
-const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'goosely', 'x', ?, 'admin')").run(OWNER);
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (2, 'mia', 'x', ?, 'user')").run(MIA);
+await profile.drain(db);
+await db.prepare('DELETE FROM network_event_outbox').run();
+const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope)
.filter((e) => e.event_type === 'network.user.updated');
.filter((e) => e.event_type === 'network.user.updated');
// Any outbound call the admin routes make is recorded; none may go to Live's retired route.
// Any outbound call the admin routes make is recorded; none may go to Live's retired route.
@@ -37,7 +38,7 @@ const realFetch = global.fetch;
@@ -37,7 +38,7 @@ const realFetch = global.fetch;
global.fetch = (url, opts) => { outbound.push(String(url)); return Promise.resolve(new Response('{}', { status: 200 })); };
global.fetch = (url, opts) => { outbound.push(String(url)); return Promise.resolve(new Response('{}', { status: 200 })); };
const createAdminRoutes = require('../server/admin/routes');
const createAdminRoutes = require('../server/admin/routes');
-const owner = db.prepare('SELECT * FROM users WHERE id = 1').get();
+const owner = await db.prepare('SELECT * FROM users WHERE id = 1').get();
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use('/api/admin', createAdminRoutes(db, {}, {}, (req, res, next) => { req.user = owner; next(); }));
app.use('/api/admin', createAdminRoutes(db, {}, {}, (req, res, next) => { req.user = owner; next(); }));
@@ -48,9 +49,9 @@ const server = http.createServer(app);
@@ -48,9 +49,9 @@ const server = http.createServer(app);
const base = `http://127.0.0.1:${server.address().port}/api/admin`;
const base = `http://127.0.0.1:${server.address().port}/api/admin`;
const call = (method, p, body) => realFetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
const call = (method, p, body) => realFetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
.then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
.then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
- const roleEvent = (n, role) => {
- assert.strictEqual(profile.drain(db), 1, 'one event for the change');
- const ev = events();
+ const roleEvent = async (n, role) => {
+ assert.strictEqual(await profile.drain(db), 1, 'one event for the change');
+ const ev = await events();
assert.strictEqual(ev.length, n);
assert.strictEqual(ev.length, n);
const e = ev[n - 1];
const e = ev[n - 1];
assert.ok(validate('events.event-envelope@1', e).valid);
assert.ok(validate('events.event-envelope@1', e).valid);
@@ -60,13 +61,13 @@ const server = http.createServer(app);
@@ -60,13 +61,13 @@ const server = http.createServer(app);
try {
try {
let r = await call('PUT', '/users/2/role', { role: 'global_mod' });
let r = await call('PUT', '/users/2/role', { role: 'global_mod' });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- roleEvent(1, 'global_mod');
+ await roleEvent(1, 'global_mod');
r = await call('PUT', '/users/2/role', { role: 'user' });
r = await call('PUT', '/users/2/role', { role: 'user' });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- roleEvent(2, 'user'); // a downgrade too: Live applies it from the event
+ await roleEvent(2, 'user'); // a downgrade too: Live applies it from the event
r = await call('POST', '/users/grant-admin', { id: 2 });
r = await call('POST', '/users/grant-admin', { id: 2 });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- roleEvent(3, 'admin');
+ await roleEvent(3, 'admin');
assert.deepStrictEqual(outbound.filter((u) => /user-role/.test(u)), [], 'no push to Live\'s retired /internal/user-role');
assert.deepStrictEqual(outbound.filter((u) => /user-role/.test(u)), [], 'no push to Live\'s retired /internal/user-role');
const src = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
assert.ok(!/pushRoleToStreamer|fetch\([^)]*user-role/.test(src), 'the push is gone from the admin routes');
assert.ok(!/pushRoleToStreamer|fetch\([^)]*user-role/.test(src), 'the push is gone from the admin routes');
@@ -74,7 +75,7 @@ const server = http.createServer(app);
@@ -74,7 +75,7 @@ const server = http.createServer(app);
} finally {
} finally {
global.fetch = realFetch;
global.fetch = realFetch;
server.close();
server.close();
- db.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -1,32 +1,28 @@
@@ -1,32 +1,28 @@
'use strict';
'use strict';
-// ADR-021 analytics bounds on OpenVibe.Network (openvibe-shared/analytics wired by
-// server/analytics/network.js, scripts/analytics-prune.js):
+// ADR-021 analytics bounds on OpenVibe.Network (openvibe-shared/analytics/pg wired by
+// server/analytics/network.js; ADR-035): the tracker runs on the service's PostgreSQL handle.
// - Network's path options (paramPrefixes, pathRules) template its usernames, anon tokens and slugs;
// - Network's path options (paramPrefixes, pathRules) template its usernames, anon tokens and slugs;
// - real requests through Network's own routers (auth, OAuth, avatars, admin analytics) leave no IP,
// - real requests through Network's own routers (auth, OAuth, avatars, admin analytics) leave no IP,
// user id, city, raw user agent, full referer, query value or username in any analytics table;
// user id, city, raw user agent, full referer, query value or username in any analytics table;
// the session id is a rotating id, paths are route templates, every row is a valid analytics/event.v1;
// the session id is a rotating id, paths are route templates, every row is a valid analytics/event.v1;
// - a request with Sec-GPC: 1 or DNT: 1 is not recorded at all;
// - a request with Sec-GPC: 1 or DNT: 1 is not recorded at all;
-// - the tracker runs on its own connection to network.db: the identity connection keeps busy_timeout 5000;
// - the admin dashboards keep their shapes (bot rows carry ua_class / session_id, never an IP);
// - the admin dashboards keep their shapes (bot rows carry ua_class / session_id, never an IP);
-// - the analytics-prune job deletes raw rows older than 30 days and leaves rollups alone;
-// - the CLI's dry run changes nothing, --apply needs --backup <new file> or --no-backup, the backup is
-// owner-only, and a scrub touches analytics rows only.
+// - pruneRawEventsPg deletes raw rows older than 30 days and leaves rollups alone.
+//
+// The SQLite-only checks (the tracker's own connection with busy_timeout/secure_delete, and the
+// analytics-prune CLI that backed up and VACUUMed network.db) no longer exist: on PostgreSQL the
+// tracker shares the service handle and retention is pruneRawEventsPg (plan T2, ADR-035).
// node test/analytics-privacy.test.js
// node test/analytics-privacy.test.js
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
-const Database = require('better-sqlite3');
-const { initDb } = require('../server/db/database');
-const { privacy, retention, event } = require('openvibe-shared/analytics');
+const { getDb } = require('../server/db/database');
+const { privacy, event } = require('openvibe-shared/analytics');
const { sqlTime } = require('openvibe-shared/analytics/tracker');
const { sqlTime } = require('openvibe-shared/analytics/tracker');
const networkAnalytics = require('../server/analytics/network');
const networkAnalytics = require('../server/analytics/network');
-const cli = require('../scripts/analytics-prune');
let failures = 0;
let failures = 0;
async function check(name, fn) {
async function check(name, fn) {
@@ -34,17 +30,15 @@ async function check(name, fn) {
@@ -34,17 +30,15 @@ async function check(name, fn) {
catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
catch (e) { failures++; console.log(' ✗', name, '\n ', e.stack); }
}
}
-const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-net-analytics-'));
-const quietInit = (file) => { const log = console.log; console.log = () => {}; try { return initDb(file); } finally { console.log = log; } };
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const CHROME = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
const FIREFOX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
const USERNAME = 'alexsecretname';
const USERNAME = 'alexsecretname';
-/** Every value in every analytics table (network.db also holds accounts, which are not analytics). */
-function dumpAnalytics(db) {
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'analytics_%'").pluck().all();
+/** Every value in every analytics table (the database also holds accounts, which are not analytics). */
+async function dumpAnalytics(db) {
+ const tables = await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE 'analytics_%'").pluck().all();
assert.ok(tables.includes('analytics_events'));
assert.ok(tables.includes('analytics_events'));
- return tables.map((t) => JSON.stringify(db.prepare(`SELECT * FROM ${t}`).all())).join('\n');
+ return (await Promise.all(tables.map(async (t) => JSON.stringify(await db.prepare(`SELECT * FROM ${t}`).all())))).join('\n');
}
}
(async () => {
(async () => {
@@ -66,11 +60,10 @@ function dumpAnalytics(db) {
@@ -66,11 +60,10 @@ function dumpAnalytics(db) {
});
});
// ── Real requests through Network's routers ──────────────
// ── Real requests through Network's routers ──────────────
- const dbFile = path.join(dir, 'network.db');
- const db = quietInit(dbFile);
- db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (42, ?, 'x', 'admin')").run(USERNAME);
+ const db = await getDb();
+ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (42, ?, 'x', 'admin')").run(USERNAME);
let clock = Date.parse('2026-09-23T10:15:00Z');
let clock = Date.parse('2026-09-23T10:15:00Z');
- const analytics = networkAnalytics.openAnalytics(dbFile, { timers: false, now: () => clock });
+ const analytics = networkAnalytics.openAnalytics(db, { timers: false, now: () => clock });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -122,9 +115,9 @@ function dumpAnalytics(db) {
@@ -122,9 +115,9 @@ function dumpAnalytics(db) {
assert.strictEqual(await get('/avatar/gpcsecretname', hdr({ 'sec-gpc': '1', 'x-forwarded-for': '198.51.100.61' })), 200);
assert.strictEqual(await get('/avatar/gpcsecretname', hdr({ 'sec-gpc': '1', 'x-forwarded-for': '198.51.100.61' })), 200);
assert.strictEqual(await get('/internal/users/by-username/dntsecretname', hdr({ dnt: '1', 'x-forwarded-for': '198.51.100.62' })), 403);
assert.strictEqual(await get('/internal/users/by-username/dntsecretname', hdr({ dnt: '1', 'x-forwarded-for': '198.51.100.62' })), 403);
await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 50));
- analytics.flush();
+ await analytics.flush();
- const rows = db.prepare('SELECT * FROM analytics_events ORDER BY id').all();
+ const rows = await db.prepare('SELECT * FROM analytics_events ORDER BY id').all();
assert.strictEqual(rows.length, 8);
assert.strictEqual(rows.length, 8);
for (const r of rows) {
for (const r of rows) {
assert.strictEqual(r.service, 'openvibe-network');
assert.strictEqual(r.service, 'openvibe-network');
@@ -147,26 +140,18 @@ function dumpAnalytics(db) {
@@ -147,26 +140,18 @@ function dumpAnalytics(db) {
assert.strictEqual(rows[0].session_id, rows[1].session_id, 'same visitor, same session');
assert.strictEqual(rows[0].session_id, rows[1].session_id, 'same visitor, same session');
assert.notStrictEqual(rows[0].session_id, rows[2].session_id);
assert.notStrictEqual(rows[0].session_id, rows[2].session_id);
- const everything = dumpAnalytics(db);
+ const everything = await dumpAnalytics(db);
for (const needle of ['203.0.113.77', '198.51.100.9', '127.0.0.1', 'Amsterdam', 'supersecret', 'secret-query',
for (const needle of ['203.0.113.77', '198.51.100.9', '127.0.0.1', 'Amsterdam', 'supersecret', 'secret-query',
'/search', 'statesecret', 'openvibe.live%2Fcb', USERNAME, 'bobsecretname', 'carolsecretname', '0123456789abcdefanon', '424242',
'/search', 'statesecret', 'openvibe.live%2Fcb', USERNAME, 'bobsecretname', 'carolsecretname', '0123456789abcdefanon', '424242',
'alex%40example.com', '[email protected]', 'Mozilla/5.0', 'curl/8.5.0', 'Bearer', token.slice(0, 20),
'alex%40example.com', '[email protected]', 'Mozilla/5.0', 'curl/8.5.0', 'Bearer', token.slice(0, 20),
'gpcsecretname', 'dntsecretname', '198.51.100.61', '198.51.100.62']) {
'gpcsecretname', 'dntsecretname', '198.51.100.61', '198.51.100.62']) {
assert.ok(!everything.includes(needle), `found ${needle} in the analytics tables`);
assert.ok(!everything.includes(needle), `found ${needle} in the analytics tables`);
}
}
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_rate_tracking').pluck().get(), 0);
- });
-
- await check('the tracker has its own connection: the identity connection keeps busy_timeout 5000', () => {
- assert.notStrictEqual(analytics.db, db);
- assert.strictEqual(db.pragma('busy_timeout', { simple: true }), 5000);
- assert.strictEqual(analytics.db.pragma('busy_timeout', { simple: true }), 250);
- assert.strictEqual(db.pragma('secure_delete', { simple: true }), 0);
});
});
await check('admin dashboards keep their shapes; bot rows carry ua_class / session_id, never an IP', async () => {
await check('admin dashboards keep their shapes; bot rows carry ua_class / session_id, never an IP', async () => {
const auth = { authorization: `Bearer ${token}`, 'user-agent': CHROME };
const auth = { authorization: `Bearer ${token}`, 'user-agent': CHROME };
- analytics.aggregate();
+ await analytics.aggregate();
const warn = console.warn;
const warn = console.warn;
console.warn = () => {}; // the remote services are unreachable here on purpose
console.warn = () => {}; // the remote services are unreachable here on purpose
try { await dashboards(auth); } finally { console.warn = warn; }
try { await dashboards(auth); } finally { console.warn = warn; }
@@ -186,112 +171,31 @@ function dumpAnalytics(db) {
@@ -186,112 +171,31 @@ function dumpAnalytics(db) {
clock = Date.now();
clock = Date.now();
await get('/api/auth/me', hdr({ authorization: `Bearer ${token}` }));
await get('/api/auth/me', hdr({ authorization: `Bearer ${token}` }));
await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 50));
- analytics.flush();
+ await analytics.flush();
const rt = await getJson('/api/admin/analytics/realtime', auth);
const rt = await getJson('/api/admin/analytics/realtime', auth);
assert.ok(rt.ok && typeof rt.realtime.visitors === 'number' && rt.realtime.visitors >= 1, JSON.stringify(rt));
assert.ok(rt.ok && typeof rt.realtime.visitors === 'number' && rt.realtime.visitors >= 1, JSON.stringify(rt));
const ov = await getJson('/api/admin/analytics/overview?days=30', auth);
const ov = await getJson('/api/admin/analytics/overview?days=30', auth);
assert.ok(ov.ok && ov.overview.services.some((s) => s.name === 'openvibe-network'));
assert.ok(ov.ok && ov.overview.services.some((s) => s.name === 'openvibe-network'));
- const hourly = db.prepare("SELECT * FROM analytics_hourly WHERE hour = '2026-09-23 10:00:00'").get();
+ const hourly = await db.prepare("SELECT * FROM analytics_hourly WHERE hour = '2026-09-23 10:00:00'").get();
assert.ok(hourly && hourly.unique_visitors >= 2 && hourly.unique_users === 1, JSON.stringify(hourly));
assert.ok(hourly && hourly.unique_visitors >= 2 && hourly.unique_users === 1, JSON.stringify(hourly));
}
}
server.close();
server.close();
- await check('analytics-prune job: raw rows older than 30 days go, rollups stay', async () => {
+ await check('analytics-prune: raw rows older than 30 days go, rollups stay', async () => {
const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, ip, user_id, created_at) VALUES ('openvibe-network', 'pageview', '/@old', '198.51.100.1', 7, ?)`);
const ins = db.prepare(`INSERT INTO analytics_events (service, event_type, path, ip, user_id, created_at) VALUES ('openvibe-network', 'pageview', '/@old', '198.51.100.1', 7, ?)`);
- for (let i = 0; i < 5; i++) ins.run(sqlTime(Date.now() - (40 + i) * 86400000));
- ins.run(sqlTime(Date.now() - 29 * 86400000));
- db.prepare("INSERT OR REPLACE INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors) VALUES ('openvibe-network', '2026-07-01', 9, 3, 4)").run();
- const totals = retention.rollupTotals(db);
- const before = db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get();
- const logs = [];
- const job = networkAnalytics.schedulePrune(analytics, { initialDelayMs: 1e9, intervalMs: 1e9, log: (l) => logs.push(l) });
- try {
- const out = await job.run();
- assert.strictEqual(out.deleted, 5);
- assert.ok(/pruned 5 raw events/.test(logs[0]), logs.join('\n'));
- } finally { job.stop(); }
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), before - 5);
- assert.deepStrictEqual(retention.rollupTotals(db), totals);
- assert.strictEqual(db.prepare('SELECT COUNT(*) FROM users').pluck().get(), 1);
+ for (let i = 0; i < 5; i++) await ins.run(sqlTime(Date.now() - (40 + i) * 86400000));
+ await ins.run(sqlTime(Date.now() - 29 * 86400000));
+ await db.prepare("INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors) VALUES ('openvibe-network', '2026-07-01', 9, 3, 4) ON CONFLICT (service, date) DO UPDATE SET pageviews = excluded.pageviews, api_calls = excluded.api_calls, unique_visitors = excluded.unique_visitors").run();
+ const daily = JSON.stringify(await db.prepare('SELECT * FROM analytics_daily ORDER BY date').all());
+ const before = await db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get();
+ const out = await networkAnalytics.schedulePrune(analytics, { days: 30 });
+ assert.strictEqual(out.removed, 5);
+ assert.strictEqual(await db.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), before - 5);
+ assert.strictEqual(JSON.stringify(await db.prepare('SELECT * FROM analytics_daily ORDER BY date').all()), daily);
+ assert.strictEqual(await db.prepare('SELECT COUNT(*) FROM users').pluck().get(), 1);
});
});
analytics.destroy();
analytics.destroy();
- analytics.db.close();
- db.close();
-
- // ── CLI against a network.db ─────────────────────────────
- const quiet = [];
- const log = (l) => quiet.push(l);
- const sha = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
- function legacyNetworkDb(name) {
- const file = path.join(dir, name);
- const d = quietInit(file);
- d.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'keepme', 'x')").run();
- const t = networkAnalytics.openAnalytics(file, { timers: false });
- t.destroy();
- t.db.close();
- const ins = d.prepare(`INSERT INTO analytics_events (service, event_type, path, method, status_code, user_id, session_id, ip, city, user_agent, referer, created_at)
- VALUES ('openvibe-network', 'pageview', ?, 'GET', 200, 7, ?, ?, 'Lyon', ?, 'https://t.co/abc?x=1', ?)`);
- const nowMs = Date.now() + 120000;
- for (let i = 0; i < 4; i++) ins.run('/avatar/keepme?s=' + i, 'legacy-session-' + i, '198.51.100.' + i, CHROME, sqlTime(nowMs - (45 + i) * 86400000));
- for (let i = 0; i < 3; i++) ins.run('/api/auth/anon/tok' + i + 'X9zz99', 'abcdef012345678' + i, '198.51.100.2' + i, FIREFOX, sqlTime(nowMs - (2 + i) * 86400000));
- ins.run('/internal/users/by-username/keepme', null, '127.0.0.1', 'node', sqlTime(nowMs - 3 * 86400000));
- d.prepare(`INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, new_users, top_paths, top_referers)
- VALUES ('openvibe-network', '2026-07-01', 100, 40, 30, 10, 2, ?, ?)`)
- .run(JSON.stringify([{ path: '/@keepme', cnt: 3 }, { path: '/internal/users/by-username/keepme', cnt: 2 }, { path: '/login', cnt: 1 }]), JSON.stringify([{ referer: 'https://www.google.com/search?q=keepme', cnt: 4 }]));
- d.pragma('wal_checkpoint(TRUNCATE)');
- d.close();
- return file;
- }
- const snapshot = (file) => { const d = new Database(file, { readonly: true }); try { return dumpAnalytics(d); } finally { d.close(); } };
-
- await check('CLI: default database is $DB_PATH, else data/network.db, from the repo root', () => {
- const root = path.join(__dirname, '..');
- assert.strictEqual(cli.defaultDbPath({}, {}), path.join(root, 'data', 'network.db'));
- assert.strictEqual(cli.defaultDbPath({}, { DB_PATH: './data/other.db' }), path.join(root, 'data', 'other.db'));
- assert.strictEqual(cli.defaultDbPath({ db: '/x/y.db' }, { DB_PATH: './data/other.db' }), '/x/y.db');
- });
-
- await check('CLI dry run (with and without --scrub) changes nothing', async () => {
- const file = legacyNetworkDb('cli-dry.db');
- const before = { hash: sha(file), dump: snapshot(file) };
- assert.strictEqual(await cli.main(['--db', file], log), 0);
- assert.strictEqual(await cli.main(['--db', file, '--scrub', '--days', '7'], log), 0);
- assert.strictEqual(sha(file), before.hash);
- assert.strictEqual(snapshot(file), before.dump);
- assert.ok(quiet.some((l) => /prune\s+4 rows/.test(l)), quiet.join('\n'));
- });
-
- await check('CLI --apply refuses without a backup choice, an existing target, or days > 30', async () => {
- const file = legacyNetworkDb('cli-refuse.db');
- const before = snapshot(file);
- assert.strictEqual(await cli.main(['--db', file, '--apply'], log), 2);
- const existing = path.join(dir, 'exists.bak');
- fs.writeFileSync(existing, 'x');
- assert.strictEqual(await cli.main(['--db', file, '--apply', '--backup', existing], log), 2);
- assert.strictEqual(await cli.main(['--db', file, '--apply', '--no-backup', '--days', '31'], log), 2);
- assert.strictEqual(snapshot(file), before);
- });
-
- await check('CLI --apply --scrub --backup: owner-only verified backup, analytics reduced, accounts untouched', async () => {
- const file = legacyNetworkDb('cli-apply.db');
- const bak = path.join(dir, 'cli-apply.backup.db');
- assert.strictEqual(await cli.main(['--db', file, '--apply', '--scrub', '--backup', bak, '--batch', '2'], log), 0);
- assert.strictEqual(fs.statSync(bak).mode & 0o777, 0o600);
- const b = new Database(bak, { readonly: true });
- assert.strictEqual(b.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 8, 'backup has every row');
- b.close();
- const d = new Database(file, { readonly: true });
- assert.strictEqual(d.prepare('SELECT COUNT(*) FROM analytics_events').pluck().get(), 4);
- assert.deepStrictEqual(d.prepare('SELECT DISTINCT path FROM analytics_events ORDER BY path').pluck().all(), ['/api/auth/anon/:param', '/internal/users/:param/:username']);
- assert.deepStrictEqual(JSON.parse(d.prepare('SELECT top_paths FROM analytics_daily').pluck().get()),
- [{ path: '/@:user', cnt: 3 }, { path: '/internal/users/:param/:username', cnt: 2 }, { path: '/login', cnt: 1 }]);
- assert.strictEqual(d.prepare("SELECT username FROM users WHERE id = 7").pluck().get(), 'keepme');
- d.close();
- const all = snapshot(file);
- for (const needle of ['198.51.100.', '127.0.0.1', 'Lyon', 'legacy-session', 'keepme', 'X9zz99', 'Mozilla/5.0']) assert.ok(!all.includes(needle), needle);
- });
- fs.rmSync(dir, { recursive: true, force: true });
if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
console.log('\nanalytics privacy: all passed');
console.log('\nanalytics privacy: all passed');
process.exit(0);
process.exit(0);
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
-const express = require('express'); const Database = require('better-sqlite3');
+const express = require('express');
+const { getDb } = require('../server/db/database');
const { createAvatarService, normalizeAvatar, verifyImage } = require('../server/profile/avatar');
const { createAvatarService, normalizeAvatar, verifyImage } = require('../server/profile/avatar');
// What counts as an avatar
// What counts as an avatar
@@ -21,20 +22,20 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
@@ -21,20 +22,20 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
assert.equal((await verifyImage(`${M}/x.png`, img)).ok, true);
assert.equal((await verifyImage(`${M}/x.png`, img)).ok, true);
assert.equal((await verifyImage(`${M}/x.png`, html)).ok, false, 'a page is not a picture');
assert.equal((await verifyImage(`${M}/x.png`, html)).ok, false, 'a page is not a picture');
- const db = new Database(':memory:');
- db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT, avatar_url TEXT); CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT); INSERT INTO users VALUES (1,'Goosely',NULL),(2,'legacy','https://evil.example/old.png')");
+ const db = getDb();
+ await db.prepare("INSERT INTO users (id, username, password_hash, avatar_url) VALUES (1, 'Goosely', 'x', NULL), (2, 'legacy', 'x', 'https://evil.example/old.png')").run();
const pushes = []; const pushHeaders = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); pushHeaders.push(o.headers); return { ok: true }; };
const pushes = []; const pushHeaders = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); pushHeaders.push(o.headers); return { ok: true }; };
// Network's own service token for the push (plan T2): live.avatar.write, audience openvibe.live.
// Network's own service token for the push (plan T2): live.avatar.write, audience openvibe.live.
const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: 'https://openvibe.network' });
const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: 'https://openvibe.network' });
const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });
const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });
- assert.equal(svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' }).changed, true);
+ assert.equal((await svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' })).changed, true);
assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');
assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');
- svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');
+ await svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');
const bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');
const bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');
const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });
const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });
assert.ok(v.ok && v.claims.sub === 'svc:network' && v.claims.cap.includes('live.avatar.write'), `the push carries Network's token for live.avatar.write: ${v.reason || ''}`);
assert.ok(v.ok && v.claims.sub === 'svc:network' && v.claims.cap.includes('live.avatar.write'), `the push carries Network's token for live.avatar.write: ${v.reason || ''}`);
- assert.equal(svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' }).status, 422);
+ assert.equal((await svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' })).status, 422);
global.fetch = realFetch;
global.fetch = realFetch;
const app = express(); app.use('/avatar', svc.pub);
const app = express(); app.use('/avatar', svc.pub);
@@ -13,29 +13,30 @@ const path = require('path');
@@ -13,29 +13,30 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-blocks-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-blocks-'));
const dbFile = path.join(dir, 'network.db');
const dbFile = path.join(dir, 'network.db');
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(dbFile);
+const db = getDb();
console.log = log;
console.log = log;
// chat and community are registered on the host, not seeded: copies of tools, then their grants seeded again.
// chat and community are registered on the host, not seeded: copies of tools, then their grants seeded again.
for (const c of ['chat', 'community']) {
for (const c of ['chat', 'community']) {
- const cols = db.prepare('PRAGMA table_info(oauth_clients)').all().map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');
- db.prepare(`INSERT OR IGNORE INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools'`).run(c);
+ const cols = (await db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?").all('oauth_clients')).map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');
+ await db.prepare(`INSERT INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools' ON CONFLICT DO NOTHING`).run(c);
}
}
-require('../server/identity/principals').ensureSchema(db);
-for (const c of ['chat', 'community', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+await require('../server/identity/principals').ensureSchema(db);
+for (const c of ['chat', 'community', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const MOD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
const MOD = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
-db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
+await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
(1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob <b>', 'x', ?, 'user'), (3, 'cat', NULL, 'x', ?, 'user'), (4, 'mod', 'Mod', 'x', ?, 'global_mod')`).run(ANN, BOB, CAT, MOD);
(1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob <b>', 'x', ?, 'user'), (3, 'cat', NULL, 'x', ?, 'user'), (4, 'mod', 'Mod', 'x', ?, 'global_mod')`).run(ANN, BOB, CAT, MOD);
-db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();
-const GUEST_SUBJECT = db.prepare('SELECT subject_id FROM users WHERE id = 5').get().subject_id;
+await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();
+const GUEST_SUBJECT = (await db.prepare('SELECT subject_id FROM users WHERE id = 5').get()).subject_id;
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
@@ -53,7 +54,7 @@ app.get('/internal/blocks', principals.guard('network.blocks.read', { legacy: fa
@@ -53,7 +54,7 @@ app.get('/internal/blocks', principals.guard('network.blocks.read', { legacy: fa
app.use('/internal', require('../server/internal/routes'));
app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
-const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.block.changed');
+const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map(r => r.envelope).filter(e => e.event_type === 'network.block.changed');
const valid = (e) => {
const valid = (e) => {
const v = validate('events.event-envelope@1', e);
const v = validate('events.event-envelope@1', e);
assert.ok(v.valid, JSON.stringify(v.errors));
assert.ok(v.valid, JSON.stringify(v.errors));
@@ -65,66 +66,66 @@ const valid = (e) => {
@@ -65,66 +66,66 @@ const valid = (e) => {
(async () => {
(async () => {
await new Promise(r => server.listen(0, '127.0.0.1', r));
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
- const tok = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
+ const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
.then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
.then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
- const as = (id) => ({ authorization: `Bearer ${tok(id)}` });
+ const as = async (id) => ({ authorization: `Bearer ${await tok(id)}` });
const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json())).access_token;
const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json())).access_token;
// ── A person blocks and unblocks ──
// ── A person blocks and unblocks ──
let r = await call('GET', '/api/v1/me/blocks');
let r = await call('GET', '/api/v1/me/blocks');
assert.strictEqual(r.status, 401, 'signed in only');
assert.strictEqual(r.status, 401, 'signed in only');
- r = await call('GET', '/api/v1/me/blocks', { headers: as(1) });
+ r = await call('GET', '/api/v1/me/blocks', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.subject, r.body.blocks], [200, ANN, []]);
assert.deepStrictEqual([r.status, r.body.subject, r.body.blocks], [200, ANN, []]);
assert.ok(r.cache.includes('no-store'));
assert.ok(r.cache.includes('no-store'));
- r = await call('PUT', '/api/v1/me/blocks/BOB', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/blocks/BOB', { headers: await as(1) });
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.deepStrictEqual([r.body.changed, r.body.block.subject, r.body.block.username, r.body.block.active, r.body.block.revision], [true, BOB, 'bob', true, 1]);
assert.deepStrictEqual([r.body.changed, r.body.block.subject, r.body.block.username, r.body.block.active, r.body.block.revision], [true, BOB, 'bob', true, 1]);
- r = await call('PUT', `/api/v1/me/blocks/${BOB}`, { headers: as(1) });
+ r = await call('PUT', `/api/v1/me/blocks/${BOB}`, { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.changed, r.body.block.revision], [200, false, 1], 'blocking again changes nothing');
assert.deepStrictEqual([r.status, r.body.changed, r.body.block.revision], [200, false, 1], 'blocking again changes nothing');
- assert.strictEqual(events().length, 1, 'and announces nothing');
- r = await call('PUT', '/api/v1/me/blocks/mod', { headers: as(1) });
+ assert.strictEqual((await events()).length, 1, 'and announces nothing');
+ r = await call('PUT', '/api/v1/me/blocks/mod', { headers: await as(1) });
assert.strictEqual(r.status, 201, 'staff can be blocked');
assert.strictEqual(r.status, 201, 'staff can be blocked');
- r = await call('GET', '/api/v1/me/blocks', { headers: as(1) });
+ r = await call('GET', '/api/v1/me/blocks', { headers: await as(1) });
assert.deepStrictEqual(r.body.blocks.map(b => b.username).sort(), ['bob', 'mod']);
assert.deepStrictEqual(r.body.blocks.map(b => b.username).sort(), ['bob', 'mod']);
const bob = r.body.blocks.find(b => b.username === 'bob');
const bob = r.body.blocks.find(b => b.username === 'bob');
assert.deepStrictEqual([bob.subject, bob.display_name], [BOB, 'Bob <b>'], 'names are data; the page renders them as text');
assert.deepStrictEqual([bob.subject, bob.display_name], [BOB, 'Bob <b>'], 'names are data; the page renders them as text');
assert.ok(bob.blocked_at);
assert.ok(bob.blocked_at);
// Refusals: yourself, a guest (by subject or name), nobody, not signed in as a person.
// Refusals: yourself, a guest (by subject or name), nobody, not signed in as a person.
- r = await call('PUT', '/api/v1/me/blocks/ann', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/blocks/ann', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);
- r = await call('PUT', `/api/v1/me/blocks/${ANN}`, { headers: as(1) });
+ r = await call('PUT', `/api/v1/me/blocks/${ANN}`, { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.self']);
- r = await call('PUT', '/api/v1/me/blocks/anon1234', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/blocks/anon1234', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);
- r = await call('PUT', '/api/v1/me/blocks/gst_01JAB2C3D4E5F6G7H8J9K0MNPQ', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/blocks/gst_01JAB2C3D4E5F6G7H8J9K0MNPQ', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest']);
if (/^usr_/.test(GUEST_SUBJECT || '')) {
if (/^usr_/.test(GUEST_SUBJECT || '')) {
- r = await call('PUT', `/api/v1/me/blocks/${GUEST_SUBJECT}`, { headers: as(1) });
+ r = await call('PUT', `/api/v1/me/blocks/${GUEST_SUBJECT}`, { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest'], 'a guest users row, by its subject');
assert.deepStrictEqual([r.status, r.body.code], [400, 'blocks.guest'], 'a guest users row, by its subject');
}
}
- r = await call('PUT', '/api/v1/me/blocks/nobody_here', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/blocks/nobody_here', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.unknown_person']);
assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.unknown_person']);
- r = await call('PUT', '/api/v1/me/blocks/bob', { headers: as(5) });
+ r = await call('PUT', '/api/v1/me/blocks/bob', { headers: await as(5) });
assert.deepStrictEqual([r.status, r.body.code], [403, 'blocks.guest'], 'a guest cannot block');
assert.deepStrictEqual([r.status, r.body.code], [403, 'blocks.guest'], 'a guest cannot block');
- assert.throws(() => blocks.setBlock(db, ANN, ANN, true), /yourself/);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_blocks').get().n, 2, 'refusals wrote nothing');
+ await assert.rejects(async () => await blocks.setBlock(db, ANN, ANN, true), /yourself/);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_blocks').get()).n, 2, 'refusals wrote nothing');
// Unblock, block again: the pair's revision grows every time.
// Unblock, block again: the pair's revision grows every time.
- r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
+ r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.changed, r.body.block.active, r.body.block.revision], [200, true, false, 2]);
assert.deepStrictEqual([r.status, r.body.changed, r.body.block.active, r.body.block.revision], [200, true, false, 2]);
- r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
+ r = await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.not_blocked']);
assert.deepStrictEqual([r.status, r.body.code], [404, 'blocks.not_blocked']);
- r = await call('PUT', '/api/v1/me/blocks/bob', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/blocks/bob', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.block.revision], [201, 3]);
assert.deepStrictEqual([r.status, r.body.block.revision], [201, 3]);
- r = await call('PUT', '/api/v1/me/blocks/ann', { headers: as(3) });
+ r = await call('PUT', '/api/v1/me/blocks/ann', { headers: await as(3) });
assert.strictEqual(r.status, 201, 'cat blocks ann');
assert.strictEqual(r.status, 201, 'cat blocks ann');
// ── Events: one per change, valid, in order, revisions growing per pair ──
// ── Events: one per change, valid, in order, revisions growing per pair ──
- const evs = events();
+ const evs = await events();
evs.forEach(valid);
evs.forEach(valid);
const annBob = evs.filter(e => e.payload.blocker === ANN && e.payload.blocked === BOB).map(e => [e.payload.active, e.payload.revision]);
const annBob = evs.filter(e => e.payload.blocker === ANN && e.payload.blocked === BOB).map(e => [e.payload.active, e.payload.revision]);
assert.deepStrictEqual(annBob, [[true, 1], [false, 2], [true, 3]]);
assert.deepStrictEqual(annBob, [[true, 1], [false, 2], [true, 3]]);
@@ -132,14 +133,14 @@ const valid = (e) => {
@@ -132,14 +133,14 @@ const valid = (e) => {
assert.strictEqual(new Set(evs.map(e => e.event_id)).size, evs.length);
assert.strictEqual(new Set(evs.map(e => e.event_id)).size, evs.length);
// In the same transaction: a change that fails after the row is written leaves neither row nor event.
// In the same transaction: a change that fails after the row is written leaves neither row nor event.
- const before = events().length;
+ const before = (await events()).length;
const enqueue = require('../server/developer/event-relay').writerFor(db);
const enqueue = require('../server/developer/event-relay').writerFor(db);
const real = enqueue.enqueue;
const real = enqueue.enqueue;
enqueue.enqueue = () => { throw new Error('outbox down'); };
enqueue.enqueue = () => { throw new Error('outbox down'); };
- assert.throws(() => blocks.setBlock(db, CAT, BOB, true), /outbox down/);
+ await assert.rejects(async () => await blocks.setBlock(db, CAT, BOB, true), /outbox down/);
enqueue.enqueue = real;
enqueue.enqueue = real;
- assert.strictEqual(blocks.isBlocked(db, CAT, BOB), false, 'rolled back with its event');
- assert.strictEqual(events().length, before);
+ assert.strictEqual(await blocks.isBlocked(db, CAT, BOB), false, 'rolled back with its event');
+ assert.strictEqual((await events()).length, before);
// ── /internal/blocks for services ──
// ── /internal/blocks for services ──
const chat = await svc('chat');
const chat = await svc('chat');
@@ -158,24 +159,24 @@ const valid = (e) => {
@@ -158,24 +159,24 @@ const valid = (e) => {
assert.ok([401, 403].includes(r.status), 'never the shared key');
assert.ok([401, 403].includes(r.status), 'never the shared key');
r = await call('GET', `/internal/blocks?subject=${ANN}`);
r = await call('GET', `/internal/blocks?subject=${ANN}`);
assert.ok([401, 403].includes(r.status), 'no credential');
assert.ok([401, 403].includes(r.status), 'no credential');
- for (const c of ['chat', 'community']) assert.ok(principals.grantsFor(db, c, 'openvibe.network').some(g => g.capability === 'network.blocks.read'), `${c} holds network.blocks.read`);
+ for (const c of ['chat', 'community']) assert.ok((await principals.grantsFor(db, c, 'openvibe.network')).some(g => g.capability === 'network.blocks.read'), `${c} holds network.blocks.read`);
// ── Network's own notifications ──
// ── Network's own notifications ──
const { NotificationService } = require('../server/notifications/notification-service');
const { NotificationService } = require('../server/notifications/notification-service');
const notes = new NotificationService(db);
const notes = new NotificationService(db);
- const count = (uid) => db.prepare('SELECT COUNT(*) AS n FROM notifications WHERE user_id = ?').get(uid).n;
- assert.strictEqual(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, sender_name: 'bob', message: 'bob followed you' }), null, 'ann blocked bob: nothing from him');
- assert.strictEqual(notes.create({ user_id: 1, type: 'MENTION', actor_subject: BOB, message: 'bob mentioned you' }), null, 'nor when the actor is named by subject');
- assert.strictEqual(count(1), 0);
- assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 3, sender_name: 'cat' }), 'cat is not blocked by ann (only the other way round)');
- assert.ok(notes.create({ user_id: 3, type: 'FOLLOW', sender_id: 2, sender_name: 'bob' }), 'cat did not block bob');
- assert.ok(notes.create({ user_id: 1, type: 'WARNING', sender_id: 4, sender_name: 'mod', message: 'a warning' }), 'a staff action reaches ann although she blocked mod');
- assert.strictEqual(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 4 }), null, 'but mod following her does not');
- assert.ok(notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, actor_subject: null }), 'actor_subject null: no known person (a Live id in sender_id)');
- assert.ok(notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');
- assert.strictEqual(notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' }).length, 1, 'bulk: only cat hears bob went live');
- await call('DELETE', '/api/v1/me/blocks/bob', { headers: as(1) });
- assert.ok(notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');
+ const count = async (uid) => (await db.prepare('SELECT COUNT(*) AS n FROM notifications WHERE user_id = ?').get(uid)).n;
+ assert.strictEqual(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, sender_name: 'bob', message: 'bob followed you' }), null, 'ann blocked bob: nothing from him');
+ assert.strictEqual(await notes.create({ user_id: 1, type: 'MENTION', actor_subject: BOB, message: 'bob mentioned you' }), null, 'nor when the actor is named by subject');
+ assert.strictEqual(await count(1), 0);
+ assert.ok(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 3, sender_name: 'cat' }), 'cat is not blocked by ann (only the other way round)');
+ assert.ok(await notes.create({ user_id: 3, type: 'FOLLOW', sender_id: 2, sender_name: 'bob' }), 'cat did not block bob');
+ assert.ok(await notes.create({ user_id: 1, type: 'WARNING', sender_id: 4, sender_name: 'mod', message: 'a warning' }), 'a staff action reaches ann although she blocked mod');
+ assert.strictEqual(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 4 }), null, 'but mod following her does not');
+ assert.ok(await notes.create({ user_id: 1, type: 'FOLLOW', sender_id: 2, actor_subject: null }), 'actor_subject null: no known person (a Live id in sender_id)');
+ assert.ok(await notes.create({ user_id: 1, type: 'SERVICE_ANNOUNCEMENT', title: 'hello' }), 'no actor: unchanged');
+ assert.strictEqual((await notes.createBulk([1, 3], { type: 'STREAM_LIVE', sender_id: 2, title: 'bob is live' })).length, 1, 'bulk: only cat hears bob went live');
+ await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });
+ assert.ok(await notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');
// ── Import of Chat's exported dm_blocks ──
// ── Import of Chat's exported dm_blocks ──
const importer = require('../scripts/import-blocks');
const importer = require('../scripts/import-blocks');
@@ -189,20 +190,20 @@ const valid = (e) => {
@@ -189,20 +190,20 @@ const valid = (e) => {
{ blocker_subject: '42', blocked_subject: BOB }, // not a subject
{ blocker_subject: '42', blocked_subject: BOB }, // not a subject
] }));
] }));
const out = [];
const out = [];
- let code = await importer.main(['--db', dbFile, '--file', file], (l) => out.push(l));
+ let code = await importer.main(['--file', file], (l) => out.push(l));
assert.strictEqual(code, 0);
assert.strictEqual(code, 0);
assert.match(out.join('\n'), /pairs {6}6; to import 1; skipped: duplicate-in-file 1, already-on-network 1, self 1, unknown-account 1, not-a-subject 1/);
assert.match(out.join('\n'), /pairs {6}6; to import 1; skipped: duplicate-in-file 1, already-on-network 1, self 1, unknown-account 1, not-a-subject 1/);
- assert.ok(!blocks.isBlocked(db, BOB, CAT), 'dry run changed nothing');
- const n = events().length;
- code = await importer.main(['--db', dbFile, '--file', file, '--apply'], () => {});
+ assert.ok(!await blocks.isBlocked(db, BOB, CAT), 'dry run changed nothing');
+ const n = (await events()).length;
+ code = await importer.main(['--file', file, '--apply'], () => {});
assert.strictEqual(code, 0);
assert.strictEqual(code, 0);
- assert.ok(blocks.isBlocked(db, BOB, CAT), 'imported');
- assert.ok(!blocks.isBlocked(db, ANN, BOB), 'an unblock on Network wins over the old Chat block');
- assert.strictEqual(events().length, n + 1);
- const imported = events().at(-1); valid(imported);
+ assert.ok(await blocks.isBlocked(db, BOB, CAT), 'imported');
+ assert.ok(!await blocks.isBlocked(db, ANN, BOB), 'an unblock on Network wins over the old Chat block');
+ assert.strictEqual((await events()).length, n + 1);
+ const imported = (await events()).at(-1); valid(imported);
assert.deepStrictEqual([imported.payload.blocker, imported.payload.blocked, imported.payload.active, imported.payload.revision], [BOB, CAT, true, 1]);
assert.deepStrictEqual([imported.payload.blocker, imported.payload.blocked, imported.payload.active, imported.payload.revision], [BOB, CAT, true, 1]);
out.length = 0;
out.length = 0;
- await importer.main(['--db', dbFile, '--file', file], (l) => out.push(l));
+ await importer.main(['--file', file], (l) => out.push(l));
assert.match(out.join('\n'), /to import 0/, 'a second run imports nothing');
assert.match(out.join('\n'), /to import 0/, 'a second run imports nothing');
// The account page lists blocks as text (DOM nodes), with unblock buttons.
// The account page lists blocks as text (DOM nodes), with unblock buttons.
@@ -215,3 +216,4 @@ const valid = (e) => {
@@ -215,3 +216,4 @@ const valid = (e) => {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('blocks: all checks passed');
console.log('blocks: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -12,25 +12,26 @@ const http = require('http');
@@ -12,25 +12,26 @@ const http = require('http');
const express = require('express');
const express = require('express');
const { ids, validate } = require('openvibe-contracts');
const { ids, validate } = require('openvibe-contracts');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { NotificationService } = require('../server/notifications/notification-service');
const { NotificationService } = require('../server/notifications/notification-service');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const creators = require('../server/analytics/creators');
const creators = require('../server/analytics/creators');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-creator-analytics-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-creator-analytics-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-require('../server/identity/principals').ensureSchema(db);
-for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+await require('../server/identity/principals').ensureSchema(db);
+for (const c of ['live', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
const CAROL = ids.newId('user'), DAVE = ids.newId('user');
const CAROL = ids.newId('user'), DAVE = ids.newId('user');
-db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?)').run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user');
+await db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?)').run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const { signToken } = require('../server/auth/routes');
const { signToken } = require('../server/auth/routes');
const SECRET = 's'.repeat(40);
const SECRET = 's'.repeat(40);
-const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, log: { log() {}, warn() {}, error() {} } });
+const consumer = await createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, log: { log() {}, warn() {}, error() {} } });
const app = express();
const app = express();
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
app.use(require('cookie-parser')());
app.use(require('cookie-parser')());
@@ -61,7 +62,7 @@ const ended = (streamId, startedAt, minutes, stats) => ({
@@ -61,7 +62,7 @@ const ended = (streamId, startedAt, minutes, stats) => ({
assert.strictEqual((await deliver(ended(502, day(2), 30, { peak_viewers: 20, avg_viewers: 10, unique_chatters: 9, messages: 200, watch_minutes: 250 }))).outcome, 'analytics:recorded');
assert.strictEqual((await deliver(ended(502, day(2), 30, { peak_viewers: 20, avg_viewers: 10, unique_chatters: 9, messages: 200, watch_minutes: 250 }))).outcome, 'analytics:recorded');
assert.strictEqual((await deliver(ended(503, day(1), 15))).outcome, 'analytics:recorded', 'an ended stream without stats still counts');
assert.strictEqual((await deliver(ended(503, day(1), 15))).outcome, 'analytics:recorded', 'an ended stream without stats still counts');
assert.strictEqual((await deliver({ ...ended(501, day(30), 60, { peak_viewers: 12, avg_viewers: 6.5, unique_chatters: 4, messages: 120, watch_minutes: 300 }) })).outcome, 'analytics:recorded', 'a new event for the same stream replaces it');
assert.strictEqual((await deliver({ ...ended(501, day(30), 60, { peak_viewers: 12, avg_viewers: 6.5, unique_chatters: 4, messages: 120, watch_minutes: 300 }) })).outcome, 'analytics:recorded', 'a new event for the same stream replaces it');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM creator_streams').get().n, 3);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM creator_streams').get()).n, 3);
assert.strictEqual((await deliver({ ...ended(504, day(1), 5), source: 'media' })).outcome, 'ignored:source');
assert.strictEqual((await deliver({ ...ended(504, day(1), 5), source: 'media' })).outcome, 'ignored:source');
// Public: streams, minutes, peak. No audience figures.
// Public: streams, minutes, peak. No audience figures.
@@ -74,12 +75,12 @@ const ended = (streamId, startedAt, minutes, stats) => ({
@@ -74,12 +75,12 @@ const ended = (streamId, startedAt, minutes, stats) => ({
assert.strictEqual(r.body.daily.reduce((n, d) => n + d.streams, 0), 3);
assert.strictEqual(r.body.daily.reduce((n, d) => n + d.streams, 0), 3);
// The creator, and Live's service token: full figures. Another person: public only. A service without the grant: refused.
// The creator, and Live's service token: full figures. Another person: public only. A service without the grant: refused.
- const creatorTok = { authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = 20').get(), keys.privateKey, config)}` };
+ const creatorTok = { authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = 20').get(), keys.privateKey, config)}` };
r = await call(`/api/v1/creators/${CAROL}/analytics`, creatorTok);
r = await call(`/api/v1/creators/${CAROL}/analytics`, creatorTok);
assert.deepStrictEqual([r.body.full, r.body.totals.messages, r.body.totals.unique_chatters, r.body.totals.watch_minutes], [true, 320, 13, 550]);
assert.deepStrictEqual([r.body.full, r.body.totals.messages, r.body.totals.unique_chatters, r.body.totals.watch_minutes], [true, 320, 13, 550]);
assert.strictEqual(r.body.totals.avg_viewers, Math.round(((6.5 * 3600 + 10 * 1800) / 6300) * 10) / 10, 'duration-weighted average');
assert.strictEqual(r.body.totals.avg_viewers, Math.round(((6.5 * 3600 + 10 * 1800) / 6300) * 10) / 10, 'duration-weighted average');
assert.match(r.cache, /private/);
assert.match(r.cache, /private/);
- r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = 21').get(), keys.privateKey, config)}` });
+ r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = 21').get(), keys.privateKey, config)}` });
assert.strictEqual(r.body.full, false);
assert.strictEqual(r.body.full, false);
r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${await svc('live')}` });
r = await call('/api/v1/creators/carol/analytics', { authorization: `Bearer ${await svc('live')}` });
assert.strictEqual(r.body.full, true);
assert.strictEqual(r.body.full, true);
@@ -87,7 +88,7 @@ const ended = (streamId, startedAt, minutes, stats) => ({
@@ -87,7 +88,7 @@ const ended = (streamId, startedAt, minutes, stats) => ({
assert.strictEqual((await call('/api/v1/creators/nobody/analytics')).status, 404);
assert.strictEqual((await call('/api/v1/creators/nobody/analytics')).status, 404);
// Privacy (ADR-021): the table holds counts and the creator's subject only.
// Privacy (ADR-021): the table holds counts and the creator's subject only.
- const cols = db.prepare('PRAGMA table_info(creator_streams)').all().map((c) => c.name);
+ const cols = (await db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?").all('creator_streams')).map((c) => c.name);
assert.deepStrictEqual(cols, creators.COLUMNS);
assert.deepStrictEqual(cols, creators.COLUMNS);
assert.ok(!cols.some((c) => /ip|viewer_id|chatter_id|user_id|follower/.test(c)), 'no viewer, chatter or IP column');
assert.ok(!cols.some((c) => /ip|viewer_id|chatter_id|user_id|follower/.test(c)), 'no viewer, chatter or IP column');
@@ -100,3 +101,4 @@ const ended = (streamId, startedAt, minutes, stats) => ({
@@ -100,3 +101,4 @@ const ended = (streamId, startedAt, minutes, stats) => ({
}
}
console.log('creator analytics: all checks passed');
console.log('creator analytics: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -1,37 +1,24 @@
@@ -1,37 +1,24 @@
'use strict';
'use strict';
-// ADR-007 data ownership: Network never opens another service's database. Booting with a
-// Live database on disk (and OPENVIBELIVE_DB_PATH pointing at it) must not copy roles from it
-// (compatibility register C-58, removed).
+// ADR-007 data ownership: Network never opens another service's database. A second boot must not copy a
+// role out of Live's database (compatibility register C-58, removed), and no server code names it.
// node test/data-ownership.test.js
// node test/data-ownership.test.js
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const Database = require('better-sqlite3');
+const { getDb, initDb } = require('../server/db/database');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-data-ownership-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-data-ownership-'));
+process.env.OPENVIBELIVE_DB_PATH = path.join(dir, 'live.db');
-// A Live database in which the linked account is an admin.
-const livePath = path.join(dir, 'live.db');
-const live = new Database(livePath);
-live.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, role TEXT)");
-live.prepare("INSERT INTO users (id, username, role) VALUES (7, 'migrated', 'admin')").run();
-live.close();
-process.env.OPENVIBELIVE_DB_PATH = livePath;
-
-const { initDb } = require('../server/db/database');
-const netPath = path.join(dir, 'network.db');
-const log = console.log; console.log = () => {};
-let db = initDb(netPath);
-db.prepare("INSERT INTO users (username, password_hash, role, legacy_source, legacy_id) VALUES ('migrated', 'x', 'user', 'live', 7)").run();
-db.close();
+(async () => {
+const db = await getDb();
+await db.prepare("INSERT INTO users (username, password_hash, role, legacy_source, legacy_id) VALUES ('migrated', 'x', 'user', 'live', 7)").run();
// The removed sync ran on every boot, so a second boot is where it would have promoted the user.
// The removed sync ran on every boot, so a second boot is where it would have promoted the user.
-db = initDb(netPath);
-console.log = log;
+await initDb();
-assert.strictEqual(db.prepare("SELECT role FROM users WHERE username = 'migrated'").get().role, 'user',
+assert.strictEqual((await db.prepare("SELECT role FROM users WHERE username = 'migrated'").get()).role, 'user',
'a boot must not copy a role out of Live\'s database');
'a boot must not copy a role out of Live\'s database');
-db.close();
// No server code names Live's database or the variable that pointed at it.
// No server code names Live's database or the variable that pointed at it.
function walk(d, out = []) {
function walk(d, out = []) {
@@ -47,3 +34,4 @@ assert.deepStrictEqual(offenders, [], 'server code must not reference Live\'s da
@@ -47,3 +34,4 @@ assert.deepStrictEqual(offenders, [], 'server code must not reference Live\'s da
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('data-ownership: ok');
console.log('data-ownership: ok');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -16,15 +16,16 @@ const http = require('http');
@@ -16,15 +16,16 @@ const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+(async () => {
const { serviceAuth, validate } = contracts;
const { serviceAuth, validate } = contracts;
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const policy = require('../server/developer/policy');
const policy = require('../server/developer/policy');
const relay = require('../server/developer/event-relay');
const relay = require('../server/developer/event-relay');
const quiet = { log() {}, warn() {}, error() {} };
const quiet = { log() {}, warn() {}, error() {} };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devdefaults-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devdefaults-'));
-const db = initDb(path.join(dir, 'network.db'));
-db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
+const db = getDb();
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -91,7 +92,7 @@ async function withoutCatalog(hidden, fn) {
@@ -91,7 +92,7 @@ async function withoutCatalog(hidden, fn) {
// contracts define them as public and active; until then they are left out like any unknown id.
// contracts define them as public and active; until then they are left out like any unknown id.
assert.ok(['media.object.list', 'media.object.delete'].every(id => policy.DEFAULT_SANDBOX_ALLOWANCE.includes(id)));
assert.ok(['media.object.list', 'media.object.delete'].every(id => policy.DEFAULT_SANDBOX_ALLOWANCE.includes(id)));
const MEDIA_VERBS = ['media.object.list', 'media.object.delete'].map(id => ({ id, version: '1.0', owner: 'media', status: 'active', visibility: 'public', description: 'test', permissions: [], resourceConstraints: ['namespace'], events: [], implementedBy: [] }));
const MEDIA_VERBS = ['media.object.list', 'media.object.delete'].map(id => ({ id, version: '1.0', owner: 'media', status: 'active', visibility: 'public', description: 'test', permissions: [], resourceConstraints: ['namespace'], events: [], implementedBy: [] }));
- await withoutCatalog(MEDIA_VERBS.map(c => c.id), () => withCatalog(MEDIA_VERBS, () => {
+ await withoutCatalog(MEDIA_VERBS.map(c => c.id), async () => await withCatalog(MEDIA_VERBS, () => {
const media = policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.'));
const media = policy.settings({}).sandboxAllowance.filter(id => id.startsWith('media.'));
assert.deepStrictEqual(media, ['media.object.delete', 'media.object.list', 'media.object.read', 'media.object.upload'], 'with the verbs in the catalog, sandbox apps may hold them');
assert.deepStrictEqual(media, ['media.object.delete', 'media.object.list', 'media.object.read', 'media.object.upload'], 'with the verbs in the catalog, sandbox apps may hold them');
}));
}));
@@ -212,10 +213,10 @@ async function withoutCatalog(hidden, fn) {
@@ -212,10 +213,10 @@ async function withoutCatalog(hidden, fn) {
});
});
// ── Relay off: nothing is sent ──
// ── Relay off: nothing is sent ──
- assert.strictEqual(relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);
+ assert.strictEqual(await relay.startRelay(db, { eventsUrl: '', privateKey: keys.privateKey, issuer: ISSUER, fetch: () => { throw new Error('no fetch when off'); } }), null);
assert.strictEqual(relay.outboxFor(db), null);
assert.strictEqual(relay.outboxFor(db), null);
// (The table itself exists from boot: user modules write their events into it whether or not a relay runs.)
// (The table itself exists from boot: user modules write their events into it whether or not a relay runs.)
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n, 0, 'nothing queued while the relay is off');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n, 0, 'nothing queued while the relay is off');
// ── Relay on: a fake Events receives every envelope once, in order, with a Network service token ──
// ── Relay on: a fake Events receives every envelope once, in order, with a Network service token ──
const received = [];
const received = [];
@@ -240,18 +241,18 @@ async function withoutCatalog(hidden, fn) {
@@ -240,18 +241,18 @@ async function withoutCatalog(hidden, fn) {
});
});
await new Promise(res => events.listen(0, '127.0.0.1', res));
await new Promise(res => events.listen(0, '127.0.0.1', res));
const eventsUrl = `http://127.0.0.1:${events.address().port}`;
const eventsUrl = `http://127.0.0.1:${events.address().port}`;
- const before = db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all().map(x => JSON.parse(x.event).event_id);
+ const before = (await db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all()).map(x => JSON.parse(x.event).event_id);
assert.ok(before.length >= 5, 'the walkthrough produced events');
assert.ok(before.length >= 5, 'the walkthrough produced events');
- let outbox = relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
+ let outbox = await relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
assert.ok(outbox);
assert.ok(outbox);
- assert.strictEqual(outbox.pending(), before.length, 'events written while the relay was off are backfilled');
+ assert.strictEqual(await outbox.pending(), before.length, 'events written while the relay was off are backfilled');
// A new event now goes into the outbox in the same transaction as its audit row.
// A new event now goes into the outbox in the same transaction as its audit row.
r = await api('owner', 'POST', `/${P}/apps`, { name: 'later', environment: 'sandbox', type: 'confidential' });
r = await api('owner', 'POST', `/${P}/apps`, { name: 'later', environment: 'sandbox', type: 'confidential' });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
- assert.strictEqual(outbox.pending(), before.length + 1);
+ assert.strictEqual(await outbox.pending(), before.length + 1);
let flushed = await outbox.flush();
let flushed = await outbox.flush();
assert.strictEqual(flushed.sent, before.length + 1, JSON.stringify(flushed));
assert.strictEqual(flushed.sent, before.length + 1, JSON.stringify(flushed));
- const all = db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all().map(x => JSON.parse(x.event));
+ const all = (await db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL ORDER BY id').all()).map(x => JSON.parse(x.event));
assert.deepStrictEqual(received.map(e => e.event_id), all.map(e => e.event_id), 'published in dev_audit id order');
assert.deepStrictEqual(received.map(e => e.event_id), all.map(e => e.event_id), 'published in dev_audit id order');
for (const e of received) {
for (const e of received) {
assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(e));
assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(e));
@@ -267,27 +268,28 @@ async function withoutCatalog(hidden, fn) {
@@ -267,27 +268,28 @@ async function withoutCatalog(hidden, fn) {
assert.ok(validate('identity.service-token-claims@1', tv.claims).valid);
assert.ok(validate('identity.service-token-claims@1', tv.claims).valid);
}
}
// Duplicates are harmless: a republished row is answered as a duplicate and stored once.
// Duplicates are harmless: a republished row is answered as a duplicate and stored once.
- db.prepare(`UPDATE ${relay.TABLE} SET sent_at = NULL WHERE id = (SELECT MAX(id) FROM ${relay.TABLE})`).run();
+ await db.prepare(`UPDATE ${relay.TABLE} SET sent_at = NULL, next_attempt_at = 0 WHERE id = (SELECT MAX(id) FROM ${relay.TABLE})`).run();
flushed = await outbox.flush();
flushed = await outbox.flush();
assert.strictEqual(flushed.sent, 1);
assert.strictEqual(flushed.sent, 1);
assert.strictEqual(stored.size, all.length, 'Events stored each event once');
assert.strictEqual(stored.size, all.length, 'Events stored each event once');
// A restart backfills nothing new and sends nothing twice.
// A restart backfills nothing new and sends nothing twice.
await relay.stopRelay(db);
await relay.stopRelay(db);
- outbox = relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
- assert.strictEqual(outbox.pending(), 0);
+ outbox = await relay.startRelay(db, { eventsUrl, privateKey: keys.privateKey, issuer: ISSUER, autoStart: false, log: quiet });
+ assert.strictEqual(await outbox.pending(), 0);
const n = received.length;
const n = received.length;
await outbox.flush();
await outbox.flush();
assert.strictEqual(received.length, n);
assert.strictEqual(received.length, n);
// The audit and the outbox row are one transaction: a failing enqueue leaves no audit row.
// The audit and the outbox row are one transaction: a failing enqueue leaves no audit row.
- const auditCount = db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get().n;
+ const auditCount = (await db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get()).n;
const origEnqueue = outbox.enqueue;
const origEnqueue = outbox.enqueue;
outbox.enqueue = () => { throw new Error('boom'); };
outbox.enqueue = () => { throw new Error('boom'); };
r = await api('owner', 'POST', `/${P}/apps`, { name: 'atomic', environment: 'sandbox', type: 'confidential' });
r = await api('owner', 'POST', `/${P}/apps`, { name: 'atomic', environment: 'sandbox', type: 'confidential' });
assert.strictEqual(r.status, 500);
assert.strictEqual(r.status, 500);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get().n, auditCount, 'no audit row without its outbox row');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dev_audit').get()).n, auditCount, 'no audit row without its outbox row');
outbox.enqueue = origEnqueue;
outbox.enqueue = origEnqueue;
await relay.stopRelay(db);
await relay.stopRelay(db);
server.close(); events.close();
server.close(); events.close();
console.log('developer defaults and events relay: all checks passed');
console.log('developer defaults and events relay: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -13,21 +13,22 @@ const http = require('http');
@@ -13,21 +13,22 @@ const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { serviceAuth, validate, capabilities } = require('openvibe-contracts');
const { serviceAuth, validate, capabilities } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const tokens = require('../server/developer/tokens');
const tokens = require('../server/developer/tokens');
+(async () => {
const logged = [];
const logged = [];
const orig = { log: console.log, warn: console.warn, error: console.error };
const orig = { log: console.log, warn: console.warn, error: console.error };
for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };
for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };
const out = (...a) => orig.log(...a);
const out = (...a) => orig.log(...a);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-exporttokens-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-exporttokens-'));
-const db = initDb(path.join(dir, 'network.db'));
-db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+const db = getDb();
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'),
(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'),
(14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();
(14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();
-const sid = (id) => subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -59,9 +60,9 @@ const minted = [];
@@ -59,9 +60,9 @@ const minted = [];
const text = await r.text();
const text = await r.text();
return { status: r.status, cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
return { status: r.status, cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
};
};
- const mint = (who, project, body) => api(who, 'POST', `/${project}/export-tokens`, body);
+ const mint = async (who, project, body) => await api(who, 'POST', `/${project}/export-tokens`, body);
const verify = (t, audience, o = {}) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience, acceptSandbox: true, ...o });
const verify = (t, audience, o = {}) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience, acceptSandbox: true, ...o });
- const exportRows = () => db.prepare("SELECT * FROM dev_audit WHERE action = 'project.export_token_issued' ORDER BY id").all();
+ const exportRows = async () => await db.prepare("SELECT * FROM dev_audit WHERE action = 'project.export_token_issued' ORDER BY id").all();
// A project with one member of each role, and a project the owner is not in.
// A project with one member of each role, and a project the owner is not in.
let r = await api('owner', 'POST', '', { name: 'Exported' });
let r = await api('owner', 'POST', '', { name: 'Exported' });
@@ -95,7 +96,7 @@ const minted = [];
@@ -95,7 +96,7 @@ const minted = [];
assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project\'s namespaces, both environments');
assert.deepStrictEqual(v.claims.ns, [P, `app.${P}.*`], 'the project\'s namespaces, both environments');
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.project_id, P);
assert.strictEqual(v.claims.env, 'production');
assert.strictEqual(v.claims.env, 'production');
- assert.strictEqual(v.claims.on_behalf_of, sid(10), 'the person exporting');
+ assert.strictEqual(v.claims.on_behalf_of, await sid(10), 'the person exporting');
assert.strictEqual(v.claims.purpose, 'export');
assert.strictEqual(v.claims.purpose, 'export');
assert.strictEqual(v.claims.exp - v.claims.iat, 300, 'five minutes');
assert.strictEqual(v.claims.exp - v.claims.iat, 300, 'five minutes');
assert.ok(Math.abs(v.claims.iat - Date.now() / 1000) < 5);
assert.ok(Math.abs(v.claims.iat - Date.now() / 1000) < 5);
@@ -121,12 +122,12 @@ const minted = [];
@@ -121,12 +122,12 @@ const minted = [];
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
assert.deepStrictEqual(v.claims.cap, ['events.app.read']);
assert.deepStrictEqual(v.claims.cap, ['events.app.read']);
assert.strictEqual(v.claims.env, 'sandbox');
assert.strictEqual(v.claims.env, 'sandbox');
- assert.strictEqual(v.claims.on_behalf_of, sid(11));
+ assert.strictEqual(v.claims.on_behalf_of, await sid(11));
assert.strictEqual(verify(r.body.access_token, 'openvibe.events', { acceptSandbox: false }).code, 'token.sandbox_refused',
assert.strictEqual(verify(r.body.access_token, 'openvibe.events', { acceptSandbox: false }).code, 'token.sandbox_refused',
'a receiver that did not opt in to sandbox tokens still refuses it');
'a receiver that did not opt in to sandbox tokens still refuses it');
// ── Everyone else is refused, and nothing is minted or audited for them ──
// ── Everyone else is refused, and nothing is minted or audited for them ──
- const before = exportRows().length;
+ const before = (await exportRows()).length;
r = await mint('dev', P, { audience: 'openvibe.media', env: 'sandbox' });
r = await mint('dev', P, { audience: 'openvibe.media', env: 'sandbox' });
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'project.forbidden', 'a developer may not export');
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'project.forbidden', 'a developer may not export');
r = await mint('viewer', P, { audience: 'openvibe.media', env: 'sandbox' });
r = await mint('viewer', P, { audience: 'openvibe.media', env: 'sandbox' });
@@ -154,7 +155,7 @@ const minted = [];
@@ -154,7 +155,7 @@ const minted = [];
assert.strictEqual(r.status, 422, JSON.stringify(body));
assert.strictEqual(r.status, 422, JSON.stringify(body));
assert.strictEqual(r.body.code, code, JSON.stringify(body));
assert.strictEqual(r.body.code, code, JSON.stringify(body));
}
}
- assert.strictEqual(exportRows().length, before, 'refusals write no export audit row');
+ assert.strictEqual((await exportRows()).length, before, 'refusals write no export audit row');
// ── The export principal opens nothing else ──
// ── The export principal opens nothing else ──
r = await api(minted[0], 'GET', `/${P}`);
r = await api(minted[0], 'GET', `/${P}`);
@@ -166,10 +167,10 @@ const minted = [];
@@ -166,10 +167,10 @@ const minted = [];
assert.deepStrictEqual(r.body.apps.map(a => a.id), [realApp], 'it is not listed as an app');
assert.deepStrictEqual(r.body.apps.map(a => a.id), [realApp], 'it is not listed as an app');
// ── The audit: one row per token, with who, what and until when; never the token ──
// ── The audit: one row per token, with who, what and until when; never the token ──
- let rows = exportRows();
+ let rows = await exportRows();
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows.length, 2);
assert.deepStrictEqual(rows.map(x => [x.project_id, x.actor, x.target]),
assert.deepStrictEqual(rows.map(x => [x.project_id, x.actor, x.target]),
- [[P, `user:${sid(10)}`, tokens.exportSubject(P)], [P, `user:${sid(11)}`, tokens.exportSubject(P)]]);
+ [[P, `user:${await sid(10)}`, tokens.exportSubject(P)], [P, `user:${await sid(11)}`, tokens.exportSubject(P)]]);
const d0 = JSON.parse(rows[0].detail);
const d0 = JSON.parse(rows[0].detail);
const c0 = verify(minted[0], 'openvibe.media').claims;
const c0 = verify(minted[0], 'openvibe.media').claims;
assert.deepStrictEqual(d0, { audience: 'openvibe.media', env: 'production', cap: ['media.object.list', 'media.object.read'], jti: c0.jti,
assert.deepStrictEqual(d0, { audience: 'openvibe.media', env: 'production', cap: ['media.object.list', 'media.object.read'], jti: c0.jti,
@@ -184,16 +185,17 @@ const minted = [];
@@ -184,16 +185,17 @@ const minted = [];
r = await mint('owner', P, { audience: 'openvibe.events', env: 'production' });
r = await mint('owner', P, { audience: 'openvibe.events', env: 'production' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
minted.push(r.body.access_token);
minted.push(r.body.access_token);
- assert.strictEqual(exportRows().length, 3);
+ assert.strictEqual((await exportRows()).length, 3);
// ── Tokens are never stored or logged ──
// ── Tokens are never stored or logged ──
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(x => x.name);
+ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map(x => x.name);
for (const t of minted) {
for (const t of minted) {
const sig = t.split('.')[2];
const sig = t.split('.')[2];
- for (const name of tables) assert.ok(!JSON.stringify(db.prepare(`SELECT * FROM "${name}"`).all()).includes(sig), `a token in ${name}`);
+ for (const name of tables) assert.ok(!JSON.stringify(await db.prepare(`SELECT * FROM "${name}"`).all()).includes(sig), `a token in ${name}`);
assert.ok(!logged.some(l => l.includes(sig)), 'a token in the logs');
assert.ok(!logged.some(l => l.includes(sig)), 'a token in the logs');
}
}
out('export tokens: all checks passed');
out('export tokens: all checks passed');
server.close();
server.close();
})().catch(err => { orig.error(err); process.exit(1); });
})().catch(err => { orig.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -11,21 +11,22 @@ const http = require('http');
@@ -11,21 +11,22 @@ const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { serviceAuth, validate } = require('openvibe-contracts');
const { serviceAuth, validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const policy = require('../server/developer/policy');
const policy = require('../server/developer/policy');
// Everything the server logs is captured, so the test can prove no secret was ever logged.
// Everything the server logs is captured, so the test can prove no secret was ever logged.
+(async () => {
const logged = [];
const logged = [];
const orig = { log: console.log, warn: console.warn, error: console.error };
const orig = { log: console.log, warn: console.warn, error: console.error };
for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };
for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };
const out = (...a) => orig.log(...a);
const out = (...a) => orig.log(...a);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devprojects-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-devprojects-'));
-const db = initDb(path.join(dir, 'network.db'));
-db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+const db = getDb();
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
-const sid = (id) => subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -83,7 +84,7 @@ const secretsSeen = [];
@@ -83,7 +84,7 @@ const secretsSeen = [];
const P = r.body.id;
const P = r.body.id;
assert.match(P, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.match(P, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(r.body.role, 'owner');
assert.strictEqual(r.body.role, 'owner');
- assert.deepStrictEqual(r.body.owner, { type: 'user', id: sid(10) });
+ assert.deepStrictEqual(r.body.owner, { type: 'user', id: await sid(10) });
assert.deepStrictEqual(r.body.environments, ['sandbox'], 'new projects are sandbox-only');
assert.deepStrictEqual(r.body.environments, ['sandbox'], 'new projects are sandbox-only');
assert.deepStrictEqual(r.body.allowance, []);
assert.deepStrictEqual(r.body.allowance, []);
@@ -102,7 +103,7 @@ const secretsSeen = [];
@@ -102,7 +103,7 @@ const secretsSeen = [];
// ── Members ──
// ── Members ──
r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });
r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
- r = await api('owner', 'POST', `/${P}/members`, { subject_id: sid(12), role: 'viewer' });
+ r = await api('owner', 'POST', `/${P}/members`, { subject_id: await sid(12), role: 'viewer' });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
r = await api('owner', 'POST', `/${P}/members`, { username: 'staff', role: 'owner' });
r = await api('owner', 'POST', `/${P}/members`, { username: 'staff', role: 'owner' });
assert.strictEqual(r.status, 403, 'ownership is not assignable');
assert.strictEqual(r.status, 403, 'ownership is not assignable');
@@ -200,7 +201,7 @@ const secretsSeen = [];
@@ -200,7 +201,7 @@ const secretsSeen = [];
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
const forged = (env) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `app:${A}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['identity.subject.resolve'],
const forged = (env) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `app:${A}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['identity.subject.resolve'],
project_id: P, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
project_id: P, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
- const resolve = (tok) => fetch(`${base}/internal/identity/resolve?subject_id=${sid(10)}`, { headers: { authorization: `Bearer ${tok}` } }).then(async x => ({ status: x.status, body: await x.json() }));
+ const resolve = async (tok) => fetch(`${base}/internal/identity/resolve?subject_id=${await sid(10)}`, { headers: { authorization: `Bearer ${tok}` } }).then(async x => ({ status: x.status, body: await x.json() }));
r = await resolve(forged('sandbox'));
r = await resolve(forged('sandbox'));
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.sandbox_refused', 'Network (a production audience) refuses sandbox tokens');
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.sandbox_refused', 'Network (a production audience) refuses sandbox tokens');
r = await resolve(forged('production'));
r = await resolve(forged('production'));
@@ -282,7 +283,7 @@ const secretsSeen = [];
@@ -282,7 +283,7 @@ const secretsSeen = [];
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
v = verify(t.body.access_token);
v = verify(t.body.access_token);
assert.strictEqual(v.claims.sub, `app:${B}`);
assert.strictEqual(v.claims.sub, `app:${B}`);
- assert.strictEqual(v.claims.on_behalf_of, sid(11));
+ assert.strictEqual(v.claims.on_behalf_of, await sid(11));
assert.deepStrictEqual(v.claims.cap, ['media.object.upload']);
assert.deepStrictEqual(v.claims.cap, ['media.object.upload']);
t = await exchange(code, { code_verifier: verifier });
t = await exchange(code, { code_verifier: verifier });
assert.strictEqual(t.status, 400, 'codes are single-use');
assert.strictEqual(t.status, 400, 'codes are single-use');
@@ -339,13 +340,13 @@ const secretsSeen = [];
@@ -339,13 +340,13 @@ const secretsSeen = [];
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const types = new Set(r.body.entries.map(e => e.event_type).filter(Boolean));
const types = new Set(r.body.entries.map(e => e.event_type).filter(Boolean));
for (const e of ['network.app.created', 'network.app.revoked', 'network.credential.rotated', 'network.credential.revoked', 'network.grant.changed']) assert.ok(types.has(e), `event ${e}`);
for (const e of ['network.app.created', 'network.app.revoked', 'network.credential.rotated', 'network.credential.revoked', 'network.grant.changed']) assert.ok(types.has(e), `event ${e}`);
- for (const row of db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL').all()) {
+ for (const row of await db.prepare('SELECT event FROM dev_audit WHERE event IS NOT NULL').all()) {
const env = JSON.parse(row.event);
const env = JSON.parse(row.event);
assert.ok(validate('events.event-envelope@1', env).valid, JSON.stringify(env));
assert.ok(validate('events.event-envelope@1', env).valid, JSON.stringify(env));
assert.strictEqual(env.source, 'network');
assert.strictEqual(env.source, 'network');
}
}
- assert.throws(() => db.prepare('UPDATE dev_audit SET action = ?').run('x'), /append-only/);
- assert.throws(() => db.prepare('DELETE FROM dev_audit').run(), /append-only/);
+ await assert.rejects(async () => await db.prepare('UPDATE dev_audit SET action = ?').run('x'), /append-only/);
+ await assert.rejects(async () => await db.prepare('DELETE FROM dev_audit').run(), /append-only/);
// ── Archive revokes every app ──
// ── Archive revokes every app ──
r = await api('dev', 'POST', `/${P}/archive`);
r = await api('dev', 'POST', `/${P}/archive`);
@@ -358,10 +359,10 @@ const secretsSeen = [];
@@ -358,10 +359,10 @@ const secretsSeen = [];
assert.strictEqual(r.status, 409);
assert.strictEqual(r.status, 409);
// ── Secrets: never stored in plaintext, never logged ──
// ── Secrets: never stored in plaintext, never logged ──
- const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(x => x.name);
+ const tables = (await db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()").all()).map(x => x.name);
for (const s of secretsSeen) {
for (const s of secretsSeen) {
for (const name of tables) {
for (const name of tables) {
- const dump = JSON.stringify(db.prepare(`SELECT * FROM "${name}"`).all());
+ const dump = JSON.stringify(await db.prepare(`SELECT * FROM "${name}"`).all());
assert.ok(!dump.includes(s), `secret stored in plaintext in ${name}`);
assert.ok(!dump.includes(s), `secret stored in plaintext in ${name}`);
}
}
assert.ok(!logged.some(l => l.includes(s)), 'secret appeared in logs');
assert.ok(!logged.some(l => l.includes(s)), 'secret appeared in logs');
@@ -370,3 +371,4 @@ const secretsSeen = [];
@@ -370,3 +371,4 @@ const secretsSeen = [];
out('developer projects: all checks passed');
out('developer projects: all checks passed');
server.close();
server.close();
})().catch(err => { orig.error(err); process.exit(1); });
})().catch(err => { orig.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -1,26 +1,56 @@
@@ -1,26 +1,56 @@
-const Database=require('better-sqlite3');const express=require('express');const assert=require('assert');
-const db=new Database(':memory:');db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO users VALUES(1,'alex'),(2,'mod')");
-process.env.OWNER_USERNAME=process.env.OWNER_USERNAME||'alex';
-const {createDomainRoutes}=require('../server/domains/routes');
-const {ownerName}=require('../server/auth/owner-guard');
-const owner=ownerName();
-const auth=(req,res,next)=>{const w=req.headers['x-as'];if(!w)return res.status(401).json({error:'auth'});req.user=w==='owner'?{id:1,username:owner,role:'admin'}:{id:2,username:'mod',role:'admin'};next();};
-const cat={getCatalog:async()=>({source:'live',catalog:{tools:[{id:'yt',hosts:{canonical:'youtube-downloader.openvibe.tools',short:'yt.openvibe.tools',aliases:[]}}],families:[]}})};
-const r=createDomainRoutes(db,auth,{catalog:cat,checkDomain:async()=>({ok:true})});
-const app=express();app.use(express.json());app.use('/api/domains',r.publicRouter);app.use('/api/admin/domains',r.adminRouter);
-const s=app.listen(0,async()=>{const b='http://127.0.0.1:'+s.address().port;const j=async(m,p,as,body)=>{const x=await fetch(b+p,{method:m,headers:{'content-type':'application/json',...(as?{'x-as':as}:{})},body:body?JSON.stringify(body):undefined});return{s:x.status,j:await x.json().catch(()=>null)}};
-let x=await j('GET','/api/admin/domains');assert.equal(x.s,401);
-x=await j('GET','/api/admin/domains','admin');assert.equal(x.s,403,'admin non-owner refused');
-x=await j('POST','/api/admin/domains','admin',{tool_id:'yt',host:'evil.com',role:'canonical'});assert.equal(x.s,403);
-x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'YoutubeDownloadOnline.com',role:'canonical'});assert.equal(x.s<300,true,JSON.stringify(x.j));
-x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'other.com',role:'canonical'});assert.ok(x.s<300);
-x=await j('POST','/api/admin/domains','owner',{tool_id:'nope',host:'a.com',role:'alias'});assert.equal(x.s,400);
-x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'bad host/../',role:'alias'});assert.equal(x.s,400);
-x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'youtubedownloader.openvibe.tools',role:'mirror'});assert.ok(x.s<300,'mirror role accepted: '+JSON.stringify(x.j));
-x=await j('POST','/api/admin/domains','owner',{tool_id:'yt',host:'ytmirror.example.com',role:'mirror'});assert.ok(x.s<300,'a tool may have several mirrors');
-x=await j('GET','/api/domains');console.log(x.s,JSON.stringify(x.j.domains));
-assert.equal(x.j.domains.filter(d=>d.role==='canonical').length,1);
-assert.equal(x.j.domains.filter(d=>d.role==='mirror').length,2);
-// an old table (CHECK without 'mirror') is rebuilt in place, rows kept
-{const D=require('better-sqlite3');const old=new D(':memory:');old.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT);CREATE TABLE audit_log(id INTEGER PRIMARY KEY,user_id INT,action TEXT,details TEXT,ip TEXT);CREATE TABLE tool_domains (id INTEGER PRIMARY KEY AUTOINCREMENT, tool_id TEXT NOT NULL, host TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias')), enabled INTEGER NOT NULL DEFAULT 1, note TEXT, created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','keep.example.com','alias')");require('../server/domains/routes').ensureSchema(old);old.prepare("INSERT INTO tool_domains (tool_id,host,role) VALUES ('yt','m.example.com','mirror')").run();assert.equal(old.prepare('SELECT COUNT(*) c FROM tool_domains').get().c,2,'rows survive the rebuild');}
-console.log('audit rows',db.prepare('select count(*) c from audit_log').get().c);console.log('domains: all checks passed');s.close();});
+'use strict';
+// Tool domains (server/domains/routes.js): who may add a host, what is refused, one canonical and one
+// short host per tool, many mirrors and aliases, and the public list. The database is the process one
+// (test/helpers/pg-preload.mjs); the old "rebuild a legacy table in place" drill is gone with SQLite
+// (the schema is migrations/NNNN_*.sql now).
+const express = require('express');
+const assert = require('assert');
+const { getDb } = require('../server/db/database');
+
+process.env.OWNER_USERNAME = process.env.OWNER_USERNAME || 'alex';
+
+(async () => {
+const db = await getDb();
+await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'alex', 'x', 'admin'), (2, 'mod', 'x', 'admin') ON CONFLICT (id) DO NOTHING").run();
+
+const { createDomainRoutes } = require('../server/domains/routes');
+const { ownerName } = require('../server/auth/owner-guard');
+const owner = ownerName();
+const auth = (req, res, next) => {
+ const w = req.headers['x-as'];
+ if (!w) return res.status(401).json({ error: 'auth' });
+ req.user = w === 'owner' ? { id: 1, username: owner, role: 'admin' } : { id: 2, username: 'mod', role: 'admin' };
+ next();
+};
+const cat = { getCatalog: async () => ({ source: 'live', catalog: { tools: [{ id: 'yt', hosts: { canonical: 'youtube-downloader.openvibe.tools', short: 'yt.openvibe.tools', aliases: [] } }], families: [] } }) };
+const r = await createDomainRoutes(db, auth, { catalog: cat, checkDomain: async () => ({ ok: true }) });
+const app = express();
+app.use(express.json());
+app.use('/api/domains', r.publicRouter);
+app.use('/api/admin/domains', r.adminRouter);
+await new Promise((resolve) => {
+ const s = app.listen(0, async () => {
+ const b = 'http://127.0.0.1:' + s.address().port;
+ const j = async (m, p, as, body) => {
+ const x = await fetch(b + p, { method: m, headers: { 'content-type': 'application/json', ...(as ? { 'x-as': as } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ return { s: x.status, j: await x.json().catch(() => null) };
+ };
+ let x = await j('GET', '/api/admin/domains'); assert.equal(x.s, 401);
+ x = await j('GET', '/api/admin/domains', 'admin'); assert.equal(x.s, 403, 'admin non-owner refused');
+ x = await j('POST', '/api/admin/domains', 'admin', { tool_id: 'yt', host: 'evil.com', role: 'canonical' }); assert.equal(x.s, 403);
+ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'YoutubeDownloadOnline.com', role: 'canonical' }); assert.equal(x.s < 300, true, JSON.stringify(x.j));
+ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'other.com', role: 'canonical' }); assert.ok(x.s < 300);
+ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'nope', host: 'a.com', role: 'alias' }); assert.equal(x.s, 400);
+ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'bad host/../', role: 'alias' }); assert.equal(x.s, 400);
+ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'youtubedownloader.openvibe.tools', role: 'mirror' }); assert.ok(x.s < 300, 'mirror role accepted: ' + JSON.stringify(x.j));
+ x = await j('POST', '/api/admin/domains', 'owner', { tool_id: 'yt', host: 'ytmirror.example.com', role: 'mirror' }); assert.ok(x.s < 300, 'a tool may have several mirrors');
+ x = await j('GET', '/api/domains');
+ assert.equal(x.j.domains.filter(d => d.role === 'canonical').length, 1);
+ assert.equal(x.j.domains.filter(d => d.role === 'mirror').length, 2);
+ console.log('audit rows', (await db.prepare('select count(*) c from audit_log').get()).c);
+ console.log('domains: all checks passed');
+ s.close();
+ resolve();
+ });
+});
+})().catch(err => { console.error(err); process.exit(1); });
@@ -13,20 +13,21 @@ const express = require('express');
@@ -13,20 +13,21 @@ const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { ids, serviceAuth } = require('openvibe-contracts');
const { ids, serviceAuth } = require('openvibe-contracts');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { NotificationService } = require('../server/notifications/notification-service');
const { NotificationService } = require('../server/notifications/notification-service');
const { createEventsConsumer, TOPICS } = require('../server/notifications/events-consumer');
const { createEventsConsumer, TOPICS } = require('../server/notifications/events-consumer');
const { createLiveFollowers } = require('../server/notifications/live-followers');
const { createLiveFollowers } = require('../server/notifications/live-followers');
const streamLive = require('../server/notifications/stream-live');
const streamLive = require('../server/notifications/stream-live');
const { topicsFrom } = require('../scripts/subscribe-events');
const { topicsFrom } = require('../scripts/subscribe-events');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-events-consumer-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-events-consumer-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
const ALICE = ids.newId('user'), BOB = ids.newId('user'), NOBODY = ids.newId('user');
const ALICE = ids.newId('user'), BOB = ids.newId('user'), NOBODY = ids.newId('user');
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id, email, email_verified) VALUES (7, 'alice', 'x', ?, '[email protected]', 1), (8, 'bob', 'x', ?, NULL, 0)").run(ALICE, BOB);
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, email, email_verified) VALUES (7, 'alice', 'x', ?, '[email protected]', 1), (8, 'bob', 'x', ?, NULL, 0)").run(ALICE, BOB);
const notifications = new NotificationService(db);
const notifications = new NotificationService(db);
const SECRET = 'a'.repeat(40), NEXT = 'b'.repeat(40);
const SECRET = 'a'.repeat(40), NEXT = 'b'.repeat(40);
@@ -54,7 +55,7 @@ const tradeEvent = (over = {}) => ({
@@ -54,7 +55,7 @@ const tradeEvent = (over = {}) => ({
},
},
...over,
...over,
});
});
-const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at, rowid').all(userId);
+const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at, seq').all(userId);
(async () => {
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
// The subscriptions this consumer is meant for, and nothing else.
@@ -65,7 +66,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -65,7 +66,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);
assert.deepStrictEqual(topicsFrom(['--topic', 'live.stream.started']), ['live.stream.started']);
assert.throws(() => topicsFrom(['--topic', 'live.*']));
assert.throws(() => topicsFrom(['--topic', 'live.*']));
- let consumer = createEventsConsumer({ db, notifications, secrets: '' });
+ let consumer = await createEventsConsumer({ db, notifications, secrets: '' });
const app = express();
const app = express();
app.use('/internal/events', (req, res, next) => consumer.router(req, res, next));
app.use('/internal/events', (req, res, next) => consumer.router(req, res, next));
app.use(express.json()); // as in server/index.js: the consumer runs before the JSON parser
app.use(express.json()); // as in server/index.js: the consumer runs before the JSON parser
@@ -84,9 +85,9 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -84,9 +85,9 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
let r = await post(dealsEvent());
let r = await post(dealsEvent());
assert.strictEqual(r.status, 503);
assert.strictEqual(r.status, 503);
assert.strictEqual(r.body.code, 'network.webhook_disabled');
assert.strictEqual(r.body.code, 'network.webhook_disabled');
- assert.strictEqual(rows(7).length, 0);
+ assert.strictEqual((await rows(7)).length, 0);
- consumer = createEventsConsumer({ db, notifications, secrets: `${SECRET},${NEXT}` });
+ consumer = await createEventsConsumer({ db, notifications, secrets: `${SECRET},${NEXT}` });
assert.strictEqual(consumer.enabled, true);
assert.strictEqual(consumer.enabled, true);
// A signed v2 Deals watch match becomes one notification for the watcher.
// A signed v2 Deals watch match becomes one notification for the watcher.
@@ -94,7 +95,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -94,7 +95,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
r = await post(e1);
r = await post(e1);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(r.body, { event_id: e1.event_id, duplicate: false, outcome: 'notified' });
assert.deepStrictEqual(r.body, { event_id: e1.event_id, duplicate: false, outcome: 'notified' });
- let mine = rows(7);
+ let mine = await rows(7);
assert.strictEqual(mine.length, 1);
assert.strictEqual(mine.length, 1);
assert.strictEqual(mine[0].type, 'DEAL_WATCH_MATCH');
assert.strictEqual(mine[0].type, 'DEAL_WATCH_MATCH');
assert.strictEqual(mine[0].category, 'service');
assert.strictEqual(mine[0].category, 'service');
@@ -109,7 +110,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -109,7 +110,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.body.duplicate, true);
assert.strictEqual(r.body.duplicate, true);
assert.strictEqual(r.body.outcome, null);
assert.strictEqual(r.body.outcome, null);
- assert.strictEqual(rows(7).length, 1);
+ assert.strictEqual((await rows(7)).length, 1);
// v1-only (the v2 headers stripped), stale v2, wrong secret, unsigned: refused, nothing stored.
// v1-only (the v2 headers stripped), stale v2, wrong secret, unsigned: refused, nothing stored.
for (const [label, opts] of [
for (const [label, opts] of [
@@ -122,14 +123,14 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -122,14 +123,14 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
r = await post(e, opts);
r = await post(e, opts);
assert.strictEqual(r.status, 401, label);
assert.strictEqual(r.status, 401, label);
assert.strictEqual(r.body.code, 'network.bad_signature', label);
assert.strictEqual(r.body.code, 'network.bad_signature', label);
- assert.ok(!db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(e.event_id), `${label}: not even recorded`);
+ assert.ok(!await db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(e.event_id), `${label}: not even recorded`);
}
}
- assert.strictEqual(rows(7).length, 1);
+ assert.strictEqual((await rows(7)).length, 1);
// The rotated-in secret verifies too.
// The rotated-in secret verifies too.
r = await post(dealsEvent({ payload: { ...dealsEvent().payload, kind: 'keyword', query: 'headphones', offer_id: 'dof_2' } }), { secret: NEXT });
r = await post(dealsEvent({ payload: { ...dealsEvent().payload, kind: 'keyword', query: 'headphones', offer_id: 'dof_2' } }), { secret: NEXT });
assert.strictEqual(r.body.outcome, 'notified');
assert.strictEqual(r.body.outcome, 'notified');
- assert.match(rows(7)[1].message, /\(matches "headphones"\)$/);
+ assert.match((await rows(7))[1].message, /\(matches "headphones"\)$/);
// Unknown types are acknowledged and ignored (and recorded, so a redelivery is a duplicate).
// Unknown types are acknowledged and ignored (and recorded, so a redelivery is a duplicate).
const other = dealsEvent({ event_type: 'deals.offer.created' });
const other = dealsEvent({ event_type: 'deals.offer.created' });
@@ -139,7 +140,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -139,7 +140,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
assert.strictEqual(r.body.duplicate, true);
assert.strictEqual(r.body.duplicate, true);
r = await post(dealsEvent({ event_type: 'constructor' }));
r = await post(dealsEvent({ event_type: 'constructor' }));
assert.strictEqual(r.body.outcome, 'ignored:type', 'no prototype lookups');
assert.strictEqual(r.body.outcome, 'ignored:type', 'no prototype lookups');
- assert.strictEqual(rows(7).length, 2);
+ assert.strictEqual((await rows(7)).length, 2);
// A publisher may not speak for another service; an unknown person gets nothing.
// A publisher may not speak for another service; an unknown person gets nothing.
r = await post(dealsEvent({ source: 'trade' }));
r = await post(dealsEvent({ source: 'trade' }));
@@ -152,7 +153,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -152,7 +153,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
// A Trade alert reaches the rule's owner.
// A Trade alert reaches the rule's owner.
r = await post(tradeEvent());
r = await post(tradeEvent());
assert.strictEqual(r.body.outcome, 'notified');
assert.strictEqual(r.body.outcome, 'notified');
- let bobs = rows(8);
+ let bobs = await rows(8);
assert.strictEqual(bobs.length, 1);
assert.strictEqual(bobs.length, 1);
assert.strictEqual(bobs[0].type, 'TRADE_ALERT');
assert.strictEqual(bobs[0].type, 'TRADE_ALERT');
assert.strictEqual(bobs[0].title, 'Trade alert: AAPL');
assert.strictEqual(bobs[0].title, 'Trade alert: AAPL');
@@ -161,31 +162,31 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -161,31 +162,31 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
assert.strictEqual(bobs[0].url, null);
assert.strictEqual(bobs[0].url, null);
r = await post(tradeEvent({ payload: { ...tradeEvent().payload, trigger: { kind: 'document', id: 'doc_1', form_type: '8-K', title: 'Current report' } } }));
r = await post(tradeEvent({ payload: { ...tradeEvent().payload, trigger: { kind: 'document', id: 'doc_1', form_type: '8-K', title: 'Current report' } } }));
assert.strictEqual(r.body.outcome, 'notified');
assert.strictEqual(r.body.outcome, 'notified');
- assert.match(rows(8)[1].message, /New 8-K: Current report\./);
+ assert.match((await rows(8))[1].message, /New 8-K: Current report\./);
r = await post(tradeEvent({ subject: { type: 'watch', id: 'x' } }));
r = await post(tradeEvent({ subject: { type: 'watch', id: 'x' } }));
assert.strictEqual(r.body.outcome, 'ignored:payload');
assert.strictEqual(r.body.outcome, 'ignored:payload');
// A link to a site whose domain serves the service is kept; a foreign host never is.
// A link to a site whose domain serves the service is kept; a foreign host never is.
r = await post(tradeEvent({ payload: { ...tradeEvent().payload, instrument: { symbol: 'X', url: 'https://openvibe.live/x' } } }));
r = await post(tradeEvent({ payload: { ...tradeEvent().payload, instrument: { symbol: 'X', url: 'https://openvibe.live/x' } } }));
- assert.strictEqual(rows(8).pop().url, 'https://openvibe.live/x');
+ assert.strictEqual((await rows(8)).pop().url, 'https://openvibe.live/x');
r = await post(tradeEvent({ payload: { ...tradeEvent().payload, instrument: { symbol: 'Y', url: 'https://evil.example/y' } } }));
r = await post(tradeEvent({ payload: { ...tradeEvent().payload, instrument: { symbol: 'Y', url: 'https://evil.example/y' } } }));
- const last = rows(8).pop();
+ const last = (await rows(8)).pop();
assert.strictEqual(last.url, null);
assert.strictEqual(last.url, null);
assert.strictEqual(JSON.parse(last.rich_content).context.planned_url, null);
assert.strictEqual(JSON.parse(last.rich_content).context.planned_url, null);
// Preferences: with the 'service' category turned off, nothing is created (and it stays handled).
// Preferences: with the 'service' category turned off, nothing is created (and it stays handled).
- const before = rows(8).length;
- notifications.setPreference(8, 'service', { enabled: false });
+ const before = (await rows(8)).length;
+ await notifications.setPreference(8, 'service', { enabled: false });
const muted = tradeEvent();
const muted = tradeEvent();
r = await post(muted);
r = await post(muted);
assert.strictEqual(r.body.outcome, 'suppressed:preference');
assert.strictEqual(r.body.outcome, 'suppressed:preference');
- assert.strictEqual(rows(8).length, before);
+ assert.strictEqual((await rows(8)).length, before);
assert.strictEqual((await post(muted)).body.duplicate, true);
assert.strictEqual((await post(muted)).body.duplicate, true);
// Email follows the same per-category choice: none by default, yes once opted in (verified address).
// Email follows the same per-category choice: none by default, yes once opted in (verified address).
- const n = { ...rows(7)[0], email_verified: 1, email_bounced_at: null };
- assert.strictEqual(notifications.shouldEmail(n), false);
- notifications.setPreference(7, 'service', { email: true });
- assert.strictEqual(notifications.shouldEmail(n), true);
+ const n = { ...(await rows(7))[0], email_verified: 1, email_bounced_at: null };
+ assert.strictEqual(await notifications.shouldEmail(n), false);
+ await notifications.setPreference(7, 'service', { email: true });
+ assert.strictEqual(await notifications.shouldEmail(n), true);
// Malformed envelopes are refused.
// Malformed envelopes are refused.
r = await post({ event_id: 'nope', event_type: 'deals.watch.matched' });
r = await post({ event_id: 'nope', event_type: 'deals.watch.matched' });
@@ -194,11 +195,11 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -194,11 +195,11 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
// ── live.stream.started: every follower of the channel, once ──────────────────────────────
// ── live.stream.started: every follower of the channel, once ──────────────────────────────
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const CAROL = ids.newId('user'), DAVE = ids.newId('user'), FRANK = ids.newId('user'), GINA = ids.newId('user'), HANK = ids.newId('user'), STRANGER = ids.newId('user');
const CAROL = ids.newId('user'), DAVE = ids.newId('user'), FRANK = ids.newId('user'), GINA = ids.newId('user'), HANK = ids.newId('user'), STRANGER = ids.newId('user');
- db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, avatar_url) VALUES
+ await db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, avatar_url) VALUES
(20, 'carol', 'x', ?, 'https://openvibe.media/avatar/carol'), (21, 'dave', 'x', ?, NULL), (22, 'erin', 'x', NULL, NULL),
(20, 'carol', 'x', ?, 'https://openvibe.media/avatar/carol'), (21, 'dave', 'x', ?, NULL), (22, 'erin', 'x', NULL, NULL),
(23, 'frank', 'x', ?, NULL), (24, 'gina', 'x', ?, NULL), (25, 'hank', 'x', ?, NULL)`).run(CAROL, DAVE, FRANK, GINA, HANK);
(23, 'frank', 'x', ?, NULL), (24, 'gina', 'x', ?, NULL), (25, 'hank', 'x', ?, NULL)`).run(CAROL, DAVE, FRANK, GINA, HANK);
- notifications.setPreference(23, 'stream', { enabled: false }); // Frank muted go-lives
- notifications.setPreference(24, 'stream_live_all', { enabled: true }); // Gina wants every go-live
+ await notifications.setPreference(23, 'stream', { enabled: false }); // Frank muted go-lives
+ await notifications.setPreference(24, 'stream_live_all', { enabled: true }); // Gina wants every go-live
// Live's GET /internal/followers, as the Live-side change specifies. Streams: 501 carol, 502 hank.
// Live's GET /internal/followers, as the Live-side change specifies. Streams: 501 carol, 502 hank.
let liveMode = 'ok'; const liveCalls = []; const liveAuth = [];
let liveMode = 'ok'; const liveCalls = []; const liveAuth = [];
const FOLLOWERS = {
const FOLLOWERS = {
@@ -224,7 +225,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -224,7 +225,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
await new Promise(r => live.listen(0, '127.0.0.1', r));
await new Promise(r => live.listen(0, '127.0.0.1', r));
const discordCalls = [];
const discordCalls = [];
let clock = Date.now();
let clock = Date.now();
- consumer = createEventsConsumer({
+ consumer = await createEventsConsumer({
db, notifications, secrets: SECRET, now: () => clock,
db, notifications, secrets: SECRET, now: () => clock,
liveFollowers: createLiveFollowers({ privateKey, issuer: 'https://openvibe.network', liveUrl: `http://127.0.0.1:${live.address().port}` }),
liveFollowers: createLiveFollowers({ privateKey, issuer: 'https://openvibe.network', liveUrl: `http://127.0.0.1:${live.address().port}` }),
discord: () => ({ sendLiveAlert: async (streamer, stream) => { discordCalls.push([streamer, stream]); return { sent: true }; } }),
discord: () => ({ sendLiveAlert: async (streamer, stream) => { discordCalls.push([streamer, stream]); return { sent: true }; } }),
@@ -239,7 +240,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -239,7 +240,7 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
},
},
...over,
...over,
});
});
- const goLives = (uid) => db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'STREAM_LIVE' ORDER BY rowid").all(uid);
+ const goLives = async (uid) => await db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'STREAM_LIVE' ORDER BY seq").all(uid);
// One delivery: Dave (by subject), Erin (by network id, no subject yet) and Gina (all go-lives) are
// One delivery: Dave (by subject), Erin (by network id, no subject yet) and Gina (all go-lives) are
// notified once each; Frank muted the category, Carol is the streamer, the stranger has no account.
// notified once each; Frank muted the category, Carol is the streamer, the stranger has no account.
@@ -248,10 +249,10 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -248,10 +249,10 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.body.outcome, 'notified');
assert.strictEqual(r.body.outcome, 'notified');
assert.deepStrictEqual(r.body.detail, { followers: 5, unresolved: 1, targets: 4, notified: 3 });
assert.deepStrictEqual(r.body.detail, { followers: 5, unresolved: 1, targets: 4, notified: 3 });
- for (const uid of [21, 22, 24]) assert.strictEqual(goLives(uid).length, 1, `user ${uid} is told once`);
- assert.strictEqual(goLives(23).length, 0, 'the stream category is muted: preferences apply');
- assert.strictEqual(goLives(20).length, 0, 'the streamer is not told about themself');
- const n1 = goLives(21)[0];
+ for (const uid of [21, 22, 24]) assert.strictEqual((await goLives(uid)).length, 1, `user ${uid} is told once`);
+ assert.strictEqual((await goLives(23)).length, 0, 'the stream category is muted: preferences apply');
+ assert.strictEqual((await goLives(20)).length, 0, 'the streamer is not told about themself');
+ const n1 = (await goLives(21))[0];
assert.strictEqual(n1.title, 'Carol 3 is live!');
assert.strictEqual(n1.title, 'Carol 3 is live!');
assert.strictEqual(n1.message, 'Building a b robot /b', 'no markup reaches the inbox');
assert.strictEqual(n1.message, 'Building a b robot /b', 'no markup reaches the inbox');
assert.strictEqual(n1.category, 'stream');
assert.strictEqual(n1.category, 'stream');
@@ -279,41 +280,41 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -279,41 +280,41 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
r = await post(s1);
r = await post(s1);
assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, true, null]);
assert.deepStrictEqual([r.status, r.body.duplicate, r.body.outcome], [200, true, null]);
assert.strictEqual(liveCalls.length, 0);
assert.strictEqual(liveCalls.length, 0);
- for (const uid of [21, 22, 24]) assert.strictEqual(goLives(uid).length, 1);
+ for (const uid of [21, 22, 24]) assert.strictEqual((await goLives(uid)).length, 1);
assert.strictEqual(discordCalls.length, 1);
assert.strictEqual(discordCalls.length, 1);
// A new stream row from the same channel within the hour (a reconnect): the announcement window holds.
// A new stream row from the same channel within the hour (a reconnect): the announcement window holds.
r = await post(liveEvent({}, { stream_id: 501 }));
r = await post(liveEvent({}, { stream_id: 501 }));
assert.strictEqual(r.body.outcome, 'skipped:cooldown');
assert.strictEqual(r.body.outcome, 'skipped:cooldown');
- assert.strictEqual(goLives(21).length, 1);
+ assert.strictEqual((await goLives(21)).length, 1);
// ...and it is the same window Live's direct POST /internal/events/stream-live claims (keyed by the Network id),
// ...and it is the same window Live's direct POST /internal/events/stream-live claims (keyed by the Network id),
// so during the switch the two paths never both announce.
// so during the switch the two paths never both announce.
- assert.strictEqual(streamLive.claimAnnouncement(db, { streamerKey: 20, streamId: 501 }).reason, 'cooldown');
+ assert.strictEqual((await streamLive.claimAnnouncement(db, { streamerKey: 20, streamId: 501 })).reason, 'cooldown');
// v1-only (and any unverifiable) live delivery: refused, not recorded, Live never asked.
// v1-only (and any unverifiable) live delivery: refused, not recorded, Live never asked.
liveCalls.length = 0;
liveCalls.length = 0;
const v1 = liveEvent({ subject: { type: 'stream', id: '502', revision: 1 }, actor: { type: 'user', id: HANK } }, { stream_id: 502, channel: { username: 'hank', subject: { type: 'user', id: HANK } } });
const v1 = liveEvent({ subject: { type: 'stream', id: '502', revision: 1 }, actor: { type: 'user', id: HANK } }, { stream_id: 502, channel: { username: 'hank', subject: { type: 'user', id: HANK } } });
r = await post(v1, { strip: ['X-OpenVibe-Signature-V2', 'X-OpenVibe-Timestamp'] });
r = await post(v1, { strip: ['X-OpenVibe-Signature-V2', 'X-OpenVibe-Timestamp'] });
assert.deepStrictEqual([r.status, r.body.code], [401, 'network.bad_signature']);
assert.deepStrictEqual([r.status, r.body.code], [401, 'network.bad_signature']);
- assert.ok(!db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));
+ assert.ok(!await db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));
assert.strictEqual(liveCalls.length, 0);
assert.strictEqual(liveCalls.length, 0);
// Live down: 503, nothing recorded, so Events' retry does the work once Live answers.
// Live down: 503, nothing recorded, so Events' retry does the work once Live answers.
liveMode = 'down';
liveMode = 'down';
r = await post(v1);
r = await post(v1);
assert.deepStrictEqual([r.status, r.body.code], [503, 'network.dependency_unavailable']);
assert.deepStrictEqual([r.status, r.body.code], [503, 'network.dependency_unavailable']);
- assert.ok(!db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));
- assert.strictEqual(goLives(21).length, 1);
+ assert.ok(!await db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(v1.event_id));
+ assert.strictEqual((await goLives(21)).length, 1);
liveMode = 'ok';
liveMode = 'ok';
r = await post(v1);
r = await post(v1);
assert.strictEqual(r.body.outcome, 'notified');
assert.strictEqual(r.body.outcome, 'notified');
- assert.strictEqual(goLives(21).length, 2, 'Dave follows Hank too');
- assert.strictEqual(goLives(24).length, 2, 'Gina gets every go-live');
+ assert.strictEqual((await goLives(21)).length, 2, 'Dave follows Hank too');
+ assert.strictEqual((await goLives(24)).length, 2, 'Gina gets every go-live');
// Refused or ignored without notifying anyone.
// Refused or ignored without notifying anyone.
liveCalls.length = 0;
liveCalls.length = 0;
- const count = () => db.prepare("SELECT COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE'").get().c;
- const total = count();
+ const count = async () => (await db.prepare("SELECT COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE'").get()).c;
+ const total = await count();
r = await post(liveEvent({ source: 'tips' }));
r = await post(liveEvent({ source: 'tips' }));
assert.strictEqual(r.body.outcome, 'ignored:source', 'only Live speaks for Live channels');
assert.strictEqual(r.body.outcome, 'ignored:source', 'only Live speaks for Live channels');
// The signatures are made at the consumer's clock (it moves forward below).
// The signatures are made at the consumer's clock (it moves forward below).
@@ -330,10 +331,11 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
@@ -330,10 +331,11 @@ const rows = (userId) => db.prepare('SELECT * FROM notifications WHERE user_id =
assert.strictEqual(r.body.outcome, 'ignored:channel-mismatch', 'stream 502 is Hank\'s, not Carol\'s');
assert.strictEqual(r.body.outcome, 'ignored:channel-mismatch', 'stream 502 is Hank\'s, not Carol\'s');
r = await post(liveEvent({}, { stream_id: 999 }), { now: clock });
r = await post(liveEvent({}, { stream_id: 999 }), { now: clock });
assert.strictEqual(r.body.outcome, 'ignored:stream');
assert.strictEqual(r.body.outcome, 'ignored:stream');
- assert.strictEqual(count(), total);
+ assert.strictEqual(await count(), total);
assert.strictEqual(discordCalls.length, 2);
assert.strictEqual(discordCalls.length, 2);
live.close();
live.close();
srv.close();
srv.close();
console.log('events consumer: all checks passed');
console.log('events consumer: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -8,15 +8,16 @@ const os = require('os');
@@ -8,15 +8,16 @@ const os = require('os');
const path = require('path');
const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { createEventsOps } = require('../server/admin/events-ops');
const { createEventsOps } = require('../server/admin/events-ops');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-eventsops-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-eventsops-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
-db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'goosely', 'x', 'admin'), (2, 'adminb', 'x', 'admin')").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'goosely', 'x', 'admin'), (2, 'adminb', 'x', 'admin')").run();
const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JRTKS';
const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JRTKS';
(async () => {
(async () => {
@@ -30,7 +31,7 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JR
@@ -30,7 +31,7 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JR
let as = 1;
let as = 1;
const app = express();
const app = express();
- app.use((req, res, next) => { req.user = db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); });
+ app.use(async (req, res, next) => { req.user = await db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); });
app.use('/api/admin/events', createEventsOps({ db, eventsUrl: `http://127.0.0.1:${ev.address().port}`, privateKey, issuer: 'https://openvibe.network' }));
app.use('/api/admin/events', createEventsOps({ db, eventsUrl: `http://127.0.0.1:${ev.address().port}`, privateKey, issuer: 'https://openvibe.network' }));
const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
const base = `http://127.0.0.1:${srv.address().port}`;
const base = `http://127.0.0.1:${srv.address().port}`;
@@ -51,7 +52,7 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JR
@@ -51,7 +52,7 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JR
r = await call('POST', '/api/admin/events/replay', { subscription_id: SUB, event_ids: [EVT] });
r = await call('POST', '/api/admin/events/replay', { subscription_id: SUB, event_ids: [EVT] });
assert.deepStrictEqual(r.body, { ok: true, queued: 1 });
assert.deepStrictEqual(r.body, { ok: true, queued: 1 });
assert.deepStrictEqual(seen.at(-1).body, { subscription_id: SUB, event_ids: [EVT] });
assert.deepStrictEqual(seen.at(-1).body, { subscription_id: SUB, event_ids: [EVT] });
- const audit = db.prepare("SELECT user_id, details FROM audit_log WHERE action = 'events_replay'").all();
+ const audit = await db.prepare("SELECT user_id, details FROM audit_log WHERE action = 'events_replay'").all();
assert.strictEqual(audit.length, 1); assert.strictEqual(audit[0].user_id, 1);
assert.strictEqual(audit.length, 1); assert.strictEqual(audit[0].user_id, 1);
assert.deepStrictEqual(JSON.parse(audit[0].details).event_ids, [EVT]);
assert.deepStrictEqual(JSON.parse(audit[0].details).event_ids, [EVT]);
@@ -64,3 +65,4 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JR
@@ -64,3 +65,4 @@ const SUB = 'sub_01M3D4CMQ6JX9ZR8SS34M1FTC4', EVT = 'evt_01M3D4GT16WXDXGZYRE97JR
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('events ops: all checks passed');
console.log('events ops: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -8,11 +8,12 @@ const assert = require('assert');
@@ -8,11 +8,12 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-notify-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-notify-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = await getDb();
console.log = log;
console.log = log;
require('../server/identity/blocks').ensureSchema(db);
require('../server/identity/blocks').ensureSchema(db);
@@ -20,7 +21,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
@@ -20,7 +21,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
-db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, avatar_url) VALUES
+await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, avatar_url) VALUES
(1, 'ann', 'Ann', 'x', ?, 'https://openvibe.media/avatar/ann'), (2, 'bob', 'Bob', 'x', ?, NULL),
(1, 'ann', 'Ann', 'x', ?, 'https://openvibe.media/avatar/ann'), (2, 'bob', 'Bob', 'x', ?, NULL),
(3, 'cat', NULL, 'x', ?, NULL), (4, 'dan', 'Dan', 'x', ?, NULL)`).run(ANN, BOB, CAT, DAN);
(3, 'cat', NULL, 'x', ?, NULL), (4, 'dan', 'Dan', 'x', ?, NULL)`).run(ANN, BOB, CAT, DAN);
@@ -30,13 +31,13 @@ const follows = require('../server/identity/follows');
@@ -30,13 +31,13 @@ const follows = require('../server/identity/follows');
follows.ensureSchema(db);
follows.ensureSchema(db);
follows.setNotifier(require('../server/notifications/follow-notify').followNotifier(notifications));
follows.setNotifier(require('../server/notifications/follow-notify').followNotifier(notifications));
-const followsOf = (userId) => db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'FOLLOW' ORDER BY created_at").all(userId);
-const announced = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope))
+const followsOf = async (userId) => await db.prepare("SELECT * FROM notifications WHERE user_id = ? AND type = 'FOLLOW' ORDER BY created_at").all(userId);
+const announced = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope)
.filter((e) => e.event_type === 'network.notification.created');
.filter((e) => e.event_type === 'network.notification.created');
// A new follow: one FOLLOW for the followed person, from the follower, linking to the follower's channel.
// A new follow: one FOLLOW for the followed person, from the follower, linking to the follower's channel.
-follows.setFollow(db, ANN, 'channel', BOB, true);
-let n = followsOf(2);
+await follows.setFollow(db, ANN, 'channel', BOB, true);
+let n = await followsOf(2);
assert.strictEqual(n.length, 1);
assert.strictEqual(n.length, 1);
assert.strictEqual(n[0].message, 'Ann followed you');
assert.strictEqual(n[0].message, 'Ann followed you');
assert.strictEqual(n[0].title, 'New Follower');
assert.strictEqual(n[0].title, 'New Follower');
@@ -45,50 +46,54 @@ assert.strictEqual(n[0].service, 'live');
@@ -45,50 +46,54 @@ assert.strictEqual(n[0].service, 'live');
assert.strictEqual(n[0].category, 'social');
assert.strictEqual(n[0].category, 'social');
assert.strictEqual(n[0].sender_id, 1);
assert.strictEqual(n[0].sender_id, 1);
assert.strictEqual(n[0].sender_avatar, 'https://openvibe.media/avatar/ann');
assert.strictEqual(n[0].sender_avatar, 'https://openvibe.media/avatar/ann');
-assert.deepStrictEqual(announced().map((e) => [e.subject.id, e.payload.type]), [[BOB, 'FOLLOW']], 'announced to the followed person');
-assert.strictEqual(followsOf(1).length, 0, 'the follower is not notified');
+assert.deepStrictEqual((await announced()).map((e) => [e.subject.id, e.payload.type]), [[BOB, 'FOLLOW']], 'announced to the followed person');
+assert.strictEqual((await followsOf(1)).length, 0, 'the follower is not notified');
// Same follow again, a flag change, an unfollow, and following again within the hour: nothing new.
// Same follow again, a flag change, an unfollow, and following again within the hour: nothing new.
-follows.setFollow(db, ANN, 'channel', BOB, true);
-follows.setFollow(db, ANN, 'channel', BOB, true, { notifyEmail: false });
-follows.setFollow(db, ANN, 'channel', BOB, false);
-follows.setFollow(db, ANN, 'channel', BOB, true);
-assert.strictEqual(followsOf(2).length, 1, 'one an hour per follower');
-assert.strictEqual(announced().length, 1);
+await follows.setFollow(db, ANN, 'channel', BOB, true);
+await follows.setFollow(db, ANN, 'channel', BOB, true, { notifyEmail: false });
+await follows.setFollow(db, ANN, 'channel', BOB, false);
+await follows.setFollow(db, ANN, 'channel', BOB, true);
+assert.strictEqual((await followsOf(2)).length, 1, 'one an hour per follower');
+assert.strictEqual((await announced()).length, 1);
// Another follower is another notification; a name falls back to the username.
// Another follower is another notification; a name falls back to the username.
-follows.setFollow(db, CAT, 'channel', BOB, true);
-n = followsOf(2);
+await follows.setFollow(db, CAT, 'channel', BOB, true);
+n = await followsOf(2);
assert.strictEqual(n.length, 2);
assert.strictEqual(n.length, 2);
assert.strictEqual(n[1].message, 'cat followed you');
assert.strictEqual(n[1].message, 'cat followed you');
// An import (Live's backfill) announces nothing and notifies nobody.
// An import (Live's backfill) announces nothing and notifies nobody.
-follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);
-assert.ok(follows.status(db, 'channel', ANN, DAN).following, 'imported');
-assert.strictEqual(followsOf(1).length, 0);
+await follows.importFollows(db, 'live', [{ follower_ref: 4, target_ref: 1 }], (ref) => ({ 4: DAN, 1: ANN })[ref]);
+assert.ok((await follows.status(db, 'channel', ANN, DAN)).following, 'imported');
+assert.strictEqual((await followsOf(1)).length, 0);
// The followed person's social preference off: the follow is made, no notification.
// The followed person's social preference off: the follow is made, no notification.
-notifications.setPreference(4, 'social', { enabled: false });
-follows.setFollow(db, ANN, 'channel', DAN, true);
-assert.ok(follows.status(db, 'channel', DAN, ANN).following);
-assert.strictEqual(followsOf(4).length, 0);
+await notifications.setPreference(4, 'social', { enabled: false });
+await follows.setFollow(db, ANN, 'channel', DAN, true);
+assert.ok((await follows.status(db, 'channel', DAN, ANN)).following);
+assert.strictEqual((await followsOf(4)).length, 0);
// A follower the followed person blocked: no notification.
// A follower the followed person blocked: no notification.
-db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject) VALUES (?, ?)').run(ANN, CAT);
-follows.setFollow(db, CAT, 'channel', ANN, true);
-assert.strictEqual(followsOf(1).length, 0, 'blocked follower');
-follows.setFollow(db, BOB, 'channel', ANN, true);
-assert.strictEqual(followsOf(1).length, 1, 'anyone else still notifies');
+await db.prepare('INSERT INTO user_blocks (blocker_subject, blocked_subject) VALUES (?, ?)').run(ANN, CAT);
+await follows.setFollow(db, CAT, 'channel', ANN, true);
+assert.strictEqual((await followsOf(1)).length, 0, 'blocked follower');
+await follows.setFollow(db, BOB, 'channel', ANN, true);
+assert.strictEqual((await followsOf(1)).length, 1, 'anyone else still notifies');
// A notifier that throws never fails the follow, and the follow and its event still commit.
// A notifier that throws never fails the follow, and the follow and its event still commit.
follows.setNotifier(() => { throw new Error('boom'); });
follows.setNotifier(() => { throw new Error('boom'); });
const warn = console.warn; console.warn = () => {};
const warn = console.warn; console.warn = () => {};
-const out = follows.setFollow(db, DAN, 'channel', CAT, true);
+const out = await follows.setFollow(db, DAN, 'channel', CAT, true);
console.warn = warn;
console.warn = warn;
assert.ok(out.changed && out.active);
assert.ok(out.changed && out.active);
-assert.ok(follows.status(db, 'channel', CAT, DAN).following);
-assert.ok(db.prepare("SELECT 1 FROM network_event_outbox WHERE envelope LIKE '%network.follow.created%' AND envelope LIKE ?").get(`%"follower":"${DAN}","target_type":"channel","target_id":"${CAT}"%`));
+assert.ok((await follows.status(db, 'channel', CAT, DAN)).following);
+{
+ const created = await db.prepare("SELECT envelope FROM network_event_outbox WHERE envelope->>'event_type' = 'network.follow.created'").all();
+ assert.ok(created.some((r) => r.envelope.payload.follower === DAN && r.envelope.payload.target_type === 'channel' && r.envelope.payload.target_id === CAT), 'the follow and its event still commit');
+}
follows.setNotifier(null);
follows.setNotifier(null);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('follow notifications: ok');
console.log('follow notifications: ok');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -12,18 +12,19 @@ const http = require('http');
@@ -12,18 +12,19 @@ const http = require('http');
const express = require('express');
const express = require('express');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { NotificationService } = require('../server/notifications/notification-service');
const { NotificationService } = require('../server/notifications/notification-service');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-auth-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follow-auth-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-require('../server/identity/principals').ensureSchema(db);
-for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+await require('../server/identity/principals').ensureSchema(db);
+for (const c of ['live', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
const CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');
const CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');
-db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?), (22, ?, ?, ?, ?)')
+await db.prepare('INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (20, ?, ?, ?, ?), (21, ?, ?, ?, ?), (22, ?, ?, ?, ?)')
.run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user', 'erin', 'x', ERIN, 'user');
.run('carol', 'x', CAROL, 'streamer', 'dave', 'x', DAVE, 'user', 'erin', 'x', ERIN, 'user');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@ -34,7 +35,7 @@ const follows = require('../server/identity/follows');
@@ -34,7 +35,7 @@ const follows = require('../server/identity/follows');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const SECRET = 's'.repeat(40);
const SECRET = 's'.repeat(40);
const notifications = new NotificationService(db);
const notifications = new NotificationService(db);
-const consumer = createEventsConsumer({ db, notifications, secrets: SECRET, followsAuthority: 'network', log: { log() {}, warn() {}, error() {} } });
+const consumer = await createEventsConsumer({ db, notifications, secrets: SECRET, followsAuthority: 'network', log: { log() {}, warn() {}, error() {} } });
const app = express();
const app = express();
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
@@ -59,13 +60,13 @@ const server = http.createServer(app);
@@ -59,13 +60,13 @@ const server = http.createServer(app);
assert.deepStrictEqual([r.status, r.body.followers, r.body.following, r.body.notify_push], [201, 1, true, false]);
assert.deepStrictEqual([r.status, r.body.followers, r.body.following, r.body.notify_push], [201, 1, true, false]);
r = await call('PUT', `/internal/follows/channel/${CAROL}`, { body: { follower: ERIN }, token: live });
r = await call('PUT', `/internal/follows/channel/${CAROL}`, { body: { follower: ERIN }, token: live });
assert.strictEqual(r.body.followers, 2);
assert.strictEqual(r.body.followers, 2);
- assert.strictEqual(db.prepare('SELECT source FROM user_follows WHERE follower_subject = ?').get(ERIN).source, 'live', 'the writing service is recorded');
+ assert.strictEqual((await db.prepare('SELECT source FROM user_follows WHERE follower_subject = ?').get(ERIN)).source, 'live', 'the writing service is recorded');
assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ' }, token: live })).status, 404, 'the follower must be a Network account');
assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ' }, token: live })).status, 404, 'the follower must be a Network account');
assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, extra: 1 }, token: live })).status, 400);
assert.strictEqual((await call('PUT', '/internal/follows/channel/carol', { body: { follower: DAVE, extra: 1 }, token: live })).status, 400);
r = await call('DELETE', `/internal/follows/channel/carol?follower=${ERIN}`, { token: live });
r = await call('DELETE', `/internal/follows/channel/carol?follower=${ERIN}`, { token: live });
assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);
assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);
await call('PUT', '/internal/follows/channel/carol', { body: { follower: ERIN }, token: live });
await call('PUT', '/internal/follows/channel/carol', { body: { follower: ERIN }, token: live });
- const evs = db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((x) => JSON.parse(x.envelope)).filter((e) => e.event_type.startsWith('network.follow.'));
+ const evs = (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((x) => x.envelope).filter((e) => e.event_type.startsWith('network.follow.'));
assert.deepStrictEqual(evs.map((e) => e.event_type), ['network.follow.created', 'network.follow.created', 'network.follow.deleted', 'network.follow.created']);
assert.deepStrictEqual(evs.map((e) => e.event_type), ['network.follow.created', 'network.follow.created', 'network.follow.deleted', 'network.follow.created']);
// ── Go-live with FOLLOWS_AUTHORITY=network: Network's followers, no Live call ──
// ── Go-live with FOLLOWS_AUTHORITY=network: Network's followers, no Live call ──
@@ -80,7 +81,7 @@ const server = http.createServer(app);
@@ -80,7 +81,7 @@ const server = http.createServer(app);
const body = await res.json();
const body = await res.json();
assert.strictEqual(res.status, 200, JSON.stringify(body));
assert.strictEqual(res.status, 200, JSON.stringify(body));
assert.strictEqual(body.outcome, 'notified', JSON.stringify(body));
assert.strictEqual(body.outcome, 'notified', JSON.stringify(body));
- const told = db.prepare("SELECT user_id FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 ORDER BY user_id").all().map((x) => x.user_id);
+ const told = (await db.prepare("SELECT user_id FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 ORDER BY user_id").all()).map((x) => x.user_id);
assert.deepStrictEqual(told, [21, 22], 'both followers, from Network\'s graph');
assert.deepStrictEqual(told, [21, 22], 'both followers, from Network\'s graph');
} finally {
} finally {
server.close();
server.close();
@@ -88,3 +89,4 @@ const server = http.createServer(app);
@@ -88,3 +89,4 @@ const server = http.createServer(app);
}
}
console.log('follows authority: all checks passed');
console.log('follows authority: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -12,22 +12,23 @@ const path = require('path');
@@ -12,22 +12,23 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follows-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-follows-'));
const dbFile = path.join(dir, 'network.db');
const dbFile = path.join(dir, 'network.db');
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(dbFile);
+const db = getDb();
console.log = log;
console.log = log;
-require('../server/identity/principals').ensureSchema(db);
-for (const c of ['live', 'tools']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+await require('../server/identity/principals').ensureSchema(db);
+for (const c of ['live', 'tools']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
-db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
+await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role) VALUES
(1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob', 'x', ?, 'streamer'), (3, 'cat', NULL, 'x', ?, 'user')`).run(ANN, BOB, CAT);
(1, 'ann', 'Ann', 'x', ?, 'user'), (2, 'bob', 'Bob', 'x', ?, 'streamer'), (3, 'cat', NULL, 'x', ?, 'user')`).run(ANN, BOB, CAT);
-db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, is_anon) VALUES (5, 'anon1234', 'x', 1)").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
@@ -45,7 +46,7 @@ app.use('/api/v1/me/follows', routers.me);
@@ -45,7 +46,7 @@ app.use('/api/v1/me/follows', routers.me);
app.use('/api/v1/follows', routers.pub);
app.use('/api/v1/follows', routers.pub);
const server = http.createServer(app);
const server = http.createServer(app);
-const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type.startsWith('network.follow.'));
+const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type.startsWith('network.follow.'));
const valid = (e) => {
const valid = (e) => {
assert.ok(validate('events.event-envelope@1', e).valid);
assert.ok(validate('events.event-envelope@1', e).valid);
const pv = validate(`${e.event_type}@1`, e.payload);
const pv = validate(`${e.event_type}@1`, e.payload);
@@ -56,46 +57,46 @@ const valid = (e) => {
@@ -56,46 +57,46 @@ const valid = (e) => {
(async () => {
(async () => {
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
- const tok = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
+ const tok = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
.then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
.then(async (r) => ({ status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => null) }));
- const as = (id) => ({ authorization: `Bearer ${tok(id)}` });
+ const as = async (id) => ({ authorization: `Bearer ${await tok(id)}` });
const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
try {
try {
// ── Follow, idempotently; events ──
// ── Follow, idempotently; events ──
assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob')).status, 401, 'signed in only');
assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob')).status, 401, 'signed in only');
- let r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(1) });
+ let r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(1) });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
assert.ok(validate('network.follow-status-result@1', r.body).valid, JSON.stringify(r.body));
assert.ok(validate('network.follow-status-result@1', r.body).valid, JSON.stringify(r.body));
assert.deepStrictEqual([r.body.target_id, r.body.followers, r.body.following, r.body.notify_email, r.body.notify_push], [BOB, 1, true, true, true]);
assert.deepStrictEqual([r.body.target_id, r.body.followers, r.body.following, r.body.notify_email, r.body.notify_push], [BOB, 1, true, true, true]);
- r = await call('PUT', '/api/v1/me/follows/channel/Bob', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/follows/channel/Bob', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.followers], [200, 1], 'following again changes nothing (and names are case-insensitive)');
assert.deepStrictEqual([r.status, r.body.followers], [200, 1], 'following again changes nothing (and names are case-insensitive)');
- assert.strictEqual(events().length, 1, 'and announces nothing');
- r = await call('PUT', `/api/v1/me/follows/channel/${BOB}`, { headers: as(1), body: { notify_email: false } });
+ assert.strictEqual((await events()).length, 1, 'and announces nothing');
+ r = await call('PUT', `/api/v1/me/follows/channel/${BOB}`, { headers: await as(1), body: { notify_email: false } });
assert.deepStrictEqual([r.status, r.body.notify_email, r.body.notify_push], [200, false, true], 'new flags: a change');
assert.deepStrictEqual([r.status, r.body.notify_email, r.body.notify_push], [200, false, true], 'new flags: a change');
- let ev = events();
+ let ev = await events();
assert.deepStrictEqual(ev.map((e) => [e.event_type, e.payload.revision, e.payload.notify_email]), [['network.follow.created', 1, true], ['network.follow.created', 2, false]]);
assert.deepStrictEqual(ev.map((e) => [e.event_type, e.payload.revision, e.payload.notify_email]), [['network.follow.created', 1, true], ['network.follow.created', 2, false]]);
ev.forEach(valid);
ev.forEach(valid);
- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/ann', { headers: as(1) })).status, 400, 'never oneself');
- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/anon1234', { headers: as(1) })).status, 404, 'a guest is not a channel');
- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/nobody', { headers: as(1) })).status, 404);
- assert.strictEqual((await call('PUT', '/api/v1/me/follows/space/bob', { headers: as(1) })).status, 404, 'unknown target type');
- assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(5) })).status, 403, 'guests do not follow');
- await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(3) });
+ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/ann', { headers: await as(1) })).status, 400, 'never oneself');
+ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/anon1234', { headers: await as(1) })).status, 404, 'a guest is not a channel');
+ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/nobody', { headers: await as(1) })).status, 404);
+ assert.strictEqual((await call('PUT', '/api/v1/me/follows/space/bob', { headers: await as(1) })).status, 404, 'unknown target type');
+ assert.strictEqual((await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(5) })).status, 403, 'guests do not follow');
+ await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(3) });
// ── Public count; the viewer's own state; lists are not public ──
// ── Public count; the viewer's own state; lists are not public ──
r = await call('GET', '/api/v1/follows/channel/bob');
r = await call('GET', '/api/v1/follows/channel/bob');
assert.deepStrictEqual([r.status, r.body], [200, { target_type: 'channel', target_id: BOB, followers: 2 }]);
assert.deepStrictEqual([r.status, r.body], [200, { target_type: 'channel', target_id: BOB, followers: 2 }]);
assert.match(r.cache, /public/);
assert.match(r.cache, /public/);
- r = await call('GET', '/api/v1/follows/channel/bob', { headers: as(1) });
+ r = await call('GET', '/api/v1/follows/channel/bob', { headers: await as(1) });
assert.deepStrictEqual([r.body.following, r.body.notify_email], [true, false]);
assert.deepStrictEqual([r.body.following, r.body.notify_email], [true, false]);
assert.match(r.cache, /private/);
assert.match(r.cache, /private/);
- r = await call('GET', '/api/v1/me/follows', { headers: as(1) });
+ r = await call('GET', '/api/v1/me/follows', { headers: await as(1) });
assert.ok(validate('network.follow-list-result@1', r.body).valid);
assert.ok(validate('network.follow-list-result@1', r.body).valid);
assert.deepStrictEqual(r.body.items.map((i) => i.target_id), [BOB]);
assert.deepStrictEqual(r.body.items.map((i) => i.target_id), [BOB]);
assert.ok([401, 403].includes((await call('GET', '/api/v1/follows/channel/bob/followers')).status), 'no token: not the owner, not a service');
assert.ok([401, 403].includes((await call('GET', '/api/v1/follows/channel/bob/followers')).status), 'no token: not the owner, not a service');
- assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: as(1) })).status, 403, 'a follower is not the owner');
- r = await call('GET', '/api/v1/follows/channel/bob/followers', { headers: as(2) });
+ assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: await as(1) })).status, 403, 'a follower is not the owner');
+ r = await call('GET', '/api/v1/follows/channel/bob/followers', { headers: await as(2) });
assert.deepStrictEqual([r.status, r.body.items.map((i) => i.follower).sort()], [200, [ANN, CAT].sort()], 'the owner sees who follows');
assert.deepStrictEqual([r.status, r.body.items.map((i) => i.follower).sort()], [200, [ANN, CAT].sort()], 'the owner sees who follows');
r = await call('GET', '/api/v1/follows/channel/bob/followers?limit=1', { headers: { authorization: `Bearer ${await svc('live')}` } });
r = await call('GET', '/api/v1/follows/channel/bob/followers?limit=1', { headers: { authorization: `Bearer ${await svc('live')}` } });
assert.deepStrictEqual([r.status, r.body.items.length, typeof r.body.next_cursor], [200, 1, 'string'], 'a service with network.follows.read, paged');
assert.deepStrictEqual([r.status, r.body.items.length, typeof r.body.next_cursor], [200, 1, 'string'], 'a service with network.follows.read, paged');
@@ -106,20 +107,20 @@ const valid = (e) => {
@@ -106,20 +107,20 @@ const valid = (e) => {
assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: { authorization: `Bearer ${await svc('tools')}` } })).status, 403, 'a service without the capability');
assert.strictEqual((await call('GET', '/api/v1/follows/channel/bob/followers', { headers: { authorization: `Bearer ${await svc('tools')}` } })).status, 403, 'a service without the capability');
// ── Unfollow, idempotently ──
// ── Unfollow, idempotently ──
- r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: as(1) });
+ r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);
assert.deepStrictEqual([r.status, r.body.following, r.body.followers], [200, false, 1]);
- r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: as(1) });
+ r = await call('DELETE', '/api/v1/me/follows/channel/bob', { headers: await as(1) });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- ev = events().filter((e) => e.event_type === 'network.follow.deleted');
+ ev = (await events()).filter((e) => e.event_type === 'network.follow.deleted');
assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.reason]), [[3, 'unfollowed']], 'one delete, revision 3');
assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.reason]), [[3, 'unfollowed']], 'one delete, revision 3');
ev.forEach(valid);
ev.forEach(valid);
- r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: as(1) });
+ r = await call('PUT', '/api/v1/me/follows/channel/bob', { headers: await as(1) });
assert.strictEqual(r.status, 200, 'a follow again (the row exists; revision 4)');
assert.strictEqual(r.status, 200, 'a follow again (the row exists; revision 4)');
- assert.strictEqual(events().pop().payload.revision, 4);
+ assert.strictEqual((await events()).pop().payload.revision, 4);
// ── Account removal ──
// ── Account removal ──
- assert.strictEqual(db.transaction(() => follows.onSubjectRemoved(db, CAT))(), 1);
- assert.deepStrictEqual(events().pop().payload.reason, 'account_removed');
+ assert.strictEqual(await db.tx(async () => await follows.onSubjectRemoved(db, CAT)), 1);
+ assert.deepStrictEqual((await events()).pop().payload.reason, 'account_removed');
// The Live backfill and its reconciliation used to be exercised here through the one-time
// The Live backfill and its reconciliation used to be exercised here through the one-time
// scripts/follows-backfill.js (retired in plan T2); importFollows() itself is covered by
// scripts/follows-backfill.js (retired in plan T2); importFollows() itself is covered by
@@ -130,3 +131,4 @@ const valid = (e) => {
@@ -130,3 +131,4 @@ const valid = (e) => {
}
}
console.log('follows: all checks passed');
console.log('follows: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -2,13 +2,13 @@
@@ -2,13 +2,13 @@
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const http = require('http');
const http = require('http');
-const Database = require('better-sqlite3');
+const { getDb } = require('../server/db/database');
const { createFrameService, BANNED } = require('../server/frame/service');
const { createFrameService, BANNED } = require('../server/frame/service');
const { siteForHost } = require('../server/frame/sites');
const { siteForHost } = require('../server/frame/sites');
-const db = new Database(':memory:');
-db.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
-const svc = createFrameService(db, { internalKey: 'change-me-in-production', services: {} }, null);
+(async () => {
+const db = getDb();
+const svc = await createFrameService(db, { internalKey: 'change-me-in-production', services: {} }, null);
assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
assert.equal(siteForHost('evil.example'), null);
@@ -20,11 +20,13 @@ assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a
@@ -20,11 +20,13 @@ assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a
assert.ok(p.soon.length >= 9, 'every planned site still listed as soon (chat launched 2026-09-24)');
assert.ok(p.soon.length >= 9, 'every planned site still listed as soon (chat launched 2026-09-24)');
// Real use reorders: community gets the history, so it rises above the cold-start order.
// Real use reorders: community gets the history, so it rises above the cold-start order.
+// user_history's foreign key needs its users (PostgreSQL enforces the constraint SQLite only declared).
+for (let i = 0; i < 9; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `h${i}`, 'x');
const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");
const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");
-for (let i = 0; i < 40; i++) ins.run(i % 9, 'community', null);
-for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
+for (let i = 0; i < 40; i++) await ins.run(i % 9, 'community', null);
+for (let i = 0; i < 6; i++) await ins.run(1, 'tools', 'dns');
(async () => {
(async () => {
- db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (date('now'), 'openvibe.games', 900), (date('now'), 'dns.openvibe.tools', 50), (date('now'), 'openvibe.network', 1000)").run();
+ await db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (substring(ov_now(), 1, 10), 'openvibe.games', 900), (substring(ov_now(), 1, 10), 'dns.openvibe.tools', 50), (substring(ov_now(), 1, 10), 'openvibe.network', 1000)").run();
const realFetch = global.fetch; global.fetch = async () => ({ ok: false });
const realFetch = global.fetch; global.fetch = async () => ({ ok: false });
await svc.refreshRank(); global.fetch = realFetch;
await svc.refreshRank(); global.fetch = realFetch;
p = svc.payloadFor('openvibe.network');
p = svc.payloadFor('openvibe.network');
@@ -49,10 +51,10 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
@@ -49,10 +51,10 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const cfg = { internalKey: 'a-real-internal-key', services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } };
const cfg = { internalKey: 'a-real-internal-key', services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } };
const aiUrl = `http://127.0.0.1:${ai.address().port}`;
const aiUrl = `http://127.0.0.1:${ai.address().port}`;
- const db2 = new Database(':memory:');
- const copySvc = (d) => createFrameService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });
+ const db2 = db; // the process's database (test/helpers/pg-preload.mjs); the copy cache lives here
+ const copySvc = async (d) => await createFrameService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });
- let s2 = copySvc(db2);
+ let s2 = await copySvc(db2);
aiMode = 'down';
aiMode = 'down';
await s2.refreshCopy();
await s2.refreshCopy();
p = s2.payloadFor('openvibe.games');
p = s2.payloadFor('openvibe.games');
@@ -66,19 +68,18 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
@@ -66,19 +68,18 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
p = s2.payloadFor('openvibe.games');
p = s2.payloadFor('openvibe.games');
assert.equal(p.footer.ai, true);
assert.equal(p.footer.ai, true);
assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.');
assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.');
- assert.equal(JSON.parse(db2.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get().value).model, 'test-model', 'the model is read from run.provenance.model (ai.run@1)');
+ assert.equal(JSON.parse((await db2.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get()).value).model, 'test-model', 'the model is read from run.provenance.model (ai.run@1)');
aiMode = 'down';
aiMode = 'down';
await s2.refreshCopy();
await s2.refreshCopy();
p = s2.payloadFor('openvibe.games');
p = s2.payloadFor('openvibe.games');
assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'AI failure keeps the last good copy');
assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'AI failure keeps the last good copy');
- s2 = copySvc(db2);
+ s2 = await copySvc(db2);
assert.equal(s2.payloadFor('openvibe.games').footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'and it survives a restart');
assert.equal(s2.payloadFor('openvibe.games').footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'and it survives a restart');
// Without a signing key there is no AI client, and still no Live call.
// Without a signing key there is no AI client, and still no Live call.
- await createFrameService(new Database(':memory:'), cfg, null).refreshCopy();
+ await (await createFrameService(db, cfg, null)).refreshCopy();
assert.equal(liveHits, 0, 'Live is never asked for copy');
assert.equal(liveHits, 0, 'Live is never asked for copy');
- live.close(); ai.close();
// The page-view beacon (navigator.sendBeacon, a no-cors request) is readable cross-origin, so no
// The page-view beacon (navigator.sendBeacon, a no-cors request) is readable cross-origin, so no
// site's console reports it as blocked.
// site's console reports it as blocked.
{
{
@@ -91,17 +92,7 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
@@ -91,17 +92,7 @@ for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
assert.equal(r.headers.get('access-control-allow-origin'), '*');
assert.equal(r.headers.get('access-control-allow-origin'), '*');
srv.close();
srv.close();
}
}
- {
- // Renamed from chrome_cache/chrome_hits: an existing database keeps its rows.
- const old = new Database(':memory:');
- old.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
- old.exec("CREATE TABLE chrome_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP); INSERT INTO chrome_cache (key, value) VALUES ('copy', '{\"x\":1}')");
- old.exec("CREATE TABLE chrome_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host)); INSERT INTO chrome_hits VALUES ('2026-09-23', 'openvibe.live', 7)");
- createFrameService(old, { internalKey: 'k', services: {} }, null);
- assert.equal(old.prepare("SELECT hits FROM frame_hits WHERE host = 'openvibe.live'").get().hits, 7);
- assert.equal(old.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get().value, '{"x":1}');
- assert.equal(old.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name IN ('chrome_cache', 'chrome_hits')").get().n, 0);
- createFrameService(old, { internalKey: 'k', services: {} }, null); // idempotent
- }
+ live.close(); ai.close();
console.log('frame service: all checks passed');
console.log('frame service: all checks passed');
})().catch(e => { console.error(e); process.exit(1); });
})().catch(e => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -9,19 +9,20 @@ const os = require('os');
@@ -9,19 +9,20 @@ const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const github = require('../server/integrations/github');
const github = require('../server/integrations/github');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-github-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-github-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
delete process.env.GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
process.env.OWNER_USERNAME = 'goosely';
process.env.OWNER_USERNAME = 'goosely';
-db.prepare("UPDATE oauth_clients SET client_secret = 'blog-secret' WHERE client_id = 'blog'").run();
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'goosely', 'x'), (2, 'anadmin', 'x')").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'blog-secret' WHERE client_id = 'blog'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'goosely', 'x'), (2, 'anadmin', 'x')").run();
const TOKEN = 'github_pat_' + 'A1b2C3d4E5'.repeat(6);
const TOKEN = 'github_pat_' + 'A1b2C3d4E5'.repeat(6);
const seen = [];
const seen = [];
@@ -39,7 +40,7 @@ app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvib
@@ -39,7 +40,7 @@ app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvib
app.locals.privateKey = keys.privateKey;
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
app.use('/oauth', require('../server/auth/oauth-routes'));
-app.use((req, _res, next) => { const u = req.headers['x-user']; req.user = u ? db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });
+app.use(async (req, _res, next) => { const u = req.headers['x-user']; req.user = u ? await db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });
app.use('/api/admin/integrations/github', github.adminRouter(db, { fetchImpl: fakeGithub }));
app.use('/api/admin/integrations/github', github.adminRouter(db, { fetchImpl: fakeGithub }));
app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read', { legacy: false }), github.internalHandler(db));
app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read', { legacy: false }), github.internalHandler(db));
const server = http.createServer(app);
const server = http.createServer(app);
@@ -65,8 +66,8 @@ const server = http.createServer(app);
@@ -65,8 +66,8 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.ok(!r.text.includes(TOKEN), 'the token is never sent back');
assert.ok(!r.text.includes(TOKEN), 'the token is never sent back');
assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'database', TOKEN.slice(-4)]);
assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'database', TOKEN.slice(-4)]);
- assert.strictEqual(github.tokenOf(db), TOKEN);
- const audit = db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();
+ assert.strictEqual(await github.tokenOf(db), TOKEN);
+ const audit = await db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();
assert.strictEqual(audit.length, 1);
assert.strictEqual(audit.length, 1);
assert.ok(!audit[0].details.includes(TOKEN), 'the audit row keeps the last four only');
assert.ok(!audit[0].details.includes(TOKEN), 'the audit row keeps the last four only');
assert.ok(!(await call('GET', A, { user: 'goosely' })).text.includes(TOKEN));
assert.ok(!(await call('GET', A, { user: 'goosely' })).text.includes(TOKEN));
@@ -104,12 +105,12 @@ const server = http.createServer(app);
@@ -104,12 +105,12 @@ const server = http.createServer(app);
assert.deepStrictEqual([r.body.github.source, r.body.github.last4], ['env', 'ZZZZ']);
assert.deepStrictEqual([r.body.github.source, r.body.github.last4], ['env', 'ZZZZ']);
assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409);
assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409);
assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);
assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);
- assert.strictEqual(github.tokenOf(db), process.env.GITHUB_TOKEN);
+ assert.strictEqual(await github.tokenOf(db), process.env.GITHUB_TOKEN);
delete process.env.GITHUB_TOKEN;
delete process.env.GITHUB_TOKEN;
// The registry's library tags read the token at call time.
// The registry's library tags read the token at call time.
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
- assert.ok(/token: \(\) => require\('\.\/integrations\/github'\)\.tokenOf\(db\)/.test(src), 'library tags get the token lazily');
+ assert.ok(/token: (async )?\(\) => (await )?require\('\.\/integrations\/github'\)\.tokenOf\(db\)/.test(src), 'library tags get the token lazily');
assert.ok(src.indexOf("app.get('/internal/integrations/github-token'") < src.indexOf("app.use('/internal', require('./internal/routes'))"), 'mounted before the /internal router');
assert.ok(src.indexOf("app.get('/internal/integrations/github-token'") < src.indexOf("app.use('/internal', require('./internal/routes'))"), 'mounted before the /internal router');
} finally {
} finally {
server.close();
server.close();
@@ -117,3 +118,4 @@ const server = http.createServer(app);
@@ -117,3 +118,4 @@ const server = http.createServer(app);
}
}
console.log('github integration: all checks passed');
console.log('github integration: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -11,20 +11,21 @@ const path = require('path');
@@ -11,20 +11,21 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate, capabilities } = require('openvibe-contracts');
const { validate, capabilities } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-grants-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-grants-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const grants = require('../server/identity/grants-admin');
const grants = require('../server/identity/grants-admin');
-for (const c of ['live', 'media']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);
-db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0)").run();
-principals.ensureSchema(db);
+for (const c of ['live', 'media']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0) ON CONFLICT DO NOTHING").run();
+await principals.ensureSchema(db);
const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
-const events = () => db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.principal_grant.changed');
-const has = (client, cap, aud = 'openvibe.network') => principals.grantsFor(db, client, aud).some((g) => g.capability === cap);
+const events = async () => (await db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.principal_grant.changed');
+const has = async (client, cap, aud = 'openvibe.network') => (await principals.grantsFor(db, client, aud)).some((g) => g.capability === cap);
const ok = (e) => { assert.ok(validate('events.event-envelope@1', e).valid); const v = validate('network.principal_grant.changed@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); };
const ok = (e) => { assert.ok(validate('events.event-envelope@1', e).valid); const v = validate('network.principal_grant.changed@1', e.payload); assert.ok(v.valid, JSON.stringify(v.errors)); };
// Every default grant names its capability owner's audience, so any of them can be revoked here.
// Every default grant names its capability owner's audience, so any of them can be revoked here.
@@ -33,56 +34,56 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
@@ -33,56 +34,56 @@ for (const [client, cap, aud] of principals.DEFAULT_GRANTS) {
assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);
assert.ok(c, `default grant ${client} ${cap} is in openvibe-contracts`);
assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);
assert.strictEqual(aud, `openvibe.${c.owner}`, `default grant ${client} ${cap} → ${aud}`);
}
}
-assert.strictEqual(events().length, 0, 'the seed is not announced');
+assert.strictEqual((await events()).length, 0, 'the seed is not announced');
// Grant, update, revoke.
// Grant, update, revoke.
-assert.ok(!has('live', 'network.staff.read'));
-let out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'route reports to moderators', expires_at: '2099-01-01T00:00:00Z' }, OWNER);
+assert.ok(!await has('live', 'network.staff.read'));
+let out = await grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'route reports to moderators', expires_at: '2099-01-01T00:00:00Z' }, OWNER);
assert.deepStrictEqual([out.change, out.audience], ['granted', 'openvibe.network'], 'the audience is the capability owner');
assert.deepStrictEqual([out.change, out.audience], ['granted', 'openvibe.network'], 'the audience is the capability owner');
-assert.ok(has('live', 'network.staff.read'));
-let ev = events().at(-1); ok(ev);
+assert.ok(await has('live', 'network.staff.read'));
+let ev = (await events()).at(-1); ok(ev);
assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });
assert.deepStrictEqual(ev.actor, { type: 'user', id: OWNER });
assert.deepStrictEqual(ev.subject, { type: 'grant', id: 'live:[email protected]' });
assert.deepStrictEqual(ev.subject, { type: 'grant', id: 'live:[email protected]' });
assert.strictEqual(ev.payload.expires_at, '2099-01-01T00:00:00.000Z');
assert.strictEqual(ev.payload.expires_at, '2099-01-01T00:00:00.000Z');
-out = grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'no expiry after all' }, OWNER);
+out = await grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: 'no expiry after all' }, OWNER);
assert.strictEqual(out.change, 'updated');
assert.strictEqual(out.change, 'updated');
-grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'not needed' }, OWNER);
-assert.ok(!has('live', 'network.staff.read'), 'the next token no longer carries it');
-assert.deepStrictEqual(events().map((e) => e.payload.change), ['granted', 'updated', 'revoked']);
-events().forEach(ok);
-assert.throws(() => grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'again' }, OWNER), /no active grant/);
-assert.deepStrictEqual(grants.changes(db, { client: 'live' }).map((c) => c.change), ['revoked', 'updated', 'granted']);
-assert.strictEqual(grants.list(db, { client: 'live' }).find((g) => g.capability === 'network.staff.read').state, 'revoked');
+await grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'not needed' }, OWNER);
+assert.ok(!await has('live', 'network.staff.read'), 'the next token no longer carries it');
+assert.deepStrictEqual((await events()).map((e) => e.payload.change), ['granted', 'updated', 'revoked']);
+(await events()).forEach(ok);
+await assert.rejects(async () => await grants.revoke(db, { client_id: 'live', capability: 'network.staff.read', reason: 'again' }, OWNER), /no active grant/);
+assert.deepStrictEqual((await grants.changes(db, { client: 'live' })).map((c) => c.change), ['revoked', 'updated', 'granted']);
+assert.strictEqual((await grants.list(db, { client: 'live' })).find((g) => g.capability === 'network.staff.read').state, 'revoked');
// Refusals.
// Refusals.
-const refused = (body, re) => assert.throws(() => grants.grant(db, { reason: 'because', ...body }, OWNER), re);
-refused({ client_id: 'nobody', capability: 'network.staff.read' }, /not a service principal/);
-refused({ client_id: 'thirdparty', capability: 'network.staff.read' }, /not a service principal/);
-refused({ client_id: 'live', capability: 'network.nope.read' }, /not in openvibe-contracts/);
-refused({ client_id: 'live', capability: 'Network.Staff' }, /malformed/);
-refused({ client_id: 'live', capability: 'network.staff.read', audience: 'openvibe.media' }, /enforced by network/);
-refused({ client_id: 'live', capability: 'network.staff.read', expires_at: '2001-01-01' }, /in the past/);
-assert.throws(() => grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: '' }, OWNER), /reason is required/);
-refused({ client_id: 'live', capability: 'network.staff.read', namespaces: ['Bad Namespace'] }, /namespace is malformed/);
-const before = events().length;
-assert.strictEqual(events().length, before, 'a refused change announces nothing');
+const refused = (body, re) => assert.rejects(async () => await grants.grant(db, { reason: 'because', ...body }, OWNER), re);
+await refused({ client_id: 'nobody', capability: 'network.staff.read' }, /not a service principal/);
+await refused({ client_id: 'thirdparty', capability: 'network.staff.read' }, /not a service principal/);
+await refused({ client_id: 'live', capability: 'network.nope.read' }, /not in openvibe-contracts/);
+await refused({ client_id: 'live', capability: 'Network.Staff' }, /malformed/);
+await refused({ client_id: 'live', capability: 'network.staff.read', audience: 'openvibe.media' }, /enforced by network/);
+await refused({ client_id: 'live', capability: 'network.staff.read', expires_at: '2001-01-01' }, /in the past/);
+await assert.rejects(async () => await grants.grant(db, { client_id: 'live', capability: 'network.staff.read', reason: '' }, OWNER), /reason is required/);
+await refused({ client_id: 'live', capability: 'network.staff.read', namespaces: ['Bad Namespace'] }, /namespace is malformed/);
+const before = (await events()).length;
+assert.strictEqual((await events()).length, before, 'a refused change announces nothing');
// Expiry: stops counting at once, recorded once.
// Expiry: stops counting at once, recorded once.
-grants.grant(db, { client_id: 'media', capability: 'network.staff.read', reason: 'a week', expires_at: '2099-01-01' }, OWNER);
-db.prepare("UPDATE principal_grants SET expires_at = datetime('now', '-1 minute') WHERE client_id = 'media' AND capability = 'network.staff.read'").run();
-assert.ok(!has('media', 'network.staff.read'), 'expired: no longer in tokens, before the job runs');
-assert.strictEqual(grants.list(db, { client: 'media' }).find((g) => g.capability === 'network.staff.read').state, 'expired');
-assert.strictEqual(grants.expireDue(db), 1);
-assert.strictEqual(grants.expireDue(db), 0, 'recorded once');
-ev = events().at(-1); ok(ev);
+await grants.grant(db, { client_id: 'media', capability: 'network.staff.read', reason: 'a week', expires_at: '2099-01-01' }, OWNER);
+await db.prepare("UPDATE principal_grants SET expires_at = datetime('now', '-1 minute') WHERE client_id = 'media' AND capability = 'network.staff.read'").run();
+assert.ok(!await has('media', 'network.staff.read'), 'expired: no longer in tokens, before the job runs');
+assert.strictEqual((await grants.list(db, { client: 'media' })).find((g) => g.capability === 'network.staff.read').state, 'expired');
+assert.strictEqual(await grants.expireDue(db), 1);
+assert.strictEqual(await grants.expireDue(db), 0, 'recorded once');
+ev = (await events()).at(-1); ok(ev);
assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.type], ['expired', null, 'system']);
assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.type], ['expired', null, 'system']);
// Routes: owner only.
// Routes: owner only.
(async () => {
(async () => {
const { createStaffApi } = require('../server/admin/staff-api');
const { createStaffApi } = require('../server/admin/staff-api');
- db.prepare("INSERT INTO users (id, username, password_hash, role, subject_id) VALUES (1, 'goosely', 'x', 'admin', ?), (2, 'mod', 'x', 'global_mod', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR'), (3, 'someone', 'x', 'user', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS')").run(OWNER);
+ await db.prepare("INSERT INTO users (id, username, password_hash, role, subject_id) VALUES (1, 'goosely', 'x', 'admin', ?), (2, 'mod', 'x', 'global_mod', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR'), (3, 'someone', 'x', 'user', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS')").run(OWNER);
let as = 1;
let as = 1;
- const requireAuth = (req, res, next) => { req.user = db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); };
+ const requireAuth = async (req, res, next) => { req.user = await db.prepare('SELECT * FROM users WHERE id = ?').get(as); next(); };
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
app.use('/api/admin/grants', requireAuth, grants.router(db));
app.use('/api/admin/grants', requireAuth, grants.router(db));
@@ -93,7 +94,7 @@ assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.ty
@@ -93,7 +94,7 @@ assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.ty
let r = await call('POST', '/api/admin/grants', { client_id: 'live', capability: 'network.staff.read', reason: 'owner via API' });
let r = await call('POST', '/api/admin/grants', { client_id: 'live', capability: 'network.staff.read', reason: 'owner via API' });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.grant.change, 'granted');
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.grant.change, 'granted');
- assert.strictEqual(events().at(-1).payload.actor_subject, OWNER);
+ assert.strictEqual((await events()).at(-1).payload.actor_subject, OWNER);
assert.strictEqual((await call('GET', '/api/admin/grants?client=live')).body.grants.find((g) => g.capability === 'network.staff.read').state, 'active');
assert.strictEqual((await call('GET', '/api/admin/grants?client=live')).body.grants.find((g) => g.capability === 'network.staff.read').state, 'active');
assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read' })).status, 400, 'a reason is required');
assert.strictEqual((await call('POST', '/api/admin/grants/revoke', { client_id: 'live', capability: 'network.staff.read' })).status, 400, 'a reason is required');
as = 2;
as = 2;
@@ -115,3 +116,4 @@ assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.ty
@@ -115,3 +116,4 @@ assert.deepStrictEqual([ev.payload.change, ev.payload.actor_subject, ev.actor.ty
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('grants admin and staff APIs: all checks passed');
console.log('grants admin and staff APIs: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -1,26 +1,21 @@
@@ -1,26 +1,21 @@
'use strict';
'use strict';
-// Boots the real server (server/index.js) in a child process for route-level tests: a scratch
-// database and data directory, loopback only, a free port, no .env (the child runs in the temp dir),
-// ephemeral signing keys, and no traffic off the machine (fetch to anything but loopback is refused
-// by a preload). stop() kills it; logs() returns what it printed.
+// Boots the real server (server/index.js) for route-level tests. Two modes:
+// bootServer() IN THIS PROCESS (the Media pattern, ADR-035): it uses the same process
+// database the preload opened (test/helpers/pg-preload.mjs), so a test can
+// seed rows and call the routes against one database. A route dump is
+// written by a preload that patches express's listen; a net guard keeps
+// every client on loopback. Callers end with process.exit(0).
+// bootServer({ child: true }) a CHILD process with its own PGlite directory (PGLITE_DIR), for a
+// standalone server whose database nobody else needs (a second, bare boot).
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const net = require('net');
const net = require('net');
const path = require('path');
const path = require('path');
const { spawn } = require('child_process');
const { spawn } = require('child_process');
+const { routeDumpEnv } = require('../security-crawl');
const ROOT = path.join(__dirname, '..', '..');
const ROOT = path.join(__dirname, '..', '..');
-const NO_EGRESS = `'use strict';
-const real = globalThis.fetch;
-globalThis.fetch = (input, init) => {
- let host = '';
- try { host = new URL(typeof input === 'string' ? input : input.url).hostname; } catch { /* */ }
- if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return real(input, init);
- return Promise.reject(new Error('test: no egress to ' + host));
-};
-`;
-
function freePort() {
function freePort() {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const s = net.createServer();
const s = net.createServer();
@@ -29,45 +24,71 @@ function freePort() {
@@ -29,45 +24,71 @@ function freePort() {
});
});
}
}
-async function bootServer({ env = {}, timeoutMs = 20000 } = {}) {
+async function bootServer({ env = {}, child = false, timeoutMs = 60000 } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-boot-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-boot-'));
- const guard = path.join(dir, 'no-egress.js');
- fs.writeFileSync(guard, NO_EGRESS);
const port = await freePort();
const port = await freePort();
- const childEnv = {
- PATH: process.env.PATH, HOME: dir, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1',
- DB_PATH: path.join(dir, 'network.db'), AVATAR_PATH: path.join(dir, 'avatars'),
+ const dump = routeDumpEnv(dir);
+ const baseEnv = {
+ NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1', PGLITE_DIR: path.join(dir, 'pglite'),
+ AVATAR_PATH: path.join(dir, 'avatars'),
JWT_PRIVATE_KEY: path.join(dir, 'none.pem'), JWT_PUBLIC_KEY: path.join(dir, 'none.pub.pem'),
JWT_PRIVATE_KEY: path.join(dir, 'none.pem'), JWT_PUBLIC_KEY: path.join(dir, 'none.pub.pem'),
BOOTSTRAP_PROFILE: 'local-dev', INTERNAL_API_KEY: 'k'.repeat(40),
BOOTSTRAP_PROFILE: 'local-dev', INTERNAL_API_KEY: 'k'.repeat(40),
OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',
OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',
OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9',
OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9',
OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
- ...env,
};
};
- const child = spawn(process.execPath, ['-r', guard, path.join(ROOT, 'server', 'index.js')], { cwd: dir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
+
+ if (child) {
+ const childEnv = { PATH: process.env.PATH, HOME: dir, ...baseEnv, ...dump, ...env };
+ const proc = spawn(process.execPath, ['-r', dump.NODE_OPTIONS.replace('--require ', ''), path.join(ROOT, 'server', 'index.js')], { cwd: dir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
+ let out = '';
+ proc.stdout.on('data', (d) => { out += d; });
+ proc.stderr.on('data', (d) => { out += d; });
+ let exited = null;
+ proc.on('exit', (code, sig) => { exited = { code, sig }; });
+ const base = `http://127.0.0.1:${port}`;
+ const t0 = Date.now();
+ for (;;) {
+ if (exited) throw new Error(`server exited early (${JSON.stringify(exited)}):\n${out}`);
+ try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not listening yet */ }
+ if (Date.now() - t0 > timeoutMs) { proc.kill('SIGKILL'); throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`); }
+ await new Promise(r => setTimeout(r, 100));
+ }
+ return {
+ base, port, dir, logs: () => out,
+ stop: () => new Promise((resolve) => {
+ if (exited) return resolve();
+ proc.once('exit', () => resolve());
+ proc.kill('SIGTERM');
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* */ } }, 3000).unref();
+ }),
+ };
+ }
+
+ // In-process: the route-dump + no-egress preload, then env, then the server module.
+ require(dump.NODE_OPTIONS.replace('--require ', ''));
+ Object.assign(process.env, baseEnv, dump, env);
+ // The preload (test/helpers/pg-preload.mjs) loads server/config.js before a test's environment exists.
+ // Refresh that same object in place — every module holds this reference — so the booted server reads
+ // the test's env (ports, keys, ADMIN_USERNAME), not the runner's.
+ const configPath = require.resolve(path.join(ROOT, 'server', 'config.js'));
+ const config = require(configPath);
+ delete require.cache[configPath];
+ Object.assign(config, require(configPath));
let out = '';
let out = '';
- child.stdout.on('data', (d) => { out += d; });
- child.stderr.on('data', (d) => { out += d; });
- let exited = null;
- child.on('exit', (code, sig) => { exited = { code, sig }; });
- const base = `http://127.0.0.1:${port}`;
- const t0 = Date.now();
- for (;;) {
- if (exited) throw new Error(`server exited early (${JSON.stringify(exited)}):\n${out}`);
- try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not listening yet */ }
- if (Date.now() - t0 > timeoutMs) { child.kill('SIGKILL'); throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`); }
- await new Promise(r => setTimeout(r, 100));
+ for (const stream of [process.stdout, process.stderr]) {
+ const write = stream.write.bind(stream);
+ stream.write = (chunk, ...rest) => { out += String(chunk); return write(chunk, ...rest); };
}
}
+ const t0 = Date.now();
+ const { app, server } = await require(path.join(ROOT, 'server', 'index.js')).ready;
+ if (!server.listening) await new Promise((r) => server.once('listening', r));
+ if (Date.now() - t0 > timeoutMs) throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`);
return {
return {
- base, port, dir,
+ base: `http://127.0.0.1:${server.address().port}`, port: server.address().port, dir, app, server,
logs: () => out,
logs: () => out,
- stop: () => new Promise((resolve) => {
- if (exited) return resolve();
- child.once('exit', () => resolve());
- child.kill('SIGTERM');
- setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, 3000).unref();
- }),
+ stop: () => new Promise((resolve) => { server.close(() => resolve()); }),
};
};
}
}
@@ -19,3 +19,14 @@ for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(ta
@@ -19,3 +19,14 @@ for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(ta
}
}
globalThis.__ovNetworkTestDb = t.db;
globalThis.__ovNetworkTestDb = t.db;
globalThis.__ovNetworkTestDbClose = t.close;
globalThis.__ovNetworkTestDbClose = t.close;
+// Owner-only DDL (a CREATE TRIGGER, say) for tests that must fail a write at the database, not in a mock: on the
+// containers the runtime role may not create in the schema, so this runs on the owner connection. PGlite has one role.
+globalThis.__ovNetworkDdl = async (sql) => {
+ if (!t.directUrl) return t.db.exec(sql);
+ const { createDb } = require('openvibe-sdk/db');
+ const owner = createDb({ url: t.directUrl, service: 'network-test-owner', max: 1 });
+ try { return await owner.exec(sql); } finally { await owner.close(); }
+};
+// initDb() adopts the handle above, attaches the handle helpers and seeds the boot data (OAuth clients, site
+// settings, built-in themes) once, exactly as the server does at boot. Every test file then reads it with getDb().
+await require(path.join(root, 'server', 'db', 'database.js')).initDb();
@@ -4,11 +4,12 @@
@@ -4,11 +4,12 @@
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const Database = require('better-sqlite3');
+const { getDb } = require('../server/db/database');
const { createHistoryRoutes, allowedUrl, serviceFromUrl } = require('../server/history/routes');
const { createHistoryRoutes, allowedUrl, serviceFromUrl } = require('../server/history/routes');
const { ssoTargets, safeNext } = require('../server/auth/sso-targets');
const { ssoTargets, safeNext } = require('../server/auth/sso-targets');
// ── pure helpers ─────────────────────────────────────────────
// ── pure helpers ─────────────────────────────────────────────
+(async () => {
assert.strictEqual(allowedUrl('https://json.openvibe.tools/?x=1&token=abc#frag'), 'https://json.openvibe.tools/?x=1', 'tokens + fragments stripped');
assert.strictEqual(allowedUrl('https://json.openvibe.tools/?x=1&token=abc#frag'), 'https://json.openvibe.tools/?x=1', 'tokens + fragments stripped');
assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network URLs are dropped');
assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network URLs are dropped');
assert.strictEqual(allowedUrl('javascript:alert(1)'), null);
assert.strictEqual(allowedUrl('javascript:alert(1)'), null);
@@ -25,15 +26,14 @@ assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fall
@@ -25,15 +26,14 @@ assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fall
assert.strictEqual(safeNext('//evil'), '/');
assert.strictEqual(safeNext('//evil'), '/');
assert.strictEqual(safeNext('/my#linked'), '/my#linked');
assert.strictEqual(safeNext('/my#linked'), '/my#linked');
-// ── API against an in-memory database ────────────────────────
-const db = new Database(':memory:');
-db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, is_anon INTEGER DEFAULT 0);
- INSERT INTO users (id, username) VALUES (1, 'alex'), (2, 'other');`);
+// ── API against the process database ─────────────────────────
+const db = getDb();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'alex', 'x'), (2, 'other', 'x')").run();
const users = { a: { id: 1, username: 'alex' }, b: { id: 2, username: 'other' }, anon: { id: 3, username: 'anon', is_anon: true } };
const users = { a: { id: 1, username: 'alex' }, b: { id: 2, username: 'other' }, anon: { id: 3, username: 'anon', is_anon: true } };
const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]; if (!u) return res.status(401).json({ error: 'no' }); req.user = u; next(); };
const requireAuth = (req, res, next) => { const u = users[req.headers['x-user']]; if (!u) return res.status(401).json({ error: 'no' }); req.user = u; next(); };
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
-app.use('/api/history', createHistoryRoutes(db, requireAuth));
+app.use('/api/history', await createHistoryRoutes(db, requireAuth));
const server = http.createServer(app);
const server = http.createServer(app);
(async () => {
(async () => {
@@ -86,3 +86,4 @@ const server = http.createServer(app);
@@ -86,3 +86,4 @@ const server = http.createServer(app);
server.close();
server.close();
console.log('history + sso targets: all checks passed');
console.log('history + sso targets: all checks passed');
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -9,27 +9,27 @@ const http = require('http');
@@ -9,27 +9,27 @@ const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subjects-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subjects-'));
const dbPath = path.join(dir, 'network.db');
const dbPath = path.join(dir, 'network.db');
-let db = initDb(dbPath);
+let db = getDb();
const quiet = console.log; console.log = () => {};
const quiet = console.log; console.log = () => {};
// ── An existing database: rows without subject ids, a Live-migrated account ──
// ── An existing database: rows without subject ids, a Live-migrated account ──
-db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();
-db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();
-db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();
-db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (2, 'live', '321'), (1, 'tools', 'network:1')").run();
-db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DROP TABLE identity_legacy_map;");
-db.close();
-db = initDb(dbPath); // boot again: backfill + seed
+await db.prepare("INSERT INTO users (id, username, password_hash, created_at, legacy_source, legacy_id) VALUES (1, 'alex', 'x', '2026-01-02 03:04:05', 'live', 77)").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (2, 'beth', 'x', '2026-05-01 00:00:00')").run();
+await db.prepare("INSERT INTO anon_users (id, anon_number, session_token) VALUES (5, 9, 'tok')").run();
+await db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (2, 'live', '321'), (1, 'tools', 'network:1')").run();
+await db.exec("UPDATE users SET subject_id = NULL; UPDATE anon_users SET subject_id = NULL; DELETE FROM identity_legacy_map");
+await subjects.ensureSchema(db); // the boot's backfill + seed (server/db/database.js seedDb)
console.log = quiet;
console.log = quiet;
-const alex = db.prepare('SELECT * FROM users WHERE id = 1').get();
-const beth = db.prepare('SELECT * FROM users WHERE id = 2').get();
-const guest = db.prepare('SELECT * FROM anon_users WHERE id = 5').get();
+const alex = await db.prepare('SELECT * FROM users WHERE id = 1').get();
+const beth = await db.prepare('SELECT * FROM users WHERE id = 2').get();
+const guest = await db.prepare('SELECT * FROM anon_users WHERE id = 5').get();
assert.ok(contracts.validate('identity.subject-ref', { type: 'user', id: alex.subject_id }).valid, 'backfilled user subject is a valid SubjectRef');
assert.ok(contracts.validate('identity.subject-ref', { type: 'user', id: alex.subject_id }).valid, 'backfilled user subject is a valid SubjectRef');
assert.ok(contracts.validate('identity.subject-ref', { type: 'guest', id: guest.subject_id }).valid, 'backfilled guest subject is gst_');
assert.ok(contracts.validate('identity.subject-ref', { type: 'guest', id: guest.subject_id }).valid, 'backfilled guest subject is gst_');
assert.ok(alex.subject_id < beth.subject_id, 'backfilled ids sort by account age');
assert.ok(alex.subject_id < beth.subject_id, 'backfilled ids sort by account age');
@@ -37,30 +37,30 @@ assert.strictEqual(alex.subject_id.slice(4, 14), contracts.ids.ulid(Date.parse('
@@ -37,30 +37,30 @@ assert.strictEqual(alex.subject_id.slice(4, 14), contracts.ids.ulid(Date.parse('
// Rebooting changes nothing.
// Rebooting changes nothing.
const before = alex.subject_id;
const before = alex.subject_id;
-console.log = () => {}; db.close(); db = initDb(dbPath); console.log = quiet;
-assert.strictEqual(db.prepare('SELECT subject_id FROM users WHERE id = 1').get().subject_id, before, 'subject ids are stable across boots');
+console.log = () => {}; await subjects.ensureSchema(db); console.log = quiet; // boot again: nothing changes
+assert.strictEqual((await db.prepare('SELECT subject_id FROM users WHERE id = 1').get()).subject_id, before, 'subject ids are stable across boots');
// Seeded map: network self ids and the Live legacy id.
// Seeded map: network self ids and the Live legacy id.
-assert.strictEqual(subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 }).subject.id, before);
-const viaLive = subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '77' });
+assert.strictEqual((await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 })).subject.id, before);
+const viaLive = await subjects.resolve(db, { source_system: 'live', source_type: 'user', source_id: '77' });
assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves by its Live id');
assert.strictEqual(viaLive.subject.id, before, 'Live-migrated account resolves by its Live id');
assert.strictEqual(viaLive.username, 'alex');
assert.strictEqual(viaLive.username, 'alex');
assert.ok(!('email' in viaLive) && !('password_hash' in viaLive), 'projection carries no private fields');
assert.ok(!('email' in viaLive) && !('password_hash' in viaLive), 'projection carries no private fields');
-assert.strictEqual(subjects.resolve(db, { subject_id: guest.subject_id }).subject.type, 'guest');
-assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '321' }).subject.id, beth.subject_id, 'seeded from a reported Live link');
-assert.strictEqual(db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get().n, 0, "OAuth 'network:<id>' links say nothing about site ids");
-assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
+assert.strictEqual((await subjects.resolve(db, { subject_id: guest.subject_id })).subject.type, 'guest');
+assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '321' })).subject.id, beth.subject_id, 'seeded from a reported Live link');
+assert.strictEqual((await db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get()).n, 0, "OAuth 'network:<id>' links say nothing about site ids");
+assert.strictEqual(await subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
// A row inserted by a path that forgot subject_id is fixed lazily.
// A row inserted by a path that forgot subject_id is fixed lazily.
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();
-db.prepare('UPDATE users SET subject_id = NULL WHERE id = 3').run();
-const cat = db.prepare('SELECT * FROM users WHERE id = 3').get();
-const catSid = subjects.ensureUserSubject(db, cat);
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();
+await db.prepare('UPDATE users SET subject_id = NULL WHERE id = 3').run();
+const cat = await db.prepare('SELECT * FROM users WHERE id = 3').get();
+const catSid = await subjects.ensureUserSubject(db, cat);
assert.ok(/^usr_/.test(catSid) && cat.subject_id === catSid);
assert.ok(/^usr_/.test(catSid) && cat.subject_id === catSid);
-assert.strictEqual(subjects.ensureUserSubject(db, cat), catSid, 'idempotent');
+assert.strictEqual(await subjects.ensureUserSubject(db, cat), catSid, 'idempotent');
// ── Legacy map writes ──
// ── Legacy map writes ──
-let r = subjects.upsertLegacy(db, [
+let r = await subjects.upsertLegacy(db, [
{ network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88, verified: true },
{ network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88, verified: true },
{ network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88 }, // repeat
{ network_user_id: 2, source_system: 'live', source_type: 'user', source_id: 88 }, // repeat
{ network_user_id: 1, source_system: 'live', source_type: 'user', source_id: 88 }, // someone else's id
{ network_user_id: 1, source_system: 'live', source_type: 'user', source_id: 88 }, // someone else's id
@@ -73,7 +73,7 @@ assert.strictEqual(r.inserted, 1);
@@ -73,7 +73,7 @@ assert.strictEqual(r.inserted, 1);
assert.strictEqual(r.unchanged, 1);
assert.strictEqual(r.unchanged, 1);
assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never repointed');
assert.deepStrictEqual(r.conflicts.map(c => c.index), [2], 'a mapped id is never repointed');
assert.deepStrictEqual(r.rejected.map(c => c.index), [3, 4, 5, 6]);
assert.deepStrictEqual(r.rejected.map(c => c.index), [3, 4, 5, 6]);
-assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '88' }).subject.id, beth.subject_id);
+assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '88' })).subject.id, beth.subject_id);
// ── HTTP: /internal/identity behind a service token (X-Internal-Key was retired in plan T2) ──
// ── HTTP: /internal/identity behind a service token (X-Internal-Key was retired in plan T2) ──
const app = express();
const app = express();
@@ -124,7 +124,7 @@ const { signToken } = require('../server/auth/routes');
@@ -124,7 +124,7 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);
assert.strictEqual(h.status, 200); assert.strictEqual(h.body.inserted, 1);
h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { authorization: `Bearer ${LIVE_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });
h = await fetch(base.replace('/identity', '/link-account'), { method: 'POST', headers: { authorization: `Bearer ${LIVE_TOKEN}`, 'content-type': 'application/json' }, body: JSON.stringify({ user_id: 3, service: 'live', service_user_id: '555' }) });
assert.strictEqual(h.status, 200);
assert.strictEqual(h.status, 200);
- assert.strictEqual(subjects.resolve(db, { source_system: 'live', source_id: '555' }).network_user_id, 3, '/internal/link-account also writes the legacy map');
+ assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '555' })).network_user_id, 3, '/internal/link-account also writes the legacy map');
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
assert.strictEqual(h.status, 400);
assert.strictEqual(h.status, 400);
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));
@@ -134,14 +134,14 @@ const { signToken } = require('../server/auth/routes');
@@ -134,14 +134,14 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual(h.body.code, 'token.missing');
assert.strictEqual(h.body.code, 'token.missing');
if (signToken) {
if (signToken) {
- const token = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });
+ const token = signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } });
const claims = jwt.decode(token);
const claims = jwt.decode(token);
assert.strictEqual(claims.sub, 1, 'sub stays the integer id');
assert.strictEqual(claims.sub, 1, 'sub stays the integer id');
assert.strictEqual(claims.subject_id, before, 'token carries the subject id');
assert.strictEqual(claims.subject_id, before, 'token carries the subject id');
}
}
server.close();
server.close();
- db.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('identity subjects: all checks passed');
console.log('identity subjects: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -8,29 +8,30 @@ const fs = require('fs');
@@ -8,29 +8,30 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const { modules } = require('openvibe-contracts');
const { modules } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-loyalty-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-loyalty-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = await getDb();
console.log = log;
console.log = log;
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
-db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('live', 'x', 'live', '[]', 1)").run();
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('live', 'x', 'live', '[]', 1) ON CONFLICT DO NOTHING").run();
const aud = principals.SELF_AUDIENCE;
const aud = principals.SELF_AUDIENCE;
-const grant = (cap) => principals.grantsFor(db, 'live', aud).find((g) => g.capability === cap);
+const grant = async (cap) => (await principals.grantsFor(db, 'live', aud)).find((g) => g.capability === cap);
// A database as an older release left it: Live's module grants without live.loyalty, and a transfer grant.
// A database as an older release left it: Live's module grants without live.loyalty, and a transfer grant.
-principals.ensureSchema(db);
-db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability IN ('network.modules.read', 'network.modules.write')").run(JSON.stringify(['live.profile', 'live.stats']));
-db.prepare("INSERT OR REPLACE INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.coins.transfer', ?, '[\"live\"]', 'default')").run(aud);
-assert.ok(grant('network.coins.transfer'), 'the old transfer grant is there before the boot');
+await principals.ensureSchema(db);
+await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability IN ('network.modules.read', 'network.modules.write')").run(JSON.stringify(['live.profile', 'live.stats']));
+await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.coins.transfer', ?, '[\"live\"]', 'default') ON CONFLICT (client_id, capability, audience) DO UPDATE SET namespaces = excluded.namespaces, granted_by = excluded.granted_by").run(aud);
+assert.ok(await grant('network.coins.transfer'), 'the old transfer grant is there before the boot');
-principals.ensureSchema(db); // the next boot
-assert.strictEqual(grant('network.coins.transfer'), undefined, 'the transfer grant is revoked at boot');
+await principals.ensureSchema(db); // the next boot
+assert.strictEqual(await grant('network.coins.transfer'), undefined, 'the transfer grant is revoked at boot');
assert.ok(principals.REVOKED_GRANTS.some(([c, cap]) => c === 'live' && cap === 'network.coins.transfer'));
assert.ok(principals.REVOKED_GRANTS.some(([c, cap]) => c === 'live' && cap === 'network.coins.transfer'));
assert.ok(!principals.DEFAULT_GRANTS.some(([, cap]) => cap === 'network.coins.transfer'), 'no default hands out the transfer grant');
assert.ok(!principals.DEFAULT_GRANTS.some(([, cap]) => cap === 'network.coins.transfer'), 'no default hands out the transfer grant');
for (const cap of ['network.modules.read', 'network.modules.write']) {
for (const cap of ['network.modules.read', 'network.modules.write']) {
- assert.deepStrictEqual(grant(cap).namespaces, ['live.profile', 'live.stats', 'live.loyalty'], `${cap} gained live.loyalty`);
+ assert.deepStrictEqual((await grant(cap)).namespaces, ['live.profile', 'live.stats', 'live.loyalty'], `${cap} gained live.loyalty`);
}
}
// live.loyalty is private: nothing of it appears in a public view.
// live.loyalty is private: nothing of it appears in a public view.
@@ -44,3 +45,4 @@ assert.ok(!modules.validateData('live.loyalty', { ...record, cashout_usd: 12 }).
@@ -44,3 +45,4 @@ assert.ok(!modules.validateData('live.loyalty', { ...record, cashout_usd: 12 }).
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('loyalty policy: all checks passed');
console.log('loyalty policy: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -15,29 +15,30 @@ const http = require('http');
@@ -15,29 +15,30 @@ const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { validate, serviceAuth } = require('openvibe-contracts');
const { validate, serviceAuth } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mod-principals-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mod-principals-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-for (const c of ['games']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);
+for (const c of ['games']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
-principals.ensureSchema(db);
+await principals.ensureSchema(db);
const modPrincipals = require('../server/identity/mod-principals');
const modPrincipals = require('../server/identity/mod-principals');
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
const svc = (name, cap = ['mods.grant.manage']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'], cap,
const svc = (name, cap = ['mods.grant.manage']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'], cap,
iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
-const mk = (name, role) => {
- const id = db.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)").run(name, role).lastInsertRowid;
- subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+const mk = async (name, role) => {
+ const id = (await db.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?) RETURNING id").run(name, role)).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
};
};
const tok = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role, iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
const tok = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role, iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
-const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.mod.grants_changed');
+const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.mod.grants_changed');
const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/fixtures/mods.mod-manifest/valid/market-stall-1.1.json'), 'utf8'));
const manifest = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/fixtures/mods.mod-manifest/valid/market-stall-1.1.json'), 'utf8'));
const authRoutes = require('../server/auth/routes');
const authRoutes = require('../server/auth/routes');
@@ -56,7 +57,7 @@ const server = http.createServer(app);
@@ -56,7 +57,7 @@ const server = http.createServer(app);
const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
.then(async (x) => ({ status: x.status, body: await x.json().catch(() => ({})) }));
.then(async (x) => ({ status: x.status, body: await x.json().catch(() => ({})) }));
try {
try {
- assert.ok(principals.grantsFor(db, 'games', 'openvibe.network').some((g) => g.capability === 'mods.grant.manage'), 'Games holds mods.grant.manage');
+ assert.ok((await principals.grantsFor(db, 'games', 'openvibe.network')).some((g) => g.capability === 'mods.grant.manage'), 'Games holds mods.grant.manage');
const games = svc('games');
const games = svc('games');
const [a, b] = manifest.permissions.capabilities;
const [a, b] = manifest.permissions.capabilities;
@@ -93,14 +94,14 @@ const server = http.createServer(app);
@@ -93,14 +94,14 @@ const server = http.createServer(app);
assert.strictEqual(x.body.revision, 3);
assert.strictEqual(x.body.revision, 3);
// ── Staff ──
// ── Staff ──
- const boss = mk('boss', 'admin'); const eve = mk('eve', 'user');
+ const boss = await mk('boss', 'admin'); const eve = await mk('eve', 'user');
x = await call('GET', '/api/admin/mods', tok(eve));
x = await call('GET', '/api/admin/mods', tok(eve));
assert.strictEqual(x.status, 403);
assert.strictEqual(x.status, 403);
x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'no' });
x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'no' });
assert.deepStrictEqual([x.status, x.body.error], [400, 'mod.reason_required']);
assert.deepStrictEqual([x.status, x.body.error], [400, 'mod.reason_required']);
x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'placed props in the spawn area' });
x = await call('POST', `/api/admin/mods/${manifest.id}/grants`, tok(boss), { capability: b, action: 'revoke', reason: 'placed props in the spawn area' });
assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [], 4]);
assert.deepStrictEqual([x.status, x.body.approved, x.body.revision], [200, [], 4]);
- assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'mod_grant_change'").get(), 'audited');
+ assert.ok(await db.prepare("SELECT 1 FROM audit_log WHERE action = 'mod_grant_change'").get(), 'audited');
// ── Revoke the install ──
// ── Revoke the install ──
x = await call('POST', `/internal/mods/${manifest.id}/revoke`, games, { actor: 'games', reason: 'the install ended' });
x = await call('POST', `/internal/mods/${manifest.id}/revoke`, games, { actor: 'games', reason: 'the install ended' });
@@ -109,7 +110,7 @@ const server = http.createServer(app);
@@ -109,7 +110,7 @@ const server = http.createServer(app);
assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.revoked'], 'never granted again');
assert.deepStrictEqual([x.status, x.body.error], [409, 'mod.revoked'], 'never granted again');
// Every change announced once, with the complete approved set.
// Every change announced once, with the complete approved set.
- const ev = events();
+ const ev = await events();
assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.change.action, e.payload.by, e.payload.approved.length]),
assert.deepStrictEqual(ev.map((e) => [e.payload.revision, e.payload.change.action, e.payload.by, e.payload.approved.length]),
[[1, 'register', 'runtime', 1], [2, 'approve', 'runtime', 2], [3, 'revoke', 'runtime', 1], [4, 'revoke', 'staff', 0], [5, 'revoke_all', 'runtime', 0]]);
[[1, 'register', 'runtime', 1], [2, 'approve', 'runtime', 2], [3, 'revoke', 'runtime', 1], [4, 'revoke', 'staff', 0], [5, 'revoke_all', 'runtime', 0]]);
for (const e of ev) assert.ok(validate('network.mod.grants_changed@1', e.payload).valid && e.visibility === 'internal' && e.subject.type === 'mod');
for (const e of ev) assert.ok(validate('network.mod.grants_changed@1', e.payload).valid && e.visibility === 'internal' && e.subject.type === 'mod');
@@ -120,3 +121,4 @@ const server = http.createServer(app);
@@ -120,3 +121,4 @@ const server = http.createServer(app);
}
}
console.log('mod principals: all checks passed');
console.log('mod principals: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -9,21 +9,22 @@ const path = require('path');
@@ -9,21 +9,22 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { NotificationService } = require('../server/notifications/notification-service');
const { NotificationService } = require('../server/notifications/notification-service');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const { createModerationAudit, rowOf } = require('../server/admin/moderation-audit');
const { createModerationAudit, rowOf } = require('../server/admin/moderation-audit');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modaudit-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modaudit-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
const MOD = ids.newId('user'), TARGET = ids.newId('user');
const MOD = ids.newId('user'), TARGET = ids.newId('user');
const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: type.split('.')[0], actor, occurred_at: new Date().toISOString(), payload });
const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: ids.newId('event'), event_type: type, version: 1, source: type.split('.')[0], actor, occurred_at: new Date().toISOString(), payload });
(async () => {
(async () => {
const audit = createModerationAudit(db);
const audit = createModerationAudit(db);
- const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: 'a'.repeat(40), moderationAudit: audit });
+ const consumer = await createEventsConsumer({ db, notifications: new NotificationService(db), secrets: 'a'.repeat(40), moderationAudit: audit });
const chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });
const chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });
const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });
const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });
@@ -31,17 +32,17 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
@@ -31,17 +32,17 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
const live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });
const live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });
const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });
const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });
- for (const e of [chat, community, tips, billing, live]) assert.strictEqual(consumer.apply(e).outcome, 'recorded', e.event_type);
- assert.strictEqual(consumer.apply(chat).duplicate, true, 'a redelivery records nothing twice');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM notifications').get().n, 0, 'audit events notify nobody');
+ for (const e of [chat, community, tips, billing, live]) assert.strictEqual((await consumer.apply(e)).outcome, 'recorded', e.event_type);
+ assert.strictEqual((await consumer.apply(chat)).duplicate, true, 'a redelivery records nothing twice');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'audit events notify nobody');
- const all = audit.list({});
+ const all = await audit.list({});
assert.strictEqual(all.items.length, 5);
assert.strictEqual(all.items.length, 5);
const liveRow = all.items.find((r) => r.service === 'live');
const liveRow = all.items.find((r) => r.service === 'live');
assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);
assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);
- assert.strictEqual(audit.list({ action: 'site_ban' }).items.length, 1);
- assert.strictEqual(audit.list({ since: '2000-01-01', until: '2001-01-01' }).items.length, 0);
- assert.strictEqual(audit.list({ since: '2000-01-01' }).items.length, 5);
+ assert.strictEqual((await audit.list({ action: 'site_ban' })).items.length, 1);
+ assert.strictEqual((await audit.list({ since: '2000-01-01', until: '2001-01-01' })).items.length, 0);
+ assert.strictEqual((await audit.list({ since: '2000-01-01' })).items.length, 5);
const csv = require('../server/admin/moderation-audit').toCsv(all.items);
const csv = require('../server/admin/moderation-audit').toCsv(all.items);
assert.ok(csv.startsWith('id,occurred_at,service,action,'));
assert.ok(csv.startsWith('id,occurred_at,service,action,'));
assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');
assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');
@@ -51,12 +52,12 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
@@ -51,12 +52,12 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
assert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);
assert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);
assert.strictEqual(byService.tips.action, 'interaction.hidden');
assert.strictEqual(byService.tips.action, 'interaction.hidden');
assert.strictEqual(byService.billing.actor_subject, MOD);
assert.strictEqual(byService.billing.actor_subject, MOD);
- assert.strictEqual(audit.list({ service: 'community' }).items.length, 1);
- assert.strictEqual(audit.list({ actor: MOD }).items.length, 4, 'tips carries no moderator identity');
- assert.strictEqual(audit.list({ target: TARGET }).items.length, 4);
- const page = audit.list({ limit: 2 });
+ assert.strictEqual((await audit.list({ service: 'community' })).items.length, 1);
+ assert.strictEqual((await audit.list({ actor: MOD })).items.length, 4, 'tips carries no moderator identity');
+ assert.strictEqual((await audit.list({ target: TARGET })).items.length, 4);
+ const page = await audit.list({ limit: 2 });
assert.strictEqual(page.items.length, 2);
assert.strictEqual(page.items.length, 2);
- assert.strictEqual(audit.list({ limit: 2, before: page.next }).items.length, 2, 'paged by id');
+ assert.strictEqual((await audit.list({ limit: 2, before: page.next })).items.length, 2, 'paged by id');
assert.strictEqual(rowOf(ev('chat.message.deleted', {})), null);
assert.strictEqual(rowOf(ev('chat.message.deleted', {})), null);
// The ten services on common.moderation-action@1 (Contracts 0.53.0): subscribed, one row each, the service from the prefix.
// The ten services on common.moderation-action@1 (Contracts 0.53.0): subscribed, one row each, the service from the prefix.
@@ -68,16 +69,16 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
@@ -68,16 +69,16 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
assert.ok(TOPICS.includes(type), `${type} is one of the consumer's subscriptions`);
assert.ok(TOPICS.includes(type), `${type} is one of the consumer's subscriptions`);
const payload = { action: 'item.hidden', target: { type: `${svc}_item`, id: `${svc}-1`, owner_subject: TARGET }, actor_subject: MOD, reason: `${svc} reason`, details: { previous: 'visible' } };
const payload = { action: 'item.hidden', target: { type: `${svc}_item`, id: `${svc}-1`, owner_subject: TARGET }, actor_subject: MOD, reason: `${svc} reason`, details: { previous: 'visible' } };
assert.ok(contracts.validate(`${type}@1`, payload).valid, `${type}: a valid payload`);
assert.ok(contracts.validate(`${type}@1`, payload).valid, `${type}: a valid payload`);
- assert.strictEqual(consumer.apply(ev(type, payload)).outcome, 'recorded', type);
+ assert.strictEqual((await consumer.apply(ev(type, payload))).outcome, 'recorded', type);
}
}
for (const svc of COMMON) {
for (const svc of COMMON) {
- const rows = audit.list({ service: svc }).items;
+ const rows = (await audit.list({ service: svc })).items;
assert.strictEqual(rows.length, 1, svc);
assert.strictEqual(rows.length, 1, svc);
const r = rows[0];
const r = rows[0];
assert.deepStrictEqual([r.action, r.actor_subject, r.target_type, r.target_id, r.target_subject, r.scope, r.reason, r.details],
assert.deepStrictEqual([r.action, r.actor_subject, r.target_type, r.target_id, r.target_subject, r.scope, r.reason, r.details],
['item.hidden', MOD, `${svc}_item`, `${svc}-1`, TARGET, null, `${svc} reason`, { previous: 'visible' }], svc);
['item.hidden', MOD, `${svc}_item`, `${svc}-1`, TARGET, null, `${svc} reason`, { previous: 'visible' }], svc);
}
}
- assert.strictEqual(audit.list({ action: 'item.hidden' }).items.length, COMMON.length);
+ assert.strictEqual((await audit.list({ action: 'item.hidden' })).items.length, COMMON.length);
// A service acting without a person names no actor; an unknown owner is no target subject.
// A service acting without a person names no actor; an unknown owner is no target subject.
const bySvc = rowOf(ev('coupons.moderation.action', { action: 'merchant.disabled', target: { type: 'merchant', id: 'mer_1', owner_subject: null }, actor_subject: null, details: {} }, { type: 'service', id: 'coupons' }));
const bySvc = rowOf(ev('coupons.moderation.action', { action: 'merchant.disabled', target: { type: 'merchant', id: 'mer_1', owner_subject: null }, actor_subject: null, details: {} }, { type: 'service', id: 'coupons' }));
assert.deepStrictEqual([bySvc.service, bySvc.action, bySvc.actor_subject, bySvc.target_subject, bySvc.reason], ['coupons', 'merchant.disabled', null, null, null]);
assert.deepStrictEqual([bySvc.service, bySvc.action, bySvc.actor_subject, bySvc.target_subject, bySvc.reason], ['coupons', 'merchant.disabled', null, null, null]);
@@ -99,3 +100,4 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
@@ -99,3 +100,4 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
} finally { srv.close(); }
} finally { srv.close(); }
console.log('moderation audit: all checks passed');
console.log('moderation audit: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -16,27 +16,28 @@ const path = require('path');
@@ -16,27 +16,28 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-module-events-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-module-events-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
+for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret']]) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
// Chat's principal is provisioned by hand in production (no seeded client); the grants follow at boot.
// Chat's principal is provisioned by hand in production (no seeded client); the grants follow at boot.
-db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'chat-secret', 'OpenVibe.Chat', '[]', 1)").run();
+await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('chat', 'chat-secret', 'OpenVibe.Chat', '[]', 1)").run();
// A Live write grant as an older boot seeded it (with chat.preferences), to see it narrow.
// A Live write grant as an older boot seeded it (with chat.preferences), to see it narrow.
-db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability = 'network.modules.write'").run(JSON.stringify(['chat.preferences', 'chat.tts_defaults', 'live.profile']));
+await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND capability = 'network.modules.write'").run(JSON.stringify(['chat.preferences', 'chat.tts_defaults', 'live.profile']));
// Chat's grants as a boot before contracts 0.41.0 seeded them (chat.preferences only), to see them widen.
// Chat's grants as a boot before contracts 0.41.0 seeded them (chat.preferences only), to see them widen.
-for (const cap of ['network.modules.read', 'network.modules.write']) db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('chat', ?, 'openvibe.network', '[\"chat.preferences\"]', 'default')").run(cap);
-require('../server/identity/principals').ensureSchema(db);
+for (const cap of ['network.modules.read', 'network.modules.write']) await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('chat', ?, 'openvibe.network', '[\"chat.preferences\"]', 'default')").run(cap);
+await require('../server/identity/principals').ensureSchema(db);
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const GUEST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPG';
const GUEST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPG';
-db.prepare(`INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', '${ANN}'), (2, 'bob', 'x', '${BOB}'), (3, 'cat', 'x', '${CAT}')`).run();
-db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id) VALUES (50, 7, 'anon-token-7', '${GUEST}')`).run();
+await db.prepare(`INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', '${ANN}'), (2, 'bob', 'x', '${BOB}'), (3, 'cat', 'x', '${CAT}')`).run();
+await db.prepare(`INSERT INTO anon_users (id, anon_number, session_token, subject_id) VALUES (50, 7, 'anon-token-7', '${GUEST}')`).run();
const modulesLib = require('../server/identity/modules');
const modulesLib = require('../server/identity/modules');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@ -54,9 +55,9 @@ app.use('/internal', require('../server/internal/routes'));
@@ -54,9 +55,9 @@ app.use('/internal', require('../server/internal/routes'));
const server = http.createServer(app);
const server = http.createServer(app);
/** Module events in the outbox, oldest first (after `afterId`). */
/** Module events in the outbox, oldest first (after `afterId`). */
-const events = (afterId = 0) => db.prepare("SELECT id, envelope FROM network_event_outbox WHERE id > ? ORDER BY id").all(afterId)
- .map(r => ({ id: r.id, ...JSON.parse(r.envelope) })).filter(e => e.event_type === 'network.module.updated');
-const lastId = () => (db.prepare('SELECT MAX(id) AS id FROM network_event_outbox').get() || {}).id || 0;
+const events = async (afterId = 0) => (await db.prepare("SELECT id, envelope FROM network_event_outbox WHERE id > ? ORDER BY id").all(afterId))
+ .map(r => ({ id: r.id, ...r.envelope })).filter(e => e.event_type === 'network.module.updated');
+const lastId = async () => (await db.prepare('SELECT MAX(id) AS id FROM network_event_outbox').get() || {}).id || 0;
const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e.id ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'id')) : e); assert.ok(v.valid, JSON.stringify(v.errors)); };
const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e.id ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== 'id')) : e); assert.ok(v.valid, JSON.stringify(v.errors)); };
(async () => {
(async () => {
@@ -64,16 +65,16 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -64,16 +65,16 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
.then(async r => ({ status: r.status, body: r.status === 204 ? null : await r.json().catch(() => null) }));
.then(async r => ({ status: r.status, body: r.status === 204 ? null : await r.json().catch(() => null) }));
- const tokenOf = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
+ const tokenOf = async (id) => ({ authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json()));
const svc = async (client) => (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) }).then(r => r.json()));
// ── Grants: Chat reads and writes the chat.* namespaces; Live's grants lost them (contracts 0.41.0) ──
// ── Grants: Chat reads and writes the chat.* namespaces; Live's grants lost them (contracts 0.41.0) ──
const CHAT_NS = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];
const CHAT_NS = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_settings', 'chat.presence_prefs'];
- const grant = (c, cap) => JSON.parse(db.prepare('SELECT namespaces FROM principal_grants WHERE client_id = ? AND capability = ?').get(c, cap).namespaces);
- assert.deepStrictEqual(grant('chat', 'network.modules.read'), CHAT_NS);
- assert.deepStrictEqual(grant('chat', 'network.modules.write'), CHAT_NS);
- assert.deepStrictEqual(grant('live', 'network.modules.write'), ['live.profile', 'live.stats', 'live.loyalty'], 'the old defaults move to the new one at boot');
- assert.deepStrictEqual(grant('live', 'network.modules.read'), ['live.profile', 'live.stats', 'live.loyalty']);
+ const grant = async (c, cap) => JSON.parse((await db.prepare('SELECT namespaces FROM principal_grants WHERE client_id = ? AND capability = ?').get(c, cap)).namespaces);
+ assert.deepStrictEqual(await grant('chat', 'network.modules.read'), CHAT_NS);
+ assert.deepStrictEqual(await grant('chat', 'network.modules.write'), CHAT_NS);
+ assert.deepStrictEqual(await grant('live', 'network.modules.write'), ['live.profile', 'live.stats', 'live.loyalty'], 'the old defaults move to the new one at boot');
+ assert.deepStrictEqual(await grant('live', 'network.modules.read'), ['live.profile', 'live.stats', 'live.loyalty']);
// Chat manages its own Events subscriptions (live.release.deployed, network.module.updated).
// Chat manages its own Events subscriptions (live.release.deployed, network.module.updated).
const chatEvents = await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: 'chat', client_secret: 'chat-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' }) }).then(r => r.json());
const chatEvents = await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: 'chat', client_secret: 'chat-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' }) }).then(r => r.json());
assert.strictEqual(chatEvents.scope, 'events.subscription.manage', JSON.stringify(chatEvents));
assert.strictEqual(chatEvents.scope, 'events.subscription.manage', JSON.stringify(chatEvents));
@@ -90,19 +91,19 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -90,19 +91,19 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
assert.strictEqual(r.status, 403, 'Live no longer writes chat.preferences');
assert.strictEqual(r.status, 403, 'Live no longer writes chat.preferences');
// ── Events on write: created, then updated with the changed keys only, no values ──
// ── Events on write: created, then updated with the changed keys only, no values ──
- let mark = lastId();
+ let mark = await lastId();
r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '0' }, body: { data: { timestamps: true, compact: false } } });
r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '0' }, body: { data: { timestamps: true, compact: false } } });
assert.strictEqual(r.status, 201, JSON.stringify(r.body)); assert.strictEqual(r.body.updated_by, 'svc:chat'); assert.strictEqual(r.body.revision, 1);
assert.strictEqual(r.status, 201, JSON.stringify(r.body)); assert.strictEqual(r.body.updated_by, 'svc:chat'); assert.strictEqual(r.body.revision, 1);
- let ev = events(mark);
+ let ev = await events(mark);
assert.strictEqual(ev.length, 1); valid(ev[0]);
assert.strictEqual(ev.length, 1); valid(ev[0]);
assert.deepStrictEqual(ev[0].actor, { type: 'service', id: 'chat' });
assert.deepStrictEqual(ev[0].actor, { type: 'service', id: 'chat' });
assert.strictEqual(ev[0].visibility, 'internal'); assert.strictEqual(ev[0].source, 'network');
assert.strictEqual(ev[0].visibility, 'internal'); assert.strictEqual(ev[0].source, 'network');
assert.deepStrictEqual(ev[0].subject, { type: 'user_module', id: `${ANN}:chat.preferences`, revision: 1 });
assert.deepStrictEqual(ev[0].subject, { type: 'user_module', id: `${ANN}:chat.preferences`, revision: 1 });
assert.deepStrictEqual(ev[0].payload, { owner: { type: 'user', id: ANN }, namespace: 'chat.preferences', namespace_owner: 'chat', schema_version: 1, revision: 1, change: 'created', reason: 'write', keys: ['compact', 'timestamps'] });
assert.deepStrictEqual(ev[0].payload, { owner: { type: 'user', id: ANN }, namespace: 'chat.preferences', namespace_owner: 'chat', schema_version: 1, revision: 1, change: 'created', reason: 'write', keys: ['compact', 'timestamps'] });
- mark = lastId();
- r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...tokenOf(1), 'if-match': '1' }, body: { data: { timestamps: true, font_scale: 1.2 } } });
+ mark = await lastId();
+ r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...await tokenOf(1), 'if-match': '1' }, body: { data: { timestamps: true, font_scale: 1.2 } } });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
- ev = events(mark);
+ ev = await events(mark);
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ANN });
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ANN });
assert.deepStrictEqual(ev[0].payload.keys, ['compact', 'font_scale'], 'timestamps did not change');
assert.deepStrictEqual(ev[0].payload.keys, ['compact', 'font_scale'], 'timestamps did not change');
@@ -110,36 +111,36 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -110,36 +111,36 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
assert.ok(!JSON.stringify(ev[0].payload).includes('1.2') && !('public' in ev[0].payload), 'a private namespace never carries a value');
assert.ok(!JSON.stringify(ev[0].payload).includes('1.2') && !('public' in ev[0].payload), 'a private namespace never carries a value');
r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '1' }, body: { data: { compact: true } } });
r = await call('PUT', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '1' }, body: { data: { compact: true } } });
assert.strictEqual(r.status, 412, 'a stale write emits nothing');
assert.strictEqual(r.status, 412, 'a stale write emits nothing');
- assert.strictEqual(events(mark).length, 1);
+ assert.strictEqual((await events(mark)).length, 1);
// ── Public fields: the changed public values travel, private ones never ──
// ── Public fields: the changed public values travel, private ones never ──
- mark = lastId();
+ mark = await lastId();
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 42, is_streamer: true, stream_minutes_30d: 900 } } });
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 42, is_streamer: true, stream_minutes_30d: 900 } } });
assert.strictEqual(r.status, 201);
assert.strictEqual(r.status, 201);
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 43, is_streamer: true, stream_minutes_30d: 950 } } });
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: live, body: { data: { followers: 43, is_streamer: true, stream_minutes_30d: 950 } } });
- ev = events(mark);
+ ev = await events(mark);
assert.deepStrictEqual(ev[0].payload.public, { followers: 42, is_streamer: true });
assert.deepStrictEqual(ev[0].payload.public, { followers: 42, is_streamer: true });
assert.deepStrictEqual(ev[1].payload.keys, ['followers', 'stream_minutes_30d']);
assert.deepStrictEqual(ev[1].payload.keys, ['followers', 'stream_minutes_30d']);
assert.deepStrictEqual(ev[1].payload.public, { followers: 43 }, 'stream_minutes_30d is not public');
assert.deepStrictEqual(ev[1].payload.public, { followers: 43 }, 'stream_minutes_30d is not public');
// ── Deletes: user and owner service; revisions never go back ──
// ── Deletes: user and owner service; revisions never go back ──
- mark = lastId();
- r = await call('DELETE', '/api/modules/chat.preferences', { headers: tokenOf(1) });
+ mark = await lastId();
+ r = await call('DELETE', '/api/modules/chat.preferences', { headers: await tokenOf(1) });
assert.strictEqual(r.status, 204);
assert.strictEqual(r.status, 204);
- ev = events(mark);
+ ev = await events(mark);
assert.strictEqual(ev.length, 1); valid(ev[0]);
assert.strictEqual(ev.length, 1); valid(ev[0]);
assert.deepStrictEqual([ev[0].payload.change, ev[0].payload.reason, ev[0].payload.revision, ev[0].subject.revision], ['deleted', 'delete', 3, 3]);
assert.deepStrictEqual([ev[0].payload.change, ev[0].payload.reason, ev[0].payload.revision, ev[0].subject.revision], ['deleted', 'delete', 3, 3]);
assert.deepStrictEqual(ev[0].payload.keys, ['font_scale', 'timestamps'], 'the keys that went');
assert.deepStrictEqual(ev[0].payload.keys, ['font_scale', 'timestamps'], 'the keys that went');
- r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...tokenOf(1), 'if-match': '0' }, body: { data: { compact: true } } });
+ r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...await tokenOf(1), 'if-match': '0' }, body: { data: { compact: true } } });
assert.strictEqual(r.status, 201); assert.strictEqual(r.body.revision, 4, 'a new record continues after the delete');
assert.strictEqual(r.status, 201); assert.strictEqual(r.body.revision, 4, 'a new record continues after the delete');
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: live });
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: live });
assert.strictEqual(r.status, 403, 'only the owner service deletes');
assert.strictEqual(r.status, 403, 'only the owner service deletes');
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '3' } });
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '3' } });
assert.strictEqual(r.status, 412, JSON.stringify(r.body));
assert.strictEqual(r.status, 412, JSON.stringify(r.body));
- mark = lastId();
+ mark = await lastId();
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '4' } });
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { ...chat, 'if-match': '4' } });
assert.strictEqual(r.status, 204);
assert.strictEqual(r.status, 204);
- ev = events(mark);
+ ev = await events(mark);
assert.deepStrictEqual([ev[0].payload.reason, ev[0].payload.revision, ev[0].actor.id], ['owner_delete', 5, 'chat']);
assert.deepStrictEqual([ev[0].payload.reason, ev[0].payload.revision, ev[0].actor.id], ['owner_delete', 5, 'chat']);
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: chat });
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: chat });
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);
@@ -147,77 +148,77 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -147,77 +148,77 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
assert.strictEqual(r.status, 401, 'never the retired key'); assert.strictEqual(r.body.code, 'token.missing');
assert.strictEqual(r.status, 401, 'never the retired key'); assert.strictEqual(r.body.code, 'token.missing');
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key', ...live } });
r = await call('DELETE', `/internal/modules/chat.preferences/${ANN}`, { headers: { 'x-internal-key': 'legacy-key', ...live } });
assert.strictEqual(r.status, 403, 'and a bearer is still judged on the token alone');
assert.strictEqual(r.status, 403, 'and a bearer is still judged on the token alone');
- assert.strictEqual(events(mark).length, 1, 'a refused delete emits no event');
+ assert.strictEqual((await events(mark)).length, 1, 'a refused delete emits no event');
// ── Account removal: rows and revisions go, one event each; the trigger guards the account row ──
// ── Account removal: rows and revisions go, one event each; the trigger guards the account row ──
- modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
- modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });
- assert.throws(() => db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');
- assert.throws(() => db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);
- mark = lastId();
- db.transaction(() => {
- assert.strictEqual(modulesLib.onSubjectRemoved(db, BOB), 2);
- db.prepare('DELETE FROM users WHERE id = 2').run();
- })();
- ev = events(mark);
+ await modulesLib.write(db, BOB, 'chat.preferences', { timestamps: true }, { writer: { type: 'service', id: 'chat' } });
+ await modulesLib.write(db, BOB, 'live.profile', { followers: 3 }, { writer: { type: 'service', id: 'live' } });
+ await assert.rejects(async () => await db.prepare('DELETE FROM users WHERE id = 2').run(), /onSubjectRemoved/, 'an account with modules cannot just be deleted');
+ await assert.rejects(async () => await db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' WHERE id = 2").run(), /onSubjectMerged/);
+ mark = await lastId();
+ await db.tx(async () => {
+ assert.strictEqual(await modulesLib.onSubjectRemoved(db, BOB), 2);
+ await db.prepare('DELETE FROM users WHERE id = 2').run();
+ });
+ ev = await events(mark);
assert.deepStrictEqual(ev.map(e => [e.payload.namespace, e.payload.change, e.payload.reason, e.actor.type]),
assert.deepStrictEqual(ev.map(e => [e.payload.namespace, e.payload.change, e.payload.reason, e.actor.type]),
[['chat.preferences', 'deleted', 'subject_removed', 'system'], ['live.profile', 'deleted', 'subject_removed', 'system']]);
[['chat.preferences', 'deleted', 'subject_removed', 'system'], ['live.profile', 'deleted', 'subject_removed', 'system']]);
ev.forEach(valid);
ev.forEach(valid);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(BOB).n, 0);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB).n, 0);
- assert.throws(() => modulesLib.onSubjectRemoved(db, 2), /subject id/);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(BOB)).n, 0);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_revisions WHERE subject_id = ?').get(BOB)).n, 0);
+ await assert.rejects(async () => await modulesLib.onSubjectRemoved(db, 2), /subject id/);
// ── Merge: CAT folds into ANN. Moved where ANN had none, dropped where ANN has one ──
// ── Merge: CAT folds into ANN. Moved where ANN had none, dropped where ANN has one ──
- modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });
- modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });
- mark = lastId();
- const merged = modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });
+ await modulesLib.write(db, CAT, 'chat.preferences', { hide_emotes: true }, { writer: { type: 'service', id: 'chat' } });
+ await modulesLib.write(db, CAT, 'live.profile', { followers: 9 }, { writer: { type: 'service', id: 'live' } });
+ mark = await lastId();
+ const merged = await modulesLib.onSubjectMerged(db, { from: CAT, into: ANN });
assert.deepStrictEqual(merged, { moved: 1, filled: 0, dropped: 1 }, 'ANN already has followers: nothing to fill (ADR-029 fills only missing fields; test/account-merge.test.js)');
assert.deepStrictEqual(merged, { moved: 1, filled: 0, dropped: 1 }, 'ANN already has followers: nothing to fill (ADR-029 fills only missing fields; test/account-merge.test.js)');
- const annPrefs = modulesLib.read(db, ANN, 'chat.preferences');
+ const annPrefs = await modulesLib.read(db, ANN, 'chat.preferences');
assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });
assert.deepStrictEqual(annPrefs.data, { hide_emotes: true });
assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');
assert.strictEqual(annPrefs.revision, 6, 'continues from ANN\'s own history');
assert.strictEqual(annPrefs.updated_by, 'svc:network');
assert.strictEqual(annPrefs.updated_by, 'svc:network');
- assert.strictEqual(modulesLib.read(db, ANN, 'live.profile').data.followers, 43, 'the survivor\'s record wins');
- ev = events(mark);
+ assert.strictEqual((await modulesLib.read(db, ANN, 'live.profile')).data.followers, 43, 'the survivor\'s record wins');
+ ev = await events(mark);
assert.deepStrictEqual(ev.map(e => [e.payload.owner.id, e.payload.namespace, e.payload.change, (e.payload.merged_into || e.payload.merged_from || {}).id]), [
assert.deepStrictEqual(ev.map(e => [e.payload.owner.id, e.payload.namespace, e.payload.change, (e.payload.merged_into || e.payload.merged_from || {}).id]), [
[ANN, 'chat.preferences', 'created', CAT], [CAT, 'chat.preferences', 'deleted', ANN], [CAT, 'live.profile', 'deleted', ANN],
[ANN, 'chat.preferences', 'created', CAT], [CAT, 'chat.preferences', 'deleted', ANN], [CAT, 'live.profile', 'deleted', ANN],
]);
]);
ev.forEach(valid);
ev.forEach(valid);
assert.ok(!('public' in ev[2].payload), 'a delete carries no values');
assert.ok(!('public' in ev[2].payload), 'a delete carries no values');
- db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go
- assert.throws(() => modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);
+ await db.prepare('DELETE FROM users WHERE id = 3').run(); // nothing left: the trigger lets it go
+ await assert.rejects(async () => await modulesLib.onSubjectMerged(db, { from: ANN, into: ANN }), /same subject/);
// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──
// ── Guest -> account: POST /api/auth/anon/:token/link moves the guest's modules ──
- modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });
- modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });
- assert.throws(() => db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);
- r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: tokenOf(1) });
+ await modulesLib.write(db, GUEST, 'chat.preferences', { compact: true }, { writer: { type: 'service', id: 'chat' } });
+ await modulesLib.write(db, GUEST, 'chat.tts_defaults', { volume: 15 }, { writer: { type: 'user' }, expectedRevision: 0 });
+ await assert.rejects(async () => await db.prepare('DELETE FROM anon_users WHERE id = 50').run(), /onSubjectRemoved/);
+ r = await call('POST', '/api/auth/anon/anon-token-7/link', { headers: await tokenOf(1) });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.tts_defaults').data, { volume: 15 }, 'moved');
- assert.deepStrictEqual(modulesLib.read(db, ANN, 'chat.preferences').data, { hide_emotes: true, compact: true }, 'the account kept its own values and gained only the field it lacked (ADR-029)');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST).n, 0);
+ assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.tts_defaults')).data, { volume: 15 }, 'moved');
+ assert.deepStrictEqual((await modulesLib.read(db, ANN, 'chat.preferences')).data, { hide_emotes: true, compact: true }, 'the account kept its own values and gained only the field it lacked (ADR-029)');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(GUEST)).n, 0);
// ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──
// ── onOwnerRemoved: a retired owner freezes its namespaces; delete-after-retention sweeps ──
- modulesLib.write(db, ANN, 'tools.usage', { recent: [{ tool: 'yt' }] }, { writer: { type: 'service', id: 'tools' } });
+ await modulesLib.write(db, ANN, 'tools.usage', { recent: [{ tool: 'yt' }] }, { writer: { type: 'service', id: 'tools' } });
const realGet = contracts.services.get;
const realGet = contracts.services.get;
contracts.services.get = (id) => (id === 'tools' || id === 'chat' ? { ...realGet(id), status: 'retired' } : realGet(id));
contracts.services.get = (id) => (id === 'tools' || id === 'chat' ? { ...realGet(id), status: 'retired' } : realGet(id));
try {
try {
- const w = modulesLib.write(db, ANN, 'chat.preferences', { compact: true }, { writer: { type: 'user' }, expectedRevision: 6 });
+ const w = await modulesLib.write(db, ANN, 'chat.preferences', { compact: true }, { writer: { type: 'user' }, expectedRevision: 6 });
assert.strictEqual(w.status, 409); assert.strictEqual(w.code, 'modules.namespace_retired');
assert.strictEqual(w.status, 409); assert.strictEqual(w.code, 'modules.namespace_retired');
- r = await call('GET', '/api/modules', { headers: tokenOf(1) });
+ r = await call('GET', '/api/modules', { headers: await tokenOf(1) });
assert.strictEqual(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable, false);
assert.strictEqual(r.body.namespaces.find(n => n.namespace === 'chat.preferences').userWritable, false);
const t0 = Date.now();
const t0 = Date.now();
- assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 }), 0, 'retention starts when Network first sees the retirement');
- assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 + 89 * 86400000 }), 0);
- mark = lastId();
- assert.strictEqual(modulesLib.sweepRetired(db, { now: t0 + 91 * 86400000 }), 1, 'tools.usage: 90 days');
- assert.deepStrictEqual(events(mark).map(e => [e.payload.namespace, e.payload.reason]), [['tools.usage', 'owner_delete']]);
- assert.ok(modulesLib.read(db, ANN, 'chat.preferences'), 'retain-readonly keeps records');
- r = await call('DELETE', '/api/modules/chat.preferences', { headers: tokenOf(1) });
+ assert.strictEqual(await modulesLib.sweepRetired(db, { now: t0 }), 0, 'retention starts when Network first sees the retirement');
+ assert.strictEqual(await modulesLib.sweepRetired(db, { now: t0 + 89 * 86400000 }), 0);
+ mark = await lastId();
+ assert.strictEqual(await modulesLib.sweepRetired(db, { now: t0 + 91 * 86400000 }), 1, 'tools.usage: 90 days');
+ assert.deepStrictEqual((await events(mark)).map(e => [e.payload.namespace, e.payload.reason]), [['tools.usage', 'owner_delete']]);
+ assert.ok(await modulesLib.read(db, ANN, 'chat.preferences'), 'retain-readonly keeps records');
+ r = await call('DELETE', '/api/modules/chat.preferences', { headers: await tokenOf(1) });
assert.strictEqual(r.status, 204, 'people can always delete their own');
assert.strictEqual(r.status, 204, 'people can always delete their own');
} finally { contracts.services.get = realGet; }
} finally { contracts.services.get = realGet; }
- modulesLib.sweepRetired(db);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_module_retirements').get().n, 0, 'un-retired again');
+ await modulesLib.sweepRetired(db);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_module_retirements').get()).n, 0, 'un-retired again');
// ── Relay: rows written while it was off are published, envelopes valid ──
// ── Relay: rows written while it was off are published, envelopes valid ──
const published = [];
const published = [];
@@ -229,14 +230,14 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -229,14 +230,14 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
return new Response(JSON.stringify(body.events ? { results } : results[0]), { status: 200, headers: { 'content-type': 'application/json' } });
return new Response(JSON.stringify(body.events ? { results } : results[0]), { status: 200, headers: { 'content-type': 'application/json' } });
};
};
const relay = require('../server/developer/event-relay');
const relay = require('../server/developer/event-relay');
- const outbox = relay.startRelay(db, { eventsUrl: 'http://events.test', privateKey: keys.privateKey, issuer: config.jwt.issuer, fetch: fakeEvents, autoStart: false, log: { log() {}, warn() {} } });
+ const outbox = await relay.startRelay(db, { eventsUrl: 'http://events.test', privateKey: keys.privateKey, issuer: config.jwt.issuer, fetch: fakeEvents, autoStart: false, log: { log() {}, warn() {} } });
await outbox.flush();
await outbox.flush();
const mod = published.filter(e => e.event_type === 'network.module.updated');
const mod = published.filter(e => e.event_type === 'network.module.updated');
- assert.strictEqual(mod.length, events().length, 'every module event went out');
+ assert.strictEqual(mod.length, (await events()).length, 'every module event went out');
mod.forEach(valid);
mod.forEach(valid);
- assert.strictEqual(outbox.pending(), 0);
+ assert.strictEqual(await outbox.pending(), 0);
// With the relay on, a write lands in the same outbox and goes on the next flush.
// With the relay on, a write lands in the same outbox and goes on the next flush.
- modulesLib.write(db, ANN, 'live.profile', { followers: 44 }, { writer: { type: 'service', id: 'live' } });
+ await modulesLib.write(db, ANN, 'live.profile', { followers: 44 }, { writer: { type: 'service', id: 'live' } });
await outbox.flush();
await outbox.flush();
assert.strictEqual(published.at(-1).payload.revision, 3); assert.strictEqual(published.at(-1).subject.id, `${ANN}:live.profile`);
assert.strictEqual(published.at(-1).payload.revision, 3); assert.strictEqual(published.at(-1).subject.id, `${ANN}:live.profile`);
await relay.stopRelay(db);
await relay.stopRelay(db);
@@ -245,3 +246,4 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
@@ -245,3 +246,4 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('module events and lifecycle: all checks passed');
console.log('module events and lifecycle: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -10,20 +10,21 @@ const path = require('path');
@@ -10,20 +10,21 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-modules-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
// chat is registered on the host, not seeded (ai is seeded since its console, WS-O task 4): add a missing one as a copy of tools, then seed the grants again.
// chat is registered on the host, not seeded (ai is seeded since its console, WS-O task 4): add a missing one as a copy of tools, then seed the grants again.
for (const c of ['chat', 'ai']) {
for (const c of ['chat', 'ai']) {
- const cols = db.prepare('PRAGMA table_info(oauth_clients)').all().map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');
- db.prepare(`INSERT OR IGNORE INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools'`).run(c);
+ const cols = (await db.prepare("SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?").all('oauth_clients')).map(x => x.name).filter(n => n !== 'client_id' && n !== 'id');
+ await db.prepare(`INSERT INTO oauth_clients (client_id, ${cols.join(', ')}) SELECT ?, ${cols.join(', ')} FROM oauth_clients WHERE client_id = 'tools' ON CONFLICT DO NOTHING`).run(c);
}
}
-require('../server/identity/principals').ensureSchema(db);
-for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret'], ['chat', 'chat-secret'], ['ai', 'ai-secret']]) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();
+await require('../server/identity/principals').ensureSchema(db);
+for (const [c, s] of [['live', 'live-secret'], ['tools', 'tools-secret'], ['games', 'games-secret'], ['chat', 'chat-secret'], ['ai', 'ai-secret']]) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(s, c);
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (1, 'ann', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA'), (2, 'bob', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB')").run();
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@ -42,7 +43,7 @@ const server = http.createServer(app);
@@ -42,7 +43,7 @@ const server = http.createServer(app);
(async () => {
(async () => {
await new Promise(r => server.listen(0, '127.0.0.1', r));
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
- const annTok = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);
+ const annTok = signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
.then(async r => ({ status: r.status, etag: r.headers.get('etag'), cache: r.headers.get('cache-control'), body: r.status === 204 ? null : await r.json().catch(() => null) }));
.then(async r => ({ status: r.status, etag: r.headers.get('etag'), cache: r.headers.get('cache-control'), body: r.status === 204 ? null : await r.json().catch(() => null) }));
const me = { authorization: `Bearer ${annTok}` };
const me = { authorization: `Bearer ${annTok}` };
@@ -94,7 +95,7 @@ const server = http.createServer(app);
@@ -94,7 +95,7 @@ const server = http.createServer(app);
assert.deepStrictEqual(r.body.data, { style: 'casual', history: false }, 'ai is a listed reader of ai.preferences');
assert.deepStrictEqual(r.body.data, { style: 'casual', history: false }, 'ai is a listed reader of ai.preferences');
r = await call('PUT', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { streams_30d: 3, peak_viewers_30d: 12, new_followers_30d: 7 } } });
r = await call('PUT', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${live}` }, body: { data: { streams_30d: 3, peak_viewers_30d: 12, new_followers_30d: 7 } } });
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
- db.prepare("UPDATE principal_grants SET namespaces = '[\"tools.usage\",\"live.stats\"]' WHERE client_id = 'tools' AND capability = 'network.modules.read'").run();
+ await db.prepare("UPDATE principal_grants SET namespaces = '[\"tools.usage\",\"live.stats\"]' WHERE client_id = 'tools' AND capability = 'network.modules.read'").run();
const tools2 = await svc('tools');
const tools2 = await svc('tools');
r = await call('GET', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${tools2}` } });
r = await call('GET', `/internal/modules/live.stats/${ANN}`, { headers: { authorization: `Bearer ${tools2}` } });
assert.deepStrictEqual(r.body.data, { streams_30d: 3, peak_viewers_30d: 12 }, 'a granted service that is not a listed reader sees public fields only');
assert.deepStrictEqual(r.body.data, { streams_30d: 3, peak_viewers_30d: 12 }, 'a granted service that is not a listed reader sees public fields only');
@@ -102,13 +103,13 @@ const server = http.createServer(app);
@@ -102,13 +103,13 @@ const server = http.createServer(app);
assert.strictEqual(r.body.data.new_followers_30d, 7, 'the owner reads the whole record');
assert.strictEqual(r.body.data.new_followers_30d, 7, 'the owner reads the whole record');
// ── Version migration: a stored v1 record reads as the current version ──
// ── Version migration: a stored v1 record reads as the current version ──
- db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES (?, 'tools.usage', 1, 1, ?)").run(ANN, JSON.stringify({ recent: [{ tool: 'img' }] }));
+ await db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES (?, 'tools.usage', 1, 1, ?)").run(ANN, JSON.stringify({ recent: [{ tool: 'img' }] }));
r = await call('GET', '/api/modules/tools.usage', { headers: me });
r = await call('GET', '/api/modules/tools.usage', { headers: me });
assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, { recent: [{ tool: 'img' }] });
assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, { recent: [{ tool: 'img' }] });
r = await call('PUT', '/api/modules/tools.usage', { headers: { ...me, 'if-match': '1' }, body: { data: { ...r.body.data, favorites: ['img'] } } });
r = await call('PUT', '/api/modules/tools.usage', { headers: { ...me, 'if-match': '1' }, body: { data: { ...r.body.data, favorites: ['img'] } } });
assert.strictEqual(r.status, 200, 'the person may star tools (tools.usage v2)');
assert.strictEqual(r.status, 200, 'the person may star tools (tools.usage v2)');
- assert.strictEqual(db.prepare("SELECT version FROM user_modules WHERE subject_id = ? AND namespace = 'tools.usage'").get(ANN).version, 2, 'the next write stores the current version');
- db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'chat.tts_defaults', 1, 1, ?)").run(JSON.stringify({ voice: 'en-1', rate: 1.2, muted: true }));
+ assert.strictEqual((await db.prepare("SELECT version FROM user_modules WHERE subject_id = ? AND namespace = 'tools.usage'").get(ANN)).version, 2, 'the next write stores the current version');
+ await db.prepare("INSERT INTO user_modules (subject_id, namespace, version, revision, data) VALUES ('usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'chat.tts_defaults', 1, 1, ?)").run(JSON.stringify({ voice: 'en-1', rate: 1.2, muted: true }));
r = await call('GET', '/internal/modules/chat.tts_defaults/usr_01JAB2C3D4E5F6G7H8J9K0MNPB', { headers: { authorization: `Bearer ${chat}` } });
r = await call('GET', '/internal/modules/chat.tts_defaults/usr_01JAB2C3D4E5F6G7H8J9K0MNPB', { headers: { authorization: `Bearer ${chat}` } });
assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, {}, 'v1 tts fields are dropped by the migration');
assert.strictEqual(r.body.version, 2); assert.deepStrictEqual(r.body.data, {}, 'v1 tts fields are dropped by the migration');
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { 'x-internal-key': 'legacy-key' }, body: { data: { followers: 1 } } });
@@ -117,7 +118,7 @@ const server = http.createServer(app);
@@ -117,7 +118,7 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 401, 'and a bearer is still judged on the token alone'); assert.strictEqual(r.body.code, 'token.malformed');
assert.strictEqual(r.status, 401, 'and a bearer is still judged on the token alone'); assert.strictEqual(r.body.code, 'token.malformed');
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${tools}` }, body: { data: { followers: 1 } } });
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${tools}` }, body: { data: { followers: 1 } } });
assert.strictEqual(r.status, 403, 'a service without the network.modules.write grant is refused');
assert.strictEqual(r.status, 403, 'a service without the network.modules.write grant is refused');
- assert.ok(db.prepare("SELECT 1 FROM principal_usage WHERE auth = 'service-token' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused service token is audited');
+ assert.ok(await db.prepare("SELECT 1 FROM principal_usage WHERE auth = 'service-token' AND allowed = 0 AND route LIKE 'PUT /internal/modules%'").get(), 'a refused service token is audited');
r = await call('PUT', `/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 1 } } });
r = await call('PUT', `/internal/modules/live.profile/usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ`, { headers: { authorization: `Bearer ${live}` }, body: { data: { followers: 1 } } });
assert.strictEqual(r.status, 404, 'unknown subject');
assert.strictEqual(r.status, 404, 'unknown subject');
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });
r = await call('PUT', `/internal/modules/live.profile/${ANN}`, { headers: { authorization: `Bearer ${live}`, 'if-match': '0' }, body: { data: { followers: 43 } } });
@@ -146,3 +147,4 @@ const server = http.createServer(app);
@@ -146,3 +147,4 @@ const server = http.createServer(app);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('modules: all checks passed');
console.log('modules: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -11,16 +11,17 @@ const path = require('path');
@@ -11,16 +11,17 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const nodes = require('../server/registry/nodes');
const nodes = require('../server/registry/nodes');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-nodes-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-nodes-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const app = express();
const app = express();
@@ -80,3 +81,4 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
@@ -80,3 +81,4 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -18,9 +18,10 @@ const crypto = require('crypto');
@@ -18,9 +18,10 @@ const crypto = require('crypto');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+(async () => {
const { ids, serviceAuth } = contracts;
const { ids, serviceAuth } = contracts;
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { NotificationService, NOTIFICATION_EVENT } = require('../server/notifications/notification-service');
const { NotificationService, NOTIFICATION_EVENT } = require('../server/notifications/notification-service');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const { createEventsConsumer } = require('../server/notifications/events-consumer');
const { createLiveFollowers } = require('../server/notifications/live-followers');
const { createLiveFollowers } = require('../server/notifications/live-followers');
@@ -34,14 +35,14 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLen
@@ -34,14 +35,14 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLen
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-notification-events-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-notification-events-'));
const DB_PATH = path.join(dir, 'network.db');
const DB_PATH = path.join(dir, 'network.db');
const quietly = (f) => { const l = console.log; console.log = () => {}; try { return f(); } finally { console.log = l; } };
const quietly = (f) => { const l = console.log; console.log = () => {}; try { return f(); } finally { console.log = l; } };
-let db = quietly(() => initDb(DB_PATH));
+let db = getDb();
const ALICE = ids.newId('user'), BOB = ids.newId('user'), CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');
const ALICE = ids.newId('user'), BOB = ids.newId('user'), CAROL = ids.newId('user'), DAVE = ids.newId('user'), ERIN = ids.newId('user');
-db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, is_anon) VALUES
+await db.prepare(`INSERT INTO users (id, username, password_hash, subject_id, is_anon) VALUES
(1, 'alice', 'x', ?, 0), (2, 'bob', 'x', ?, 0), (3, 'guest_1', 'x', NULL, 1), (4, 'nosubject', 'x', NULL, 0),
(1, 'alice', 'x', ?, 0), (2, 'bob', 'x', ?, 0), (3, 'guest_1', 'x', NULL, 1), (4, 'nosubject', 'x', NULL, 0),
(20, 'carol', 'x', ?, 0), (21, 'dave', 'x', ?, 0), (22, 'erin', 'x', ?, 0)`).run(ALICE, BOB, CAROL, DAVE, ERIN);
(20, 'carol', 'x', ?, 0), (21, 'dave', 'x', ?, 0), (22, 'erin', 'x', ?, 0)`).run(ALICE, BOB, CAROL, DAVE, ERIN);
-const outbox = (d = db) => d.prepare(`SELECT envelope FROM ${eventRelay.TABLE} ORDER BY id`).all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === NOTIFICATION_EVENT);
+const outbox = async (d = db) => (await d.prepare(`SELECT envelope FROM ${eventRelay.TABLE} ORDER BY id`).all()).map((r) => r.envelope).filter((e) => e.event_type === NOTIFICATION_EVENT);
const PAYLOAD_KEYS = ['category', 'created_at', 'notification_id', 'priority', 'service', 'type', 'unread_count'];
const PAYLOAD_KEYS = ['category', 'created_at', 'notification_id', 'priority', 'service', 'type', 'unread_count'];
const checkEnvelope = (env, subject) => {
const checkEnvelope = (env, subject) => {
const v = contracts.validate('events.event-envelope@1', env);
const v = contracts.validate('events.event-envelope@1', env);
@@ -57,48 +58,49 @@ const checkEnvelope = (env, subject) => {
@@ -57,48 +58,49 @@ const checkEnvelope = (env, subject) => {
(async () => {
(async () => {
// ── 1. network.notification.created, in the notification's transaction ────────────────────────
// ── 1. network.notification.created, in the notification's transaction ────────────────────────
let notifications = new NotificationService(db);
let notifications = new NotificationService(db);
- const n1 = notifications.create({ user_id: 1, type: 'CONTENT_REPLY', category: 'social', title: 'New reply to your comment', message: 'bob replied: secret words', sender_id: 2, sender_name: 'bob', url: 'https://openvibe.live/vod/5#comments', service: 'live' });
+ const n1 = await notifications.create({ user_id: 1, type: 'CONTENT_REPLY', category: 'social', title: 'New reply to your comment', message: 'bob replied: secret words', sender_id: 2, sender_name: 'bob', url: 'https://openvibe.live/vod/5#comments', service: 'live' });
assert.ok(n1);
assert.ok(n1);
- let evs = outbox();
+ let evs = await outbox();
assert.strictEqual(evs.length, 1);
assert.strictEqual(evs.length, 1);
checkEnvelope(evs[0], ALICE);
checkEnvelope(evs[0], ALICE);
assert.deepStrictEqual({ ...evs[0].payload, created_at: null }, { notification_id: n1.id, type: 'CONTENT_REPLY', category: 'social', priority: 'normal', service: 'live', created_at: null, unread_count: 1 });
assert.deepStrictEqual({ ...evs[0].payload, created_at: null }, { notification_id: n1.id, type: 'CONTENT_REPLY', category: 'social', priority: 'normal', service: 'live', created_at: null, unread_count: 1 });
assert.ok(!JSON.stringify(evs[0]).includes('secret words') && !JSON.stringify(evs[0]).includes('bob') && !JSON.stringify(evs[0]).includes(BOB), 'no text, no sender, anywhere in the envelope');
assert.ok(!JSON.stringify(evs[0]).includes('secret words') && !JSON.stringify(evs[0]).includes('bob') && !JSON.stringify(evs[0]).includes(BOB), 'no text, no sender, anywhere in the envelope');
- notifications.create({ user_id: 1, type: 'STREAM_LIVE', title: 'carol is live!', sender_id: 20, service: 'live' });
- evs = outbox();
+ await notifications.create({ user_id: 1, type: 'STREAM_LIVE', title: 'carol is live!', sender_id: 20, service: 'live' });
+ evs = await outbox();
assert.strictEqual(evs.length, 2);
assert.strictEqual(evs.length, 2);
assert.strictEqual(evs[1].payload.unread_count, 2, 'the unread count after the insert');
assert.strictEqual(evs[1].payload.unread_count, 2, 'the unread count after the insert');
assert.strictEqual(evs[1].payload.category, 'stream');
assert.strictEqual(evs[1].payload.category, 'stream');
assert.notStrictEqual(evs[0].event_id, evs[1].event_id);
assert.notStrictEqual(evs[0].event_id, evs[1].event_id);
// Nothing stored, nothing announced: a muted category; a blocked sender is the same rule (create() returns null).
// Nothing stored, nothing announced: a muted category; a blocked sender is the same rule (create() returns null).
- notifications.setPreference(2, 'social', { enabled: false });
- assert.strictEqual(notifications.create({ user_id: 2, type: 'FOLLOW', category: 'social', title: 'x' }), null);
+ await notifications.setPreference(2, 'social', { enabled: false });
+ assert.strictEqual(await notifications.create({ user_id: 2, type: 'FOLLOW', category: 'social', title: 'x' }), null);
// Stored but never announced: a guest, an account without a usr_ subject.
// Stored but never announced: a guest, an account without a usr_ subject.
- assert.ok(notifications.create({ user_id: 3, type: 'WELCOME', title: 'hi' }));
- assert.ok(notifications.create({ user_id: 4, type: 'WELCOME', title: 'hi' }));
- assert.strictEqual(outbox().length, 2);
+ assert.ok(await notifications.create({ user_id: 3, type: 'WELCOME', title: 'hi' }));
+ assert.ok(await notifications.create({ user_id: 4, type: 'WELCOME', title: 'hi' }));
+ assert.strictEqual((await outbox()).length, 2);
// Odd stored shapes are normalised in the event, never dropped with the notification.
// Odd stored shapes are normalised in the event, never dropped with the notification.
- notifications.create({ user_id: 2, type: 'arena_hot', category: 'Game Stuff', priority: 'urgent', title: 'x', service: 'Live!' });
- evs = outbox();
+ await notifications.create({ user_id: 2, type: 'arena_hot', category: 'Game Stuff', priority: 'urgent', title: 'x', service: 'Live!' });
+ evs = await outbox();
assert.strictEqual(evs.length, 3);
assert.strictEqual(evs.length, 3);
checkEnvelope(evs[2], BOB);
checkEnvelope(evs[2], BOB);
assert.deepStrictEqual([evs[2].payload.type, evs[2].payload.category, evs[2].payload.priority, evs[2].payload.service], ['GENERIC', 'system', 'normal', null]);
assert.deepStrictEqual([evs[2].payload.type, evs[2].payload.category, evs[2].payload.priority, evs[2].payload.service], ['GENERIC', 'system', 'normal', null]);
// Both or neither: when the event cannot be queued, the notification is not stored either.
// Both or neither: when the event cannot be queued, the notification is not stored either.
- db.exec(`CREATE TRIGGER outbox_down BEFORE INSERT ON ${eventRelay.TABLE} BEGIN SELECT RAISE(ABORT, 'outbox down'); END`);
- const before = db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get().c;
- assert.throws(() => notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' }), /outbox down/);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get().c, before, 'no notification without its event');
- db.exec('DROP TRIGGER outbox_down');
+ await globalThis.__ovNetworkDdl(`CREATE OR REPLACE FUNCTION ov_test_outbox_down() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'outbox down'; END $$;
+ CREATE TRIGGER outbox_down BEFORE INSERT ON ${eventRelay.TABLE} FOR EACH ROW EXECUTE FUNCTION ov_test_outbox_down()`);
+ const before = (await db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get()).c;
+ await assert.rejects(async () => await notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' }), /outbox down/);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS c FROM notifications WHERE user_id = 1').get()).c, before, 'no notification without its event');
+ await globalThis.__ovNetworkDdl(`DROP TRIGGER IF EXISTS outbox_down ON ${eventRelay.TABLE}; DROP FUNCTION IF EXISTS ov_test_outbox_down()`);
// A bulk fan-out is one transaction with one event per recipient.
// A bulk fan-out is one transaction with one event per recipient.
- const bulk = notifications.createBulk([1, 2, 21], { type: 'SERVICE_ANNOUNCEMENT', category: 'admin', title: 'Maintenance tonight' });
+ const bulk = await notifications.createBulk([1, 2, 21], { type: 'SERVICE_ANNOUNCEMENT', category: 'admin', title: 'Maintenance tonight' });
assert.strictEqual(bulk.length, 3);
assert.strictEqual(bulk.length, 3);
- assert.deepStrictEqual(outbox().slice(-3).map((e) => e.subject.id), [ALICE, BOB, DAVE]);
+ assert.deepStrictEqual((await outbox()).slice(-3).map((e) => e.subject.id), [ALICE, BOB, DAVE]);
// The relay publishes it to Events like every other Network event.
// The relay publishes it to Events like every other Network event.
const published = [];
const published = [];
- const relay = eventRelay.startRelay(db, {
+ const relay = await eventRelay.startRelay(db, {
eventsUrl: 'http://events.test', privateKey, issuer: ISSUER, autoStart: false, log: { log() {}, warn() {} },
eventsUrl: 'http://events.test', privateKey, issuer: ISSUER, autoStart: false, log: { log() {}, warn() {} },
fetch: async (url, init) => {
fetch: async (url, init) => {
const body = JSON.parse(init.body);
const body = JSON.parse(init.body);
@@ -108,7 +110,7 @@ const checkEnvelope = (env, subject) => {
@@ -108,7 +110,7 @@ const checkEnvelope = (env, subject) => {
},
},
});
});
notifications = new NotificationService(db);
notifications = new NotificationService(db);
- notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' });
+ await notifications.create({ user_id: 1, type: 'WELCOME', title: 'hi' });
await relay.flush();
await relay.flush();
assert.ok(published.some((e) => e.event_type === NOTIFICATION_EVENT && e.subject.id === ALICE), 'relayed to Events');
assert.ok(published.some((e) => e.event_type === NOTIFICATION_EVENT && e.subject.id === ALICE), 'relayed to Events');
await eventRelay.stopRelay(db);
await eventRelay.stopRelay(db);
@@ -127,7 +129,7 @@ const checkEnvelope = (env, subject) => {
@@ -127,7 +129,7 @@ const checkEnvelope = (env, subject) => {
const r = await fetch(`${base}/api/v1/realtime/ticket`, { method: 'POST', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers } });
const r = await fetch(`${base}/api/v1/realtime/ticket`, { method: 'POST', headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers } });
return { status: r.status, body: await r.json().catch(() => null), headers: r.headers };
return { status: r.status, body: await r.json().catch(() => null), headers: r.headers };
};
};
- const aliceSession = signToken(db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, config);
+ const aliceSession = signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), privateKey, config);
let r = await mint(aliceSession);
let r = await mint(aliceSession);
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.headers.get('cache-control'), 'no-store');
assert.strictEqual(r.headers.get('cache-control'), 'no-store');
@@ -145,13 +147,13 @@ const checkEnvelope = (env, subject) => {
@@ -145,13 +147,13 @@ const checkEnvelope = (env, subject) => {
assert.notStrictEqual(jwt.decode(again.body.ticket).jti, claims.jti, 'every ticket is its own');
assert.notStrictEqual(jwt.decode(again.body.ticket).jti, claims.jti, 'every ticket is its own');
// A ticket is never a session: not here, not with the session issuer anyone checks.
// A ticket is never a session: not here, not with the session issuer anyone checks.
- assert.ok(session.verifySession(r.body.ticket, { db, publicKey, config }).error, 'Network\'s session guard refuses it');
+ assert.ok((await session.verifySession(r.body.ticket, { db, publicKey, config })).error, 'Network\'s session guard refuses it');
assert.strictEqual((await mint(r.body.ticket)).status, 401, 'no ticket for a ticket');
assert.strictEqual((await mint(r.body.ticket)).status, 401, 'no ticket for a ticket');
assert.throws(() => jwt.verify(r.body.ticket, publicKey, { algorithms: ['RS256'], issuer: ISSUER }), /issuer/, 'services checking the session issuer refuse it');
assert.throws(() => jwt.verify(r.body.ticket, publicKey, { algorithms: ['RS256'], issuer: ISSUER }), /issuer/, 'services checking the session issuer refuse it');
assert.throws(() => jwt.verify(r.body.ticket, publicKey, { algorithms: ['RS256'], issuer: `${ISSUER}/realtime`, audience: 'openvibe.live' }), /audience/);
assert.throws(() => jwt.verify(r.body.ticket, publicKey, { algorithms: ['RS256'], issuer: `${ISSUER}/realtime`, audience: 'openvibe.live' }), /audience/);
// Refused: signed out, a guest, a service token, and everyone while the operator has it off.
// Refused: signed out, a guest, a service token, and everyone while the operator has it off.
assert.strictEqual((await mint(null)).status, 401);
assert.strictEqual((await mint(null)).status, 401);
- r = await mint(signToken(db.prepare('SELECT * FROM users WHERE id = 3').get(), privateKey, config));
+ r = await mint(signToken(await db.prepare('SELECT * FROM users WHERE id = 3').get(), privateKey, config));
assert.deepStrictEqual([r.status, r.body.code], [403, 'realtime.guest']);
assert.deepStrictEqual([r.status, r.body.code], [403, 'realtime.guest']);
const svc = serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: [], iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: 'tok_x' }, privateKey);
const svc = serviceAuth.signServiceToken({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.network'], cap: [], iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: 'tok_x' }, privateKey);
assert.strictEqual((await mint(svc)).status, 401, 'services get no ticket');
assert.strictEqual((await mint(svc)).status, 401, 'services get no ticket');
@@ -160,10 +162,10 @@ const checkEnvelope = (env, subject) => {
@@ -160,10 +162,10 @@ const checkEnvelope = (env, subject) => {
assert.deepStrictEqual([r.status, r.body.code], [503, 'realtime.disabled']);
assert.deepStrictEqual([r.status, r.body.code], [503, 'realtime.disabled']);
enabled = true;
enabled = true;
// An account that has no subject yet is given one.
// An account that has no subject yet is given one.
- r = await mint(signToken(db.prepare('SELECT * FROM users WHERE id = 4').get(), privateKey, config));
+ r = await mint(signToken(await db.prepare('SELECT * FROM users WHERE id = 4').get(), privateKey, config));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.body.subject, /^usr_/);
assert.match(r.body.subject, /^usr_/);
- assert.strictEqual(db.prepare('SELECT subject_id FROM users WHERE id = 4').get().subject_id, r.body.subject);
+ assert.strictEqual((await db.prepare('SELECT subject_id FROM users WHERE id = 4').get()).subject_id, r.body.subject);
// The stream URL: the default, an operator's origin, or the default for anything odd.
// The stream URL: the default, an operator's origin, or the default for anything odd.
assert.strictEqual(ticketMod.streamUrlFrom(''), 'https://events.openvibe.network/realtime/stream');
assert.strictEqual(ticketMod.streamUrlFrom(''), 'https://events.openvibe.network/realtime/stream');
assert.strictEqual(ticketMod.streamUrlFrom('https://events.example.test/'), 'https://events.example.test/realtime/stream');
assert.strictEqual(ticketMod.streamUrlFrom('https://events.example.test/'), 'https://events.example.test/realtime/stream');
@@ -186,11 +188,11 @@ const checkEnvelope = (env, subject) => {
@@ -186,11 +188,11 @@ const checkEnvelope = (env, subject) => {
await new Promise((r2) => live.listen(0, '127.0.0.1', r2));
await new Promise((r2) => live.listen(0, '127.0.0.1', r2));
const SECRET = 's'.repeat(40);
const SECRET = 's'.repeat(40);
const logged = []; const discord = [];
const logged = []; const discord = [];
- function boot() {
+ async function boot() {
// A fresh process: a new connection to the same file, new services, nothing in memory.
// A fresh process: a new connection to the same file, new services, nothing in memory.
- const d = quietly(() => initDb(DB_PATH));
+ const d = getDb();
const n = new NotificationService(d);
const n = new NotificationService(d);
- const c = createEventsConsumer({
+ const c = await createEventsConsumer({
db: d, notifications: n, secrets: SECRET,
db: d, notifications: n, secrets: SECRET,
liveFollowers: createLiveFollowers({ privateKey, issuer: ISSUER, liveUrl: `http://127.0.0.1:${live.address().port}` }),
liveFollowers: createLiveFollowers({ privateKey, issuer: ISSUER, liveUrl: `http://127.0.0.1:${live.address().port}` }),
discord: () => ({ sendLiveAlert: async (s) => { discord.push(s.username); return { sent: true }; } }),
discord: () => ({ sendLiveAlert: async (s) => { discord.push(s.username); return { sent: true }; } }),
@@ -206,16 +208,17 @@ const checkEnvelope = (env, subject) => {
@@ -206,16 +208,17 @@ const checkEnvelope = (env, subject) => {
const res = await fetch(proc.url, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });
const res = await fetch(proc.url, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });
return { status: res.status, body: await res.json() };
return { status: res.status, body: await res.json() };
};
};
- const stop = (proc) => new Promise((r2) => { proc.server.close(() => { proc.db.close(); r2(); }); });
+ // The database handle is the process's shared one (test/helpers/pg-preload.mjs): a "restart" closes the
+ // server, not the database.
+ const stop = (proc) => new Promise((r2) => proc.server.close(r2));
const started = {
const started = {
event_id: ids.newId('event'), event_type: 'live.stream.started', version: 1, source: 'live',
event_id: ids.newId('event'), event_type: 'live.stream.started', version: 1, source: 'live',
actor: { type: 'user', id: CAROL }, subject: { type: 'stream', id: '701', revision: 1 }, visibility: 'public', priority: 'important',
actor: { type: 'user', id: CAROL }, subject: { type: 'stream', id: '701', revision: 1 }, visibility: 'public', priority: 'important',
occurred_at: new Date().toISOString(),
occurred_at: new Date().toISOString(),
payload: { stream_id: 701, channel: { username: 'carol', display_name: 'Carol', url: 'https://openvibe.live/@carol', subject: { type: 'user', id: CAROL } }, title: 'Go', category: null, protocol: 'whip', is_nsfw: false, started_at: new Date().toISOString() },
payload: { stream_id: 701, channel: { username: 'carol', display_name: 'Carol', url: 'https://openvibe.live/@carol', subject: { type: 'user', id: CAROL } }, title: 'Go', category: null, protocol: 'whip', is_nsfw: false, started_at: new Date().toISOString() },
};
};
- db.close();
- const goLives = (d) => d.prepare("SELECT user_id, COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 AND user_id IN (21, 22) GROUP BY user_id ORDER BY user_id").all().map((x) => [x.user_id, x.c]);
- const goLiveEvents = (d) => outbox(d).filter((e) => [DAVE, ERIN].includes(e.subject.id) && e.payload.type === 'STREAM_LIVE').length;
+ const goLives = async (d) => (await d.prepare("SELECT user_id, COUNT(*) AS c FROM notifications WHERE type = 'STREAM_LIVE' AND sender_id = 20 AND user_id IN (21, 22) GROUP BY user_id ORDER BY user_id").all()).map((x) => [x.user_id, x.c]);
+ const goLiveEvents = async (d) => (await outbox(d)).filter((e) => [DAVE, ERIN].includes(e.subject.id) && e.payload.type === 'STREAM_LIVE').length;
// The process dies in the middle of the fan-out (Dave's notification written, Erin's not): nothing
// The process dies in the middle of the fan-out (Dave's notification written, Erin's not): nothing
// of it survives, not the notification, its event, the announcement window or the inbox claim.
// of it survives, not the notification, its event, the announcement window or the inbox claim.
@@ -225,11 +228,11 @@ const checkEnvelope = (env, subject) => {
@@ -225,11 +228,11 @@ const checkEnvelope = (env, subject) => {
proc.notifications.create = (data) => { if (++calls === 2) throw new Error('SIGKILL'); return create(data); };
proc.notifications.create = (data) => { if (++calls === 2) throw new Error('SIGKILL'); return create(data); };
let r3 = await deliver(proc, started);
let r3 = await deliver(proc, started);
assert.deepStrictEqual([r3.status, r3.body.code], [500, 'network.event_failed']);
assert.deepStrictEqual([r3.status, r3.body.code], [500, 'network.event_failed']);
- assert.deepStrictEqual(goLives(proc.db), [], 'the half-done fan-out rolled back');
- assert.strictEqual(goLiveEvents(proc.db), 0);
- assert.ok(!proc.db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(started.event_id));
- const claimed = proc.db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'stream_live_announcements'").get()
- ? proc.db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE stream_id = '701'").get().c : 0;
+ assert.deepStrictEqual(await goLives(proc.db), [], 'the half-done fan-out rolled back');
+ assert.strictEqual(await goLiveEvents(proc.db), 0);
+ assert.ok(!await proc.db.prepare('SELECT 1 FROM network_event_inbox WHERE event_id = ?').get(started.event_id));
+ const claimed = await proc.db.prepare("SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'stream_live_announcements'").get()
+ ? (await proc.db.prepare("SELECT COUNT(*) AS c FROM stream_live_announcements WHERE stream_id = '701'").get()).c : 0;
assert.strictEqual(claimed, 0, 'the announcement window is not spent');
assert.strictEqual(claimed, 0, 'the announcement window is not spent');
await stop(proc);
await stop(proc);
@@ -238,8 +241,8 @@ const checkEnvelope = (env, subject) => {
@@ -238,8 +241,8 @@ const checkEnvelope = (env, subject) => {
r3 = await deliver(proc, started);
r3 = await deliver(proc, started);
assert.strictEqual(r3.status, 200, JSON.stringify(r3.body));
assert.strictEqual(r3.status, 200, JSON.stringify(r3.body));
assert.deepStrictEqual([r3.body.duplicate, r3.body.outcome], [false, 'notified']);
assert.deepStrictEqual([r3.body.duplicate, r3.body.outcome], [false, 'notified']);
- assert.deepStrictEqual(goLives(proc.db), [[21, 1], [22, 1]]);
- assert.strictEqual(goLiveEvents(proc.db), 2, 'one network.notification.created per follower');
+ assert.deepStrictEqual(await goLives(proc.db), [[21, 1], [22, 1]]);
+ assert.strictEqual(await goLiveEvents(proc.db), 2, 'one network.notification.created per follower');
await new Promise((r2) => setImmediate(r2));
await new Promise((r2) => setImmediate(r2));
assert.deepStrictEqual(discord, ['carol'], 'the Discord alert once, after the commit');
assert.deepStrictEqual(discord, ['carol'], 'the Discord alert once, after the commit');
assert.ok(logged.some((m) => m.includes(`live.stream.started ${started.event_id}: notified`)), 'the outcome is in the log (C-85 evidence)');
assert.ok(logged.some((m) => m.includes(`live.stream.started ${started.event_id}: notified`)), 'the outcome is in the log (C-85 evidence)');
@@ -250,14 +253,15 @@ const checkEnvelope = (env, subject) => {
@@ -250,14 +253,15 @@ const checkEnvelope = (env, subject) => {
liveCalls.length = 0;
liveCalls.length = 0;
r3 = await deliver(proc, started);
r3 = await deliver(proc, started);
assert.deepStrictEqual([r3.status, r3.body.duplicate, r3.body.outcome], [200, true, null]);
assert.deepStrictEqual([r3.status, r3.body.duplicate, r3.body.outcome], [200, true, null]);
- assert.deepStrictEqual(goLives(proc.db), [[21, 1], [22, 1]], 'nobody is told twice');
- assert.strictEqual(goLiveEvents(proc.db), 2);
+ assert.deepStrictEqual(await goLives(proc.db), [[21, 1], [22, 1]], 'nobody is told twice');
+ assert.strictEqual(await goLiveEvents(proc.db), 2);
assert.strictEqual(liveCalls.length, 0, 'Live is not asked again');
assert.strictEqual(liveCalls.length, 0, 'Live is not asked again');
await new Promise((r2) => setImmediate(r2));
await new Promise((r2) => setImmediate(r2));
assert.deepStrictEqual(discord, ['carol']);
assert.deepStrictEqual(discord, ['carol']);
await stop(proc);
await stop(proc);
- live.close();
+ live.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('notification events: all checks passed');
console.log('notification events: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -9,14 +9,15 @@ const path = require('path');
@@ -9,14 +9,15 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pkce-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pkce-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -96,3 +97,4 @@ const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKe
@@ -96,3 +97,4 @@ const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKe
console.log('oauth pkce: all checks passed');
console.log('oauth pkce: all checks passed');
server.close();
server.close();
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -11,14 +11,15 @@ const path = require('path');
@@ -11,14 +11,15 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const metrics = require('openvibe-shared/metrics');
const metrics = require('openvibe-shared/metrics');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const observability = require('../server/observability');
const observability = require('../server/observability');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-observability-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-observability-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -138,17 +139,15 @@ const server = http.createServer(app);
@@ -138,17 +139,15 @@ const server = http.createServer(app);
assert.strictEqual(body.ready, true, 'outside production the dev key only degrades');
assert.strictEqual(body.ready, true, 'outside production the dev key only degrades');
assert.deepStrictEqual(body.degraded, ['signing_key']);
assert.deepStrictEqual(body.degraded, ['signing_key']);
- // The database gone: not ready.
- console.log = () => {};
- const db2 = initDb(path.join(dir, 'other.db'));
- console.log = log;
+ // The database gone: not ready. A handle whose query rejects stands in for a PostgreSQL server
+ // that answers nothing (there is no second, unopenable database file any more — plan T2).
+ const db2 = { prepare: () => ({ get: async () => { throw new Error('connection refused'); } }) };
const ready3 = observability.createNetworkReadiness({ db: db2, release: 'x', production: true, getKeys: () => ({ privateKey: keys.privateKey, publicKey: keys.publicKey }) });
const ready3 = observability.createNetworkReadiness({ db: db2, release: 'x', production: true, getKeys: () => ({ privateKey: keys.privateKey, publicKey: keys.publicKey }) });
- db2.close();
body = await ready3.run();
body = await ready3.run();
assert.strictEqual(body.ready, false);
assert.strictEqual(body.ready, false);
assert.deepStrictEqual(body.failed, ['db']);
assert.deepStrictEqual(body.failed, ['db']);
server.close();
server.close();
- db.close();
console.log('observability: all checks passed');
console.log('observability: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -14,14 +14,23 @@ const http = require('http');
@@ -14,14 +14,23 @@ const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { bootServer } = require('./helpers/boot-server');
const { bootServer } = require('./helpers/boot-server');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'];
const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'];
(async () => {
(async () => {
// ── The real server: discovery at the issuer's root ──
// ── The real server: discovery at the issuer's root ──
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
- const srv = await bootServer({ env: { OV_NETWORK_URL: ISSUER, BASE_URL: ISSUER } });
+ // A real RSA key pair on disk: the server loads JWT_PRIVATE_KEY/JWT_PUBLIC_KEY from these paths and must
+ // publish exactly this key's kid and modulus at /api/.well-known/jwks (decision 2, plan T2: the key
+ // stays a file/env key, never in the database).
+ const jwksKeys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-jwks-'));
+ const privPath = path.join(keyDir, 'private.pem');
+ const pubPath = path.join(keyDir, 'public.pem');
+ fs.writeFileSync(privPath, jwksKeys.privateKey, { mode: 0o600 });
+ fs.writeFileSync(pubPath, jwksKeys.publicKey);
+ const srv = await bootServer({ env: { OV_NETWORK_URL: ISSUER, BASE_URL: ISSUER, JWT_PRIVATE_KEY: privPath, JWT_PUBLIC_KEY: pubPath } });
try {
try {
const get = (p) => fetch(srv.base + p).then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json().catch(() => null) }));
const get = (p) => fetch(srv.base + p).then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json().catch(() => null) }));
const root = await get('/.well-known/openid-configuration');
const root = await get('/.well-known/openid-configuration');
@@ -51,17 +60,28 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
@@ -51,17 +60,28 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
const r = await fetch(srv.base + new URL(root.body[k]).pathname, { redirect: 'manual' });
const r = await fetch(srv.base + new URL(root.body[k]).pathname, { redirect: 'manual' });
assert.notStrictEqual(r.status, 404, `${k} is served (got ${r.status})`);
assert.notStrictEqual(r.status, 404, `${k} is served (got ${r.status})`);
}
}
+ // The published JWKS is the configured key: the same kid the id_token header carries, and the same
+ // modulus and exponent as JWT_PUBLIC_KEY (decision 2, plan T2).
+ const jwks = await get('/api/.well-known/jwks');
+ assert.strictEqual(jwks.status, 200);
+ assert.strictEqual(jwks.body.algorithm, 'RS256', 'a real key is served, never the HS256 fallback secret');
+ const jwk = (jwks.body.keys || []).find((k) => k.kid === 'ov-network-1');
+ assert.ok(jwk, 'the JWKS publishes kid ov-network-1');
+ const pub = crypto.createPublicKey(jwksKeys.publicKey).export({ format: 'jwk' });
+ assert.strictEqual(jwk.kty, 'RSA');
+ assert.strictEqual(jwk.n, pub.n, "the served modulus is the configured key's modulus");
+ assert.strictEqual(jwk.e, pub.e, 'and the exponent');
// The platform descriptor points at the standard location.
// The platform descriptor points at the standard location.
assert.strictEqual((await get('/.well-known/openvibe')).body.openid_configuration, `${ISSUER}/.well-known/openid-configuration`);
assert.strictEqual((await get('/.well-known/openvibe')).body.openid_configuration, `${ISSUER}/.well-known/openid-configuration`);
- } catch (err) { console.error(srv.logs()); throw err; } finally { await srv.stop(); }
+ } catch (err) { console.error(srv.logs()); throw err; } finally { await srv.stop(); fs.rmSync(keyDir, { recursive: true, force: true }); }
// ── What it advertises is true: id_token and userinfo ──
// ── What it advertises is true: id_token and userinfo ──
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-oidc-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-oidc-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
- const db = initDb(path.join(dir, 'network.db'));
+ const db = await getDb();
console.log = log;
console.log = log;
- db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
- db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (7, 'viewer', 'x', 'Viewer')").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+ await db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (7, 'viewer', 'x', 'Viewer')").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const app = express();
const app = express();
app.use(express.json());
app.use(express.json());
@@ -134,4 +154,4 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
@@ -134,4 +154,4 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
server.close();
server.close();
console.log('oidc discovery: all checks passed');
console.log('oidc discovery: all checks passed');
-})().catch((err) => { console.error(err); process.exit(1); });
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -11,18 +11,19 @@ const path = require('path');
@@ -11,18 +11,19 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const alerts = require('../server/operator/alerts');
const alerts = require('../server/operator/alerts');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-opalerts-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
process.env.OWNER_USERNAME = 'Boss';
process.env.OWNER_USERNAME = 'Boss';
process.env.OPERATOR_ALERT_USERNAMES = 'deputy, nobody-here';
process.env.OPERATOR_ALERT_USERNAMES = 'deputy, nobody-here';
-db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'boss', 'x'), (2, 'deputy', 'x'), (3, 'someone', 'x')").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'boss', 'x'), (2, 'deputy', 'x'), (3, 'someone', 'x')").run();
const sent = [];
const sent = [];
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
@@ -102,17 +103,17 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
@@ -102,17 +103,17 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
// A day later, a critical still firing: one reminder; then quiet again.
// A day later, a critical still firing: one reminder; then quiet again.
sent.length = 0;
sent.length = 0;
const later = Date.now() + alerts.REMIND_MS + 1000;
const later = Date.now() + alerts.REMIND_MS + 1000;
- let out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: `r${sent.length}` }; }, now: later });
+ let out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: `r${sent.length}` }; }, now: later });
assert.deepStrictEqual(out, { ok: true, firing: 1, opened: 0, reminded: 1, resolved: 0, notified: 2 });
assert.deepStrictEqual(out, { ok: true, firing: 1, opened: 0, reminded: 1, resolved: 0, notified: 2 });
assert.strictEqual(sent[0].title, 'Still firing: OpenVibeBackupMissed');
assert.strictEqual(sent[0].title, 'Still firing: OpenVibeBackupMissed');
- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });
assert.strictEqual(out.reminded, 0);
assert.strictEqual(out.reminded, 0);
// A warning is never reminded.
// A warning is never reminded.
- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 120000 });
- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 2 * alerts.REMIND_MS });
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 120000 });
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('Warned', { severity: 'warning' })] }, { notify: () => ({ id: 'w' }), now: later + 2 * alerts.REMIND_MS });
assert.strictEqual(out.reminded, 1, 'only the critical one');
assert.strictEqual(out.reminded, 1, 'only the critical one');
- alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: later + 2 * alerts.REMIND_MS + 1 });
+ await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: later + 2 * alerts.REMIND_MS + 1 });
// Fires again within 12 h of resolving: the same notifications are revised; nothing is created or pushed.
// Fires again within 12 h of resolving: the same notifications are revised; nothing is created or pushed.
sent.length = 0; revised.length = 0;
sent.length = 0; revised.length = 0;
@@ -129,21 +130,21 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
@@ -129,21 +130,21 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
sent.length = 0;
sent.length = 0;
r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('JustInfo', { severity: 'info' })] }, auth);
r = await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('JustInfo', { severity: 'info' })] }, auth);
assert.deepStrictEqual([r.body.opened, r.body.notified, sent.length], [1, 0, 0]);
assert.deepStrictEqual([r.body.opened, r.body.notified, sent.length], [1, 0, 0]);
- assert.ok(alerts.list(db).some((x) => x.name === 'JustInfo' && x.state === 'firing'));
+ assert.ok((await alerts.list(db)).some((x) => x.name === 'JustInfo' && x.state === 'firing'));
// After 12 h resolved, firing again is a new episode with new notifications.
// After 12 h resolved, firing again is a new episode with new notifications.
- alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null });
+ await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null });
const much = Date.now() + alerts.FLAP_MS + 60000;
const much = Date.now() + alerts.FLAP_MS + 60000;
sent.length = 0;
sent.length = 0;
- out = alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, { notify: (u, n) => { sent.push(n); return { id: `x${sent.length}` }; }, now: much });
+ out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, { notify: (u, n) => { sent.push(n); return { id: `x${sent.length}` }; }, now: much });
assert.deepStrictEqual([out.opened, out.notified], [1, 2]);
assert.deepStrictEqual([out.opened, out.notified], [1, 2]);
assert.ok(sent.every((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed'));
assert.ok(sent.every((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed'));
- alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: much + 1000 });
+ await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: much + 1000 });
// An empty set resolves everything from that source; the list keeps them.
// An empty set resolves everything from that source; the list keeps them.
r = await post({ source: 'prometheus', alerts: [] }, auth);
r = await post({ source: 'prometheus', alerts: [] }, auth);
assert.deepStrictEqual([r.body.firing, r.body.resolved], [0, 1]);
assert.deepStrictEqual([r.body.firing, r.body.resolved], [0, 1]);
- const rows = alerts.list(db);
+ const rows = await alerts.list(db);
assert.ok(rows.every((x) => x.state === 'resolved'), JSON.stringify(rows.map((x) => [x.name, x.state])));
assert.ok(rows.every((x) => x.state === 'resolved'), JSON.stringify(rows.map((x) => [x.name, x.state])));
assert.ok(rows.every((x) => !('description' in x) || x.description === undefined));
assert.ok(rows.every((x) => !('description' in x) || x.description === undefined));
@@ -153,8 +154,8 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
@@ -153,8 +154,8 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);
assert.deepStrictEqual([r.status, r.body.opened, r.body.notified], [200, 1, 0]);
} finally {
} finally {
server.close();
server.close();
- db.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log('operator alerts: all checks passed');
console.log('operator alerts: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -10,19 +10,20 @@ const path = require('path');
@@ -10,19 +10,20 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { serviceAuth, validate } = require('openvibe-contracts');
const { serviceAuth, validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
-db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();
// A database seeded before media.analyze (Live's AI grants as the old default) is moved at the next boot.
// A database seeded before media.analyze (Live's AI grants as the old default) is moved at the next boot.
-db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));
-require('../server/identity/principals').ensureSchema(db);
+await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));
+await require('../server/identity/principals').ensureSchema(db);
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -72,11 +73,11 @@ const server = http.createServer(app);
@@ -72,11 +73,11 @@ const server = http.createServer(app);
const creditOnly = t.body.access_token;
const creditOnly = t.body.access_token;
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit network.coins.mint' });
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network', scope: 'network.coins.credit network.coins.mint' });
assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'asking for an ungranted capability fails');
assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'asking for an ungranted capability fails');
- db.prepare("UPDATE oauth_clients SET client_secret = 'openre-secret' WHERE client_id = 'openre'").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'openre-secret' WHERE client_id = 'openre'").run();
t = await token({ client_id: 'openre', client_secret: 'openre-secret', audience: 'openvibe.network' });
t = await token({ client_id: 'openre', client_secret: 'openre-secret', audience: 'openvibe.network' });
assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'a client with no grants gets no token');
assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope', 'a client with no grants gets no token');
// The canonical channel/owner resolver on Live (D20-R1): OpenRe, Media and Community hold it, nobody else.
// The canonical channel/owner resolver on Live (D20-R1): OpenRe, Media and Community hold it, nobody else.
- db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
for (const [client, secret] of [['openre', 'openre-secret'], ['media', 'media-secret'], ['community', 'community-secret']]) {
for (const [client, secret] of [['openre', 'openre-secret'], ['media', 'media-secret'], ['community', 'community-secret']]) {
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.live' });
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.live' });
assert.strictEqual(t.status, 200, `${client}: ${JSON.stringify(t.body)}`);
assert.strictEqual(t.status, 200, `${client}: ${JSON.stringify(t.body)}`);
@@ -86,7 +87,7 @@ const server = http.createServer(app);
@@ -86,7 +87,7 @@ const server = http.createServer(app);
}
}
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.live', scope: 'live.lineage.resolve' });
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.live', scope: 'live.lineage.resolve' });
assert.strictEqual(t.status, 400, 'Live does not grant itself its own resolver');
assert.strictEqual(t.status, 400, 'Live does not grant itself its own resolver');
- const holders = db.prepare("SELECT client_id FROM principal_grants WHERE capability = 'live.lineage.resolve' AND audience = 'openvibe.live' AND revoked_at IS NULL ORDER BY client_id").all().map(x => x.client_id);
+ const holders = (await db.prepare("SELECT client_id FROM principal_grants WHERE capability = 'live.lineage.resolve' AND audience = 'openvibe.live' AND revoked_at IS NULL ORDER BY client_id").all()).map(x => x.client_id);
assert.deepStrictEqual(holders, ['community', 'media', 'openre']);
assert.deepStrictEqual(holders, ['community', 'media', 'openre']);
t = await token({ client_id: 'media', client_secret: 'media-secret', audience: 'openvibe.network' });
t = await token({ client_id: 'media', client_secret: 'media-secret', audience: 'openvibe.network' });
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
@@ -137,7 +138,7 @@ const server = http.createServer(app);
@@ -137,7 +138,7 @@ const server = http.createServer(app);
assert.strictEqual(r, 404, 'the key-only internal routes are gone');
assert.strictEqual(r, 404, 'the key-only internal routes are gone');
// Community resolves authors with its own token; Live's token lacks that grant.
// Community resolves authors with its own token; Live's token lacks that grant.
- db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');
assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');
@@ -146,7 +147,7 @@ const server = http.createServer(app);
@@ -146,7 +147,7 @@ const server = http.createServer(app);
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403, 'a token narrowed to coins cannot resolve identities');
assert.strictEqual(r.status, 403, 'a token narrowed to coins cannot resolve identities');
// Media's owner_subject backfill: Live user ids -> subjects, an unknown id answers null.
// Media's owner_subject backfill: Live user ids -> subjects, an unknown id answers null.
- db.prepare("INSERT OR IGNORE INTO identity_legacy_map (source_system, source_type, source_id, subject_id) SELECT 'live', 'user', '42', subject_id FROM users WHERE id = 7").run();
+ await db.prepare("INSERT INTO identity_legacy_map (source_system, source_type, source_id, subject_id) SELECT 'live', 'user', '42', subject_id FROM users WHERE id = 7 ON CONFLICT DO NOTHING").run();
r = await post('/internal/identity/resolve-batch', { system: 'live', type: 'user', ids: ['42', '43'] }, { authorization: `Bearer ${mediaResolve}` });
r = await post('/internal/identity/resolve-batch', { system: 'live', type: 'user', ids: ['42', '43'] }, { authorization: `Bearer ${mediaResolve}` });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.match(r.body.results['42'].subject.id, /^usr_/); assert.strictEqual(r.body.results['43'], null);
assert.match(r.body.results['42'].subject.id, /^usr_/); assert.strictEqual(r.body.results['43'], null);
@@ -158,7 +159,7 @@ const server = http.createServer(app);
@@ -158,7 +159,7 @@ const server = http.createServer(app);
assert.deepStrictEqual(cm.body.scope.split(' '), ['community.comment.moderate', 'community.comment.write', 'community.paste.create', 'community.paste.moderate', 'community.paste.write', 'community.pulse.write']);
assert.deepStrictEqual(cm.body.scope.split(' '), ['community.comment.moderate', 'community.comment.write', 'community.paste.create', 'community.paste.moderate', 'community.paste.write', 'community.pulse.write']);
const tl = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.tools' });
const tl = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.tools' });
assert.deepStrictEqual(tl.body.scope.split(' '), ['tools.job.read', 'tools.tool.run'], 'Live runs Tools on the service tier, never probes');
assert.deepStrictEqual(tl.body.scope.split(' '), ['tools.job.read', 'tools.tool.run'], 'Live runs Tools on the service tier, never probes');
- db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });
const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });
assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');
assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');
@@ -189,7 +190,7 @@ const server = http.createServer(app);
@@ -189,7 +190,7 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
r = await post('/internal/link-account', { user_id: 7, service: 'live', service_user_id: '4242' }, LIVE);
r = await post('/internal/link-account', { user_id: 7, service: 'live', service_user_id: '4242' }, LIVE);
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.strictEqual(db.prepare("SELECT service_user_id FROM linked_accounts WHERE user_id = 7 AND service = 'live'").get().service_user_id, '4242');
+ assert.strictEqual((await db.prepare("SELECT service_user_id FROM linked_accounts WHERE user_id = 7 AND service = 'live'").get()).service_user_id, '4242');
r = await post('/internal/link-account', { user_id: 7, service: 'games', service_user_id: '4242' }, LIVE);
r = await post('/internal/link-account', { user_id: 7, service: 'games', service_user_id: '4242' }, LIVE);
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token links accounts only for its own service');
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.owner_denied', 'a token links accounts only for its own service');
// The key is refused on all five with token.missing: the gate asks for a Bearer and nothing else.
// The key is refused on all five with token.missing: the gate asks for a Bearer and nothing else.
@@ -207,11 +208,11 @@ const server = http.createServer(app);
@@ -207,11 +208,11 @@ const server = http.createServer(app);
}
}
// What the key never had to answer for is now a matter of ownership: a Games token maps its own system's
// What the key never had to answer for is now a matter of ownership: a Games token maps its own system's
// ids, and Live's token still may not (the token-based equivalent of the key's no-ownership-rule note).
// ids, and Live's token still may not (the token-based equivalent of the key's no-ownership-rule note).
- db.prepare("UPDATE oauth_clients SET client_secret = 'games-secret' WHERE client_id = 'games'").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'games-secret' WHERE client_id = 'games'").run();
const GAMES = { authorization: `Bearer ${(await token({ client_id: 'games', client_secret: 'games-secret', audience: 'openvibe.network' })).body.access_token}` };
const GAMES = { authorization: `Bearer ${(await token({ client_id: 'games', client_secret: 'games-secret', audience: 'openvibe.network' })).body.access_token}` };
assert.strictEqual((await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }, GAMES)).status, 200, 'a Games token maps its own ids');
assert.strictEqual((await post('/internal/identity/legacy-map', { entries: [{ network_user_id: 8, source_system: 'games', source_id: '77' }] }, GAMES)).status, 200, 'a Games token maps its own ids');
assert.strictEqual((await post('/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }, GAMES)).status, 200, 'and links its own accounts');
assert.strictEqual((await post('/internal/link-account', { user_id: 8, service: 'games', service_user_id: '77' }, GAMES)).status, 200, 'and links its own accounts');
- const decided = db.prepare("SELECT route FROM principal_usage WHERE principal = 'svc:live' AND allowed = 1").all().map(x => x.route);
+ const decided = (await db.prepare("SELECT route FROM principal_usage WHERE principal = 'svc:live' AND allowed = 1").all()).map(x => x.route);
for (const route of ['GET /internal/url-registry/resolved', 'GET /internal/coins/stats', 'POST /internal/resolve-anon', 'POST /internal/identity/legacy-map', 'POST /internal/link-account']) {
for (const route of ['GET /internal/url-registry/resolved', 'GET /internal/coins/stats', 'POST /internal/resolve-anon', 'POST /internal/identity/legacy-map', 'POST /internal/link-account']) {
assert.ok(decided.includes(route), `token use of ${route} is recorded`);
assert.ok(decided.includes(route), `token use of ${route} is recorded`);
}
}
@@ -229,15 +230,15 @@ const server = http.createServer(app);
@@ -229,15 +230,15 @@ const server = http.createServer(app);
assert.strictEqual(r.body.code, 'token.bad_signature');
assert.strictEqual(r.body.code, 'token.bad_signature');
// The retired key leaves no trace in the usage audit: only service principals are recorded there now.
// The retired key leaves no trace in the usage audit: only service principals are recorded there now.
await new Promise((resolve) => setTimeout(resolve, 20));
await new Promise((resolve) => setTimeout(resolve, 20));
- assert.deepStrictEqual(db.prepare("SELECT DISTINCT principal FROM principal_usage WHERE principal NOT LIKE 'svc:%' AND principal <> 'unknown'").all(), [], 'no principal outside the svc: namespace is audited (a refused stranger is "unknown")');
+ assert.deepStrictEqual(await db.prepare("SELECT DISTINCT principal FROM principal_usage WHERE principal NOT LIKE 'svc:%' AND principal <> 'unknown'").all(), [], 'no principal outside the svc: namespace is audited (a refused stranger is "unknown")');
// ── Revocation: new tokens stop carrying a revoked grant ──
// ── Revocation: new tokens stop carrying a revoked grant ──
- db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();
+ await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = 'live' AND capability = 'network.coins.debit'").run();
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.network' });
assert.ok(!t.body.scope.split(' ').includes('network.coins.debit'));
assert.ok(!t.body.scope.split(' ').includes('network.coins.debit'));
// ── Usage audit ──
// ── Usage audit ──
- const usage = db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();
+ const usage = await db.prepare('SELECT principal, route, auth, allowed, code, count FROM principal_usage ORDER BY principal, route, allowed').all();
assert.ok(usage.some(u => u.principal === 'svc:live' && u.route === 'POST /internal/coins/credit' && u.allowed === 1 && u.count === 1));
assert.ok(usage.some(u => u.principal === 'svc:live' && u.route === 'POST /internal/coins/credit' && u.allowed === 1 && u.count === 1));
assert.ok(usage.some(u => u.auth === 'service-token' && u.allowed === 1), 'every allow is a service token');
assert.ok(usage.some(u => u.auth === 'service-token' && u.allowed === 1), 'every allow is a service token');
assert.ok(!usage.some(u => u.auth === 'internal-key' || u.principal === 'legacy-key'), 'the retired key is not an auth path any more');
assert.ok(!usage.some(u => u.auth === 'internal-key' || u.principal === 'legacy-key'), 'the retired key is not an auth path any more');
@@ -249,3 +250,4 @@ const server = http.createServer(app);
@@ -249,3 +250,4 @@ const server = http.createServer(app);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('principals: all checks passed');
console.log('principals: all checks passed');
})().catch(err => { console.error(err); process.exit(1); });
})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -8,53 +8,54 @@ const fs = require('fs');
@@ -8,53 +8,54 @@ const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-profile-events-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-profile-events-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = await getDb();
console.log = log;
console.log = log;
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (5, 'early', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE')").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id) VALUES (5, 'early', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE')").run();
const profile = require('../server/identity/profile-events');
const profile = require('../server/identity/profile-events');
-profile.ensureSchema(db);
-assert.strictEqual(profile.drain(db), 1, 'the first boot announces every existing account once');
-profile.ensureSchema(db);
-assert.strictEqual(profile.drain(db), 0, 'and only the first');
-db.prepare('DELETE FROM network_event_outbox').run();
-const events = () => db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'network.user.updated');
+await profile.ensureSchema(db);
+assert.strictEqual(await profile.drain(db), 1, 'the first boot announces every existing account once');
+await profile.ensureSchema(db);
+assert.strictEqual(await profile.drain(db), 0, 'and only the first');
+await db.prepare('DELETE FROM network_event_outbox').run();
+const events = async () => (await db.prepare("SELECT envelope FROM network_event_outbox ORDER BY id").all()).map((r) => r.envelope).filter((e) => e.event_type === 'network.user.updated');
const ALEX = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const ALEX = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role, display_name) VALUES (7, 'alex', 'x', ?, 'user', 'Alex')").run(ALEX);
-db.prepare("INSERT INTO users (id, username, password_hash, is_anon, anon_number) VALUES (8, 'anon-1', 'x', 1, 1)").run();
-assert.strictEqual(profile.drain(db), 1, 'the new account; never the guest');
-let ev = events();
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role, display_name) VALUES (7, 'alex', 'x', ?, 'user', 'Alex')").run(ALEX);
+await db.prepare("INSERT INTO users (id, username, password_hash, is_anon, anon_number) VALUES (8, 'anon-1', 'x', 1, 1)").run();
+assert.strictEqual(await profile.drain(db), 1, 'the new account; never the guest');
+let ev = await events();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.ok(validate('events.event-envelope@1', ev[0]).valid);
assert.ok(validate('events.event-envelope@1', ev[0]).valid);
assert.ok(validate('network.user.updated@1', ev[0].payload).valid, JSON.stringify(validate('network.user.updated@1', ev[0].payload).errors));
assert.ok(validate('network.user.updated@1', ev[0].payload).valid, JSON.stringify(validate('network.user.updated@1', ev[0].payload).errors));
assert.deepStrictEqual([ev[0].payload.revision, ev[0].payload.changed, ev[0].subject], [1, ['created'], { type: 'user', id: ALEX, revision: 1 }]);
assert.deepStrictEqual([ev[0].payload.revision, ev[0].payload.changed, ev[0].subject], [1, ['created'], { type: 'user', id: ALEX, revision: 1 }]);
// Changes from anywhere (here plain SQL, as an admin route or an import would) are recorded.
// Changes from anywhere (here plain SQL, as an admin route or an import would) are recorded.
-db.prepare("UPDATE users SET role = 'global_mod' WHERE id = 7").run();
-db.prepare("UPDATE users SET avatar_url = 'https://openvibe.media/avatar/alex', display_name = 'Alex!' WHERE id = 7").run();
-db.prepare("UPDATE users SET last_seen = CURRENT_TIMESTAMP WHERE id = 7").run();
-assert.strictEqual(profile.drain(db), 1, 'several quick changes become one event');
-ev = events();
+await db.prepare("UPDATE users SET role = 'global_mod' WHERE id = 7").run();
+await db.prepare("UPDATE users SET avatar_url = 'https://openvibe.media/avatar/alex', display_name = 'Alex!' WHERE id = 7").run();
+await db.prepare("UPDATE users SET last_seen = ov_now() WHERE id = 7").run();
+assert.strictEqual(await profile.drain(db), 1, 'several quick changes become one event');
+ev = await events();
assert.strictEqual(ev[1].payload.revision, 2);
assert.strictEqual(ev[1].payload.revision, 2);
assert.deepStrictEqual(ev[1].payload.changed.sort(), ['avatar_url', 'display_name', 'role']);
assert.deepStrictEqual(ev[1].payload.changed.sort(), ['avatar_url', 'display_name', 'role']);
assert.strictEqual(ev[1].payload.role, 'global_mod'); assert.strictEqual(ev[1].payload.display_name, 'Alex!');
assert.strictEqual(ev[1].payload.role, 'global_mod'); assert.strictEqual(ev[1].payload.display_name, 'Alex!');
-assert.strictEqual(profile.drain(db), 0, 'last_seen is not a profile change');
+assert.strictEqual(await profile.drain(db), 0, 'last_seen is not a profile change');
-db.prepare("UPDATE users SET is_banned = 1, username = 'alex2' WHERE id = 7").run();
-profile.drain(db);
-ev = events();
+await db.prepare("UPDATE users SET is_banned = 1, username = 'alex2' WHERE id = 7").run();
+await profile.drain(db);
+ev = await events();
assert.deepStrictEqual([ev[2].payload.revision, ev[2].payload.banned, ev[2].payload.username], [3, true, 'alex2']);
assert.deepStrictEqual([ev[2].payload.revision, ev[2].payload.banned, ev[2].payload.username], [3, true, 'alex2']);
assert.deepStrictEqual(ev[2].payload.changed.sort(), ['banned', 'username']);
assert.deepStrictEqual(ev[2].payload.changed.sort(), ['banned', 'username']);
// A new account without a subject yet waits for one.
// A new account without a subject yet waits for one.
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (9, 'nosub', 'x')").run();
-assert.strictEqual(profile.drain(db), 0, 'no subject yet: waits');
-db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB' WHERE id = 9").run();
-assert.strictEqual(profile.drain(db), 1, 'announced once it has one');
-db.close();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (9, 'nosub', 'x')").run();
+assert.strictEqual(await profile.drain(db), 0, 'no subject yet: waits');
+await db.prepare("UPDATE users SET subject_id = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB' WHERE id = 9").run();
+assert.strictEqual(await profile.drain(db), 1, 'announced once it has one');
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('profile events: all checks passed');
console.log('profile events: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -16,20 +16,21 @@ const express = require('express');
@@ -16,20 +16,21 @@ const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { ids, validate } = require('openvibe-contracts');
const { ids, validate } = require('openvibe-contracts');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const { NotificationService } = require('../server/notifications/notification-service');
const { NotificationService } = require('../server/notifications/notification-service');
const { createEventsConsumer, TOPICS } = require('../server/notifications/events-consumer');
const { createEventsConsumer, TOPICS } = require('../server/notifications/events-consumer');
const { createProjectUsage, parseQuery } = require('../server/developer/usage');
const { createProjectUsage, parseQuery } = require('../server/developer/usage');
const { topicsFrom } = require('../scripts/subscribe-events');
const { topicsFrom } = require('../scripts/subscribe-events');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-project-usage-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-project-usage-'));
const quiet = { log: console.log, warn: console.warn };
const quiet = { log: console.log, warn: console.warn };
console.log = () => {}; console.warn = () => {};
console.log = () => {}; console.warn = () => {};
-const db = initDb(path.join(dir, 'network.db'));
-db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+const db = getDb();
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'), (14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();
(10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'viewer', 'x', 'user'), (14, 'stranger', 'x', 'user'), (15, 'staff', 'x', 'admin')`).run();
-const sid = (id) => subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -40,7 +41,7 @@ app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry:
@@ -40,7 +41,7 @@ app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry:
app.locals.privateKey = keys.privateKey;
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
const projectUsage = createProjectUsage(db, { log: { warn() {} } });
const projectUsage = createProjectUsage(db, { log: { warn() {} } });
-const consumer = createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, projectUsage });
+const consumer = await createEventsConsumer({ db, notifications: new NotificationService(db), secrets: SECRET, projectUsage });
app.use('/internal/events', consumer.router);
app.use('/internal/events', consumer.router);
app.use('/api/v1/projects', require('../server/developer/routes').router());
app.use('/api/v1/projects', require('../server/developer/routes').router());
const server = http.createServer(app);
const server = http.createServer(app);
@@ -96,31 +97,31 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
@@ -96,31 +97,31 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
const raw = JSON.stringify({ event: jobs, seq: 1 });
const raw = JSON.stringify({ event: jobs, seq: 1 });
const res = await fetch(`${base}/internal/events`, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });
const res = await fetch(`${base}/internal/events`, { method: 'POST', headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) }, body: raw });
assert.deepStrictEqual([res.status, (await res.json()).outcome], [200, 'recorded']);
assert.deepStrictEqual([res.status, (await res.json()).outcome], [200, 'recorded']);
- assert.strictEqual(consumer.apply(jobs).duplicate, true, 'a redelivery records nothing twice');
+ assert.strictEqual((await consumer.apply(jobs)).duplicate, true, 'a redelivery records nothing twice');
// The same hour re-sent with new totals replaces it; an older revision never does.
// The same hour re-sent with new totals replaces it; an older revision never does.
const resent = rollup('tools', { ...jobs.payload, quantity: 42, errors: 3, error_codes: { 'tools.job.failed': 2, 'tools.job.timeout': 1 }, revision: 2 });
const resent = rollup('tools', { ...jobs.payload, quantity: 42, errors: 3, error_codes: { 'tools.job.failed': 2, 'tools.job.timeout': 1 }, revision: 2 });
- assert.strictEqual(consumer.apply(resent).outcome, 'recorded');
- assert.strictEqual(consumer.apply(rollup('tools', { ...jobs.payload, quantity: 1, revision: 1 })).outcome, 'ignored:stale');
- assert.strictEqual(consumer.apply(rollup('tools', hour(yesterday, { capability: 'tools.tool.run', dimension: 'image-resize', quantity: 5, errors: 0 }))).outcome, 'recorded');
- assert.strictEqual(consumer.apply(rollup('tools', hour(thisHour - HOUR, { capability: 'tools.job.create', dimension: 'img.process', env: 'sandbox', quantity: 7, errors: 0 }))).outcome, 'recorded');
+ assert.strictEqual((await consumer.apply(resent)).outcome, 'recorded');
+ assert.strictEqual((await consumer.apply(rollup('tools', { ...jobs.payload, quantity: 1, revision: 1 }))).outcome, 'ignored:stale');
+ assert.strictEqual((await consumer.apply(rollup('tools', hour(yesterday, { capability: 'tools.tool.run', dimension: 'image-resize', quantity: 5, errors: 0 })))).outcome, 'recorded');
+ assert.strictEqual((await consumer.apply(rollup('tools', hour(thisHour - HOUR, { capability: 'tools.job.create', dimension: 'img.process', env: 'sandbox', quantity: 7, errors: 0 })))).outcome, 'recorded');
const published = rollup('events', hour(thisHour, {
const published = rollup('events', hour(thisHour, {
capability: 'events.app.publish', unit: 'events', env: 'sandbox', quantity: 1800, errors: 61, error_codes: { 'events.quota_exceeded': 60, 'events.type_not_allowed': 1 },
capability: 'events.app.publish', unit: 'events', env: 'sandbox', quantity: 1800, errors: 61, error_codes: { 'events.quota_exceeded': 60, 'events.type_not_allowed': 1 },
samples: [{ at: new Date(thisHour + 120000).toISOString(), code: 'events.quota_exceeded', status: 429, trace_id: TRACE }],
samples: [{ at: new Date(thisHour + 120000).toISOString(), code: 'events.quota_exceeded', status: 429, trace_id: TRACE }],
}));
}));
- assert.strictEqual(consumer.apply(published).outcome, 'recorded');
+ assert.strictEqual((await consumer.apply(published)).outcome, 'recorded');
// What is not a project's rollup is ignored.
// What is not a project's rollup is ignored.
const other = await api('stranger', 'POST', '', { name: 'Someone else' });
const other = await api('stranger', 'POST', '', { name: 'Someone else' });
const ignored = [
const ignored = [
[rollup('tools', { ...jobs.payload, project_id: `prj_${ids.ulid()}` }), 'ignored:project'],
[rollup('tools', { ...jobs.payload, project_id: `prj_${ids.ulid()}` }), 'ignored:project'],
[rollup('events', jobs.payload, { source: 'tools' }), 'ignored:source'],
[rollup('events', jobs.payload, { source: 'tools' }), 'ignored:source'],
- [rollup('tools', { ...jobs.payload, owner: { type: 'user', id: sid(10) } }), 'ignored:payload'],
+ [rollup('tools', { ...jobs.payload, owner: { type: 'user', id: await sid(10) } }), 'ignored:payload'],
[rollup('tools', { ...jobs.payload, window_end: new Date(thisHour + 2 * HOUR).toISOString() }), 'ignored:window'],
[rollup('tools', { ...jobs.payload, window_end: new Date(thisHour + 2 * HOUR).toISOString() }), 'ignored:window'],
[rollup('tools', jobs.payload, { subject: { type: 'project', id: other.body.id } }), 'ignored:subject'],
[rollup('tools', jobs.payload, { subject: { type: 'project', id: other.body.id } }), 'ignored:subject'],
[rollup('tools', { ...jobs.payload, window_start: new Date(thisHour + 3 * HOUR).toISOString(), window_end: new Date(thisHour + 4 * HOUR).toISOString() }), 'ignored:future'],
[rollup('tools', { ...jobs.payload, window_start: new Date(thisHour + 3 * HOUR).toISOString(), window_end: new Date(thisHour + 4 * HOUR).toISOString() }), 'ignored:future'],
];
];
- for (const [e, outcome] of ignored) assert.strictEqual(consumer.apply(e).outcome, outcome, outcome);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM notifications').get().n, 0, 'usage notifies nobody');
+ for (const [e, outcome] of ignored) assert.strictEqual((await consumer.apply(e)).outcome, outcome, outcome);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'usage notifies nobody');
// ── The dashboard's answer ──
// ── The dashboard's answer ──
r = await api('owner', 'GET', `/${P}/usage?days=7`);
r = await api('owner', 'GET', `/${P}/usage?days=7`);
@@ -151,7 +152,7 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
@@ -151,7 +152,7 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
['events', 'events.quota_exceeded', 429, TRACE, null],
['events', 'events.quota_exceeded', 429, TRACE, null],
['tools', 'tools.job.timeout', 504, TRACE, JOB],
['tools', 'tools.job.timeout', 504, TRACE, JOB],
], 'newest first; the re-sent hour did not duplicate its sample');
], 'newest first; the re-sent hour did not duplicate its sample');
- for (const who of [10, 11, 12]) assert.ok(!r.text.includes(sid(who)), 'no subject id in the answer');
+ for (const who of [10, 11, 12]) assert.ok(!r.text.includes(await sid(who)), 'no subject id in the answer');
// Filters.
// Filters.
r = await api('owner', 'GET', `/${P}/usage?env=sandbox`);
r = await api('owner', 'GET', `/${P}/usage?env=sandbox`);
@@ -172,10 +173,10 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
@@ -172,10 +173,10 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
// ── Retention: windows and samples of the past go; the daily numbers stay ──
// ── Retention: windows and samples of the past go; the daily numbers stay ──
const later = createProjectUsage(db, { now: () => Date.now() + 40 * 24 * HOUR });
const later = createProjectUsage(db, { now: () => Date.now() + 40 * 24 * HOUR });
- later.prune();
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_usage_windows').get().n, 0);
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM dev_usage_errors').get().n, 0);
- assert.ok(db.prepare('SELECT COUNT(*) AS n FROM dev_usage_daily').get().n > 0);
+ await later.prune();
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dev_usage_windows').get()).n, 0);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM dev_usage_errors').get()).n, 0);
+ assert.ok((await db.prepare('SELECT COUNT(*) AS n FROM dev_usage_daily').get()).n > 0);
} finally {
} finally {
server.close();
server.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
@@ -183,3 +184,4 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
@@ -183,3 +184,4 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
}
}
console.log('project usage: all checks passed');
console.log('project usage: all checks passed');
})().catch((e) => { console.log = quiet.log; console.error(e); process.exit(1); });
})().catch((e) => { console.log = quiet.log; console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -11,7 +11,7 @@ const os = require('os');
@@ -11,7 +11,7 @@ const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const secrets = require('../server/secrets');
const secrets = require('../server/secrets');
const ENV_NAMES = ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY'];
const ENV_NAMES = ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY'];
@@ -23,48 +23,48 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -23,48 +23,48 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
(async () => {
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secrets-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secrets-'));
const dbPath = path.join(dir, 'network.db');
const dbPath = path.join(dir, 'network.db');
- const db = await quiet(() => initDb(dbPath));
- const set = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
- const dbVal = (k) => (db.prepare('SELECT value FROM site_settings WHERE key = ?').get(k) || {}).value;
- set.run('resend_api_key', 're_db_key_111', 'string');
- set.run('resend_webhook_secret', 'whsec_db_222', 'string');
- set.run('discord_bot_token', 'db.bot.token.333', 'secret');
- set.run('discord_oauth_client_secret', 'db-oauth-secret-444', 'secret');
- set.run('net.ipinfo_token', 'ipinfo-555', 'secret');
- set.run('ses_secret_access_key', 'ses-666', 'string');
- set.run('some_new_api_key', 'unknown-777', 'string');
+ const db = await getDb();
+ const set = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');
+ const dbVal = async (k) => (await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(k) || {}).value;
+ await set.run('resend_api_key', 're_db_key_111', 'string');
+ await set.run('resend_webhook_secret', 'whsec_db_222', 'string');
+ await set.run('discord_bot_token', 'db.bot.token.333', 'secret');
+ await set.run('discord_oauth_client_secret', 'db-oauth-secret-444', 'secret');
+ await set.run('net.ipinfo_token', 'ipinfo-555', 'secret');
+ await set.run('ses_secret_access_key', 'ses-666', 'string');
+ await set.run('some_new_api_key', 'unknown-777', 'string');
// ── Environment first, database fallback ──
// ── Environment first, database fallback ──
- assert.strictEqual(db.getSetting('resend_api_key'), 're_db_key_111', 'no variable: the database value');
- assert.strictEqual(secrets.source(db, 'resend_api_key'), 'database');
+ assert.strictEqual(await db.getSetting('resend_api_key'), 're_db_key_111', 'no variable: the database value');
+ assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'database');
process.env.RESEND_API_KEY = 're_env_key_999';
process.env.RESEND_API_KEY = 're_env_key_999';
- assert.strictEqual(db.getSetting('resend_api_key'), 're_env_key_999', 'the variable wins');
- assert.strictEqual(secrets.source(db, 'resend_api_key'), 'env');
+ assert.strictEqual(await db.getSetting('resend_api_key'), 're_env_key_999', 'the variable wins');
+ assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'env');
process.env.RESEND_API_KEY = ' ';
process.env.RESEND_API_KEY = ' ';
- assert.strictEqual(db.getSetting('resend_api_key'), 're_db_key_111', 'a blank variable is unset');
+ assert.strictEqual(await db.getSetting('resend_api_key'), 're_db_key_111', 'a blank variable is unset');
process.env.RESEND_API_KEY = 're_env_key_999';
process.env.RESEND_API_KEY = 're_env_key_999';
- assert.strictEqual(secrets.source(db, 'resend_webhook_secret'), 'database');
- assert.strictEqual(db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
- const rep = secrets.report(db);
+ assert.strictEqual(await secrets.source(db, 'resend_webhook_secret'), 'database');
+ assert.strictEqual(await db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
+ const rep = await secrets.report(db);
assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'github_token']);
assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'github_token']);
assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');
assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');
- assert.match(secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);
+ assert.match(await secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);
// The email service reads it through db.getSetting, and says where it came from.
// The email service reads it through db.getSetting, and says where it came from.
const { EmailService } = require('../server/notifications/email-service');
const { EmailService } = require('../server/notifications/email-service');
const email = await quiet(() => new EmailService(db));
const email = await quiet(() => new EmailService(db));
- assert.strictEqual(email.getStatus().api_key_source, 'env');
- assert.strictEqual(email.getStatus().api_key_env, 'RESEND_API_KEY');
- assert.ok(email.getStatus().api_key.startsWith('re_env'), 'the masked key shown is the one in use');
+ assert.strictEqual((await email.getStatus()).api_key_source, 'env');
+ assert.strictEqual((await email.getStatus()).api_key_env, 'RESEND_API_KEY');
+ assert.ok((await email.getStatus()).api_key.startsWith('re_env'), 'the masked key shown is the one in use');
// ── Web push: the public key follows the private key from the environment ──
// ── Web push: the public key follows the private key from the environment ──
const webpush = require('web-push');
const webpush = require('web-push');
const pair = webpush.generateVAPIDKeys();
const pair = webpush.generateVAPIDKeys();
process.env.VAPID_PRIVATE_KEY = pair.privateKey;
process.env.VAPID_PRIVATE_KEY = pair.privateKey;
const push = require('../server/push/push-service');
const push = require('../server/push/push-service');
- await quiet(() => push.initVapid(db));
- assert.strictEqual(push.getPublicKey(), pair.publicKey, 'the public key is derived from VAPID_PRIVATE_KEY');
- assert.notStrictEqual(dbVal('vapid_private_key'), pair.privateKey, 'the environment key is not copied into the database');
+ await quiet(async () => await push.initVapid(db));
+ assert.strictEqual(await push.getPublicKey(), pair.publicKey, 'the public key is derived from VAPID_PRIVATE_KEY');
+ assert.notStrictEqual(await dbVal('vapid_private_key'), pair.privateKey, 'the environment key is not copied into the database');
// ── Admin API: sources shown, env-provided secrets never saved ──
// ── Admin API: sources shown, env-provided secrets never saved ──
const createAdminRoutes = require('../server/admin/routes');
const createAdminRoutes = require('../server/admin/routes');
@@ -76,7 +76,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -76,7 +76,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
const requireAuth = (req, _res, next) => { req.user = who; next(); };
const requireAuth = (req, _res, next) => { req.user = who; next(); };
const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());
const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());
const discordService = { getStatus: () => ({ connected: false }), reinit: async () => {} };
const discordService = { getStatus: () => ({ connected: false }), reinit: async () => {} };
- db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin')").run();
+ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin')").run();
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));
app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));
const server = http.createServer(app);
const server = http.createServer(app);
@@ -97,7 +97,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -97,7 +97,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
r = await call('PUT', '/api/admin/email', { api_key: 're_typed_in_ui', from_name: 'OpenVibe' });
r = await call('PUT', '/api/admin/email', { api_key: 're_typed_in_ui', from_name: 'OpenVibe' });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(r.body.skipped, ['resend_api_key']);
assert.deepStrictEqual(r.body.skipped, ['resend_api_key']);
- assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111', 'the database copy is not overwritten');
+ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the database copy is not overwritten');
// Generic settings: an env-provided secret shows no value and cannot be saved; a database one can.
// Generic settings: an env-provided secret shows no value and cannot be saved; a database one can.
process.env.RESEND_WEBHOOK_SECRET = 'whsec_env_888';
process.env.RESEND_WEBHOOK_SECRET = 'whsec_env_888';
@@ -107,12 +107,12 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -107,12 +107,12 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
assert.strictEqual(r.body.skipped, true);
assert.strictEqual(r.body.skipped, true);
assert.strictEqual(r.body.env, 'RESEND_WEBHOOK_SECRET');
assert.strictEqual(r.body.env, 'RESEND_WEBHOOK_SECRET');
- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved while the environment provides it');
+ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved while the environment provides it');
delete process.env.RESEND_WEBHOOK_SECRET;
delete process.env.RESEND_WEBHOOK_SECRET;
r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
assert.ok(!r.body.skipped);
assert.ok(!r.body.skipped);
- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_from_ui', 'saved when the environment does not provide it');
- set.run('resend_webhook_secret', 'whsec_db_222', 'string');
+ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_from_ui', 'saved when the environment does not provide it');
+ await set.run('resend_webhook_secret', 'whsec_db_222', 'string');
// Discord: the same rule.
// Discord: the same rule.
process.env.DISCORD_BOT_TOKEN = 'env.bot.token';
process.env.DISCORD_BOT_TOKEN = 'env.bot.token';
@@ -121,8 +121,8 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -121,8 +121,8 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
assert.strictEqual(r.body.sources.discord_oauth_client_secret.source, 'database');
assert.strictEqual(r.body.sources.discord_oauth_client_secret.source, 'database');
r = await call('PUT', '/api/admin/discord', { settings: { discord_bot_token: 'typed.token', discord_guild_id: '42' } });
r = await call('PUT', '/api/admin/discord', { settings: { discord_bot_token: 'typed.token', discord_guild_id: '42' } });
assert.deepStrictEqual(r.body.skipped, ['discord_bot_token']);
assert.deepStrictEqual(r.body.skipped, ['discord_bot_token']);
- assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333');
- assert.strictEqual(dbVal('discord_guild_id'), '42', 'other Discord settings still save');
+ assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333');
+ assert.strictEqual(await dbVal('discord_guild_id'), '42', 'other Discord settings still save');
server.close();
server.close();
// ── scripts/secrets-out-of-db.js ──
// ── scripts/secrets-out-of-db.js ──
@@ -132,7 +132,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -132,7 +132,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
fs.writeFileSync(envFile, ['NODE_ENV=production', 'DISCORD_OAUTH_CLIENT_SECRET=rotated-secret', 'RESEND_WEBHOOK_SECRET=', ''].join('\n'), { mode: 0o600 });
fs.writeFileSync(envFile, ['NODE_ENV=production', 'DISCORD_OAUTH_CLIENT_SECRET=rotated-secret', 'RESEND_WEBHOOK_SECRET=', ''].join('\n'), { mode: 0o600 });
const out = [];
const out = [];
const running = { vars: { RESEND_API_KEY: 're_db_key_111', DISCORD_BOT_TOKEN: 'db.bot.token.333', DISCORD_OAUTH_CLIENT_SECRET: 'rotated-secret' }, pid: 4242 };
const running = { vars: { RESEND_API_KEY: 're_db_key_111', DISCORD_BOT_TOKEN: 'db.bot.token.333', DISCORD_OAUTH_CLIENT_SECRET: 'rotated-secret' }, pid: 4242 };
- const run = (argv, service = running) => script.main(['--db', dbPath, '--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service });
+ const run = (argv, service = running) => script.main(['--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service, db });
const values = ['re_db_key_111', 'whsec_db_222', 'db.bot.token.333', 'db-oauth-secret-444', 'ipinfo-555', 'ses-666', 'unknown-777', 'rotated-secret'];
const values = ['re_db_key_111', 'whsec_db_222', 'db.bot.token.333', 'db-oauth-secret-444', 'ipinfo-555', 'ses-666', 'unknown-777', 'rotated-secret'];
const noValues = () => { const text = out.join('\n'); for (const v of values) assert.ok(!text.includes(v), `the script never prints a value (${v.slice(0, 3)}…)`); };
const noValues = () => { const text = out.join('\n'); for (const v of values) assert.ok(!text.includes(v), `the script never prints a value (${v.slice(0, 3)}…)`); };
@@ -167,43 +167,43 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -167,43 +167,43 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
assert.match(text, /discord_oauth_client_secret\n.*DIFFERENT value.*\n\s+--apply: keep: the env file value differs/);
assert.match(text, /discord_oauth_client_secret\n.*DIFFERENT value.*\n\s+--apply: keep: the env file value differs/);
assert.match(text, /net\.ipinfo_token\n.*\n\s+--apply: BLANK \(Network never reads it/);
assert.match(text, /net\.ipinfo_token\n.*\n\s+--apply: BLANK \(Network never reads it/);
assert.match(text, /some_new_api_key\n.*\n\s+--apply: keep: not classified/);
assert.match(text, /some_new_api_key\n.*\n\s+--apply: keep: not classified/);
- assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111', 'the dry run changes nothing');
+ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the dry run changes nothing');
out.length = 0;
out.length = 0;
assert.strictEqual(await run(['--apply']), 2, '--apply refuses without --backup');
assert.strictEqual(await run(['--apply']), 2, '--apply refuses without --backup');
// A service that was not restarted after the env file changed keeps its database copy.
// A service that was not restarted after the env file changed keeps its database copy.
out.length = 0;
out.length = 0;
- const backup = path.join(dir, 'pre-secrets.db');
+ const backup = path.join(dir, 'pre-secrets.json');
assert.strictEqual(await run(['--apply', '--backup', backup], { vars: { RESEND_API_KEY: 're_db_key_111' }, pid: 1 }), 0);
assert.strictEqual(await run(['--apply', '--backup', backup], { vars: { RESEND_API_KEY: 're_db_key_111' }, pid: 1 }), 0);
- assert.strictEqual(dbVal('resend_api_key'), '', 'blanked: env file and running service have it');
- assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');
- assert.strictEqual(dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');
- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: the running service does not have it');
- assert.strictEqual(dbVal('net.ipinfo_token'), '', 'unused secret blanked');
- assert.strictEqual(dbVal('ses_secret_access_key'), '', 'unused secret blanked');
- assert.strictEqual(dbVal('some_new_api_key'), 'unknown-777', 'unclassified left alone');
+ assert.strictEqual(await dbVal('resend_api_key'), '', 'blanked: env file and running service have it');
+ assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');
+ assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');
+ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: the running service does not have it');
+ assert.strictEqual(await dbVal('net.ipinfo_token'), '', 'unused secret blanked');
+ assert.strictEqual(await dbVal('ses_secret_access_key'), '', 'unused secret blanked');
+ assert.strictEqual(await dbVal('some_new_api_key'), 'unknown-777', 'unclassified left alone');
assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600, 'the backup is owner-only');
assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600, 'the backup is owner-only');
noValues();
noValues();
// After the restart, and with --allow-different for the rotated one.
// After the restart, and with --allow-different for the rotated one.
out.length = 0;
out.length = 0;
- assert.strictEqual(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.db'), '--allow-different']), 0);
- assert.strictEqual(dbVal('discord_bot_token'), '');
- assert.strictEqual(dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');
- assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'still kept until the service has it');
+ assert.strictEqual(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.json'), '--allow-different']), 0);
+ assert.strictEqual(await dbVal('discord_bot_token'), '');
+ assert.strictEqual(await dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');
+ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'still kept until the service has it');
noValues();
noValues();
// An unreadable env file refuses --apply.
// An unreadable env file refuses --apply.
out.length = 0;
out.length = 0;
- assert.strictEqual(await script.main(['--db', dbPath, '--env-file', path.join(dir, 'missing.env'), '--apply', '--backup', path.join(dir, 'x.db')], (m) => out.push(m), { readService: () => running }), 2);
+ assert.strictEqual(await script.main(['--env-file', path.join(dir, 'missing.env'), '--apply', '--backup', path.join(dir, 'x.json')], (m) => out.push(m), { readService: () => running, db }), 2);
// Rollback from the first backup: every blanked value comes back; nothing printed.
// Rollback from the first backup: every blanked value comes back; nothing printed.
out.length = 0;
out.length = 0;
- assert.strictEqual(await script.main(['--db', dbPath, '--restore-from', backup], (m) => out.push(m)), 0);
- assert.strictEqual(dbVal('discord_bot_token'), '', 'the rollback dry run changes nothing');
- assert.strictEqual(await script.main(['--db', dbPath, '--restore-from', backup, '--apply'], (m) => out.push(m)), 0);
- assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111');
- assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333');
- assert.strictEqual(dbVal('net.ipinfo_token'), 'ipinfo-555');
- assert.strictEqual(dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'restored from the backup taken before it was blanked');
+ assert.strictEqual(await script.main(['--restore-from', backup], (m) => out.push(m), { db }), 0);
+ assert.strictEqual(await dbVal('discord_bot_token'), '', 'the rollback dry run changes nothing');
+ assert.strictEqual(await script.main(['--restore-from', backup, '--apply'], (m) => out.push(m), { db }), 0);
+ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111');
+ assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333');
+ assert.strictEqual(await dbVal('net.ipinfo_token'), 'ipinfo-555');
+ assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'restored from the backup taken before it was blanked');
noValues();
noValues();
for (const n of ENV_NAMES) delete process.env[n];
for (const n of ENV_NAMES) delete process.env[n];
@@ -6,43 +6,30 @@
@@ -6,43 +6,30 @@
// node test/refresh-tokens.test.js
// node test/refresh-tokens.test.js
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
-const Database = require('better-sqlite3');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const refreshTokens = require('../server/auth/refresh-tokens');
const refreshTokens = require('../server/auth/refresh-tokens');
const quiet = (fn) => { const log = console.log; console.log = () => {}; try { return fn(); } finally { console.log = log; } };
const quiet = (fn) => { const log = console.log; console.log = () => {}; try { return fn(); } finally { console.log = log; } };
(async () => {
(async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-refresh-'));
- const dbPath = path.join(dir, 'network.db');
-
- // ── A database as the old code left it: raw refresh tokens, no family or generation ──
- {
- const old = new Database(dbPath);
- old.exec(`CREATE TABLE oauth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, client_id TEXT NOT NULL,
- user_id INTEGER NOT NULL, scope TEXT DEFAULT 'profile theme', expires_at DATETIME NOT NULL, revoked INTEGER DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`);
- old.close();
- }
+ // ── Rows as the old code left them: raw refresh tokens, no family or generation ──
const RAW_LIVE = crypto.randomBytes(48).toString('hex');
const RAW_LIVE = crypto.randomBytes(48).toString('hex');
const RAW_ROTATED = crypto.randomBytes(48).toString('hex');
const RAW_ROTATED = crypto.randomBytes(48).toString('hex');
+ const db = getDb();
+ // The token rows' foreign key needs their user: PostgreSQL enforces the constraint SQLite only declared.
+ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
{
{
- const old = new Database(dbPath);
- const ins = old.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at, revoked) VALUES (?, ?, ?, ?, ?)');
- ins.run(RAW_LIVE, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 0);
- ins.run(RAW_ROTATED, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 1);
- old.close();
+ const ins = db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at, revoked) VALUES (?, ?, ?, ?, ?)');
+ await ins.run(RAW_LIVE, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 0);
+ await ins.run(RAW_ROTATED, 'live', 7, new Date(Date.now() + 86400e3).toISOString(), 1);
}
}
- // Boot: initDb only adds the columns; the raw rows are still raw (and still work, below).
- const db = quiet(() => initDb(dbPath));
- let rows = db.prepare('SELECT * FROM oauth_tokens ORDER BY id').all();
+ // The columns family_id, generation and revoked_reason are part of the migration; the raw rows are
+ // still raw (and still work, below).
+ let rows = await db.prepare('SELECT * FROM oauth_tokens ORDER BY id').all();
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows.length, 2);
assert.ok('family_id' in rows[0] && 'generation' in rows[0] && 'revoked_reason' in rows[0], 'boot adds the columns');
assert.ok('family_id' in rows[0] && 'generation' in rows[0] && 'revoked_reason' in rows[0], 'boot adds the columns');
assert.strictEqual(rows[0].token, RAW_LIVE, 'boot changes no data');
assert.strictEqual(rows[0].token, RAW_LIVE, 'boot changes no data');
@@ -51,9 +38,8 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r
@@ -51,9 +38,8 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r
// the server hashes one on its first use, below.
// the server hashes one on its first use, below.
assert.strictEqual(rows[1].token, RAW_ROTATED, 'a raw row stays raw until it is used');
assert.strictEqual(rows[1].token, RAW_ROTATED, 'a raw row stays raw until it is used');
- db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
- db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
- db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -77,67 +63,67 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r
@@ -77,67 +63,67 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
return t.body.refresh_token;
return t.body.refresh_token;
};
};
- const rowOf = (token) => db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshTokens.hash(token));
- const age = (token, seconds) => db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now', ?) WHERE token = ?").run(`-${seconds} seconds`, refreshTokens.hash(token));
+ const rowOf = async (token) => await db.prepare('SELECT * FROM oauth_tokens WHERE token = ?').get(refreshTokens.hash(token));
+ const age = async (token, seconds) => await db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now', ?) WHERE token = ?").run(`-${seconds} seconds`, refreshTokens.hash(token));
// ── A migrated (pre-change) token still refreshes, once ──
// ── A migrated (pre-change) token still refreshes, once ──
let r = await refresh(RAW_LIVE);
let r = await refresh(RAW_LIVE);
assert.strictEqual(r.status, 200, `a token written before the change still works: ${JSON.stringify(r.body)}`);
assert.strictEqual(r.status, 200, `a token written before the change still works: ${JSON.stringify(r.body)}`);
- const legacyFamily = rowOf(RAW_LIVE).family_id;
- assert.strictEqual(rowOf(r.body.refresh_token).family_id, legacyFamily, 'its successor continues the legacy family');
- assert.strictEqual(rowOf(r.body.refresh_token).generation, 1);
+ const legacyFamily = (await rowOf(RAW_LIVE)).family_id;
+ assert.strictEqual((await rowOf(r.body.refresh_token)).family_id, legacyFamily, 'its successor continues the legacy family');
+ assert.strictEqual((await rowOf(r.body.refresh_token)).generation, 1);
r = await refresh(RAW_ROTATED);
r = await refresh(RAW_ROTATED);
assert.strictEqual(r.status, 400, 'a token the old code had already rotated stays dead');
assert.strictEqual(r.status, 400, 'a token the old code had already rotated stays dead');
// ── New sign-ins: hash only, family, generations ──
// ── New sign-ins: hash only, family, generations ──
const t0 = await signIn();
const t0 = await signIn();
assert.match(t0, /^[0-9a-f]{96}$/);
assert.match(t0, /^[0-9a-f]{96}$/);
- const row0 = rowOf(t0);
+ const row0 = await rowOf(t0);
assert.ok(row0, 'found by its hash');
assert.ok(row0, 'found by its hash');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(t0).n, 0, 'the token itself is not stored');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(t0)).n, 0, 'the token itself is not stored');
assert.strictEqual(row0.generation, 0);
assert.strictEqual(row0.generation, 0);
assert.match(row0.family_id, /^fam_[0-9a-f]{24}$/);
assert.match(row0.family_id, /^fam_[0-9a-f]{24}$/);
r = await refresh(t0);
r = await refresh(t0);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const t1 = r.body.refresh_token;
const t1 = r.body.refresh_token;
- assert.strictEqual(rowOf(t1).family_id, row0.family_id, 'rotation stays in the family');
- assert.strictEqual(rowOf(t1).generation, 1, 'generation counts rotations');
- assert.strictEqual(rowOf(t0).revoked_reason, 'rotated');
+ assert.strictEqual((await rowOf(t1)).family_id, row0.family_id, 'rotation stays in the family');
+ assert.strictEqual((await rowOf(t1)).generation, 1, 'generation counts rotations');
+ assert.strictEqual((await rowOf(t0)).revoked_reason, 'rotated');
r = await refresh(t1);
r = await refresh(t1);
const t2 = r.body.refresh_token;
const t2 = r.body.refresh_token;
- assert.strictEqual(rowOf(t2).generation, 2);
+ assert.strictEqual((await rowOf(t2)).generation, 2);
// The stored hash is not a token, and a malformed token is refused without a lookup.
// The stored hash is not a token, and a malformed token is refused without a lookup.
- r = await refresh(rowOf(t2).token);
+ r = await refresh((await rowOf(t2)).token);
assert.strictEqual(r.status, 400, 'presenting the stored hash does not work');
assert.strictEqual(r.status, 400, 'presenting the stored hash does not work');
- r = await refresh(rowOf(t2).token.slice('sha256:'.length));
+ r = await refresh((await rowOf(t2)).token.slice('sha256:'.length));
assert.strictEqual(r.status, 400, 'nor its hex');
assert.strictEqual(r.status, 400, 'nor its hex');
// Another client cannot use (or burn) the family.
// Another client cannot use (or burn) the family.
r = await refresh(t2, 'tools', 'tools-secret');
r = await refresh(t2, 'tools', 'tools-secret');
assert.strictEqual(r.status, 400);
assert.strictEqual(r.status, 400);
- assert.strictEqual(rowOf(t2).revoked, 0, 'a client mismatch revokes nothing');
+ assert.strictEqual((await rowOf(t2)).revoked, 0, 'a client mismatch revokes nothing');
// ── Reuse within the grace window (two tabs at once): refused, family intact ──
// ── Reuse within the grace window (two tabs at once): refused, family intact ──
r = await refresh(t1);
r = await refresh(t1);
assert.strictEqual(r.status, 400, 'a just-rotated token is single-use');
assert.strictEqual(r.status, 400, 'a just-rotated token is single-use');
- assert.strictEqual(rowOf(t2).revoked, 0, 'a concurrent double refresh does not revoke the family');
+ assert.strictEqual((await rowOf(t2)).revoked, 0, 'a concurrent double refresh does not revoke the family');
// ── Reuse after the grace window: the whole family is revoked ──
// ── Reuse after the grace window: the whole family is revoked ──
- age(t1, refreshTokens.REUSE_GRACE_S + 5);
+ await age(t1, refreshTokens.REUSE_GRACE_S + 5);
r = await refresh(t1);
r = await refresh(t1);
assert.strictEqual(r.status, 400, 'a replayed rotated token is refused');
assert.strictEqual(r.status, 400, 'a replayed rotated token is refused');
- assert.strictEqual(rowOf(t2).revoked, 1, 'and its family is revoked: the latest generation dies too');
- assert.strictEqual(rowOf(t2).revoked_reason, 'reuse');
+ assert.strictEqual((await rowOf(t2)).revoked, 1, 'and its family is revoked: the latest generation dies too');
+ assert.strictEqual((await rowOf(t2)).revoked_reason, 'reuse');
r = await refresh(t2);
r = await refresh(t2);
assert.strictEqual(r.status, 400, 'the latest token no longer refreshes');
assert.strictEqual(r.status, 400, 'the latest token no longer refreshes');
- const auditRow = db.prepare("SELECT * FROM audit_log WHERE action = 'oauth_refresh_reuse' ORDER BY id DESC").get();
+ const auditRow = await db.prepare("SELECT * FROM audit_log WHERE action = 'oauth_refresh_reuse' ORDER BY id DESC").get();
assert.ok(auditRow, 'reuse is audited');
assert.ok(auditRow, 'reuse is audited');
const details = JSON.parse(auditRow.details);
const details = JSON.parse(auditRow.details);
assert.strictEqual(details.family_id, row0.family_id);
assert.strictEqual(details.family_id, row0.family_id);
assert.strictEqual(details.generation_presented, 1);
assert.strictEqual(details.generation_presented, 1);
assert.strictEqual(details.generation_current, 2);
assert.strictEqual(details.generation_current, 2);
- assert.ok(!auditRow.details.includes(t1) && !auditRow.details.includes(rowOf(t1).token), 'the audit carries no token or hash');
+ assert.ok(!auditRow.details.includes(t1) && !auditRow.details.includes((await rowOf(t1)).token), 'the audit carries no token or hash');
// Other families of the same account are untouched.
// Other families of the same account are untouched.
const other = await signIn();
const other = await signIn();
r = await refresh(other);
r = await refresh(other);
@@ -152,15 +138,15 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r
@@ -152,15 +138,15 @@ const quiet = (fn) => { const log = console.log; console.log = () => {}; try { r
// ── A raw row that appears after boot (defensive path) is hashed on first use ──
// ── A raw row that appears after boot (defensive path) is hashed on first use ──
const late = crypto.randomBytes(48).toString('hex');
const late = crypto.randomBytes(48).toString('hex');
- db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)').run(late, 'live', 7, new Date(Date.now() + 86400e3).toISOString());
+ await db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)').run(late, 'live', 7, new Date(Date.now() + 86400e3).toISOString());
r = await refresh(late);
r = await refresh(late);
assert.strictEqual(r.status, 200, 'a raw row still works');
assert.strictEqual(r.status, 200, 'a raw row still works');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(late).n, 0, 'and is hashed on use');
- assert.strictEqual(rowOf(late).revoked_reason, 'rotated');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens WHERE token = ?').get(late)).n, 0, 'and is hashed on use');
+ assert.strictEqual((await rowOf(late)).revoked_reason, 'rotated');
// Expired tokens are refused.
// Expired tokens are refused.
const exp = await signIn();
const exp = await signIn();
- db.prepare('UPDATE oauth_tokens SET expires_at = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), refreshTokens.hash(exp));
+ await db.prepare('UPDATE oauth_tokens SET expires_at = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), refreshTokens.hash(exp));
assert.strictEqual((await refresh(exp)).status, 400, 'an expired refresh token is refused');
assert.strictEqual((await refresh(exp)).status, 400, 'an expired refresh token is refused');
server.close();
server.close();
@@ -1,11 +1,15 @@
@@ -1,11 +1,15 @@
+'use strict';
+// The URL registry seeds its bootstrap values on first boot and resolves them; the process database
+// already migrated (test/helpers/pg-preload.mjs). Only the bootstrap rows are added here.
const assert = require('assert');
const assert = require('assert');
-const Database = require('better-sqlite3');
const urlRegistry = require('../server/url-registry');
const urlRegistry = require('../server/url-registry');
+const { getDb } = require('../server/db/database');
-const db = new Database(':memory:');
+(async () => {
+const db = await getDb();
urlRegistry.initializeUrlRegistry(db);
urlRegistry.initializeUrlRegistry(db);
-urlRegistry.seedBootstrapRegistry(db, { OV_LIVE_URL: 'https://bootstrap.example.com', OV_NETWORK_URL: 'https://openvibe.network' }, 'local-dev');
-const resolved = urlRegistry.getResolvedRegistry(db, {});
+await urlRegistry.seedBootstrapRegistry(db, { OV_LIVE_URL: 'https://bootstrap.example.com', OV_NETWORK_URL: 'https://openvibe.network' }, 'local-dev');
+const resolved = await urlRegistry.getResolvedRegistry(db, {});
assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://bootstrap.example.com');
assert.strictEqual(resolved.OV_LIVE_URL.value, 'https://bootstrap.example.com');
assert.strictEqual(resolved.OV_LIVE_URL.source, 'bootstrap');
assert.strictEqual(resolved.OV_LIVE_URL.source, 'bootstrap');
assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://openvibe.network');
assert.strictEqual(resolved.OV_NETWORK_URL.value, 'https://openvibe.network');
@@ -17,21 +21,22 @@ assert.strictEqual(resolved.OV_MEDIA_INTERNAL_URL.value, 'http://127.0.0.1:4100'
@@ -17,21 +21,22 @@ assert.strictEqual(resolved.OV_MEDIA_INTERNAL_URL.value, 'http://127.0.0.1:4100'
assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.value, 'http://127.0.0.1:3000');
assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.value, 'http://127.0.0.1:3000');
assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.source, 'bootstrap');
assert.strictEqual(resolved.OV_LIVE_INTERNAL_URL.source, 'bootstrap');
-const storedExtra = db.prepare('SELECT key, value, type FROM url_registry WHERE key = ?').get('ALLOWED_EXTRA_ORIGINS');
+const storedExtra = await db.prepare('SELECT key, value, type FROM url_registry WHERE key = ?').get('ALLOWED_EXTRA_ORIGINS');
assert.strictEqual(storedExtra.type, 'json_array');
assert.strictEqual(storedExtra.type, 'json_array');
assert.strictEqual(storedExtra.value, '[]');
assert.strictEqual(storedExtra.value, '[]');
-assert.deepStrictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, []);
+assert.deepStrictEqual((await urlRegistry.getAllRegistryEntries(db)).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, []);
-const updated = urlRegistry.setRegistryEntry(db, 'ALLOWED_EXTRA_ORIGINS', ['https://cdn.example.com'], null);
+const updated = await urlRegistry.setRegistryEntry(db, 'ALLOWED_EXTRA_ORIGINS', ['https://cdn.example.com'], null);
assert.deepStrictEqual(updated.value, ['https://cdn.example.com']);
assert.deepStrictEqual(updated.value, ['https://cdn.example.com']);
-assert.deepStrictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, ['https://cdn.example.com']);
+assert.deepStrictEqual((await urlRegistry.getAllRegistryEntries(db)).find(e => e.key === 'ALLOWED_EXTRA_ORIGINS').value, ['https://cdn.example.com']);
-const scalarUpdated = urlRegistry.setRegistryEntry(db, 'NETWORK_NAME', 'CoolTools', null);
+const scalarUpdated = await urlRegistry.setRegistryEntry(db, 'NETWORK_NAME', 'CoolTools', null);
assert.strictEqual(scalarUpdated.value, 'CoolTools');
assert.strictEqual(scalarUpdated.value, 'CoolTools');
-assert.strictEqual(urlRegistry.getAllRegistryEntries(db).find(e => e.key === 'NETWORK_NAME').value, 'CoolTools');
+assert.strictEqual((await urlRegistry.getAllRegistryEntries(db)).find(e => e.key === 'NETWORK_NAME').value, 'CoolTools');
-const overrides = urlRegistry.loadOverrides(db);
+const overrides = await urlRegistry.loadOverrides(db);
assert.deepStrictEqual(overrides.ALLOWED_EXTRA_ORIGINS, ['https://cdn.example.com']);
assert.deepStrictEqual(overrides.ALLOWED_EXTRA_ORIGINS, ['https://cdn.example.com']);
assert.strictEqual(overrides.NETWORK_NAME, 'CoolTools');
assert.strictEqual(overrides.NETWORK_NAME, 'CoolTools');
console.log('✅ openvibe-network registry bootstrap test passed');
console.log('✅ openvibe-network registry bootstrap test passed');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -163,7 +163,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
@@ -163,7 +163,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
assert.strictEqual(code, 1, '--strict fails on a warning');
assert.strictEqual(code, 1, '--strict fails on a warning');
assert.ok(!lines.some(l => l.startsWith('::warning')), 'annotations only under GitHub Actions');
assert.ok(!lines.some(l => l.startsWith('::warning')), 'annotations only under GitHub Actions');
lines.length = 0;
lines.length = 0;
- code = await drift.main(['--registry', base], { fetchImpl: (u, o) => (String(u).startsWith(base) ? fetch(u, o) : fakeGithub(u, o)), env: {}, out });
+ code = await drift.main(['--registry', base], { fetchImpl: async (u, o) => (String(u).startsWith(base) ? fetch(u, o) : await fakeGithub(u, o)), env: {}, out });
assert.strictEqual(code, 0);
assert.strictEqual(code, 0);
assert.ok(lines.some(l => l.startsWith(`WARN openvibe-contracts live: pins v0.28.0, latest is ${LATEST}`)), lines.join('\n'));
assert.ok(lines.some(l => l.startsWith(`WARN openvibe-contracts live: pins v0.28.0, latest is ${LATEST}`)), lines.join('\n'));
assert.ok(lines.some(l => l.includes(`network: pins ${LATEST} (latest)`)), lines.join('\n'));
assert.ok(lines.some(l => l.includes(`network: pins ${LATEST} (latest)`)), lines.join('\n'));
@@ -13,28 +13,29 @@ const cookieParser = require('cookie-parser');
@@ -13,28 +13,29 @@ const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const bcrypt = require('bcryptjs');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const revocation = require('../server/auth/revocation');
const revocation = require('../server/auth/revocation');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-revoke-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-revoke-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
const hash = bcrypt.hashSync('oldpass1', 4);
const hash = bcrypt.hashSync('oldpass1', 4);
-const mk = (name, withSubject = true) => {
- const id = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(name, hash).lastInsertRowid;
- if (withSubject) subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
- else db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);
- return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+const mk = async (name, withSubject = true) => {
+ const id = (await db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id').run(name, hash)).lastInsertRowid;
+ if (withSubject) await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ else await db.prepare('UPDATE users SET subject_id = NULL WHERE id = ?').run(id);
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
};
};
// A session token issued 10 seconds ago (the cutoff has second precision; a token from the same second survives).
// A session token issued 10 seconds ago (the cutoff has second precision; a token from the same second survives).
const oldToken = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id || undefined, username: u.username, role: u.role || 'user', iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
const oldToken = (u) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id || undefined, username: u.username, role: u.role || 'user', iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
-const events = () => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map(r => JSON.parse(r.envelope)).filter(e => e.event_type === 'network.user.token_valid_after');
+const events = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map(r => r.envelope).filter(e => e.event_type === 'network.user.token_valid_after');
const app = express();
const app = express();
app.locals.db = db; app.locals.config = config; app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;
app.locals.db = db; app.locals.config = config; app.locals.privateKey = keys.privateKey; app.locals.publicKey = keys.publicKey;
@@ -49,53 +50,53 @@ const server = http.createServer(app);
@@ -49,53 +50,53 @@ const server = http.createServer(app);
.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
try {
try {
revocation.revokeTokens.length; // module loads
revocation.revokeTokens.length; // module loads
- assert.throws(() => revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);
+ await assert.rejects(async () => await revocation.revokeTokens(db, 1, { reason: 'because' }), /unknown reason/);
// Sign out everywhere, this device too.
// Sign out everywhere, this device too.
- const ann = mk('ann'); const annTok = oldToken(ann);
+ const ann = await mk('ann'); const annTok = oldToken(ann);
assert.strictEqual((await call('GET', '/me', annTok)).status, 200);
assert.strictEqual((await call('GET', '/me', annTok)).status, 200);
- db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);
+ await db.prepare("INSERT INTO user_sessions (user_id, session_token, is_active, expires_at) VALUES (?, 'sess-ann', 1, datetime('now', '+1 day'))").run(ann.id);
let r = await call('POST', '/sign-out-everywhere', annTok);
let r = await call('POST', '/sign-out-everywhere', annTok);
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual((await call('GET', '/me', annTok)).status, 401, 'the old token is refused');
assert.strictEqual((await call('GET', '/me', annTok)).status, 401, 'the old token is refused');
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id).n, 0);
- let ev = events();
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ? AND is_active = 1').get(ann.id)).n, 0);
+ let ev = await events();
assert.strictEqual(ev.length, 1);
assert.strictEqual(ev.length, 1);
assert.deepStrictEqual([ev[0].source, ev[0].subject, ev[0].payload.subject.id, ev[0].payload.reason, ev[0].payload.valid_after], ['network', { type: 'user', id: ann.subject_id }, ann.subject_id, 'signed_out_everywhere', r.body.valid_after]);
assert.deepStrictEqual([ev[0].source, ev[0].subject, ev[0].payload.subject.id, ev[0].payload.reason, ev[0].payload.valid_after], ['network', { type: 'user', id: ann.subject_id }, ann.subject_id, 'signed_out_everywhere', r.body.valid_after]);
assert.ok(validate('events.event-envelope@1', ev[0]).valid && validate('network.user.token_valid_after@1', ev[0].payload).valid);
assert.ok(validate('events.event-envelope@1', ev[0]).valid && validate('network.user.token_valid_after@1', ev[0].payload).valid);
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ann.subject_id }, 'the person did it');
assert.deepStrictEqual(ev[0].actor, { type: 'user', id: ann.subject_id }, 'the person did it');
// Sign out other devices: the old token dies, this browser gets a fresh one.
// Sign out other devices: the old token dies, this browser gets a fresh one.
- const bob = mk('bob'); const bobTok = oldToken(bob);
+ const bob = await mk('bob'); const bobTok = oldToken(bob);
r = await call('DELETE', '/sessions', bobTok);
r = await call('DELETE', '/sessions', bobTok);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.body.token);
assert.ok(r.body.token);
assert.strictEqual((await call('GET', '/me', bobTok)).status, 401);
assert.strictEqual((await call('GET', '/me', bobTok)).status, 401);
assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200, 'the fresh token works');
assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200, 'the fresh token works');
- assert.strictEqual(events().at(-1).payload.reason, 'signed_out_everywhere');
+ assert.strictEqual((await events()).at(-1).payload.reason, 'signed_out_everywhere');
// Password change: every older token ends; the reply's token works.
// Password change: every older token ends; the reply's token works.
- const cat = mk('cat'); const catTok = oldToken(cat);
+ const cat = await mk('cat'); const catTok = oldToken(cat);
r = await call('POST', '/change-password', catTok, { current_password: 'oldpass1', new_password: 'newpass2' });
r = await call('POST', '/change-password', catTok, { current_password: 'oldpass1', new_password: 'newpass2' });
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual((await call('GET', '/me', catTok)).status, 401);
assert.strictEqual((await call('GET', '/me', catTok)).status, 401);
assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200);
assert.strictEqual((await call('GET', '/me', r.body.token)).status, 200);
- assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).payload.subject.id], ['password_changed', cat.subject_id]);
+ assert.deepStrictEqual([(await events()).at(-1).payload.reason, (await events()).at(-1).payload.subject.id], ['password_changed', cat.subject_id]);
// A ban (staff actor) and an account without a subject (no event, the cutoff still moves).
// A ban (staff actor) and an account without a subject (no event, the cutoff still moves).
- const dan = mk('dan');
- revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });
- assert.deepStrictEqual([events().at(-1).payload.reason, events().at(-1).actor.id], ['banned', ann.subject_id]);
- const eve = mk('eve', false);
- const n = events().length;
- const out = revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });
+ const dan = await mk('dan');
+ await revocation.revokeTokens(db, dan.id, { reason: 'banned', actor: { type: 'user', id: ann.subject_id } });
+ assert.deepStrictEqual([(await events()).at(-1).payload.reason, (await events()).at(-1).actor.id], ['banned', ann.subject_id]);
+ const eve = await mk('eve', false);
+ const n = (await events()).length;
+ const out = await revocation.revokeTokens(db, eve.id, { reason: 'staff_revoked' });
assert.strictEqual(out.event, null);
assert.strictEqual(out.event, null);
- assert.strictEqual(events().length, n);
- assert.ok(db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id).token_valid_after);
+ assert.strictEqual((await events()).length, n);
+ assert.ok((await db.prepare('SELECT token_valid_after FROM users WHERE id = ?').get(eve.id)).token_valid_after);
// Wiring: reset and ban call revokeTokens; the admin can end someone's sessions.
// Wiring: reset and ban call revokeTokens; the admin can end someone's sessions.
const authSrc = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
const authSrc = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
- assert.ok(/revokeTokens\(db, reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));
+ assert.ok(/revokeTokens\((?:db|t), reset\.user_id, \{ reason: 'password_reset'/.test(authSrc));
const adminSrc = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
const adminSrc = fs.readFileSync(path.join(__dirname, '../server/admin/routes.js'), 'utf8');
assert.ok(/reason: 'banned'/.test(adminSrc) && /router\.post\('\/users\/:id\/sign-out'/.test(adminSrc) && /reason: 'staff_revoked'/.test(adminSrc));
assert.ok(/reason: 'banned'/.test(adminSrc) && /router\.post\('\/users\/:id\/sign-out'/.test(adminSrc) && /reason: 'staff_revoked'/.test(adminSrc));
assert.ok(!/token_valid_after = CURRENT_TIMESTAMP/.test(authSrc), 'every cutoff goes through revokeTokens (so it is announced)');
assert.ok(!/token_valid_after = CURRENT_TIMESTAMP/.test(authSrc), 'every cutoff goes through revokeTokens (so it is announced)');
@@ -105,3 +106,4 @@ const server = http.createServer(app);
@@ -105,3 +106,4 @@ const server = http.createServer(app);
}
}
console.log('revocation: all checks passed');
console.log('revocation: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -26,14 +26,14 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -26,14 +26,14 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
const { users, dev, db } = w;
const { users, dev, db } = w;
const PA = dev.PA.id; const AA = dev.PA.app; const PB = dev.PB.id; const AB = dev.PB.app;
const PA = dev.PA.id; const AA = dev.PA.app; const PB = dev.PB.id; const AB = dev.PB.app;
const DEV_TABLES = ['dev_projects', 'dev_project_members', 'dev_apps', 'dev_credentials', 'dev_grants', 'dev_quotas'];
const DEV_TABLES = ['dev_projects', 'dev_project_members', 'dev_apps', 'dev_credentials', 'dev_grants', 'dev_quotas'];
- const snap = (tables, where = {}) => JSON.stringify(tables.map((t) => db.prepare(`SELECT * FROM "${t}" ${where[t] || ''}`).all()));
+ const snap = async (tables, where = {}) => JSON.stringify((await Promise.all(tables.map(async (t) => await db.prepare(`SELECT * FROM "${t}" ${where[t] || ''}`).all()))));
const secretNeedles = { ...w.secrets };
const secretNeedles = { ...w.secrets };
const refused = [];
const refused = [];
const expectRefused = async (who, method, p, body, { tables, where, codes = [401, 403, 404] } = {}) => {
const expectRefused = async (who, method, p, body, { tables, where, codes = [401, 403, 404] } = {}) => {
- const before = tables ? snap(tables, where) : null;
+ const before = tables ? await snap(tables, where) : null;
const r = await w.call(who, method, p, body);
const r = await w.call(who, method, p, body);
if (!codes.includes(r.status)) refused.push(`${method} ${p} as ${typeof who === 'string' ? who : 'token'} → ${r.status} ${r.text.slice(0, 160)}`);
if (!codes.includes(r.status)) refused.push(`${method} ${p} as ${typeof who === 'string' ? who : 'token'} → ${r.status} ${r.text.slice(0, 160)}`);
- if (tables && snap(tables, where) !== before) refused.push(`${method} ${p}: the database changed on a refusal`);
+ if (tables && await snap(tables, where) !== before) refused.push(`${method} ${p}: the database changed on a refusal`);
for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${method} ${p}: the refusal carries ${l.label}`);
for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${method} ${p}: the refusal carries ${l.label}`);
if (/"(client_secret|access_token|refresh_token)"/.test(r.text)) refused.push(`${method} ${p}: the refusal carries a token field`);
if (/"(client_secret|access_token|refresh_token)"/.test(r.text)) refused.push(`${method} ${p}: the refusal carries a token field`);
return r;
return r;
@@ -106,13 +106,13 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -106,13 +106,13 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
await ok('alice', 'POST', `/api/v1/projects/${PA}/apps/${AA}/credentials/${dev.PA.credential}/revoke`, {});
await ok('alice', 'POST', `/api/v1/projects/${PA}/apps/${AA}/credentials/${dev.PA.credential}/revoke`, {});
await ok('bob', 'POST', `/api/v1/projects/${PB}/apps/${AB}/credentials/rotate`, {}, 201);
await ok('bob', 'POST', `/api/v1/projects/${PB}/apps/${AB}/credentials/rotate`, {}, 201);
await ok('alice', 'DELETE', `/api/v1/projects/${PA}/members/${users.carol.subject}`);
await ok('alice', 'DELETE', `/api/v1/projects/${PA}/members/${users.carol.subject}`);
- assert.strictEqual(db.prepare('SELECT name FROM dev_projects WHERE id = ?').get(PA).name, 'alice-private-project-renamed');
- assert.ok(db.prepare('SELECT revoked_at FROM dev_credentials WHERE id = ?').get(dev.PA.credential).revoked_at, 'alice revoked her own credential');
+ assert.strictEqual((await db.prepare('SELECT name FROM dev_projects WHERE id = ?').get(PA)).name, 'alice-private-project-renamed');
+ assert.ok((await db.prepare('SELECT revoked_at FROM dev_credentials WHERE id = ?').get(dev.PA.credential)).revoked_at, 'alice revoked her own credential');
// ── Personal data: bob against alice's ids ───────────────────────
// ── Personal data: bob against alice's ids ───────────────────────
- const notif = db.prepare('SELECT id FROM notifications WHERE user_id = ? ORDER BY created_at DESC').get(users.alice.id);
- const session = db.prepare('SELECT id FROM user_sessions WHERE user_id = ? AND is_active = 1').get(users.alice.id);
- const hist = db.prepare('SELECT id FROM user_history WHERE user_id = ?').get(users.alice.id);
+ const notif = await db.prepare('SELECT id FROM notifications WHERE user_id = ? ORDER BY created_at DESC').get(users.alice.id);
+ const session = await db.prepare('SELECT id FROM user_sessions WHERE user_id = ? AND is_active = 1').get(users.alice.id);
+ const hist = await db.prepare('SELECT id FROM user_history WHERE user_id = ?').get(users.alice.id);
assert.ok(notif && session && hist, 'alice has a notification, a session and a history entry');
assert.ok(notif && session && hist, 'alice has a notification, a session and a history entry');
const ALICE = {
const ALICE = {
tables: ['notifications', 'user_sessions', 'user_history', 'users', 'user_modules', 'user_follows', 'user_blocks', 'push_subscriptions', 'linked_accounts'],
tables: ['notifications', 'user_sessions', 'user_history', 'users', 'user_modules', 'user_follows', 'user_blocks', 'push_subscriptions', 'linked_accounts'],
@@ -123,8 +123,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -123,8 +123,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
},
},
};
};
// Column names differ between modules; fall back to the whole table when a guess is wrong.
// Column names differ between modules; fall back to the whole table when a guess is wrong.
- for (const t of ALICE.tables) { try { db.prepare(`SELECT * FROM "${t}" ${ALICE.where[t]}`).all(); } catch { ALICE.where[t] = ''; } }
- const aliceBefore = snap(ALICE.tables, ALICE.where);
+ for (const t of ALICE.tables) { try { await db.prepare(`SELECT * FROM "${t}" ${ALICE.where[t]}`).all(); } catch { ALICE.where[t] = ''; } }
+ const aliceBefore = await snap(ALICE.tables, ALICE.where);
const personal = [
const personal = [
['POST', `/api/notifications/${notif.id}/read`, {}],
['POST', `/api/notifications/${notif.id}/read`, {}],
['POST', `/api/notifications/${notif.id}/dismiss`, {}],
['POST', `/api/notifications/${notif.id}/dismiss`, {}],
@@ -143,16 +143,16 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -143,16 +143,16 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
for (const [m, p, body] of personal) {
for (const [m, p, body] of personal) {
const r = await w.call('bob', m, p, body, m === 'PUT' && p.startsWith('/api/modules') ? { 'if-match': '0' } : {});
const r = await w.call('bob', m, p, body, m === 'PUT' && p.startsWith('/api/modules') ? { 'if-match': '0' } : {});
for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${m} ${p}: carries ${l.label}`);
for (const l of crawler.leaks(r, secretNeedles)) refused.push(`${m} ${p}: carries ${l.label}`);
- if (snap(ALICE.tables, ALICE.where) !== aliceBefore) { refused.push(`${m} ${p} as bob changed alice's data (${r.status})`); break; }
+ if (await snap(ALICE.tables, ALICE.where) !== aliceBefore) { refused.push(`${m} ${p} as bob changed alice's data (${r.status})`); break; }
}
}
// Positive controls: alice can do each of these to her own data.
// Positive controls: alice can do each of these to her own data.
await ok('alice', 'POST', `/api/notifications/${notif.id}/read`, {});
await ok('alice', 'POST', `/api/notifications/${notif.id}/read`, {});
- assert.strictEqual(db.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id).is_read, 1);
+ assert.strictEqual((await db.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id)).is_read, 1);
await ok('alice', 'DELETE', `/api/history/${hist.id}`);
await ok('alice', 'DELETE', `/api/history/${hist.id}`);
- assert.ok(!db.prepare('SELECT id FROM user_history WHERE id = ?').get(hist.id));
+ assert.ok(!await db.prepare('SELECT id FROM user_history WHERE id = ?').get(hist.id));
await ok('alice', 'DELETE', `/api/auth/sessions/${session.id}`);
await ok('alice', 'DELETE', `/api/auth/sessions/${session.id}`);
- assert.strictEqual(db.prepare('SELECT is_active FROM user_sessions WHERE id = ?').get(session.id).is_active, 0);
- assert.strictEqual(db.prepare('SELECT bio FROM users WHERE id = ?').get(users.bob.id).bio, 'bob-was-here', 'the ids in the body were ignored: bob edited his own profile');
+ assert.strictEqual((await db.prepare('SELECT is_active FROM user_sessions WHERE id = ?').get(session.id)).is_active, 0);
+ assert.strictEqual((await db.prepare('SELECT bio FROM users WHERE id = ?').get(users.bob.id)).bio, 'bob-was-here', 'the ids in the body were ignored: bob edited his own profile');
// ── Service principals outside their own app ───────────────────
// ── Service principals outside their own app ───────────────────
const live = { authorization: `Bearer ${w.liveToken}` };
const live = { authorization: `Bearer ${w.liveToken}` };
@@ -179,4 +179,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -179,4 +179,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
} finally {
} finally {
await w.stop();
await w.stop();
}
}
-})().catch((err) => { console.error(err); process.exit(1); });
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -27,8 +27,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -27,8 +27,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });
const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });
assert.strictEqual(closed.status, 200, closed.text);
assert.strictEqual(closed.status, 200, closed.text);
const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();
const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();
- db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
- db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);
+ await db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
+ await db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);
const alice = {
const alice = {
'alice email': users.alice.email,
'alice email': users.alice.email,
@@ -158,4 +158,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -158,4 +158,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
} finally {
} finally {
await w.stop();
await w.stop();
}
}
-})().catch((err) => { console.error(err); process.exit(1); });
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
// without its key files never publishes its ephemeral HS256 secret as a "public key".
// without its key files never publishes its ephemeral HS256 secret as a "public key".
// node test/security-secrets.test.js
// node test/security-secrets.test.js
const assert = require('assert');
const assert = require('assert');
+const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
const { buildWorld } = require('./security-world');
const { buildWorld } = require('./security-world');
const { bootServer } = require('./helpers/boot-server');
const { bootServer } = require('./helpers/boot-server');
@@ -111,6 +112,11 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -111,6 +112,11 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
assert.strictEqual(jwks.status, 200);
assert.strictEqual(jwks.status, 200);
assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
+ // The configured key (decision 2, plan T2): the kid and modulus JWKS serves are the signing key's.
+ const jwk = jwks.body.keys[0];
+ assert.strictEqual(jwk.kty, 'RSA');
+ assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
+ assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
assert.ok(!/PRIVATE/.test(jwks.text));
assert.ok(!/PRIVATE/.test(jwks.text));
// ── Error paths: every write route, refused or malformed ─────────
// ── Error paths: every write route, refused or malformed ─────────
@@ -165,21 +171,22 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -165,21 +171,22 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
delete all['verification key']; // stored in its own table by design; only its readers are checked above
delete all['verification key']; // stored in its own table by design; only its readers are checked above
const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
- const tables = w.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE '%outbox%' OR name LIKE '%audit%' OR name LIKE '%log%' OR name LIKE '%usage%' OR name LIKE '%changes%' OR name LIKE '%alerts%' OR name LIKE '%revisions%' OR name = 'notifications')").all().map((r) => r.name);
+ const tables = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_name = 'notifications')").all()).map((r) => r.name);
assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));
assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));
const rowHits = [];
const rowHits = [];
for (const t of tables) {
for (const t of tables) {
- const text = JSON.stringify(w.db.prepare(`SELECT * FROM "${t}"`).all());
+ const text = JSON.stringify(await w.db.prepare(`SELECT * FROM "${t}"`).all());
for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);
for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);
}
}
assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');
assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');
- assert.ok(w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n > 0, 'the outbox was written meanwhile');
+ assert.ok((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');
} finally {
} finally {
await w.stop();
await w.stop();
}
}
- // ── Without key files: the ephemeral HS256 secret is never published ───
- const bare = await bootServer();
+ // ── Without key files: the ephemeral HS256 secret is never published (a child of its own, so its
+ // database and process are not the seeded world's) ───
+ const bare = await bootServer({ child: true });
try {
try {
const r = await fetch(`${bare.base}/api/.well-known/jwks`);
const r = await fetch(`${bare.base}/api/.well-known/jwks`);
const body = await r.json();
const body = await r.json();
@@ -195,4 +202,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -195,4 +202,4 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
await bare.stop();
await bare.stop();
}
}
out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
-})().catch((err) => { console.error(err); process.exit(1); });
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -13,17 +13,18 @@ const http = require('http');
@@ -13,17 +13,18 @@ const http = require('http');
const express = require('express');
const express = require('express');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const { verifySession } = require('../server/auth/session');
const { verifySession } = require('../server/auth/session');
const { signAssertion } = require('../server/auth/fedcm');
const { signAssertion } = require('../server/auth/fedcm');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secsess-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secsess-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
-db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed', 'x')").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
+await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed', 'x')").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const ISSUER = 'https://openvibe.network';
const ISSUER = 'https://openvibe.network';
@@ -43,7 +44,7 @@ const server = http.createServer(app);
@@ -43,7 +44,7 @@ const server = http.createServer(app);
const REDIRECT = 'https://openvibe.live/api/auth/callback';
const REDIRECT = 'https://openvibe.live/api/auth/callback';
const ctx = { db, publicKey: keys.publicKey, privateKey: keys.privateKey, config };
const ctx = { db, publicKey: keys.publicKey, privateKey: keys.privateKey, config };
-const user7 = db.prepare('SELECT * FROM users WHERE id = 7').get();
+const user7 = await db.prepare('SELECT * FROM users WHERE id = 7').get();
const assertion = signAssertion(user7, 'https://evil.openvibe.tools', 'n', ctx);
const assertion = signAssertion(user7, 'https://evil.openvibe.tools', 'n', ctx);
const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h', ...opts });
const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h', ...opts });
@@ -54,8 +55,8 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
@@ -54,8 +55,8 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
.then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
// ── FedCM assertions are not sessions ────────────────────────────
// ── FedCM assertions are not sessions ────────────────────────────
- assert.ok(!verifySession(sign(7), ctx).error, 'a normal user token is a session');
- assert.ok(verifySession(assertion, ctx).error, 'a FedCM assertion is not a Network session');
+ assert.ok(!(await verifySession(sign(7), ctx)).error, 'a normal user token is a session');
+ assert.ok((await verifySession(assertion, ctx)).error, 'a FedCM assertion is not a Network session');
let r = await post('/oauth/confirm', { token: assertion, client_id: 'live', redirect_uri: REDIRECT });
let r = await post('/oauth/confirm', { token: assertion, client_id: 'live', redirect_uri: REDIRECT });
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');
r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
@@ -86,10 +87,10 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
@@ -86,10 +87,10 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
const t = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });
const t = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
// backdate what was issued so far, then change the password now
// backdate what was issued so far, then change the password now
- db.prepare("UPDATE oauth_tokens SET created_at = datetime('now', '-10 minutes') WHERE user_id = 8").run();
- db.prepare("UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = 8").run();
+ await db.prepare("UPDATE oauth_tokens SET created_at = datetime('now', '-10 minutes') WHERE user_id = 8").run();
+ await db.prepare("UPDATE users SET token_valid_after = ov_now() WHERE id = 8").run();
const stale = jwt.sign({ sub: 8, id: 8, username: 'changed', iat: Math.floor(Date.now() / 1000) - 600 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
const stale = jwt.sign({ sub: 8, id: 8, username: 'changed', iat: Math.floor(Date.now() / 1000) - 600 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
- assert.ok(verifySession(stale, ctx).error, 'the session guard refuses the revoked token');
+ assert.ok((await verifySession(stale, ctx)).error, 'the session guard refuses the revoked token');
r = await post('/oauth/confirm', { token: stale, client_id: 'live', redirect_uri: REDIRECT });
r = await post('/oauth/confirm', { token: stale, client_id: 'live', redirect_uri: REDIRECT });
assert.strictEqual(r.status, 401, 'a revoked token cannot start an OAuth sign-in');
assert.strictEqual(r.status, 401, 'a revoked token cannot start an OAuth sign-in');
r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'live-secret', refresh_token: t.body.refresh_token });
r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'live-secret', refresh_token: t.body.refresh_token });
@@ -111,3 +112,4 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
@@ -111,3 +112,4 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('session token confusion and revocation: all checks passed');
console.log('session token confusion and revocation: all checks passed');
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
})().catch((err) => { console.error(err); server.close(); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -76,7 +76,7 @@ net.Socket.prototype.connect = function (...args) {
@@ -76,7 +76,7 @@ net.Socket.prototype.connect = function (...args) {
return this;
return this;
};
};
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const push = require('../server/push/push-service');
const push = require('../server/push/push-service');
const { normalizeAvatar } = require('../server/profile/avatar');
const { normalizeAvatar } = require('../server/profile/avatar');
const { buildWorld } = require('./security-world');
const { buildWorld } = require('./security-world');
@@ -192,28 +192,28 @@ function inventory(root) {
@@ -192,28 +192,28 @@ function inventory(root) {
// ── 1. Web push ──────────────────────────────────────────────────
// ── 1. Web push ──────────────────────────────────────────────────
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ssrf-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ssrf-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
- const db = initDb(path.join(dir, 'network.db'));
- push.initVapid(db);
+ const db = await getDb();
+ await push.initVapid(db);
console.log = log;
console.log = log;
- db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'pushy', 'x')").run();
+ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'pushy', 'x')").run();
const listener = await counter();
const listener = await counter();
const P = listener.port;
const P = listener.port;
const keys = { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM', auth: 'tBHItJI5svbpez7KI4CCXg' };
const keys = { p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM', auth: 'tBHItJI5svbpez7KI4CCXg' };
const refusedAtSubscribe = [];
const refusedAtSubscribe = [];
for (const endpoint of internalUrls(P)) {
for (const endpoint of internalUrls(P)) {
- try { push.subscribe(7, { endpoint, keys }); refusedAtSubscribe.push(`accepted ${endpoint}`); } catch (e) { assert.match(e.message, /Invalid push subscription/); }
+ try { await push.subscribe(7, { endpoint, keys }); refusedAtSubscribe.push(`accepted ${endpoint}`); } catch (e) { assert.match(e.message, /Invalid push subscription/); }
}
}
assert.deepStrictEqual(refusedAtSubscribe, [], 'every internal endpoint is refused at subscribe');
assert.deepStrictEqual(refusedAtSubscribe, [], 'every internal endpoint is refused at subscribe');
- push.subscribe(7, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys }); // a real push service is fine
- push.subscribe(7, { endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/abc', keys });
- assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = 7').get().n, 2);
- db.prepare('DELETE FROM push_subscriptions').run();
+ await push.subscribe(7, { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys }); // a real push service is fine
+ await push.subscribe(7, { endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/abc', keys });
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = 7').get()).n, 2);
+ await db.prepare('DELETE FROM push_subscriptions').run();
// Rows stored before the rule, and names that resolve inward at send time.
// Rows stored before the rule, and names that resolve inward at send time.
const rows = [`https://127.0.0.1:${P}/x`, `https://[::ffff:127.0.0.1]:${P}/x`, `https://2130706433:${P}/x`, `https://localhost:${P}/x`,
const rows = [`https://127.0.0.1:${P}/x`, `https://[::ffff:127.0.0.1]:${P}/x`, `https://2130706433:${P}/x`, `https://localhost:${P}/x`,
`https://loop.example.com:${P}/x`, `https://loop.example.com.:${P}/x`, `https://v6loop.example.com:${P}/x`, `https://meta.example.com:${P}/x`, `https://mixed.example.com:${P}/x`,
`https://loop.example.com:${P}/x`, `https://loop.example.com.:${P}/x`, `https://v6loop.example.com:${P}/x`, `https://meta.example.com:${P}/x`, `https://mixed.example.com:${P}/x`,
`https://rebind.example.com:${P}/x`, `https://pub.example.com:${P}/x`];
`https://rebind.example.com:${P}/x`, `https://pub.example.com:${P}/x`];
const ins = db.prepare('INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) VALUES (7, ?, ?, ?)');
const ins = db.prepare('INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth) VALUES (7, ?, ?, ?)');
- for (const e of rows) ins.run(e, keys.p256dh, keys.auth);
+ for (const e of rows) await ins.run(e, keys.p256dh, keys.auth);
connects.length = 0;
connects.length = 0;
await push.sendPush(7, { title: 't', message: 'm' });
await push.sendPush(7, { title: 't', message: 'm' });
await new Promise((r) => setTimeout(r, 300));
await new Promise((r) => setTimeout(r, 300));
@@ -224,10 +224,9 @@ function inventory(root) {
@@ -224,10 +224,9 @@ function inventory(root) {
assert.ok(connects.includes(`rebind.example.com→${PUBLIC} ${P}`), 'a rebinding name: the socket connects to the address that was checked');
assert.ok(connects.includes(`rebind.example.com→${PUBLIC} ${P}`), 'a rebinding name: the socket connects to the address that was checked');
assert.ok(connects.includes(`pub.example.com→${PUBLIC} ${P}`), 'public names are still pushed to (positive control)');
assert.ok(connects.includes(`pub.example.com→${PUBLIC} ${P}`), 'public names are still pushed to (positive control)');
assert.deepStrictEqual(connects.filter((c) => !c.endsWith(' denied') && !c.includes(`→${PUBLIC} `)), [], 'no connection to any internal address');
assert.deepStrictEqual(connects.filter((c) => !c.endsWith(' denied') && !c.includes(`→${PUBLIC} `)), [], 'no connection to any internal address');
- const left = db.prepare('SELECT endpoint FROM push_subscriptions').all().map((r) => r.endpoint);
+ const left = (await db.prepare('SELECT endpoint FROM push_subscriptions').all()).map((r) => r.endpoint);
assert.ok(!left.some((e) => /127\.0\.0\.1|\[::ffff|2130706433|localhost/.test(e)), 'rows with an internal address are dropped');
assert.ok(!left.some((e) => /127\.0\.0\.1|\[::ffff|2130706433|localhost/.test(e)), 'rows with an internal address are dropped');
await listener.close();
await listener.close();
- db.close();
// ── 2. Avatars: Network itself fetches openvibe.media only ─────────
// ── 2. Avatars: Network itself fetches openvibe.media only ─────────
for (const u of [...internalUrls(8443, '/a.png'), 'https://[email protected]/a.png', 'https://openvibe.media.evil.test/a.png', 'https://127.0.0.1/p/abc/screenshot', 'https://[::1]/p/abc']) {
for (const u of [...internalUrls(8443, '/a.png'), 'https://[email protected]/a.png', 'https://openvibe.media.evil.test/a.png', 'https://127.0.0.1/p/abc/screenshot', 'https://[::1]/p/abc']) {
@@ -267,8 +266,8 @@ function inventory(root) {
@@ -267,8 +266,8 @@ function inventory(root) {
assert.strictEqual(target.count(), 0, 'nothing connected to the address a person named');
assert.strictEqual(target.count(), 0, 'nothing connected to the address a person named');
assert.deepStrictEqual(made.filter((c) => c.endsWith(` ${T}`)), [], 'no connection to the chosen port at all');
assert.deepStrictEqual(made.filter((c) => c.endsWith(` ${T}`)), [], 'no connection to the chosen port at all');
const hosts = new Set(made.map((c) => c.split(' ')[0]));
const hosts = new Set(made.map((c) => c.split(' ')[0]));
- for (const h of hosts) assert.ok(['127.0.0.1', 'discord.com', 'gateway.discord.gg'].includes(h), `unexpected destination ${h}`);
- assert.match(gh.text, /no egress to api\.github\.com/, 'the GitHub integration test goes to api.github.com only');
+ for (const h of hosts) assert.ok(['127.0.0.1', 'discord.com', 'gateway.discord.gg', 'api.github.com'].includes(h), `unexpected destination ${h}`);
+ assert.match(gh.text, /no egress to api\.github\.com|could not be reached/, 'the GitHub integration test goes to api.github.com only (the egress guard refuses the socket; undici surfaces it as a fetch failure)');
assert.ok(ingested.length >= 10 && ingested.every((x) => x.path === '/internal/avatar-ingest'), `avatar imports went to Media's ingest instead (${ingested.length})`);
assert.ok(ingested.length >= 10 && ingested.every((x) => x.path === '/internal/avatar-ingest'), `avatar imports went to Media's ingest instead (${ingested.length})`);
assert.ok(ingested.some((x) => x.url === `https://127.0.0.1:${T}/a.png`), 'Media was asked to import the URL; Network did not fetch it');
assert.ok(ingested.some((x) => x.url === `https://127.0.0.1:${T}/a.png`), 'Media was asked to import the URL; Network did not fetch it');
} finally {
} finally {
@@ -278,4 +277,4 @@ function inventory(root) {
@@ -278,4 +277,4 @@ function inventory(root) {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
out(`security ssrf: ${Object.keys(inv).length} reviewed call sites, all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
out(`security ssrf: ${Object.keys(inv).length} reviewed call sites, all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
-})().catch((err) => { console.error(err); process.exit(1); });
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -18,7 +18,6 @@ const crypto = require('crypto');
@@ -18,7 +18,6 @@ const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
-const Database = require('better-sqlite3');
const { bootServer } = require('./helpers/boot-server');
const { bootServer } = require('./helpers/boot-server');
const crawler = require('./security-crawl');
const crawler = require('./security-crawl');
@@ -73,8 +72,8 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -73,8 +72,8 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
},
},
});
});
const routes = await crawler.readRoutes(dump.OV_ROUTE_DUMP);
const routes = await crawler.readRoutes(dump.OV_ROUTE_DUMP);
- const db = new Database(path.join(srv.dir, 'network.db'));
- db.pragma('busy_timeout = 5000');
+ const { getDb } = require('../server/db/database');
+ const db = getDb(); // the same handle the in-process server booted on (test/helpers/pg-preload.mjs)
// ── Secrets, by label ────────────────────────────────────────────
// ── Secrets, by label ────────────────────────────────────────────
const secrets = {};
const secrets = {};
@@ -89,22 +88,22 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -89,22 +88,22 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
// ── People ───────────────────────────────────────────────────────
// ── People ───────────────────────────────────────────────────────
const subjects = require('../server/identity/subjects');
const subjects = require('../server/identity/subjects');
const hash = '$2a$10$' + 'x'.repeat(53);
const hash = '$2a$10$' + 'x'.repeat(53);
- const addUser = (username, role = 'user', email = null) => {
- db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);
- const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
- subjects.ensureUserSubject(db, row);
- return db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);
+ const addUser = async (username, role = 'user', email = null) => {
+ await db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);
+ const row = await db.prepare('SELECT * FROM users WHERE username = ?').get(username);
+ await subjects.ensureUserSubject(db, row);
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);
};
};
const rows = {
const rows = {
- alice: addUser('alice', 'user', '[email protected]'),
- bob: addUser('bob', 'user', '[email protected]'),
- carol: addUser('carol', 'user', '[email protected]'),
- staff: addUser('staffer', 'admin', '[email protected]'),
+ alice: await addUser('alice', 'user', '[email protected]'),
+ bob: await addUser('bob', 'user', '[email protected]'),
+ carol: await addUser('carol', 'user', '[email protected]'),
+ staff: await addUser('staffer', 'admin', '[email protected]'),
};
};
- const ownerRow = db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();
+ const ownerRow = await db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();
if (!ownerRow || ownerRow.role !== 'admin') throw new Error('the server did not create the ADMIN_USERNAME account');
if (!ownerRow || ownerRow.role !== 'admin') throw new Error('the server did not create the ADMIN_USERNAME account');
- subjects.ensureUserSubject(db, ownerRow);
- rows.owner = db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);
+ await subjects.ensureUserSubject(db, ownerRow);
+ rows.owner = await db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);
secrets['owner password hash'] = rows.owner.password_hash;
secrets['owner password hash'] = rows.owner.password_hash;
const sign = (u) => jwt.sign({ sub: u.id, id: u.id, username: u.username, role: u.role }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '2h' });
const sign = (u) => jwt.sign({ sub: u.id, id: u.id, username: u.username, role: u.role }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '2h' });
const users = {};
const users = {};
@@ -112,9 +111,9 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -112,9 +111,9 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
// First-party OAuth clients get unique secrets (stored in clear in oauth_clients).
// First-party OAuth clients get unique secrets (stored in clear in oauth_clients).
const clientSecrets = {};
const clientSecrets = {};
- for (const c of db.prepare('SELECT client_id FROM oauth_clients').all()) {
+ for (const c of await db.prepare('SELECT client_id FROM oauth_clients').all()) {
clientSecrets[c.client_id] = `${S('client')}-${c.client_id}-${crypto.randomBytes(6).toString('hex')}`;
clientSecrets[c.client_id] = `${S('client')}-${c.client_id}-${crypto.randomBytes(6).toString('hex')}`;
- db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);
+ await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);
secrets[`oauth client secret ${c.client_id}`] = clientSecrets[c.client_id];
secrets[`oauth client secret ${c.client_id}`] = clientSecrets[c.client_id];
}
}
@@ -159,7 +158,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -159,7 +158,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
const rot = once('credential rotation', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/apps/${dev.PA.app}/credentials/rotate`, {}), 201, 'rotate'));
const rot = once('credential rotation', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/apps/${dev.PA.app}/credentials/rotate`, {}), 201, 'rotate'));
dev.PA.secret2 = rot.body.credential.client_secret; dev.PA.credential2 = rot.body.credential.id;
dev.PA.secret2 = rot.body.credential.client_secret; dev.PA.credential2 = rot.body.credential.id;
secrets['dev credential PA rotated'] = dev.PA.secret2;
secrets['dev credential PA rotated'] = dev.PA.secret2;
- for (const c of db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;
+ for (const c of await db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;
// A grant request (pending) to have a grant row on each app.
// A grant request (pending) to have a grant row on each app.
for (const P of ['PA', 'PB']) await call(dev[P].owner, 'POST', `/api/v1/projects/${dev[P].id}/apps/${dev[P].app}/grants`, { capability: 'media.object.upload' });
for (const P of ['PA', 'PB']) await call(dev[P].owner, 'POST', `/api/v1/projects/${dev[P].id}/apps/${dev[P].app}/grants`, { capability: 'media.object.upload' });
const exp = once('export token', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }), 201, 'export token'));
const exp = once('export token', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }), 201, 'export token'));
@@ -183,28 +182,28 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -183,28 +182,28 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
secrets['alice push endpoint'] = pushSub.endpoint;
secrets['alice push endpoint'] = pushSub.endpoint;
secrets['alice push auth'] = pushSub.keys.auth;
secrets['alice push auth'] = pushSub.keys.auth;
// A refresh token through the OAuth code flow (client 'live').
// A refresh token through the OAuth code flow (client 'live').
- const REDIRECT = (JSON.parse(db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get().redirect_uris) || [])[0];
+ const REDIRECT = (JSON.parse((await db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get()).redirect_uris) || [])[0];
const conf = await call(null, 'POST', '/oauth/confirm', { token: users.alice.token, client_id: 'live', redirect_uri: REDIRECT });
const conf = await call(null, 'POST', '/oauth/confirm', { token: users.alice.token, client_id: 'live', redirect_uri: REDIRECT });
if (conf.status === 200 && conf.body && conf.body.redirect) {
if (conf.status === 200 && conf.body && conf.body.redirect) {
const code = new URL(conf.body.redirect).searchParams.get('code');
const code = new URL(conf.body.redirect).searchParams.get('code');
const t = once('oauth code exchange', await call(null, 'POST', '/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: clientSecrets.live, code, redirect_uri: REDIRECT }));
const t = once('oauth code exchange', await call(null, 'POST', '/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: clientSecrets.live, code, redirect_uri: REDIRECT }));
if (t.body && t.body.refresh_token) secrets['alice refresh token'] = t.body.refresh_token;
if (t.body && t.body.refresh_token) secrets['alice refresh token'] = t.body.refresh_token;
}
}
- for (const r of db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;
+ for (const r of await db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;
// An anonymous identity seen from another address.
// An anonymous identity seen from another address.
const anonToken = `anon-${crypto.randomBytes(12).toString('hex')}`;
const anonToken = `anon-${crypto.randomBytes(12).toString('hex')}`;
- db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');
+ await db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');
secrets['anon session token'] = anonToken;
secrets['anon session token'] = anonToken;
// Provider-secret copies in the database (the environment wins, but the copies are secrets too).
// Provider-secret copies in the database (the environment wins, but the copies are secrets too).
for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'github_token', 'net.ipinfo_token', 'ses_secret_access_key']) {
for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'github_token', 'net.ipinfo_token', 'ses_secret_access_key']) {
const v = S(`db-${k.replace(/[._]/g, '-')}`);
const v = S(`db-${k.replace(/[._]/g, '-')}`);
- db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, 'secret')").run(k, v);
+ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES (?, ?, 'secret') ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type").run(k, v);
secrets[`db setting ${k}`] = v;
secrets[`db setting ${k}`] = v;
}
}
const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;
const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;
- db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);
+ await db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);
// A per-user password hash is never shown to anyone.
// A per-user password hash is never shown to anyone.
- for (const u of ['alice', 'bob', 'staff']) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id).password_hash; }
+ for (const u of ['alice', 'bob', 'staff']) { await db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = (await db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id)).password_hash; }
const callers = {
const callers = {
anonymous: {},
anonymous: {},
@@ -219,7 +218,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
@@ -219,7 +218,7 @@ async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,
liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,
// Every TCP connection the server made so far, as 'host port' lines (the preload logs them).
// Every TCP connection the server made so far, as 'host port' lines (the preload logs them).
connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },
connects: () => { try { return fs.readFileSync(dump.OV_CONNECT_LOG, 'utf8').split('\n').filter(Boolean); } catch { return []; } },
- stop: async () => { try { db.close(); } catch { /* */ } await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
+ stop: async () => { await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
};
};
}
}
@@ -1,26 +1,27 @@
@@ -1,26 +1,27 @@
'use strict';
'use strict';
// server/setup/service-principal.js: create/rotate a service client; the secret lands in the env file (0600),
// server/setup/service-principal.js: create/rotate a service client; the secret lands in the env file (0600),
-// never on stdout; existing env lines are kept.
+// never on stdout; existing env lines are kept. The database is the process one (test/helpers/pg-preload.mjs).
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const sp = require('../server/setup/service-principal');
const sp = require('../server/setup/service-principal');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sp-'));
-const dbPath = path.join(dir, 'network.db');
-const log = console.log; console.log = () => {}; initDb(dbPath).close(); console.log = log;
+const db = await getDb();
const env = path.join(dir, 'events.env');
const env = path.join(dir, 'events.env');
fs.writeFileSync(env, 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');
fs.writeFileSync(env, 'PORT=4300\nOV_OAUTH_CLIENT_SECRET=old\n');
const out = [];
const out = [];
-console.log = (...a) => out.push(a.join(' '));
-assert.strictEqual(sp.main(['create', 'events', '--env-file', env, '--db', dbPath]), 0);
-assert.strictEqual(sp.main(['create', 'events', '--env-file', env, '--db', dbPath]), 1, 'no double create');
+const log = console.log; const err = console.error;
+console.log = (...a) => out.push(a.join(' ')); console.error = () => {};
+assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 0);
+assert.strictEqual(await sp.main(['create', 'events', '--env-file', env]), 1, 'no double create');
const first = fs.readFileSync(env, 'utf8');
const first = fs.readFileSync(env, 'utf8');
-assert.strictEqual(sp.main(['rotate', 'events', '--env-file', env, '--db', dbPath]), 0);
-console.log = log;
+assert.strictEqual(await sp.main(['rotate', 'events', '--env-file', env]), 0);
+console.log = log; console.error = err;
const second = fs.readFileSync(env, 'utf8');
const second = fs.readFileSync(env, 'utf8');
const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];
const secretOf = (s) => s.match(/^OV_OAUTH_CLIENT_SECRET=(.+)$/m)[1];
assert.ok(/^PORT=4300$/m.test(second), 'other lines kept');
assert.ok(/^PORT=4300$/m.test(second), 'other lines kept');
@@ -28,9 +29,9 @@ assert.strictEqual((second.match(/OV_OAUTH_CLIENT_SECRET=/g) || []).length, 1, '
@@ -28,9 +29,9 @@ assert.strictEqual((second.match(/OV_OAUTH_CLIENT_SECRET=/g) || []).length, 1, '
assert.notStrictEqual(secretOf(first), secretOf(second), 'rotate changes the secret');
assert.notStrictEqual(secretOf(first), secretOf(second), 'rotate changes the secret');
assert.strictEqual(fs.statSync(env).mode & 0o777, 0o600);
assert.strictEqual(fs.statSync(env).mode & 0o777, 0o600);
assert.ok(!out.join('\n').includes(secretOf(second)) && !out.join('\n').includes(secretOf(first)), 'secrets are never printed');
assert.ok(!out.join('\n').includes(secretOf(second)) && !out.join('\n').includes(secretOf(first)), 'secrets are never printed');
-const Database = require('better-sqlite3');
-const row = new Database(dbPath).prepare("SELECT client_secret, redirect_uris FROM oauth_clients WHERE client_id = 'events'").get();
+const row = await db.prepare("SELECT client_secret, redirect_uris FROM oauth_clients WHERE client_id = 'events'").get();
assert.strictEqual(row.client_secret, secretOf(second));
assert.strictEqual(row.client_secret, secretOf(second));
assert.strictEqual(row.redirect_uris, '[]', 'service principals cannot do user sign-in');
assert.strictEqual(row.redirect_uris, '[]', 'service principals cannot do user sign-in');
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('service principal provisioning: all checks passed');
console.log('service principal provisioning: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });
@@ -12,19 +12,19 @@ const os = require('os');
@@ -12,19 +12,19 @@ const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
for (const n of ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY']) delete process.env[n];
for (const n of ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY']) delete process.env[n];
process.env.OWNER_USERNAME = 'owner';
process.env.OWNER_USERNAME = 'owner';
const quiet = async (fn) => { const l = console.log, w = console.warn; console.log = () => {}; console.warn = () => {}; try { return await fn(); } finally { console.log = l; console.warn = w; } };
const quiet = async (fn) => { const l = console.log, w = console.warn; console.log = () => {}; console.warn = () => {}; try { return await fn(); } finally { console.log = l; console.warn = w; } };
(async () => {
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-netcfg-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-netcfg-'));
- const db = await quiet(() => initDb(path.join(dir, 'network.db')));
- const set = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
- const row = (k) => db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(k);
- set.run('discord_bot_token', 'example-bot-token-not-real', 'secret');
- set.run('migr_pref_email_null', 'true', 'boolean');
- set.run('vapid_private_key', 'example-vapid-private', 'secret');
+ const db = getDb();
+ const set = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');
+ const row = async (k) => await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(k);
+ await set.run('discord_bot_token', 'example-bot-token-not-real', 'secret');
+ await set.run('migr_pref_email_null', 'true', 'boolean');
+ await set.run('vapid_private_key', 'example-vapid-private', 'secret');
const createAdminRoutes = require('../server/admin/routes');
const createAdminRoutes = require('../server/admin/routes');
const createDiscordRoutes = require('../server/discord/routes');
const createDiscordRoutes = require('../server/discord/routes');
@@ -36,14 +36,14 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -36,14 +36,14 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
let who = { id: 1, username: 'owner', role: 'admin', subject_id: 'usr_01KKT9AC60KM7CRTB3WN1Z8P56' };
let who = { id: 1, username: 'owner', role: 'admin', subject_id: 'usr_01KKT9AC60KM7CRTB3WN1Z8P56' };
const requireAuth = (req, _res, next) => { req.user = who; next(); };
const requireAuth = (req, _res, next) => { req.user = who; next(); };
const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());
const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());
- db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin'), (2, 'helper', 'x', 'admin')").run();
+ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin'), (2, 'helper', 'x', 'admin')").run();
app.use('/api/admin/discord', createDiscordRoutes(db, { getStatus: () => ({ connected: false }), reinit: async () => {} }, requireAuth, requireAdmin));
app.use('/api/admin/discord', createDiscordRoutes(db, { getStatus: () => ({ connected: false }), reinit: async () => {} }, requireAuth, requireAdmin));
app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));
app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));
const server = http.createServer(app);
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const call = (method, p, body) => fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, body) => fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
.then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
.then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
- const cfg = require('../server/admin/site-config').forDb(db);
+ const cfg = await require('../server/admin/site-config').forDb(db);
// ── Revision 1: configuration only ──
// ── Revision 1: configuration only ──
await quiet(() => cfg.sync());
await quiet(() => cfg.sync());
@@ -56,26 +56,26 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -56,26 +56,26 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
let r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '9', type: 'number', reason: 'fewer alerts' }));
let r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '9', type: 'number', reason: 'fewer alerts' }));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.body.revision, rev1 + 1);
assert.strictEqual(r.body.revision, rev1 + 1);
- assert.deepStrictEqual(row('stream_live_daily_cap'), { value: '9', type: 'number' });
- assert.strictEqual(db.getSetting('stream_live_daily_cap'), 9);
+ assert.deepStrictEqual(await row('stream_live_daily_cap'), { value: '9', type: 'number' });
+ assert.strictEqual(await db.getSetting('stream_live_daily_cap'), 9);
r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'brand_new_flag', value: 'true', type: 'boolean' }));
r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'brand_new_flag', value: 'true', type: 'boolean' }));
- assert.deepStrictEqual(row('brand_new_flag'), { value: 'true', type: 'boolean' }, 'a new row takes the given type');
+ assert.deepStrictEqual(await row('brand_new_flag'), { value: 'true', type: 'boolean' }, 'a new row takes the given type');
// ── Email and Discord changes are revisions too ──
// ── Email and Discord changes are revisions too ──
r = await quiet(() => call('PUT', '/api/admin/email', { from_name: 'OpenVibe Team' }));
r = await quiet(() => call('PUT', '/api/admin/email', { from_name: 'OpenVibe Team' }));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.strictEqual(row('email_from_name').value, 'OpenVibe Team');
+ assert.strictEqual((await row('email_from_name')).value, 'OpenVibe Team');
r = await quiet(() => call('PUT', '/api/admin/discord', { settings: { discord_dedupe_minutes: '15', discord_bot_token: 'example-new-bot-token' } }));
r = await quiet(() => call('PUT', '/api/admin/discord', { settings: { discord_dedupe_minutes: '15', discord_bot_token: 'example-new-bot-token' } }));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.deepStrictEqual(row('discord_dedupe_minutes'), { value: '15', type: 'number' });
- assert.deepStrictEqual(row('discord_bot_token'), { value: 'example-new-bot-token', type: 'secret' }, 'a secret row stays a secret row');
+ assert.deepStrictEqual(await row('discord_dedupe_minutes'), { value: '15', type: 'number' });
+ assert.deepStrictEqual(await row('discord_bot_token'), { value: 'example-new-bot-token', type: 'secret' }, 'a secret row stays a secret row');
// ── Written around the journal: recorded first, never reverted ──
// ── Written around the journal: recorded first, never reverted ──
- set.run('email_daily_cap', '777', 'number');
+ await set.run('email_daily_cap', '777', 'number');
const before = cfg.store.revision();
const before = cfg.store.revision();
r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '12', type: 'number' }));
r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '12', type: 'number' }));
assert.strictEqual(cfg.store.revision(), before + 2, 'a sync revision, then the change');
assert.strictEqual(cfg.store.revision(), before + 2, 'a sync revision, then the change');
- assert.strictEqual(row('email_daily_cap').value, '777');
+ assert.strictEqual((await row('email_daily_cap')).value, '777');
// ── History: who, why, secrets as fingerprints ──
// ── History: who, why, secrets as fingerprints ──
r = await call('GET', '/api/admin/config/network.site_settings/history?limit=50');
r = await call('GET', '/api/admin/config/network.site_settings/history?limit=50');
@@ -90,7 +90,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -90,7 +90,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
// ── Rollback undoes the last change only; the secret and the outside row stay ──
// ── Rollback undoes the last change only; the secret and the outside row stay ──
r = await quiet(() => call('POST', '/api/admin/config/network.site_settings/rollback', { reason: 'too many' }));
r = await quiet(() => call('POST', '/api/admin/config/network.site_settings/rollback', { reason: 'too many' }));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
- assert.deepStrictEqual([row('stream_live_daily_cap').value, row('email_daily_cap').value, row('discord_bot_token').value], ['9', '777', 'example-new-bot-token']);
+ assert.deepStrictEqual([(await row('stream_live_daily_cap')).value, (await row('email_daily_cap')).value, (await row('discord_bot_token')).value], ['9', '777', 'example-new-bot-token']);
assert.strictEqual((await call('POST', '/api/admin/config/other.namespace/rollback', {})).status, 404);
assert.strictEqual((await call('POST', '/api/admin/config/other.namespace/rollback', {})).status, 404);
// Only the owner reads or rolls back the configuration.
// Only the owner reads or rolls back the configuration.
@@ -11,15 +11,16 @@ const path = require('path');
@@ -11,15 +11,16 @@ const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
const { validate } = require('openvibe-contracts');
const { validate } = require('openvibe-contracts');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-incidents-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-incidents-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-require('../server/identity/principals').ensureSchema(db);
-for (const c of ['host', 'live']) db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
-db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'boss', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'admin'), (2, 'pat', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'user')").run();
+await require('../server/identity/principals').ensureSchema(db);
+for (const c of ['host', 'live']) await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${c}-secret`, c);
+await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, role) VALUES (1, 'boss', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'admin'), (2, 'pat', 'x', 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB', 'user')").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
@@ -41,13 +42,13 @@ const server = http.createServer(app);
@@ -41,13 +42,13 @@ const server = http.createServer(app);
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
const call = (method, p, { body, headers = {} } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...headers }, body: body ? JSON.stringify(body) : undefined })
.then(async (r) => ({ status: r.status, text: await r.clone().text(), body: await r.json().catch(() => null) }));
.then(async (r) => ({ status: r.status, text: await r.clone().text(), body: await r.json().catch(() => null) }));
const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
const svc = async (client) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: `${client}-secret`, audience: 'openvibe.network' }) })).json()).access_token;
- const as = (id) => ({ authorization: `Bearer ${signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
+ const as = async (id) => ({ authorization: `Bearer ${signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config)}` });
try {
try {
const host = { authorization: `Bearer ${await svc('host')}` };
const host = { authorization: `Bearer ${await svc('host')}` };
// Who may write.
// Who may write.
const open = { kind: 'incident', title: 'Live streams stall on start', severity: 'major', services: ['live'], message: 'New streams take a minute to start.' };
const open = { kind: 'incident', title: 'Live streams stall on start', severity: 'major', services: ['live'], message: 'New streams take a minute to start.' };
assert.ok([401, 403].includes((await call('POST', '/api/v1/status/incidents', { body: open })).status), 'nobody');
assert.ok([401, 403].includes((await call('POST', '/api/v1/status/incidents', { body: open })).status), 'nobody');
- assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: as(2) })).status, 403, 'a person who is not staff');
+ assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: await as(2) })).status, 403, 'a person who is not staff');
assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: { authorization: `Bearer ${await svc('live')}` } })).status, 403, 'a service without network.status.incident');
assert.strictEqual((await call('POST', '/api/v1/status/incidents', { body: open, headers: { authorization: `Bearer ${await svc('live')}` } })).status, 403, 'a service without network.status.incident');
let r = await call('POST', '/api/v1/status/incidents', { body: open, headers: host });
let r = await call('POST', '/api/v1/status/incidents', { body: open, headers: host });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(r.status, 201, r.text);
@@ -57,7 +58,7 @@ const server = http.createServer(app);
@@ -57,7 +58,7 @@ const server = http.createServer(app);
assert.match(inc, /^inc_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.match(inc, /^inc_[0-9A-HJKMNP-TV-Z]{26}$/);
// A staff admin updates it through its states; closed refuses more.
// A staff admin updates it through its states; closed refuses more.
- r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'identified', message: 'A restart left the ingest socket unbound.' }, headers: as(1) });
+ r = await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'identified', message: 'A restart left the ingest socket unbound.' }, headers: await as(1) });
assert.deepStrictEqual([r.status, r.body.state, r.body.updates.length], [200, 'identified', 2]);
assert.deepStrictEqual([r.status, r.body.state, r.body.updates.length], [200, 'identified', 2]);
assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'completed', message: 'x' }, headers: host })).status, 400, 'a maintenance state on an incident');
assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'completed', message: 'x' }, headers: host })).status, 400, 'a maintenance state on an incident');
assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'ok', title: 'renamed' }, headers: host })).status, 400, 'the request contract');
assert.strictEqual((await call('POST', `/api/v1/status/incidents/${inc}/updates`, { body: { state: 'resolved', message: 'ok', title: 'renamed' }, headers: host })).status, 400, 'the request contract');
@@ -95,3 +96,4 @@ const server = http.createServer(app);
@@ -95,3 +96,4 @@ const server = http.createServer(app);
}
}
console.log('status incidents: all checks passed');
console.log('status incidents: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
@@ -11,13 +11,14 @@ const os = require('os');
@@ -11,13 +11,14 @@ const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-themes-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-themes-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = getDb();
console.log = log;
console.log = log;
-db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'ann', 'x', 'user'), (2, 'bob', 'x', 'user'), (3, 'boss', 'x', 'admin')").run();
+await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'ann', 'x', 'user'), (2, 'bob', 'x', 'user'), (3, 'boss', 'x', 'admin')").run();
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
const { signToken } = require('../server/auth/routes');
const { signToken } = require('../server/auth/routes');
@@ -30,14 +31,14 @@ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey:
@@ -30,14 +31,14 @@ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey:
app.use('/api/themes', themes);
app.use('/api/themes', themes);
app.use('/api/admin/themes', requireAuth, requireAdmin, themes.reviewRouter());
app.use('/api/admin/themes', requireAuth, requireAdmin, themes.reviewRouter());
const server = http.createServer(app);
const server = http.createServer(app);
-const token = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
+const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
(async () => {
(async () => {
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
- const call = (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
.then(async (r) => ({ status: r.status, headers: r.headers, body: await r.json().catch(() => null) }));
.then(async (r) => ({ status: r.status, headers: r.headers, body: await r.json().catch(() => null) }));
- db.prepare("INSERT OR IGNORE INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES ('vibe', 'Vibe', 'vibe', 'dark', '{\"--accent\":\"#5b7cfa\"}', 1, 1)").run();
+ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public) VALUES ('vibe', 'Vibe', 'vibe', 'dark', '{\"--accent\":\"#5b7cfa\"}', 1, 1) ON CONFLICT DO NOTHING").run();
// ── Values are allow-listed ──
// ── Values are allow-listed ──
const good = { '--accent': '#ff6a3d', '--bg-primary': '#101010', '--accent-rgb': '255, 106, 61', '--shadow': '0 8px 24px rgba(0, 0, 0, .4)' };
const good = { '--accent': '#ff6a3d', '--bg-primary': '#101010', '--accent-rgb': '255, 106, 61', '--shadow': '0 8px 24px rgba(0, 0, 0, .4)' };
@@ -96,3 +97,4 @@ const token = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').g
@@ -96,3 +97,4 @@ const token = (id) => signToken(db.prepare('SELECT * FROM users WHERE id = ?').g
console.log('themes review: all checks passed');
console.log('themes review: all checks passed');
process.exit(0);
process.exit(0);
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+// Single-use tokens consumed twice at once (decision 1, plan T2): on SQLite the single writer hid the
+// race; on PostgreSQL under READ COMMITTED two concurrent consumers can both read "unused" before either
+// writes, so each claim is a conditional UPDATE inside a transaction and exactly one wins. Covers the two
+// consume paths: an email-verification link (auth/email-verify.consumeToken) and a password-reset link
+// (POST /api/auth/reset-password).
+// node test/token-consume.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const bcrypt = require('bcryptjs');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const emailVerify = require('../server/auth/email-verify');
+const resetTokens = require('../server/auth/reset-tokens');
+
+(async () => {
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-token-'));
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+
+const hash = bcrypt.hashSync('oldpass1', 4);
+const mk = async (name, email) => {
+ const id = (await db.prepare('INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, email, hash)).lastInsertRowid;
+ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+};
+
+const app = express();
+app.locals.db = db;
+app.use(express.json()); app.use(cookieParser());
+app.use('/api/auth', require('../server/auth/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/auth`;
+ const call = (p, body) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+ try {
+ // ── Email verification: two uses of one link, one success ──
+ const user = await mk('emily', '[email protected]');
+ const raw = crypto.randomBytes(32).toString('base64url');
+ await db.prepare("INSERT INTO email_verification_tokens (token_hash, user_id, email, expires_at) VALUES (?, ?, ?, datetime('now', '+1 hour'))")
+ .run(crypto.createHash('sha256').update(raw).digest('hex'), user.id, user.email);
+ const uses = await Promise.all([1, 2].map(() => emailVerify.consumeToken(db, raw).then((r) => r.ok).catch(() => false)));
+ assert.strictEqual(uses.filter(Boolean).length, 1, 'one of two concurrent uses of a verification link wins');
+ assert.ok((await db.prepare('SELECT email_verified FROM users WHERE id = ?').get(user.id)).email_verified, 'the account is verified once');
+ const tokenRow = await db.prepare('SELECT used_at FROM email_verification_tokens WHERE user_id = ?').get(user.id);
+ assert.ok(tokenRow.used_at, 'the link is marked used');
+
+ // ── Password reset: two uses of one link, one success ──
+ const frank = await mk('frank', '[email protected]');
+ const rawReset = crypto.randomBytes(32).toString('hex');
+ await db.prepare("INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, datetime('now', '+1 hour'))")
+ .run(frank.id, resetTokens.hashResetToken(rawReset));
+ const resets = await Promise.all([1, 2].map(() => call('/reset-password', { token: rawReset, new_password: 'newpass9' })));
+ assert.strictEqual(resets.filter((r) => r.status === 200).length, 1, 'one of two concurrent uses of a reset link wins');
+ assert.strictEqual(resets.filter((r) => r.status === 400).length, 1, 'the other is refused (already used)');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ? AND used_at IS NULL').get(frank.id)).n, 0, 'the link is marked used');
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('single-use token consume: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -62,4 +62,4 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
@@ -62,4 +62,4 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
} finally {
} finally {
await srv.stop();
await srv.stop();
}
}
-})().catch((err) => { console.error(err); process.exit(1); });
+})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
@@ -5,46 +5,47 @@ const assert = require('assert');
@@ -5,46 +5,47 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
-const { initDb } = require('../server/db/database');
+const { getDb } = require('../server/db/database');
const u = require('../server/identity/usernames');
const u = require('../server/identity/usernames');
+(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-usernames-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-usernames-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
-const db = initDb(path.join(dir, 'network.db'));
+const db = await getDb();
console.log = log;
console.log = log;
-const add = (name, display = null) => db.prepare('INSERT INTO users (username, display_name, password_hash) VALUES (?, ?, ?)').run(name, display, 'x').lastInsertRowid;
+const add = async (name, display = null) => (await db.prepare('INSERT INTO users (username, display_name, password_hash) VALUES (?, ?, ?) RETURNING id').run(name, display, 'x')).lastInsertRowid;
try {
try {
- const ann = add('ann_old', 'Ann_Old');
- const bob = add('bob', 'Bob the Builder');
- assert.deepStrictEqual(u.rename(db, ann, 'ann_new', { actorId: bob }), { from: 'ann_old', to: 'ann_new' });
- let row = db.prepare('SELECT username, display_name FROM users WHERE id = ?').get(ann);
+ const ann = await add('ann_old', 'Ann_Old');
+ const bob = await add('bob', 'Bob the Builder');
+ assert.deepStrictEqual(await u.rename(db, ann, 'ann_new', { actorId: bob }), { from: 'ann_old', to: 'ann_new' });
+ let row = await db.prepare('SELECT username, display_name FROM users WHERE id = ?').get(ann);
assert.deepStrictEqual([row.username, row.display_name], ['ann_new', 'ann_new'], 'a display name that was the username follows it');
assert.deepStrictEqual([row.username, row.display_name], ['ann_new', 'ann_new'], 'a display name that was the username follows it');
- assert.strictEqual(u.renamedTo(db, 'ANN_OLD'), 'ann_new', 'case-insensitive');
- assert.strictEqual(u.renamedTo(db, 'ann_new'), null, 'a current name is never "renamed"');
- assert.strictEqual(u.renamedTo(db, 'nobody_here'), null);
- assert.strictEqual(u.renamedTo(db, '../etc'), null);
+ assert.strictEqual(await u.renamedTo(db, 'ANN_OLD'), 'ann_new', 'case-insensitive');
+ assert.strictEqual(await u.renamedTo(db, 'ann_new'), null, 'a current name is never "renamed"');
+ assert.strictEqual(await u.renamedTo(db, 'nobody_here'), null);
+ assert.strictEqual(await u.renamedTo(db, '../etc'), null);
// Reserved for everyone else, not for the person who held it.
// Reserved for everyone else, not for the person who held it.
- assert.strictEqual(u.isReserved(db, 'ann_old'), true);
- assert.match(u.problemWith(db, 'ann_old', bob), /belonged to someone else/);
- assert.throws(() => u.rename(db, bob, 'ann_old'), /belonged to someone else/);
- assert.strictEqual(u.problemWith(db, 'ann_old', ann), null, 'Ann may take her old name back');
+ assert.strictEqual(await u.isReserved(db, 'ann_old'), true);
+ assert.match(await u.problemWith(db, 'ann_old', bob), /belonged to someone else/);
+ await assert.rejects(async () => await u.rename(db, bob, 'ann_old'), /belonged to someone else/);
+ assert.strictEqual(await u.problemWith(db, 'ann_old', ann), null, 'Ann may take her old name back');
// Several renames: every old name points at the current one.
// Several renames: every old name points at the current one.
- u.rename(db, ann, 'ann_third');
- assert.strictEqual(u.renamedTo(db, 'ann_old'), 'ann_third');
- assert.strictEqual(u.renamedTo(db, 'ann_new'), 'ann_third');
- assert.deepStrictEqual(u.historyOf(db, ann).map(h => [h.old_username, h.new_username]), [['ann_new', 'ann_third'], ['ann_old', 'ann_new']]);
+ await u.rename(db, ann, 'ann_third');
+ assert.strictEqual(await u.renamedTo(db, 'ann_old'), 'ann_third');
+ assert.strictEqual(await u.renamedTo(db, 'ann_new'), 'ann_third');
+ assert.deepStrictEqual((await u.historyOf(db, ann)).map(h => [h.old_username, h.new_username]), [['ann_new', 'ann_third'], ['ann_old', 'ann_new']]);
// A display name that is not the username stays; bad and taken names are refused.
// A display name that is not the username stays; bad and taken names are refused.
- u.rename(db, bob, 'bobby');
- assert.strictEqual(db.prepare('SELECT display_name FROM users WHERE id = ?').get(bob).display_name, 'Bob the Builder');
- assert.throws(() => u.rename(db, bob, 'ann_third'), /taken/);
- assert.throws(() => u.rename(db, bob, 'no spaces'), /3-24 letters/);
- assert.throws(() => u.rename(db, bob, 'anon123'), /anon/);
- assert.throws(() => u.rename(db, bob, 'System'), /reserved by the system/);
- assert.throws(() => u.rename(db, 9999, 'whoever'), /No such user/);
+ await u.rename(db, bob, 'bobby');
+ assert.strictEqual((await db.prepare('SELECT display_name FROM users WHERE id = ?').get(bob)).display_name, 'Bob the Builder');
+ await assert.rejects(async () => await u.rename(db, bob, 'ann_third'), /taken/);
+ await assert.rejects(async () => await u.rename(db, bob, 'no spaces'), /3-24 letters/);
+ await assert.rejects(async () => await u.rename(db, bob, 'anon123'), /anon/);
+ await assert.rejects(async () => await u.rename(db, bob, 'System'), /reserved by the system/);
+ await assert.rejects(async () => await u.rename(db, 9999, 'whoever'), /No such user/);
// Wiring: registration refuses reserved names, admins rename (owner-protected), the lookup is public.
// Wiring: registration refuses reserved names, admins rename (owner-protected), the lookup is public.
const auth = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
const auth = fs.readFileSync(path.join(__dirname, '../server/auth/routes.js'), 'utf8');
@@ -53,12 +54,13 @@ try {
@@ -53,12 +54,13 @@ try {
assert.ok(/router\.put\('\/users\/:id\/username'/.test(admin) && /Only the owner renames the owner/.test(admin));
assert.ok(/router\.put\('\/users\/:id\/username'/.test(admin) && /Only the owner renames the owner/.test(admin));
assert.ok(/app\.get\('\/api\/v1\/users\/names\/:name'/.test(fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8')));
assert.ok(/app\.get\('\/api\/v1\/users\/names\/:name'/.test(fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8')));
// The public record: an old name and the current one both lead to the same person.
// The public record: an old name and the current one both lead to the same person.
- assert.deepStrictEqual(u.lookup(db, 'ann_old'), { current: 'ann_third', network_id: ann, renamed: true, previous_names: ['ann_new', 'ann_old'] });
- assert.deepStrictEqual(u.lookup(db, 'ANN_THIRD'), { current: 'ann_third', network_id: ann, renamed: false, previous_names: ['ann_new', 'ann_old'] });
- assert.strictEqual(u.lookup(db, 'nobody_here'), null);
- db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(ann);
- assert.strictEqual(u.lookup(db, 'ann_old'), null, 'banned accounts are not looked up');
+ assert.deepStrictEqual(await u.lookup(db, 'ann_old'), { current: 'ann_third', network_id: ann, renamed: true, previous_names: ['ann_new', 'ann_old'] });
+ assert.deepStrictEqual(await u.lookup(db, 'ANN_THIRD'), { current: 'ann_third', network_id: ann, renamed: false, previous_names: ['ann_new', 'ann_old'] });
+ assert.strictEqual(await u.lookup(db, 'nobody_here'), null);
+ await db.prepare('UPDATE users SET is_banned = 1 WHERE id = ?').run(ann);
+ assert.strictEqual(await u.lookup(db, 'ann_old'), null, 'banned accounts are not looked up');
} finally {
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
}
}
console.log('usernames: all checks passed');
console.log('usernames: all checks passed');
+})().catch(err => { console.error(err); process.exit(1); });