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.

OP OpenVibers committed 29 Sept 2026 · 206ab36064 · 148 files, +4247 −5034


Changes

ddiff --git a/.env.example b/.env.example
iindex 632d0e33..4f5830af 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=data
AVATAR_PATH=data/avatars
JWT_PRIVATE_KEY=data/keys/private.pem
JWT_PUBLIC_KEY=data/keys/public.pem
ddiff --git a/README.md b/README.md
iindex cadff02b..3ebe5f11 100644
---- a/README.md
++++ b/README.md
@@@ -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 environment
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
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.
```
--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 works
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)
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
and 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 rolls
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
--`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
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.
- **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.
- **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
ddiff --git a/STATUS.json b/STATUS.json
iindex 1dea029c..6ee174b4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/deploy/systemd/openvibe-network.service b/deploy/systemd/openvibe-network.service
iindex b07495ec..1ac1e801 100644
---- a/deploy/systemd/openvibe-network.service
++++ b/deploy/systemd/openvibe-network.service
@@@ -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
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 7bb43fdd..f082ce22 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -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. |
### Roles
ddiff --git a/migrations/0003_rowid_seq.sql b/migrations/0003_rowid_seq.sql
nnew file mode 100644
iindex 00000000..ed843601
---- /dev/null
++++ b/migrations/0003_rowid_seq.sql
@@@ -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;
ddiff --git a/migrations/0004_identity_by_default.sql b/migrations/0004_identity_by_default.sql
nnew file mode 100644
iindex 00000000..d85f9c5e
---- /dev/null
++++ b/migrations/0004_identity_by_default.sql
@@@ -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 $$;
ddiff --git a/migrations/0005_themes_review.sql b/migrations/0005_themes_review.sql
nnew file mode 100644
iindex 00000000..a88f025d
---- /dev/null
++++ b/migrations/0005_themes_review.sql
@@@ -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;
ddiff --git a/migrations/0006_config.sql b/migrations/0006_config.sql
nnew file mode 100644
iindex 00000000..22f7cc6f
---- /dev/null
++++ b/migrations/0006_config.sql
@@@ -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
++);
ddiff --git a/package.json b/package.json
iindex c9185381..689ecc7b 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/analytics-prune.js b/scripts/analytics-prune.js
iindex 1bfce2a3..5b9b5bc7 100644
---- a/scripts/analytics-prune.js
++++ b/scripts/analytics-prune.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 };
ddiff --git a/scripts/import-blocks.js b/scripts/import-blocks.js
iindex dfc9a9bc..276deb30 100644
---- a/scripts/import-blocks.js
++++ b/scripts/import-blocks.js
@@@ -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); });
ddiff --git a/scripts/lib/db-ops.js b/scripts/lib/db-ops.js
iindex b8d09ac0..c5a93f9c 100644
---- a/scripts/lib/db-ops.js
++++ b/scripts/lib/db-ops.js
@@@ -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 };
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
iindex 725694c6..401f5dc9 100644
---- a/scripts/migrate-to-postgres.js
++++ b/scripts/migrate-to-postgres.js
@@@ -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 };
ddiff --git a/scripts/pg-preflight-collisions.js b/scripts/pg-preflight-collisions.js
iindex bc94e887..e52806ae 100644
---- a/scripts/pg-preflight-collisions.js
++++ b/scripts/pg-preflight-collisions.js
@@@ -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]. */
ddiff --git a/scripts/secrets-out-of-db.js b/scripts/secrets-out-of-db.js
iindex e697be36..1e557fd3 100644
---- a/scripts/secrets-out-of-db.js
++++ b/scripts/secrets-out-of-db.js
@@@ -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', 'vapi
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) {
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); });
}
ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
iindex 35556dab..2e33ed4e 100644
---- a/server/admin/analytics-routes.js
++++ b/server/admin/analytics-routes.js
@@@ -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 parallel
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 =>
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(`
SELECT
COUNT(*) 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 hits
FROM analytics_events
WHERE created_at >= ? AND is_bot = 0
ddiff --git a/server/admin/events-ops.js b/server/admin/events-ops.js
iindex 582cc2b9..f6a99774 100644
---- a/server/admin/events-ops.js
++++ b/server/admin/events-ops.js
@@@ -71,7 +71,7 @@ function createEventsOps({ db, eventsUrl, privateKey, issuer, fetchImpl = global
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 });
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) {
ddiff --git a/server/admin/moderation-audit.js b/server/admin/moderation-audit.js
iindex 30ff7805..9757cb27 100644
---- a/server/admin/moderation-audit.js
++++ b/server/admin/moderation-audit.js
@@@ -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 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;
}
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 1fb18c00..56d0e827 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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 count
FROM email_delivery_log
WHERE 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_at
FROM email_delivery_log
ORDER 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 here
try {
-- 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 settings
const 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 here
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.
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_seen
FROM 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_seen
FROM 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_log
WHERE 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 user
if (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 a
LEFT JOIN users u ON u.id = a.user_id
ORDER 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 masked
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 })
);
res.json({ ok: true });
ddiff --git a/server/admin/site-config.js b/server/admin/site-config.js
iindex b85d6496..36ae6c61 100644
---- a/server/admin/site-config.js
++++ b/server/admin/site-config.js
@@@ -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' });
}
ddiff --git a/server/admin/staff-api.js b/server/admin/staff-api.js
iindex 53ca9f17..13790fab 100644
---- a/server/admin/staff-api.js
++++ b/server/admin/staff-api.js
@@@ -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;
ddiff --git a/server/analytics/creators.js b/server/analytics/creators.js
iindex efa78f93..692acab7 100644
---- a/server/analytics/creators.js
++++ b/server/analytics/creators.js
@@@ -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;
}
ddiff --git a/server/analytics/network.js b/server/analytics/network.js
iindex 2f71dcfb..6f29db49 100644
---- a/server/analytics/network.js
++++ b/server/analytics/network.js
@@@ -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 };
ddiff --git a/server/auth/actor-limits.js b/server/auth/actor-limits.js
iindex 069219de..764dcd20 100644
---- a/server/auth/actor-limits.js
++++ b/server/auth/actor-limits.js
@@@ -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, regist
limits: { 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 });
ddiff --git a/server/auth/admin-limits.js b/server/auth/admin-limits.js
iindex 19cca00a..e2576ede 100644
---- a/server/auth/admin-limits.js
++++ b/server/auth/admin-limits.js
@@@ -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());
}
ddiff --git a/server/auth/discord-link.js b/server/auth/discord-link.js
iindex c6fd3b0e..e015e367 100644
---- a/server/auth/discord-link.js
++++ b/server/auth/discord-link.js
@@@ -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 SET
service_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 });
});
ddiff --git a/server/auth/email-verify.js b/server/auth/email-verify.js
iindex 8ec4c88d..b6550113 100644
---- a/server/auth/email-verify.js
++++ b/server/auth/email-verify.js
@@@ -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' });
ddiff --git a/server/auth/fedcm.js b/server/auth/fedcm.js
iindex cc53ae19..e0b523f0 100644
---- a/server/auth/fedcm.js
++++ b/server/auth/fedcm.js
@@@ -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 || '') });
});
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 5f3eaa3a..6e16c3d4 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -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 };
}
ddiff --git a/server/auth/oidc.js b/server/auth/oidc.js
iindex 0a841e80..d3549e5c 100644
---- a/server/auth/oidc.js
++++ b/server/auth/oidc.js
@@@ -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 }) });
}
ddiff --git a/server/auth/realtime-ticket.js b/server/auth/realtime-ticket.js
iindex 93a925e6..17088aed 100644
---- a/server/auth/realtime-ticket.js
++++ b/server/auth/realtime-ticket.js
@@@ -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() });
ddiff --git a/server/auth/refresh-tokens.js b/server/auth/refresh-tokens.js
iindex ef283df0..d62bece9 100644
---- a/server/auth/refresh-tokens.js
++++ b/server/auth/refresh-tokens.js
@@@ -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 it
return { ok: true, row, familyId: row.family_id || `fam_legacy_${row.id}`, generation: (row.generation || 0) + 1 };
}
ddiff --git a/server/auth/reset-tokens.js b/server/auth/reset-tokens.js
iindex 9e906ba1..014b08aa 100644
---- a/server/auth/reset-tokens.js
++++ b/server/auth/reset-tokens.js
@@@ -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);
ddiff --git a/server/auth/revocation.js b/server/auth/revocation.js
iindex 8d1985aa..9ff7fe28 100644
---- a/server/auth/revocation.js
++++ b/server/auth/revocation.js
@@@ -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. */
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 1fe4dc3b..c4b03466 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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.email
FROM password_reset_tokens prt
JOIN users u ON u.id = prt.user_id
WHERE 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 used
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);
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 change
const 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 a
INNER JOIN anon_ip_log l ON l.anon_id = a.id
WHERE 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_sessions
WHERE user_id = ? AND is_active = 1
ORDER 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_at
FROM 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_effects
WHERE 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 target
const 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 });
});
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex 2a149b82..fc188cdd 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -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;
ddiff --git a/server/auth/sso-check.js b/server/auth/sso-check.js
iindex fc9a9a91..27547287 100644
---- a/server/auth/sso-check.js
++++ b/server/auth/sso-check.js
@@@ -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');
ddiff --git a/server/coins/routes.js b/server/coins/routes.js
iindex f0faccbf..7a537087 100644
---- a/server/coins/routes.js
++++ b/server/coins/routes.js
@@@ -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_at
FROM coin_transactions
WHERE user_id = ?
ddiff --git a/server/coins/wallet.js b/server/coins/wallet.js
iindex 7bb78c0e..e8bb43f0 100644
---- a/server/coins/wallet.js
++++ b/server/coins/wallet.js
@@@ -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, idempot
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) {
* 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 };
ddiff --git a/server/config.js b/server/config.js
iindex ec46b4e1..01b1069d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/db/database.js b/server/db/database.js
iindex fc081ce7..8862170e 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 SET
name = 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_name
FROM verification_keys vk
LEFT 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 };
ddiff --git a/server/deploy/routes.js b/server/deploy/routes.js
iindex 33aee508..a53b6a07 100644
---- a/server/deploy/routes.js
++++ b/server/deploy/routes.js
@@@ -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 token
const 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 config
for (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 status
const 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 });
ddiff --git a/server/developer/event-relay.js b/server/developer/event-relay.js
iindex a527c2b8..b757e3ca 100644
---- a/server/developer/event-relay.js
++++ b/server/developer/event-relay.js
@@@ -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 configured
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. */
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;
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 6c08281e..2907e93d 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -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));
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex b0f9ee57..9fbc6dd4 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -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, setti
if (!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_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.
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 = {
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex 39f77711..a4a86cbf 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -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 };
}
ddiff --git a/server/developer/usage.js b/server/developer/usage.js
iindex 97799ba4..48c49aeb 100644
---- a/server/developer/usage.js
++++ b/server/developer/usage.js
@@@ -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 SET
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
(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,
ddiff --git a/server/discord/discord-service.js b/server/discord/discord-service.js
iindex b4954c2d..259abe2e 100644
---- a/server/discord/discord-service.js
++++ b/server/discord/discord-service.js
@@@ -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;
}
ddiff --git a/server/discord/routes.js b/server/discord/routes.js
iindex 3e2ce7ea..79b9cca6 100644
---- a/server/discord/routes.js
++++ b/server/discord/routes.js
@@@ -15,21 +15,21 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
router.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, r
for (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, r
if (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 });
}
ddiff --git a/server/domains/routes.js b/server/domains/routes.js
iindex ecbc50c8..396b1f0d 100644
---- a/server/domains/routes.js
++++ b/server/domains/routes.js
@@@ -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 }); }
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 67915f47..a39e2f1d 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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 stays
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) {
@@@ -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();
ddiff --git a/server/grant-admin.js b/server/grant-admin.js
iindex 5718ee07..7d91a7e9 100644
---- a/server/grant-admin.js
++++ b/server/grant-admin.js
@@@ -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 };
ddiff --git a/server/history/routes.js b/server/history/routes.js
iindex 625ca5cc..4acbb54e 100644
---- a/server/history/routes.js
++++ b/server/history/routes.js
@@@ -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 });
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 10c68280..0d56996c 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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, notify
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: '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 };
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
iindex f0ae684b..f5a4c4b6 100644
---- a/server/identity/account-merge.js
++++ b/server/identity/account-merge.js
@@@ -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, r
const 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 };
ddiff --git a/server/identity/blocks.js b/server/identity/blocks.js
iindex 20be8147..10fa9957 100644
---- a/server/identity/blocks.js
++++ b/server/identity/blocks.js
@@@ -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_url
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) => ({
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, display
function 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));
};
}
ddiff --git a/server/identity/follows.js b/server/identity/follows.js
iindex 5c4f3c14..64c777a2 100644
---- a/server/identity/follows.js
++++ b/server/identity/follows.js
@@@ -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 = new
else 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 = new
out.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++;
}
ddiff --git a/server/identity/grants-admin.js b/server/identity/grants-admin.js
iindex 95752c5c..884e40cd 100644
---- a/server/identity/grants-admin.js
++++ b/server/identity/grants-admin.js
@@@ -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, expir
const 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);
ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
iindex 723e68ce..42320d40 100644
---- a/server/identity/internal-routes.js
++++ b/server/identity/internal-routes.js
@@@ -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;
ddiff --git a/server/identity/mod-principals.js b/server/identity/mod-principals.js
iindex 4bb94250..2674e2c1 100644
---- a/server/identity/mod-principals.js
++++ b/server/identity/mod-principals.js
@@@ -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); }
});
ddiff --git a/server/identity/module-events.js b/server/identity/module-events.js
iindex 02abf1ed..91a62863 100644
---- a/server/identity/module-events.js
++++ b/server/identity/module-events.js
@@@ -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;
}
ddiff --git a/server/identity/modules.js b/server/identity/modules.js
iindex f3b2f642..a6003873 100644
---- a/server/identity/modules.js
++++ b/server/identity/modules.js
@@@ -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-retired
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;
-- 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();
});
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex ad1f70be..6431d19c 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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();
});
};
ddiff --git a/server/identity/profile-events.js b/server/identity/profile-events.js
iindex 3a9fb822..c9f8c9b4 100644
---- a/server/identity/profile-events.js
++++ b/server/identity/profile-events.js
@@@ -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; }
ddiff --git a/server/identity/subjects.js b/server/identity/subjects.js
iindex a9a4decd..508cd04f 100644
---- a/server/identity/subjects.js
++++ b/server/identity/subjects.js
@@@ -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_id
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;
}
/** 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;
}
ddiff --git a/server/identity/usernames.js b/server/identity/usernames.js
iindex 50f3d6b1..bc627541 100644
---- a/server/identity/usernames.js
++++ b/server/identity/usernames.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 834db65c..1b59f91d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 config
if (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 API
const 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 minutes
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
// 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 };
ddiff --git a/server/integrations/github.js b/server/integrations/github.js
iindex daef4371..273b24ad 100644
---- a/server/integrations/github.js
++++ b/server/integrations/github.js
@@@ -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) });
};
}
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 47941af1..1ab0cfe1 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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 SET
service_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 deduplicate
const 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'), (req
const 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 a
INNER JOIN anon_ip_log l ON l.anon_id = a.id
WHERE 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 exists
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 */ }
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 IP
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 */ }
console.log(`[Internal] New unified anon #${anonNumber} for IP ${ip}`);
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
iindex bf6d5c1b..a4bf6120 100644
---- a/server/notifications/email-service.js
++++ b/server/notifications/email-service.js
@@@ -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, '&amp;')
.replace(/</g, '&lt;')
@@@ -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, B
provider: '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, B
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 {
-- this._logDelivery.run(
++ await this._logDelivery.run(
emailType,
recipient,
subject || null,
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex b88e1872..70f24651 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -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 = null
async '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 themselves
const 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 = null
const 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 = null
let 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);
ddiff --git a/server/notifications/follow-notify.js b/server/notifications/follow-notify.js
iindex ebf9a6b8..41e25259 100644
---- a/server/notifications/follow-notify.js
++++ b/server/notifications/follow-notify.js
@@@ -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,
ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
iindex 9c9cbf7c..a13ea02b 100644
---- a/server/notifications/notification-service.js
++++ b/server/notifications/notification-service.js
@@@ -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 email
if (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`);
ddiff --git a/server/notifications/resend-webhook.js b/server/notifications/resend-webhook.js
iindex 69095b42..4c3731f9 100644
---- a/server/notifications/resend-webhook.js
++++ b/server/notifications/resend-webhook.js
@@@ -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 });
});
ddiff --git a/server/notifications/routes.js b/server/notifications/routes.js
iindex c4d36929..1d6d83f3 100644
---- a/server/notifications/routes.js
++++ b/server/notifications/routes.js
@@@ -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, requ
const 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' });
ddiff --git a/server/notifications/stream-live.js b/server/notifications/stream-live.js
iindex 82334e97..ed3e4381 100644
---- a/server/notifications/stream-live.js
++++ b/server/notifications/stream-live.js
@@@ -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). */
ddiff --git a/server/observability.js b/server/observability.js
iindex 0a2c5f67..85d7e792 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -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: () => {
ddiff --git a/server/operator/alerts.js b/server/operator/alerts.js
iindex abdeb63a..c67808a2 100644
---- a/server/operator/alerts.js
++++ b/server/operator/alerts.js
@@@ -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 = proc
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 = ?");
-- 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 = proc
out.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 = proc
if (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 };
ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
iindex d68493d7..e1897181 100644
---- a/server/profile/avatar.js
++++ b/server/profile/avatar.js
@@@ -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 };
ddiff --git a/server/push/push-service.js b/server/push/push-service.js
iindex 66a835fe..585da5ad 100644
---- a/server/push/push-service.js
++++ b/server/push/push-service.js
@@@ -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 subscriptions
if (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 };
ddiff --git a/server/push/routes.js b/server/push/routes.js
iindex 45328b88..6052ffb0 100644
---- a/server/push/routes.js
++++ b/server/push/routes.js
@@@ -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,
ddiff --git a/server/registry/nodes.js b/server/registry/nodes.js
iindex 39252a1b..42897df4 100644
---- a/server/registry/nodes.js
++++ b/server/registry/nodes.js
@@@ -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 };
}
ddiff --git a/server/reset-db.js b/server/reset-db.js
iindex e587003c..65dce456 100644
---- a/server/reset-db.js
++++ b/server/reset-db.js
@@@ -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 };
ddiff --git a/server/secrets.js b/server/secrets.js
iindex 9bb90cf0..dba4e9d1 100644
---- a/server/secrets.js
++++ b/server/secrets.js
@@@ -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 };
ddiff --git a/server/setup/routes.js b/server/setup/routes.js
iindex 5cb7cda3..20408f37 100644
---- a/server/setup/routes.js
++++ b/server/setup/routes.js
@@@ -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({
ddiff --git a/server/setup/service-principal.js b/server/setup/service-principal.js
iindex ddb34b09..9fc255c1 100644
---- a/server/setup/service-principal.js
++++ b/server/setup/service-principal.js
@@@ -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 };
ddiff --git a/server/status/incidents.js b/server/status/incidents.js
iindex a80e437c..ae844499 100644
---- a/server/status/incidents.js
++++ b/server/status/incidents.js
@@@ -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;
}
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex a2f471ac..ff59eaf5 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -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;
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex a9933725..ad150f89 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -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;
}
ddiff --git a/server/url-registry.js b/server/url-registry.js
iindex 15ca7f9b..48a4f2e8 100644
---- a/server/url-registry.js
++++ b/server/url-registry.js
@@@ -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 SET
value = 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 = {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 694b9d15..9072c1ea 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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); });
ddiff --git a/test/account-merge.test.js b/test/account-merge.test.js
iindex 4810f29a..a32726ce 100644
---- a/test/account-merge.test.js
++++ b/test/account-merge.test.js
@@@ -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); });
ddiff --git a/test/actor-limits.test.js b/test/actor-limits.test.js
iindex 2f5dd644..9cf9015b 100644
---- a/test/actor-limits.test.js
++++ b/test/actor-limits.test.js
@@@ -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); });
ddiff --git a/test/admin-role-events.test.js b/test/admin-role-events.test.js
iindex 95d5920a..a6c45f54 100644
---- a/test/admin-role-events.test.js
++++ b/test/admin-role-events.test.js
@@@ -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 event
r = 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); });
ddiff --git a/test/analytics-privacy.test.js b/test/analytics-privacy.test.js
iindex b7f48d2a..6002f9a0 100644
---- a/test/analytics-privacy.test.js
++++ b/test/analytics-privacy.test.js
@@@ -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.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 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 purpose
try { 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);
ddiff --git a/test/avatar.test.js b/test/avatar.test.js
iindex 88d6e64d..43411ed2 100644
---- a/test/avatar.test.js
++++ b/test/avatar.test.js
@@@ -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);
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex 25f728da..4be4f65d 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -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: fa
app.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); });
ddiff --git a/test/creator-analytics.test.js b/test/creator-analytics.test.js
iindex fdc9132c..0bca1ab2 100644
---- a/test/creator-analytics.test.js
++++ b/test/creator-analytics.test.js
@@@ -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); });
ddiff --git a/test/data-ownership.test.js b/test/data-ownership.test.js
iindex b528d7aa..be500214 100644
---- a/test/data-ownership.test.js
++++ b/test/data-ownership.test.js
@@@ -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.js
const 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 da
fs.rmSync(dir, { recursive: true, force: true });
console.log('data-ownership: ok');
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/developer-defaults.test.js b/test/developer-defaults.test.js
iindex eff8c9d4..471d9d1f 100644
---- a/test/developer-defaults.test.js
++++ b/test/developer-defaults.test.js
@@@ -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); });
ddiff --git a/test/developer-export-tokens.test.js b/test/developer-export-tokens.test.js
iindex afd34d68..f9817c7a 100644
---- a/test/developer-export-tokens.test.js
++++ b/test/developer-export-tokens.test.js
@@@ -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); });
ddiff --git a/test/developer-projects.test.js b/test/developer-projects.test.js
iindex fcd1448e..32247d29 100644
---- a/test/developer-projects.test.js
++++ b/test/developer-projects.test.js
@@@ -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); });
ddiff --git a/test/domains.test.js b/test/domains.test.js
iindex 323b939d..0ec50065 100644
---- a/test/domains.test.js
++++ b/test/domains.test.js
@@@ -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); });
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 72402389..7513cebb 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -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); });
ddiff --git a/test/events-ops.test.js b/test/events-ops.test.js
iindex e2c2bb4c..6c9e777b 100644
---- a/test/events-ops.test.js
++++ b/test/events-ops.test.js
@@@ -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_01M3D4GT16WXDXGZYRE97JR
let 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_01M3D4GT16WXDXGZYRE97JR
r = 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_01M3D4GT16WXDXGZYRE97JR
fs.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); });
ddiff --git a/test/follow-notify.test.js b/test/follow-notify.test.js
iindex c0f77adc..cbab3592 100644
---- a/test/follow-notify.test.js
++++ b/test/follow-notify.test.js
@@@ -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); });
ddiff --git a/test/follows-authority.test.js b/test/follows-authority.test.js
iindex bcad4aa3..4f1768ae 100644
---- a/test/follows-authority.test.js
++++ b/test/follows-authority.test.js
@@@ -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); });
ddiff --git a/test/follows.test.js b/test/follows.test.js
iindex 1f71a944..225d1943 100644
---- a/test/follows.test.js
++++ b/test/follows.test.js
@@@ -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); });
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 5464b074..ef5bde85 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -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/'), 'a
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.
++// 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); });
ddiff --git a/test/github-integration.test.js b/test/github-integration.test.js
iindex 163187b1..93c0b689 100644
---- a/test/github-integration.test.js
++++ b/test/github-integration.test.js
@@@ -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://openvib
app.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); });
ddiff --git a/test/grants-admin.test.js b/test/grants-admin.test.js
iindex d22863fe..8f7d37e7 100644
---- a/test/grants-admin.test.js
++++ b/test/grants-admin.test.js
@@@ -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.ty
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(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.ty
fs.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); });
ddiff --git a/test/helpers/boot-server.js b/test/helpers/boot-server.js
iindex 9f0a9a89..3ddbce32 100644
---- a/test/helpers/boot-server.js
++++ b/test/helpers/boot-server.js
@@@ -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()); }),
};
}
ddiff --git a/test/helpers/pg-preload.mjs b/test/helpers/pg-preload.mjs
iindex 320b9bdb..88bb12c1 100644
---- a/test/helpers/pg-preload.mjs
++++ b/test/helpers/pg-preload.mjs
@@@ -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();
ddiff --git a/test/history-sso.test.js b/test/history-sso.test.js
iindex ae310ebe..7053a357 100644
---- a/test/history-sso.test.js
++++ b/test/history-sso.test.js
@@@ -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 fall
assert.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); });
ddiff --git a/test/identity-subjects.test.js b/test/identity-subjects.test.js
iindex 2cb83390..8a4df0c6 100644
---- a/test/identity-subjects.test.js
++++ b/test/identity-subjects.test.js
@@@ -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); });
ddiff --git a/test/loyalty-policy.test.js b/test/loyalty-policy.test.js
iindex cf45a02a..63506dbf 100644
---- a/test/loyalty-policy.test.js
++++ b/test/loyalty-policy.test.js
@@@ -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); });
ddiff --git a/test/mod-principals.test.js b/test/mod-principals.test.js
iindex ac366f5d..dd9f3ef8 100644
---- a/test/mod-principals.test.js
++++ b/test/mod-principals.test.js
@@@ -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); });
ddiff --git a/test/moderation-audit.test.js b/test/moderation-audit.test.js
iindex 1af97d4b..f2b86081 100644
---- a/test/moderation-audit.test.js
++++ b/test/moderation-audit.test.js
@@@ -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: 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 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: id
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.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: id
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' } };
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); });
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
iindex 888e1a18..40ff00fc 100644
---- a/test/module-events.test.js
++++ b/test/module-events.test.js
@@@ -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', e
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 })
.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', e
assert.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', e
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 } } });
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', e
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 } });
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', e
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 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', e
fs.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); });
ddiff --git a/test/modules.test.js b/test/modules.test.js
iindex d4397500..a71cb44b 100644
---- a/test/modules.test.js
++++ b/test/modules.test.js
@@@ -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); });
ddiff --git a/test/nodes.test.js b/test/nodes.test.js
iindex 93400f9f..4cf06fc0 100644
---- a/test/nodes.test.js
++++ b/test/nodes.test.js
@@@ -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'], locatio
fs.rmSync(dir, { recursive: true, force: true });
}
})().catch((err) => { console.error(err); process.exit(1); });
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/notification-events.test.js b/test/notification-events.test.js
iindex c13c127e..fd475bd4 100644
---- a/test/notification-events.test.js
++++ b/test/notification-events.test.js
@@@ -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', { modulusLen
const 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); });
ddiff --git a/test/oauth-pkce.test.js b/test/oauth-pkce.test.js
iindex 457cc192..76615232 100644
---- a/test/oauth-pkce.test.js
++++ b/test/oauth-pkce.test.js
@@@ -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.privateKe
console.log('oauth pkce: all checks passed');
server.close();
})().catch(err => { console.error(err); process.exit(1); });
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex 4c039802..e2f758d9 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -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); });
ddiff --git a/test/oidc-discovery.test.js b/test/oidc-discovery.test.js
iindex 3239e39d..9380193d 100644
---- a/test/oidc-discovery.test.js
++++ b/test/oidc-discovery.test.js
@@@ -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_ur
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})`);
}
++ // 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_ur
server.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); });
ddiff --git a/test/operator-alerts.test.js b/test/operator-alerts.test.js
iindex 355f5b49..6c2e5c07 100644
---- a/test/operator-alerts.test.js
++++ b/test/operator-alerts.test.js
@@@ -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); });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 1e328e0a..37980ba7 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -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); });
ddiff --git a/test/profile-events.test.js b/test/profile-events.test.js
iindex 1d322a8b..cb5cda92 100644
---- a/test/profile-events.test.js
++++ b/test/profile-events.test.js
@@@ -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); });
ddiff --git a/test/project-usage.test.js b/test/project-usage.test.js
iindex cbcae5b2..82714232 100644
---- a/test/project-usage.test.js
++++ b/test/project-usage.test.js
@@@ -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); });
ddiff --git a/test/provider-secrets.test.js b/test/provider-secrets.test.js
iindex 2478478e..c9301aee 100644
---- a/test/provider-secrets.test.js
++++ b/test/provider-secrets.test.js
@@@ -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.l
const 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.l
r = 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.l
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.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.l
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' } });
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.l
fs.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.l
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, /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];
ddiff --git a/test/refresh-tokens.test.js b/test/refresh-tokens.test.js
iindex 7d0dfee0..24eb3ff7 100644
---- a/test/refresh-tokens.test.js
++++ b/test/refresh-tokens.test.js
@@@ -6,43 +6,30 @@
// node test/refresh-tokens.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 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 { r
assert.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();
ddiff --git a/test/registry-bootstrap.test.js b/test/registry-bootstrap.test.js
iindex e616847c..aee761c0 100644
---- a/test/registry-bootstrap.test.js
++++ b/test/registry-bootstrap.test.js
@@@ -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); });
ddiff --git a/test/registry-topics.test.js b/test/registry-topics.test.js
iindex bd151fac..21381466 100644
---- a/test/registry-topics.test.js
++++ b/test/registry-topics.test.js
@@@ -163,7 +163,7 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
assert.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'));
ddiff --git a/test/revocation.test.js b/test/revocation.test.js
iindex 4188990e..812f5966 100644
---- a/test/revocation.test.js
++++ b/test/revocation.test.js
@@@ -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); });
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex e4c992ce..9048d27c 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 6e0759b2..11c4bc16 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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); });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
iindex 6bdaf836..56af26d1 100644
---- a/test/security-secrets.test.js
++++ b/test/security-secrets.test.js
@@@ -17,6 +17,7 @@
// without its key files never publishes its ephemeral HS256 secret as a "public key".
// node test/security-secrets.test.js
const 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 above
const 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); });
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex 2c009af9..4e3203e0 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex afa5c8fd..f6a6cc6a 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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); });
ddiff --git a/test/security-world.js b/test/security-world.js
iindex 3afacabf..6930aee5 100644
---- a/test/security-world.js
++++ b/test/security-world.js
@@@ -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 { /* */ } },
};
}
ddiff --git a/test/service-principal.test.js b/test/service-principal.test.js
iindex ffb16014..82d3084b 100644
---- a/test/service-principal.test.js
++++ b/test/service-principal.test.js
@@@ -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); });
ddiff --git a/test/site-config.test.js b/test/site-config.test.js
iindex 5274378f..f5d13538 100644
---- a/test/site-config.test.js
++++ b/test/site-config.test.js
@@@ -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.l
let 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.l
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.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.
ddiff --git a/test/status-incidents.test.js b/test/status-incidents.test.js
iindex 12267908..08ce6537 100644
---- a/test/status-incidents.test.js
++++ b/test/status-incidents.test.js
@@@ -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); });
ddiff --git a/test/themes-review.test.js b/test/themes-review.test.js
iindex 4632f832..d8ec37d2 100644
---- a/test/themes-review.test.js
++++ b/test/themes-review.test.js
@@@ -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 = ?').g
console.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); });
ddiff --git a/test/token-consume.test.js b/test/token-consume.test.js
nnew file mode 100644
iindex 00000000..bc9e4f78
---- /dev/null
++++ b/test/token-consume.test.js
@@@ -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); });
ddiff --git a/test/unknown-paths.test.js b/test/unknown-paths.test.js
iindex 263d7374..1ceef1d8 100644
---- a/test/unknown-paths.test.js
++++ b/test/unknown-paths.test.js
@@@ -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); });
ddiff --git a/test/usernames.test.js b/test/usernames.test.js
iindex 6724783d..de43519d 100644
---- a/test/usernames.test.js
++++ b/test/usernames.test.js
@@@ -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); });