openvibers/openvibe.network Commits
Network on PostgreSQL, the foundation (plan T2): openvibe-sdk v0.25.1 with pg, iovalkey and PGlite; migrations 0001_core (69 tables generated from the booted SQLite schema: identities, text timestamps via ov_now(), case-insensitive usernames by a unique lower(username) index, triggers in PL/pgSQL, boot seeds left to the code) and 0002_analytics; DATABASE_URL/DATABASE_DIRECT_URL/PGLITE_DIR/VALKEY_URL config; scripts/migrate-to-postgres.js (import with per-table count and checksum, --pglite dry run) and scripts/pg-preflight-collisions.js; the per-process migrated test database runner (not wired into npm test until the code is converted). Additive: the service still runs on SQLite and npm test is unchanged.
Changes
nnew file mode 100644@@@ -0,0 +1,952 @@++-- phase: expand++-- OpenVibe.Network on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk++-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),++-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-29; never edited after it runs.++-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).++CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$++BEGIN++ IF t IS NULL THEN RETURN NULL; END IF;++ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;++ IF t ~ '\d\d:\d\d(:\d\d(\.\d+)?)?\s*(Z|[+-]\d\d(:?\d\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;++ RETURN t::timestamp;++EXCEPTION WHEN others THEN RETURN NULL;++END $$;++CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;++CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;++CREATE FUNCTION ov_now_iso(modifier text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char((statement_timestamp() AT TIME ZONE 'UTC') + modifier::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;++CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$++DECLARE ts timestamp := ov_ts(t);++BEGIN++ IF ts IS NULL THEN RETURN NULL; END IF;++ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;++ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');++EXCEPTION WHEN others THEN RETURN NULL;++END $$;++CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;++++CREATE TABLE users (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ username text COLLATE "C" NOT NULL,++ email text COLLATE "C" UNIQUE,++ password_hash text COLLATE "C" NOT NULL,++ display_name text COLLATE "C",++ avatar_url text COLLATE "C",++ bio text COLLATE "C" DEFAULT '',++ role text COLLATE "C" DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),++ profile_color text COLLATE "C" DEFAULT '#8b5cf6',++ is_banned bigint DEFAULT 0,++ ban_reason text COLLATE "C",++ token_valid_after text COLLATE "C" DEFAULT NULL,++ legacy_source text COLLATE "C",++ legacy_id bigint,++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now(),++ last_seen text COLLATE "C" DEFAULT ov_now(),++ is_anon bigint DEFAULT 0,++ anon_number bigint,++ name_effect text COLLATE "C",++ particle_effect text COLLATE "C",++ email_verified bigint DEFAULT 0,++ email_verified_at text COLLATE "C",++ email_bounced_at text COLLATE "C",++ email_bounce_reason text COLLATE "C",++ history_paused bigint DEFAULT 0,++ subject_id text COLLATE "C",++ profile_revision bigint NOT NULL DEFAULT 0,++ merged_into bigint,++ deleted_at text COLLATE "C"++);++++CREATE TABLE oauth_clients (++ client_id text COLLATE "C" PRIMARY KEY,++ client_secret text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ redirect_uris text COLLATE "C" NOT NULL,++ is_first_party bigint DEFAULT 0,++ created_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE oauth_codes (++ code text COLLATE "C" PRIMARY KEY,++ client_id text COLLATE "C" NOT NULL,++ user_id bigint NOT NULL,++ redirect_uri text COLLATE "C" NOT NULL,++ scope text COLLATE "C" DEFAULT 'profile theme',++ expires_at text COLLATE "C" NOT NULL,++ used bigint DEFAULT 0,++ created_at text COLLATE "C" DEFAULT ov_now(),++ code_challenge text COLLATE "C",++ code_challenge_method text COLLATE "C",++ nonce text COLLATE "C",++ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),++ FOREIGN KEY (user_id) REFERENCES users(id)++);++++CREATE TABLE oauth_tokens (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ token text COLLATE "C" UNIQUE NOT NULL,++ client_id text COLLATE "C" NOT NULL,++ user_id bigint NOT NULL,++ scope text COLLATE "C" DEFAULT 'profile theme',++ expires_at text COLLATE "C" NOT NULL,++ revoked bigint DEFAULT 0,++ created_at text COLLATE "C" DEFAULT ov_now(),++ family_id text COLLATE "C",++ generation bigint NOT NULL DEFAULT 0,++ revoked_reason text COLLATE "C",++ revoked_at text COLLATE "C",++ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),++ FOREIGN KEY (user_id) REFERENCES users(id)++);++++CREATE TABLE user_preferences (++ user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ theme_id text COLLATE "C" DEFAULT 'vibe',++ custom_theme_variables text COLLATE "C",++ language text COLLATE "C" DEFAULT 'en',++ notifications_enabled bigint DEFAULT 1,++ updated_at text COLLATE "C" DEFAULT ov_now(),++ display_prefs text COLLATE "C",++ FOREIGN KEY (user_id) REFERENCES users(id)++);++++CREATE TABLE themes (++ id text COLLATE "C" PRIMARY KEY,++ name text COLLATE "C" NOT NULL,++ slug text COLLATE "C" UNIQUE NOT NULL,++ author_id bigint,++ description text COLLATE "C" DEFAULT '',++ mode text COLLATE "C" DEFAULT 'dark' CHECK(mode IN ('dark','light')),++ variables text COLLATE "C" NOT NULL,++ preview_colors text COLLATE "C",++ is_builtin bigint DEFAULT 0,++ is_public bigint DEFAULT 1,++ downloads bigint DEFAULT 0,++ rating_sum bigint DEFAULT 0,++ rating_count bigint DEFAULT 0,++ tags text COLLATE "C" DEFAULT '[]',++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (author_id) REFERENCES users(id)++);++++CREATE TABLE linked_accounts (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ service_user_id text COLLATE "C" NOT NULL,++ linked_at text COLLATE "C" DEFAULT ov_now(),++ service_username text COLLATE "C",++ last_used_at text COLLATE "C",++ FOREIGN KEY (user_id) REFERENCES users(id),++ UNIQUE(service, service_user_id)++);++++CREATE TABLE audit_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint,++ action text COLLATE "C" NOT NULL,++ details text COLLATE "C",++ ip text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE ip_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint,++ ip text COLLATE "C" NOT NULL,++ action text COLLATE "C" DEFAULT 'login',++ country text COLLATE "C",++ region text COLLATE "C",++ city text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id)++);++++CREATE TABLE site_settings (++ key text COLLATE "C" PRIMARY KEY,++ value text COLLATE "C",++ type text COLLATE "C" DEFAULT 'string'++);++++CREATE TABLE url_registry (++ key text COLLATE "C" PRIMARY KEY,++ label text COLLATE "C" NOT NULL,++ category text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ scope text COLLATE "C" NOT NULL,++ type text COLLATE "C" NOT NULL,++ value text COLLATE "C",++ description text COLLATE "C",++ updated_by bigint,++ updated_at text COLLATE "C" DEFAULT ov_now(),++ source text COLLATE "C" NOT NULL DEFAULT 'admin'++);++++CREATE TABLE notifications (++ id text COLLATE "C" PRIMARY KEY,++ user_id bigint NOT NULL,++ type text COLLATE "C" NOT NULL,++ category text COLLATE "C" NOT NULL,++ priority text COLLATE "C" NOT NULL DEFAULT 'normal',++ title text COLLATE "C" NOT NULL,++ message text COLLATE "C",++ icon text COLLATE "C",++ sender_id bigint,++ sender_name text COLLATE "C",++ sender_avatar text COLLATE "C",++ service text COLLATE "C",++ url text COLLATE "C",++ rich_content text COLLATE "C",++ is_read bigint DEFAULT 0,++ is_dismissed bigint DEFAULT 0,++ is_emailed bigint DEFAULT 0,++ expires_at text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE notification_preferences (++ user_id bigint NOT NULL,++ category text COLLATE "C" NOT NULL,++ enabled bigint DEFAULT 1,++ sound bigint DEFAULT 1,++ toasts bigint DEFAULT 1,++ email bigint DEFAULT 0,++ PRIMARY KEY (user_id, category),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE email_delivery_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ email_type text COLLATE "C" NOT NULL,++ recipient text COLLATE "C" NOT NULL,++ subject text COLLATE "C",++ status text COLLATE "C" NOT NULL CHECK(status IN ('sent', 'failed')),++ error_message text COLLATE "C",++ user_id bigint,++ notification_id text COLLATE "C",++ metadata text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,++ FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL++);++++CREATE TABLE password_reset_tokens (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ token_hash text COLLATE "C" UNIQUE NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ used_at text COLLATE "C",++ requested_ip text COLLATE "C",++ requested_user_agent text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE anon_users (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ anon_number bigint UNIQUE NOT NULL,++ fingerprint text COLLATE "C",++ session_token text COLLATE "C" UNIQUE NOT NULL,++ display_name text COLLATE "C",++ preferences text COLLATE "C" DEFAULT '{}',++ total_messages bigint DEFAULT 0,++ total_commands bigint DEFAULT 0,++ first_seen text COLLATE "C" DEFAULT ov_now(),++ last_seen text COLLATE "C" DEFAULT ov_now(),++ ip text COLLATE "C",++ subject_id text COLLATE "C"++);++++CREATE TABLE user_sessions (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ session_token text COLLATE "C" UNIQUE NOT NULL,++ device_name text COLLATE "C",++ ip text COLLATE "C",++ user_agent text COLLATE "C",++ is_active bigint DEFAULT 1,++ last_used text COLLATE "C" DEFAULT ov_now(),++ created_at text COLLATE "C" DEFAULT ov_now(),++ expires_at text COLLATE "C" NOT NULL,++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE user_effects (++ user_id bigint NOT NULL,++ effect_type text COLLATE "C" NOT NULL,++ effect_id text COLLATE "C" NOT NULL,++ is_active bigint DEFAULT 0,++ acquired_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (user_id, effect_type, effect_id),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE follows (++ follower_id bigint NOT NULL,++ followed_id bigint NOT NULL,++ created_at text COLLATE "C" DEFAULT ov_now(),++ 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++);++++CREATE TABLE wallets (++ user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ balance bigint NOT NULL DEFAULT 0,++ updated_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE coin_transactions (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ app_id text COLLATE "C",++ delta bigint NOT NULL,++ reason text COLLATE "C",++ ref text COLLATE "C",++ idempotency_key text COLLATE "C" UNIQUE,++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE verification_keys (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ key text COLLATE "C" UNIQUE NOT NULL,++ target_username text COLLATE "C" NOT NULL,++ note text COLLATE "C" DEFAULT '',++ created_by bigint NOT NULL,++ used_by bigint,++ status text COLLATE "C" DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),++ created_at text COLLATE "C" DEFAULT ov_now(),++ used_at text COLLATE "C",++ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,++ FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL++);++++CREATE TABLE anon_ip_log (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ anon_id bigint NOT NULL,++ ip text COLLATE "C" NOT NULL,++ first_seen text COLLATE "C" DEFAULT ov_now(),++ last_seen text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,++ UNIQUE(anon_id, ip)++);++++CREATE TABLE push_subscriptions (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ endpoint text COLLATE "C" NOT NULL UNIQUE,++ keys_p256dh text COLLATE "C" NOT NULL,++ keys_auth text COLLATE "C" NOT NULL,++ user_agent text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE email_verification_tokens (++ token_hash text COLLATE "C" PRIMARY KEY,++ user_id bigint NOT NULL,++ email text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" DEFAULT ov_now(),++ expires_at text COLLATE "C" NOT NULL,++ used_at text COLLATE "C",++ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE++);++++CREATE TABLE identity_legacy_map (++ source_system text COLLATE "C" NOT NULL,++ source_type text COLLATE "C" NOT NULL,++ source_id text COLLATE "C" NOT NULL,++ subject_id text COLLATE "C" NOT NULL,++ first_seen_at text COLLATE "C" DEFAULT ov_now(),++ verified_at text COLLATE "C",++ metadata text COLLATE "C",++ PRIMARY KEY (source_system, source_type, source_id)++);++++CREATE TABLE principal_grants (++ client_id text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ audience text COLLATE "C" NOT NULL,++ namespaces text COLLATE "C" NOT NULL DEFAULT '[]',++ granted_at text COLLATE "C" DEFAULT ov_now(),++ granted_by text COLLATE "C",++ revoked_at text COLLATE "C",++ expires_at text COLLATE "C",++ reason text COLLATE "C",++ revoked_by text COLLATE "C",++ PRIMARY KEY (client_id, capability, audience)++);++++CREATE TABLE principal_usage (++ principal text COLLATE "C" NOT NULL,++ route text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ auth text COLLATE "C" NOT NULL,++ allowed bigint NOT NULL,++ code text COLLATE "C" NOT NULL DEFAULT '',++ count bigint NOT NULL DEFAULT 0,++ first_at text COLLATE "C" DEFAULT ov_now(),++ last_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (principal, route, auth, allowed, code)++);++++CREATE TABLE principal_grant_changes (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ client_id text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ audience text COLLATE "C" NOT NULL,++ change text COLLATE "C" NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),++ namespaces text COLLATE "C" NOT NULL DEFAULT '[]',++ expires_at text COLLATE "C",++ reason text COLLATE "C",++ actor text COLLATE "C",++ event_id text COLLATE "C",++ at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()++);++++CREATE TABLE user_modules (++ subject_id text COLLATE "C" NOT NULL,++ namespace text COLLATE "C" NOT NULL,++ version bigint NOT NULL,++ revision bigint NOT NULL DEFAULT 0,++ data text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" DEFAULT ov_now(),++ updated_by text COLLATE "C",++ PRIMARY KEY (subject_id, namespace)++);++++CREATE TABLE user_module_revisions (++ subject_id text COLLATE "C" NOT NULL,++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ PRIMARY KEY (subject_id, namespace)++);++++CREATE TABLE user_module_retirements (++ namespace text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ retired_seen_at bigint NOT NULL++);++++++CREATE TABLE dev_projects (++ id text COLLATE "C" PRIMARY KEY,++ owner_subject text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ environment_policy text COLLATE "C" NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),++ allowance text COLLATE "C" NOT NULL DEFAULT '[]',++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ archived_at text COLLATE "C",++ archived_by text COLLATE "C"++);++++CREATE TABLE dev_project_members (++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),++ subject_id text COLLATE "C" NOT NULL,++ role text COLLATE "C" NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),++ added_at text COLLATE "C" NOT NULL,++ added_by text COLLATE "C" NOT NULL,++ PRIMARY KEY (project_id, subject_id)++);++++CREATE TABLE dev_apps (++ id text COLLATE "C" PRIMARY KEY,++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),++ name text COLLATE "C" NOT NULL,++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),++ oauth_client_id text COLLATE "C" NOT NULL UNIQUE,++ client_type text COLLATE "C" NOT NULL CHECK (client_type IN ('confidential', 'public')),++ redirect_uris text COLLATE "C" NOT NULL DEFAULT '[]',++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C",++ revoked_by text COLLATE "C"++);++++CREATE TABLE dev_credentials (++ id text COLLATE "C" PRIMARY KEY,++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),++ secret_hash text COLLATE "C" NOT NULL UNIQUE,++ hint text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C",++ revoked_at text COLLATE "C",++ revoked_by text COLLATE "C",++ last_used_at text COLLATE "C"++);++++CREATE TABLE dev_grants (++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),++ capability text COLLATE "C" NOT NULL,++ audience text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),++ requested_by text COLLATE "C" NOT NULL,++ requested_at text COLLATE "C" NOT NULL,++ decided_by text COLLATE "C",++ decided_at text COLLATE "C",++ PRIMARY KEY (app_id, capability)++);++++CREATE TABLE dev_quotas (++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),++ capability text COLLATE "C" NOT NULL,++ limit_value bigint NOT NULL CHECK (limit_value >= 0),++ quota_window text COLLATE "C" NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),++ unit text COLLATE "C" NOT NULL DEFAULT 'requests',++ updated_at text COLLATE "C" NOT NULL,++ updated_by text COLLATE "C" NOT NULL,++ PRIMARY KEY (project_id, capability)++);++++CREATE TABLE dev_auth_codes (++ code_hash text COLLATE "C" PRIMARY KEY,++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),++ user_subject text COLLATE "C" NOT NULL,++ redirect_uri text COLLATE "C" NOT NULL,++ scope text COLLATE "C" NOT NULL DEFAULT '',++ code_challenge text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ used bigint NOT NULL DEFAULT 0++);++++CREATE TABLE dev_audit (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at text COLLATE "C" NOT NULL,++ project_id text COLLATE "C",++ actor text COLLATE "C" NOT NULL,++ action text COLLATE "C" NOT NULL,++ target text COLLATE "C",++ detail text COLLATE "C" NOT NULL DEFAULT '{}',++ request_id text COLLATE "C",++ event_type text COLLATE "C",++ event text COLLATE "C"++);++++CREATE TABLE dev_usage_windows (++ project_id text COLLATE "C" NOT NULL,++ env text COLLATE "C" NOT NULL CHECK (env IN ('sandbox', 'production')),++ service text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ dimension text COLLATE "C" NOT NULL DEFAULT '',++ unit text COLLATE "C" NOT NULL,++ window_start text COLLATE "C" NOT NULL,++ window_end text COLLATE "C" NOT NULL,++ quantity bigint NOT NULL,++ errors bigint NOT NULL,++ error_codes text COLLATE "C" NOT NULL DEFAULT '{}',++ revision bigint NOT NULL DEFAULT 1,++ event_id text COLLATE "C" NOT NULL,++ recorded_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (project_id, env, service, capability, dimension, unit, window_start)++);++++CREATE TABLE dev_usage_daily (++ project_id text COLLATE "C" NOT NULL,++ env text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ dimension text COLLATE "C" NOT NULL DEFAULT '',++ unit text COLLATE "C" NOT NULL,++ day text COLLATE "C" NOT NULL,++ quantity bigint NOT NULL DEFAULT 0,++ errors bigint NOT NULL DEFAULT 0,++ error_codes text COLLATE "C" NOT NULL DEFAULT '{}',++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (project_id, env, service, capability, dimension, unit, day)++);++++CREATE TABLE dev_usage_errors (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ project_id text COLLATE "C" NOT NULL,++ env text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ at text COLLATE "C" NOT NULL,++ code text COLLATE "C" NOT NULL,++ status bigint,++ trace_id text COLLATE "C",++ ref text COLLATE "C" NOT NULL DEFAULT '',++ event_id text COLLATE "C" NOT NULL++);++++CREATE TABLE user_blocks (++ blocker_subject text COLLATE "C" NOT NULL,++ blocked_subject text COLLATE "C" NOT NULL,++ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),++ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ PRIMARY KEY (blocker_subject, blocked_subject),++ CHECK (blocker_subject <> blocked_subject)++);++++CREATE TABLE user_follows (++ follower_subject text COLLATE "C" NOT NULL,++ target_type text COLLATE "C" NOT NULL CHECK (target_type IN ('channel')),++ target_id text COLLATE "C" NOT NULL,++ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),++ notify_email bigint NOT NULL DEFAULT 1 CHECK (notify_email IN (0, 1)),++ notify_push bigint NOT NULL DEFAULT 1 CHECK (notify_push IN (0, 1)),++ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),++ source text COLLATE "C" NOT NULL DEFAULT 'network',++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),++ PRIMARY KEY (follower_subject, target_type, target_id),++ CHECK (follower_subject <> target_id)++);++++CREATE TABLE follow_import_holds (++ source text COLLATE "C" NOT NULL,++ follower_ref text COLLATE "C" NOT NULL,++ target_ref text COLLATE "C" NOT NULL,++ reason text COLLATE "C" NOT NULL,++ seen_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (source, follower_ref, target_ref)++);++++CREATE TABLE status_incidents (++ id text COLLATE "C" PRIMARY KEY,++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('incident', 'maintenance')),++ title text COLLATE "C" NOT NULL,++ severity text COLLATE "C" CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),++ state text COLLATE "C" NOT NULL,++ services text COLLATE "C" NOT NULL DEFAULT '[]',++ starts_at text COLLATE "C" NOT NULL,++ ends_at text COLLATE "C",++ created_by text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL++);++++CREATE TABLE status_incident_updates (++ incident_id text COLLATE "C" NOT NULL REFERENCES status_incidents(id),++ at text COLLATE "C" NOT NULL,++ state text COLLATE "C" NOT NULL,++ message text COLLATE "C" NOT NULL,++ author text COLLATE "C" NOT NULL++);++++CREATE TABLE creator_streams (++ stream_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ creator_subject text COLLATE "C" NOT NULL,++ title text COLLATE "C",++ category text COLLATE "C",++ started_at text COLLATE "C" NOT NULL,++ ended_at text COLLATE "C" NOT NULL,++ duration_seconds bigint NOT NULL DEFAULT 0,++ peak_viewers bigint,++ avg_viewers double precision,++ unique_chatters bigint,++ messages bigint,++ watch_minutes bigint,++ received_at text COLLATE "C" NOT NULL++);++++CREATE TABLE platform_nodes (++ id text COLLATE "C" PRIMARY KEY,++ source text COLLATE "C" NOT NULL,++ doc text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL,++ reported_at text COLLATE "C" NOT NULL++);++++CREATE TABLE username_history (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ old_username text COLLATE "C" NOT NULL,++ new_username text COLLATE "C" NOT NULL,++ changed_by bigint,++ changed_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE user_profile_changes (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ changed text COLLATE "C" NOT NULL,++ at bigint NOT NULL++);++++CREATE TABLE subject_aliases (++ alias_id text COLLATE "C" PRIMARY KEY,++ subject_id text COLLATE "C" NOT NULL,++ merge_id text COLLATE "C" NOT NULL,++ merged_at text COLLATE "C" NOT NULL,++ merged_by text COLLATE "C" NOT NULL++);++++CREATE TABLE account_merges (++ id text COLLATE "C" PRIMARY KEY,++ from_subject text COLLATE "C" NOT NULL UNIQUE,++ into_subject text COLLATE "C" NOT NULL,++ from_user_id bigint NOT NULL,++ into_user_id bigint NOT NULL,++ initiated_by text COLLATE "C" NOT NULL CHECK (initiated_by IN ('person', 'staff')),++ actor_subject text COLLATE "C" NOT NULL,++ reason text COLLATE "C",++ moved text COLLATE "C" NOT NULL,++ pre_state text COLLATE "C",++ merged_at text COLLATE "C" NOT NULL,++ split_until text COLLATE "C" NOT NULL,++ reduced_at text COLLATE "C"++);++++CREATE TABLE account_merge_intents (++ id text COLLATE "C" PRIMARY KEY,++ into_user_id bigint NOT NULL,++ created_at bigint NOT NULL,++ expires_at bigint NOT NULL,++ used_at bigint++);++++CREATE TABLE account_exports (++ id text COLLATE "C" PRIMARY KEY,++ user_id bigint NOT NULL,++ subject text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),++ expected text COLLATE "C" NOT NULL DEFAULT '[]',++ requested_at text COLLATE "C" NOT NULL,++ deadline text COLLATE "C" NOT NULL,++ ready_at text COLLATE "C",++ expires_at text COLLATE "C",++ size_bytes bigint,++ services text COLLATE "C",++ error text COLLATE "C"++);++++CREATE TABLE account_export_parts (++ export_id text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ received_at text COLLATE "C" NOT NULL,++ files bigint NOT NULL,++ bytes bigint NOT NULL,++ body text COLLATE "C",++ PRIMARY KEY (export_id, service)++);++++CREATE TABLE account_deletions (++ id text COLLATE "C" PRIMARY KEY,++ user_id bigint NOT NULL,++ subject text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),++ requested_at text COLLATE "C" NOT NULL,++ delete_after text COLLATE "C" NOT NULL,++ cancelled_at text COLLATE "C",++ cancelled_by text COLLATE "C",++ deleted_at text COLLATE "C",++ expected text COLLATE "C",++ erased text COLLATE "C"++);++++CREATE TABLE account_deletion_confirmations (++ deletion_id text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ received_at text COLLATE "C" NOT NULL,++ completed_at text COLLATE "C" NOT NULL,++ erased text COLLATE "C" NOT NULL,++ retained text COLLATE "C" NOT NULL,++ PRIMARY KEY (deletion_id, service)++);++++CREATE TABLE mod_principals (++ mod_id text COLLATE "C" PRIMARY KEY,++ owner text COLLATE "C" NOT NULL,++ runtime text COLLATE "C" NOT NULL,++ name text COLLATE "C",++ version text COLLATE "C",++ publisher text COLLATE "C",++ manifest text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),++ revision bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C",++ updated_at text COLLATE "C" NOT NULL,++ revoked_at text COLLATE "C",++ revoked_by text COLLATE "C"++);++++CREATE TABLE mod_principal_grants (++ mod_id text COLLATE "C" NOT NULL,++ capability text COLLATE "C" NOT NULL,++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),++ changed_at text COLLATE "C" NOT NULL,++ changed_by text COLLATE "C",++ PRIMARY KEY (mod_id, capability)++);++++CREATE TABLE stream_live_announcements (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ streamer_key text COLLATE "C" NOT NULL,++ stream_id text COLLATE "C",++ sent_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE operator_alerts (++ source text COLLATE "C" NOT NULL,++ fingerprint text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ severity text COLLATE "C" NOT NULL,++ summary text COLLATE "C" NOT NULL,++ description text COLLATE "C",++ service text COLLATE "C",++ state text COLLATE "C" NOT NULL CHECK (state IN ('firing', 'resolved')),++ started_at text COLLATE "C" NOT NULL,++ opened_at bigint NOT NULL,++ last_seen_at bigint NOT NULL,++ notified_at bigint,++ resolved_at bigint,++ notices text COLLATE "C",++ reopened bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (source, fingerprint)++);++++CREATE TABLE tool_domains (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ tool_id text COLLATE "C" NOT NULL,++ host text COLLATE "C" NOT NULL UNIQUE,++ role text COLLATE "C" NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),++ enabled bigint NOT NULL DEFAULT 1,++ note text COLLATE "C",++ created_by bigint,++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE user_history (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ user_id bigint NOT NULL,++ service text COLLATE "C",++ sub text COLLATE "C",++ type text COLLATE "C" NOT NULL DEFAULT 'page',++ title text COLLATE "C" NOT NULL,++ url text COLLATE "C" NOT NULL,++ icon text COLLATE "C",++ meta text COLLATE "C",++ hits bigint NOT NULL DEFAULT 1,++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now(),++ FOREIGN KEY (user_id) REFERENCES users(id)++);++++CREATE TABLE frame_cache (++ key text COLLATE "C" PRIMARY KEY,++ value text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" DEFAULT ov_now()++);++++CREATE TABLE frame_hits (++ day text COLLATE "C" NOT NULL,++ host text COLLATE "C" NOT NULL,++ hits bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (day, host)++);++++CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username));++CREATE INDEX idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);++CREATE INDEX idx_notif_user_cat ON notifications(user_id, category);++CREATE INDEX idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;++CREATE INDEX idx_email_delivery_created ON email_delivery_log(created_at DESC);++CREATE INDEX idx_email_delivery_status ON email_delivery_log(status, created_at DESC);++CREATE INDEX idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);++CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);++CREATE INDEX idx_password_reset_expires ON password_reset_tokens(expires_at);++CREATE INDEX idx_sessions_user ON user_sessions(user_id, is_active);++CREATE INDEX idx_sessions_token ON user_sessions(session_token);++CREATE INDEX idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);++CREATE INDEX idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);++CREATE INDEX idx_vkeys_key ON verification_keys(key);++CREATE INDEX idx_vkeys_target ON verification_keys(target_username);++CREATE INDEX idx_vkeys_status ON verification_keys(status);++CREATE INDEX idx_anon_ip_log_ip ON anon_ip_log(ip);++CREATE INDEX idx_anon_ip_log_anon ON anon_ip_log(anon_id);++CREATE INDEX idx_push_sub_user ON push_subscriptions(user_id);++CREATE INDEX idx_push_sub_endpoint ON push_subscriptions(endpoint);++CREATE INDEX idx_oauth_tokens_family ON oauth_tokens(family_id);++CREATE INDEX idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);++CREATE UNIQUE INDEX idx_linked_user_service ON linked_accounts(user_id, service);++CREATE INDEX idx_identity_legacy_subject ON identity_legacy_map(subject_id);++CREATE UNIQUE INDEX idx_users_subject_id ON users(subject_id);++CREATE UNIQUE INDEX idx_anon_users_subject_id ON anon_users(subject_id);++CREATE INDEX idx_grant_changes_client ON principal_grant_changes(client_id, id);++CREATE INDEX idx_dev_projects_owner ON dev_projects(owner_subject);++CREATE INDEX idx_dev_members_subject ON dev_project_members(subject_id);++CREATE INDEX idx_dev_apps_project ON dev_apps(project_id);++CREATE INDEX idx_dev_credentials_app ON dev_credentials(app_id);++CREATE INDEX idx_dev_audit_project ON dev_audit(project_id, id);++CREATE INDEX idx_dev_usage_windows_when ON dev_usage_windows(window_start);++CREATE INDEX idx_dev_usage_windows_project ON dev_usage_windows(project_id, recorded_at);++CREATE INDEX idx_dev_usage_daily_day ON dev_usage_daily(project_id, day);++CREATE UNIQUE INDEX idx_dev_usage_errors_once ON dev_usage_errors(project_id, env, service, capability, at, code, ref);++CREATE INDEX idx_dev_usage_errors_recent ON dev_usage_errors(project_id, at);++CREATE INDEX idx_user_blocks_blocked ON user_blocks(blocked_subject, active);++CREATE INDEX idx_user_follows_target ON user_follows(target_type, target_id, active, created_at);++CREATE INDEX idx_status_incident_updates ON status_incident_updates(incident_id, at);++CREATE INDEX idx_creator_streams_creator ON creator_streams(creator_subject, started_at);++CREATE INDEX idx_username_history_old ON username_history(lower(old_username));++CREATE INDEX idx_username_history_user ON username_history(user_id);++CREATE INDEX idx_user_profile_changes_user ON user_profile_changes(user_id);++CREATE INDEX idx_subject_aliases_subject ON subject_aliases(subject_id);++CREATE INDEX idx_account_exports_user ON account_exports(user_id, requested_at);++CREATE UNIQUE INDEX idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';++CREATE INDEX idx_sla_key ON stream_live_announcements(streamer_key, sent_at DESC);++CREATE INDEX idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);++CREATE INDEX idx_user_history_user_time ON user_history(user_id, updated_at DESC);++CREATE INDEX idx_user_history_user_url ON user_history(user_id, url);++++CREATE FUNCTION user_modules_guard_users_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'; END IF; RETURN OLD; END $$;++CREATE TRIGGER user_modules_guard_users_delete BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_users_delete();++++CREATE FUNCTION user_modules_guard_users_rekey() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND NEW.subject_id IS DISTINCT FROM OLD.subject_id AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectMerged first'; END IF; RETURN NEW; END $$;++CREATE TRIGGER user_modules_guard_users_rekey BEFORE UPDATE OF subject_id ON users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_users_rekey();++++CREATE FUNCTION user_modules_guard_anon_users_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'; END IF; RETURN OLD; END $$;++CREATE TRIGGER user_modules_guard_anon_users_delete BEFORE DELETE ON anon_users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_anon_users_delete();++++CREATE FUNCTION user_modules_guard_anon_users_rekey() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND NEW.subject_id IS DISTINCT FROM OLD.subject_id AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectMerged first'; END IF; RETURN NEW; END $$;++CREATE TRIGGER user_modules_guard_anon_users_rekey BEFORE UPDATE OF subject_id ON anon_users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_anon_users_rekey();++++CREATE FUNCTION dev_audit_append_only_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'dev_audit is append-only'; RETURN NEW; END $$;++CREATE TRIGGER dev_audit_append_only_update BEFORE UPDATE ON dev_audit FOR EACH ROW EXECUTE FUNCTION dev_audit_append_only_update();++++CREATE FUNCTION dev_audit_append_only_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'dev_audit is append-only'; RETURN OLD; END $$;++CREATE TRIGGER dev_audit_append_only_delete BEFORE DELETE ON dev_audit FOR EACH ROW EXECUTE FUNCTION dev_audit_append_only_delete();++++CREATE FUNCTION users_profile_changed() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF COALESCE(NEW.is_anon, 0) = 0 AND (OLD.username IS DISTINCT FROM NEW.username OR OLD.display_name IS DISTINCT FROM NEW.display_name OR OLD.avatar_url IS DISTINCT FROM NEW.avatar_url OR OLD.profile_color IS DISTINCT FROM NEW.profile_color OR OLD.role IS DISTINCT FROM NEW.role OR OLD.is_banned IS DISTINCT FROM NEW.is_banned) THEN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, trim((CASE WHEN OLD.username IS DISTINCT FROM NEW.username THEN 'username ' ELSE '' END) || (CASE WHEN OLD.display_name IS DISTINCT FROM NEW.display_name THEN 'display_name ' ELSE '' END) || (CASE WHEN OLD.avatar_url IS DISTINCT FROM NEW.avatar_url THEN 'avatar_url ' ELSE '' END) || (CASE WHEN OLD.profile_color IS DISTINCT FROM NEW.profile_color THEN 'profile_color ' ELSE '' END) || (CASE WHEN OLD.role IS DISTINCT FROM NEW.role THEN 'role ' ELSE '' END) || (CASE WHEN OLD.is_banned IS DISTINCT FROM NEW.is_banned THEN 'banned ' ELSE '' END)), (extract(epoch FROM statement_timestamp()) * 1000)::bigint); END IF; RETURN NULL; END $$;++CREATE TRIGGER users_profile_changed AFTER UPDATE OF username, display_name, avatar_url, profile_color, role, is_banned ON users FOR EACH ROW EXECUTE FUNCTION users_profile_changed();++++CREATE FUNCTION users_profile_created() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF COALESCE(NEW.is_anon, 0) = 0 THEN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, 'created', (extract(epoch FROM statement_timestamp()) * 1000)::bigint); END IF; RETURN NULL; END $$;++CREATE TRIGGER users_profile_created AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION users_profile_created();++++-- The rows the SQLite boot seeded (built-in themes, default settings, the first-party OAuth clients with their++-- per-install generated secrets, the platform's default principal grants) are not frozen here: server/db/database.js++-- seeds them idempotently at boot, exactly as it did on SQLite, so each install generates its own client secrets.++++-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled++CREATE TABLE IF NOT EXISTS network_event_inbox (++ consumer text NOT NULL,++ event_id text NOT NULL,++ processed_at bigint NOT NULL,++ PRIMARY KEY (consumer, event_id)++);++++-- openvibe-sdk/events PostgreSQL outbox++CREATE TABLE IF NOT EXISTS network_event_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);++CREATE INDEX IF NOT EXISTS network_event_outbox_due ON network_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;++CREATE INDEX IF NOT EXISTS network_event_outbox_sent ON network_event_outbox (sent_at) WHERE sent_at IS NOT NULL;nnew file mode 100644@@@ -0,0 +1,56 @@++-- phase: expand++-- The five request-analytics tables (openvibe-shared/analytics/pg.js analyticsSchema()), so the runtime role creates none.++-- Behavioural deltas from the SQLite tracker are in that module's header; the privacy bounds (DNT/GPC, path templating) are unchanged.++CREATE TABLE IF NOT EXISTS analytics_events (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ service text NOT NULL,++ event_type text NOT NULL DEFAULT 'pageview',++ path text,++ method text DEFAULT 'GET',++ status_code integer,++ response_time_ms integer,++ user_id bigint,++ session_id text,++ ip text,++ country text,++ city text,++ user_agent text,++ referer text,++ is_bot integer DEFAULT 0,++ bot_type text,++ device_type text,++ browser text,++ os text,++ authenticated integer NOT NULL DEFAULT 0,++ created_at text NOT NULL++);++CREATE TABLE IF NOT EXISTS analytics_hourly (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ service text NOT NULL, hour text NOT NULL,++ pageviews bigint DEFAULT 0, api_calls bigint DEFAULT 0, unique_visitors bigint DEFAULT 0, unique_users bigint DEFAULT 0,++ bot_hits bigint DEFAULT 0, avg_response_ms integer DEFAULT 0, error_count bigint DEFAULT 0, bandwidth_bytes bigint DEFAULT 0,++ top_paths text, top_referers text,++ UNIQUE (service, hour)++);++CREATE TABLE IF NOT EXISTS analytics_daily (++ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,++ service text NOT NULL, date text NOT NULL,++ pageviews bigint DEFAULT 0, api_calls bigint DEFAULT 0, unique_visitors bigint DEFAULT 0, unique_users bigint DEFAULT 0,++ new_users bigint DEFAULT 0, bot_hits bigint DEFAULT 0, avg_response_ms integer DEFAULT 0, error_count bigint DEFAULT 0,++ top_paths text, top_referers text, top_countries text, device_breakdown text, browser_breakdown text,++ UNIQUE (service, date)++);++CREATE TABLE IF NOT EXISTS analytics_visitor_days (++ service text NOT NULL, day text NOT NULL, hour text NOT NULL, vhash text NOT NULL,++ authenticated integer NOT NULL DEFAULT 0,++ PRIMARY KEY (service, hour, vhash)++);++CREATE TABLE IF NOT EXISTS analytics_day_salts (++ service text NOT NULL, day text NOT NULL, salt text NOT NULL,++ PRIMARY KEY (service, day)++);++CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events (created_at);++CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events (service, created_at);++CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events (service, path, created_at);++CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events (is_bot, created_at);++CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_days (service, day);@@@ -18,13 +18,18 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",++ "pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"}},"node_modules/@discordjs/builders": {@@@ -157,6 +162,19 @@"url": "https://github.com/discordjs/discord.js?sponsor"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },"node_modules/@sapphire/async-queue": {"version": "1.5.5","resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",@@@ -489,6 +507,15 @@"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==","license": "ISC"},++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/concat-stream": {"version": "1.6.2","resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",@@@ -609,6 +636,15 @@"node": ">=4.0.0"}},++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },"node_modules/depd": {"version": "2.0.0","resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",@@@ -1126,6 +1162,49 @@"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==","license": "ISC"},++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },"node_modules/ipaddr.js": {"version": "1.9.1","resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",@@@ -1202,12 +1281,24 @@"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==","license": "MIT"},++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },"node_modules/lodash.includes": {"version": "4.3.0","resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz","integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==","license": "MIT"},++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },"node_modules/lodash.isboolean": {"version": "3.0.3","resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",@@@ -1481,12 +1572,32 @@}},"node_modules/openvibe-sdk": {-- "version": "0.12.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",-- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",++ "version": "0.25.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",++ "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==","license": "MIT","engines": {"node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }}},"node_modules/openvibe-shared": {@@@ -1513,6 +1624,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.0",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.0",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.0",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.0"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.0",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.0",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.0",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/prebuild-install": {"version": "7.1.3","resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",@@@ -1645,6 +1884,27 @@"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==","license": "MIT"},++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },"node_modules/require-from-string": {"version": "2.0.2","resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",@@@ -1860,6 +2120,21 @@"simple-concat": "^1.0.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -8,7 +8,10 @@"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","reset-db": "node server/reset-db.js",-- "grant-admin": "node server/grant-admin.js"++ "grant-admin": "node server/grant-admin.js",++ "test:pg": "NETWORK_TEST_STORE=pg node test/run.js",++ "migrate-to-postgres": "node scripts/migrate-to-postgres.js",++ "pg-preflight": "node scripts/pg-preflight-collisions.js"},"license": "MIT","dependencies": {@@@ -21,12 +24,17 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",++ "iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",++ "pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"}}nnew file mode 100644@@@ -0,0 +1,42 @@++#!/usr/bin/env node++'use strict';++/**++ * The one-time move of Network's SQLite database (DB_PATH) into its PostgreSQL schema (plan T2, ADR-035; the++ * procedure is openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by runSqliteMigration).++ * Opus runs it at the cutover, after scripts/pg-preflight-collisions.js.++ *++ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]++ *++ * --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.++ * --json the report as JSON.++ *++ * Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes++ * through PgBouncer in transaction mode, which cannot hold the import's transaction) and copies into emptied++ * tables, so a rehearsal against a scratch DATABASE_URL is repeatable. Exit 0 only if every table verified.++ *++ * The legacy integer-keyed `follows` table is imported like every other: server/auth/routes.js and++ * server/internal/routes.js still read it (the subject-keyed user_follows is the forward model).++ */++require('dotenv').config();++const path = require('path');++const { runSqliteMigration } = require('openvibe-sdk/db');++const config = require('../server/config');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++++// importSqlite's per-table options. Empty: every PostgreSQL column is fed by the same-named SQLite one.++const TABLES = {};++++// SQLite tables the import leaves behind. Empty: even the SDK's SQLite outbox/inbox rows are carried over++// (unsent events keep their place in the PostgreSQL outbox).++const SKIP_SOURCE = [];++++if (require.main === module) {++ runSqliteMigration({ service: 'network', sqlite: config.db.path, 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 };nnew file mode 100644@@@ -0,0 +1,66 @@++#!/usr/bin/env node++'use strict';++/**++ * Before the PostgreSQL cutover (plan T2, ADR-035): SQLite's `COLLATE NOCASE` made some columns compare++ * case-insensitively, and username uniqueness inherited it, so `Alex` and `alex` cannot both exist here.++ * PostgreSQL compares text case-sensitively; the port keeps the behaviour with a unique index on++ * lower(username) and every lookup rewritten to lower(). That only works if the existing data has no two++ * rows that fold together — this lists them, so they are fixed on the SQLite side before the import.++ *++ * node scripts/pg-preflight-collisions.js [--sqlite path/to/network.db] [--json]++ *++ * Exit 0 = no collisions; 1 = at least one (the cutover must wait); 2 = unusable input. Read-only: the++ * SQLite file is opened in read-only mode and never written. Reports, for each case-insensitive column that++ * must stay unique, the folding keys with more than one distinct spelling.++ */++require('dotenv').config();++const fs = require('fs');++const config = require('../server/config');++++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);++const asJson = args.includes('--json');++++/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */++const CHECKS = [++ ['users', 'username', '1 = 1', 'users.username is UNIQUE: two spellings folding together would make the column ambiguous'],++ ['verification_keys', 'target_username', "status = 'active'", 'an active reserved-username claim would match two people'],++ ['username_history', 'old_username', '1 = 1', 'a former name would resolve to two accounts'],++];++++function main() {++ if (!fs.existsSync(file)) { console.error(`no such SQLite database: ${file}`); return 2; }++ let Database;++ try { Database = require('better-sqlite3'); } catch { console.error('better-sqlite3 is not installed; run npm install'); return 2; }++ let db;++ try { db = new Database(file, { readonly: true, fileMustExist: true }); } catch (e) { console.error(`cannot open ${file}: ${e.message}`); return 2; }++++ const report = { sqlite: file, checked: [], collisions: [] };++ for (const [table, column, where, why] of CHECKS) {++ const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);++ if (!exists) { report.checked.push({ table, column, rows: 0, note: 'table absent' }); continue; }++ const rows = db.prepare(`SELECT lower(${column}) AS folded, COUNT(DISTINCT ${column}) AS spellings,++ group_concat(DISTINCT ${column}) AS names, COUNT(*) AS rows++ FROM ${table} WHERE ${where} GROUP BY folded HAVING COUNT(DISTINCT ${column}) > 1++ ORDER BY folded`).all();++ report.checked.push({ table, column, rows: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get().n });++ for (const r of rows) report.collisions.push({ table, column, folded: r.folded, spellings: r.spellings, names: r.names, rows: r.rows, why });++ }++ db.close();++++ if (asJson) { console.log(JSON.stringify(report, null, 2)); return report.collisions.length ? 1 : 0; }++++ console.log(`pg-preflight-collisions: ${file}`);++ for (const c of report.checked) console.log(` ${c.table}.${c.column}: ${c.rows} row(s)${c.note ? ` (${c.note})` : ''}`);++ if (!report.collisions.length) { console.log('no case-fold collisions: safe to import'); return 0; }++ console.log(`\n${report.collisions.length} case-fold collision(s) — resolve these on the SQLite side first:`);++ for (const c of report.collisions) {++ console.log(` ${c.table}.${c.column}: ${c.names} (fold "${c.folded}", ${c.rows} rows)`);++ console.log(` ${c.why}`);++ }++ return 1;++}++++if (require.main === module) process.exit(main());++module.exports = { CHECKS };@@@ -53,9 +53,24 @@ module.exports = {// scripts/subscribe-events.js.eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',-- // Database++ // 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.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',++ },++++ // 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 || '',},// Admin auto-creationnnew file mode 100644@@@ -0,0 +1,21 @@++// Loaded with `node --import` into every test process (test/run.js): one migrated database for the process++// (plan T2, ADR-035) — PGlite by default, or, with NETWORK_TEST_STORE=pg (npm run test:pg), the PostgreSQL +++// PgBouncer containers with roles and a schema of its own. server/db/database.js initDb() adopts it, so test++// files open no database themselves. The process ends with the test (a container schema is dropped by++// `scripts/test-services.sh down`).++import { createRequire } from 'node:module';++import path from 'node:path';++import { fileURLToPath } from 'node:url';++++const require = createRequire(import.meta.url);++const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');++const { createTestDb } = require('openvibe-sdk/testing');++const t = await createTestDb({ migrations: path.join(root, 'migrations'), store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', max: 4 });++// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids++// are set to start at 100000 here, clear of every id a test names.++for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns++ WHERE table_schema = current_schema() AND is_identity = 'YES'`).all()) {++ if (r.seq) await t.db.prepare('SELECT setval(?::regclass, 100000)').get(r.seq);++}++globalThis.__ovNetworkTestDb = t.db;++globalThis.__ovNetworkTestDbClose = t.close;nnew file mode 100644@@@ -0,0 +1,18 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — one at a time, each in its own process, and fails if++ * any of them fails (openvibe-shared/test-runner).++ *++ * npm test # everything++ * npm test -- usernames follows # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ *++ * Each test gets a migrated PostgreSQL database of its own (PGlite; the containers under npm run test:pg) and++ * local servers on random ports. A test that cannot run something prints `<label>: skipped (<why>)` and is++ * listed with ○, not counted as passed.++ */++'use strict';++const { pathToFileURL } = require('url');++// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).++const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: 1, hide: /^\[DB\] /, nodeArgs: ['--import', preload] });
new file mode 100644
new file mode 100644
@@ -0,0 +1,952 @@
@@ -0,0 +1,952 @@
+-- phase: expand
+-- OpenVibe.Network on PostgreSQL (ADR-035, roadmap WS-X2): the tables as they were on SQLite (converted by openvibe-sdk
+-- tools/asyncify/sqlite-schema-to-pg: text COLLATE "C" compares like SQLite, integers are bigint, identities keep their ids),
+-- then the openvibe-publishing stores and the openvibe-sdk inbox and outbox. Generated once on 2026-09-29; never edited after it runs.
+-- SQLite's text timestamps and date functions (openvibe-sdk tools/asyncify SQLITE_DATE_FUNCTIONS).
+CREATE FUNCTION ov_ts(t text) RETURNS timestamp LANGUAGE plpgsql STABLE AS $$
+BEGIN
+ IF t IS NULL THEN RETURN NULL; END IF;
+ IF t = 'now' THEN RETURN statement_timestamp() AT TIME ZONE 'UTC'; END IF;
+ IF t ~ '\d\d:\d\d(:\d\d(\.\d+)?)?\s*(Z|[+-]\d\d(:?\d\d)?)$' THEN RETURN t::timestamptz AT TIME ZONE 'UTC'; END IF;
+ RETURN t::timestamp;
+EXCEPTION WHEN others THEN RETURN NULL;
+END $$;
+CREATE FUNCTION ov_now() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') $$;
+CREATE FUNCTION ov_now_iso() RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char(statement_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
+CREATE FUNCTION ov_now_iso(modifier text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT to_char((statement_timestamp() AT TIME ZONE 'UTC') + modifier::interval, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') $$;
+CREATE FUNCTION datetime(t text, modifier text DEFAULT NULL) RETURNS text LANGUAGE plpgsql STABLE AS $$
+DECLARE ts timestamp := ov_ts(t);
+BEGIN
+ IF ts IS NULL THEN RETURN NULL; END IF;
+ IF modifier IS NOT NULL THEN ts := ts + modifier::interval; END IF;
+ RETURN to_char(ts, 'YYYY-MM-DD HH24:MI:SS');
+EXCEPTION WHEN others THEN RETURN NULL;
+END $$;
+CREATE FUNCTION julianday(t text) RETURNS double precision LANGUAGE sql STABLE AS $$ SELECT extract(epoch FROM ov_ts(t))::double precision / 86400.0 + 2440587.5 $$;
+
+CREATE TABLE users (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ username text COLLATE "C" NOT NULL,
+ email text COLLATE "C" UNIQUE,
+ password_hash text COLLATE "C" NOT NULL,
+ display_name text COLLATE "C",
+ avatar_url text COLLATE "C",
+ bio text COLLATE "C" DEFAULT '',
+ role text COLLATE "C" DEFAULT 'user' CHECK(role IN ('user','streamer','global_mod','admin')),
+ profile_color text COLLATE "C" DEFAULT '#8b5cf6',
+ is_banned bigint DEFAULT 0,
+ ban_reason text COLLATE "C",
+ token_valid_after text COLLATE "C" DEFAULT NULL,
+ legacy_source text COLLATE "C",
+ legacy_id bigint,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ last_seen text COLLATE "C" DEFAULT ov_now(),
+ is_anon bigint DEFAULT 0,
+ anon_number bigint,
+ name_effect text COLLATE "C",
+ particle_effect text COLLATE "C",
+ email_verified bigint DEFAULT 0,
+ email_verified_at text COLLATE "C",
+ email_bounced_at text COLLATE "C",
+ email_bounce_reason text COLLATE "C",
+ history_paused bigint DEFAULT 0,
+ subject_id text COLLATE "C",
+ profile_revision bigint NOT NULL DEFAULT 0,
+ merged_into bigint,
+ deleted_at text COLLATE "C"
+);
+
+CREATE TABLE oauth_clients (
+ client_id text COLLATE "C" PRIMARY KEY,
+ client_secret text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ redirect_uris text COLLATE "C" NOT NULL,
+ is_first_party bigint DEFAULT 0,
+ created_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE oauth_codes (
+ code text COLLATE "C" PRIMARY KEY,
+ client_id text COLLATE "C" NOT NULL,
+ user_id bigint NOT NULL,
+ redirect_uri text COLLATE "C" NOT NULL,
+ scope text COLLATE "C" DEFAULT 'profile theme',
+ expires_at text COLLATE "C" NOT NULL,
+ used bigint DEFAULT 0,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ code_challenge text COLLATE "C",
+ code_challenge_method text COLLATE "C",
+ nonce text COLLATE "C",
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+CREATE TABLE oauth_tokens (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ token text COLLATE "C" UNIQUE NOT NULL,
+ client_id text COLLATE "C" NOT NULL,
+ user_id bigint NOT NULL,
+ scope text COLLATE "C" DEFAULT 'profile theme',
+ expires_at text COLLATE "C" NOT NULL,
+ revoked bigint DEFAULT 0,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ family_id text COLLATE "C",
+ generation bigint NOT NULL DEFAULT 0,
+ revoked_reason text COLLATE "C",
+ revoked_at text COLLATE "C",
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+CREATE TABLE user_preferences (
+ user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ theme_id text COLLATE "C" DEFAULT 'vibe',
+ custom_theme_variables text COLLATE "C",
+ language text COLLATE "C" DEFAULT 'en',
+ notifications_enabled bigint DEFAULT 1,
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ display_prefs text COLLATE "C",
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+CREATE TABLE themes (
+ id text COLLATE "C" PRIMARY KEY,
+ name text COLLATE "C" NOT NULL,
+ slug text COLLATE "C" UNIQUE NOT NULL,
+ author_id bigint,
+ description text COLLATE "C" DEFAULT '',
+ mode text COLLATE "C" DEFAULT 'dark' CHECK(mode IN ('dark','light')),
+ variables text COLLATE "C" NOT NULL,
+ preview_colors text COLLATE "C",
+ is_builtin bigint DEFAULT 0,
+ is_public bigint DEFAULT 1,
+ downloads bigint DEFAULT 0,
+ rating_sum bigint DEFAULT 0,
+ rating_count bigint DEFAULT 0,
+ tags text COLLATE "C" DEFAULT '[]',
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (author_id) REFERENCES users(id)
+);
+
+CREATE TABLE linked_accounts (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ service_user_id text COLLATE "C" NOT NULL,
+ linked_at text COLLATE "C" DEFAULT ov_now(),
+ service_username text COLLATE "C",
+ last_used_at text COLLATE "C",
+ FOREIGN KEY (user_id) REFERENCES users(id),
+ UNIQUE(service, service_user_id)
+);
+
+CREATE TABLE audit_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint,
+ action text COLLATE "C" NOT NULL,
+ details text COLLATE "C",
+ ip text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE ip_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint,
+ ip text COLLATE "C" NOT NULL,
+ action text COLLATE "C" DEFAULT 'login',
+ country text COLLATE "C",
+ region text COLLATE "C",
+ city text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+CREATE TABLE site_settings (
+ key text COLLATE "C" PRIMARY KEY,
+ value text COLLATE "C",
+ type text COLLATE "C" DEFAULT 'string'
+);
+
+CREATE TABLE url_registry (
+ key text COLLATE "C" PRIMARY KEY,
+ label text COLLATE "C" NOT NULL,
+ category text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ scope text COLLATE "C" NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ value text COLLATE "C",
+ description text COLLATE "C",
+ updated_by bigint,
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ source text COLLATE "C" NOT NULL DEFAULT 'admin'
+);
+
+CREATE TABLE notifications (
+ id text COLLATE "C" PRIMARY KEY,
+ user_id bigint NOT NULL,
+ type text COLLATE "C" NOT NULL,
+ category text COLLATE "C" NOT NULL,
+ priority text COLLATE "C" NOT NULL DEFAULT 'normal',
+ title text COLLATE "C" NOT NULL,
+ message text COLLATE "C",
+ icon text COLLATE "C",
+ sender_id bigint,
+ sender_name text COLLATE "C",
+ sender_avatar text COLLATE "C",
+ service text COLLATE "C",
+ url text COLLATE "C",
+ rich_content text COLLATE "C",
+ is_read bigint DEFAULT 0,
+ is_dismissed bigint DEFAULT 0,
+ is_emailed bigint DEFAULT 0,
+ expires_at text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE notification_preferences (
+ user_id bigint NOT NULL,
+ category text COLLATE "C" NOT NULL,
+ enabled bigint DEFAULT 1,
+ sound bigint DEFAULT 1,
+ toasts bigint DEFAULT 1,
+ email bigint DEFAULT 0,
+ PRIMARY KEY (user_id, category),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE email_delivery_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email_type text COLLATE "C" NOT NULL,
+ recipient text COLLATE "C" NOT NULL,
+ subject text COLLATE "C",
+ status text COLLATE "C" NOT NULL CHECK(status IN ('sent', 'failed')),
+ error_message text COLLATE "C",
+ user_id bigint,
+ notification_id text COLLATE "C",
+ metadata text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
+ FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE SET NULL
+);
+
+CREATE TABLE password_reset_tokens (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ token_hash text COLLATE "C" UNIQUE NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ used_at text COLLATE "C",
+ requested_ip text COLLATE "C",
+ requested_user_agent text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE anon_users (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ anon_number bigint UNIQUE NOT NULL,
+ fingerprint text COLLATE "C",
+ session_token text COLLATE "C" UNIQUE NOT NULL,
+ display_name text COLLATE "C",
+ preferences text COLLATE "C" DEFAULT '{}',
+ total_messages bigint DEFAULT 0,
+ total_commands bigint DEFAULT 0,
+ first_seen text COLLATE "C" DEFAULT ov_now(),
+ last_seen text COLLATE "C" DEFAULT ov_now(),
+ ip text COLLATE "C",
+ subject_id text COLLATE "C"
+);
+
+CREATE TABLE user_sessions (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ session_token text COLLATE "C" UNIQUE NOT NULL,
+ device_name text COLLATE "C",
+ ip text COLLATE "C",
+ user_agent text COLLATE "C",
+ is_active bigint DEFAULT 1,
+ last_used text COLLATE "C" DEFAULT ov_now(),
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ expires_at text COLLATE "C" NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE user_effects (
+ user_id bigint NOT NULL,
+ effect_type text COLLATE "C" NOT NULL,
+ effect_id text COLLATE "C" NOT NULL,
+ is_active bigint DEFAULT 0,
+ acquired_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (user_id, effect_type, effect_id),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE follows (
+ follower_id bigint NOT NULL,
+ followed_id bigint NOT NULL,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ 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
+);
+
+CREATE TABLE wallets (
+ user_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ balance bigint NOT NULL DEFAULT 0,
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE coin_transactions (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ app_id text COLLATE "C",
+ delta bigint NOT NULL,
+ reason text COLLATE "C",
+ ref text COLLATE "C",
+ idempotency_key text COLLATE "C" UNIQUE,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE verification_keys (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ key text COLLATE "C" UNIQUE NOT NULL,
+ target_username text COLLATE "C" NOT NULL,
+ note text COLLATE "C" DEFAULT '',
+ created_by bigint NOT NULL,
+ used_by bigint,
+ status text COLLATE "C" DEFAULT 'active' CHECK(status IN ('active', 'used', 'revoked')),
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ used_at text COLLATE "C",
+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE TABLE anon_ip_log (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ anon_id bigint NOT NULL,
+ ip text COLLATE "C" NOT NULL,
+ first_seen text COLLATE "C" DEFAULT ov_now(),
+ last_seen text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (anon_id) REFERENCES anon_users(id) ON DELETE CASCADE,
+ UNIQUE(anon_id, ip)
+);
+
+CREATE TABLE push_subscriptions (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ endpoint text COLLATE "C" NOT NULL UNIQUE,
+ keys_p256dh text COLLATE "C" NOT NULL,
+ keys_auth text COLLATE "C" NOT NULL,
+ user_agent text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE email_verification_tokens (
+ token_hash text COLLATE "C" PRIMARY KEY,
+ user_id bigint NOT NULL,
+ email text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ expires_at text COLLATE "C" NOT NULL,
+ used_at text COLLATE "C",
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE TABLE identity_legacy_map (
+ source_system text COLLATE "C" NOT NULL,
+ source_type text COLLATE "C" NOT NULL,
+ source_id text COLLATE "C" NOT NULL,
+ subject_id text COLLATE "C" NOT NULL,
+ first_seen_at text COLLATE "C" DEFAULT ov_now(),
+ verified_at text COLLATE "C",
+ metadata text COLLATE "C",
+ PRIMARY KEY (source_system, source_type, source_id)
+);
+
+CREATE TABLE principal_grants (
+ client_id text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ audience text COLLATE "C" NOT NULL,
+ namespaces text COLLATE "C" NOT NULL DEFAULT '[]',
+ granted_at text COLLATE "C" DEFAULT ov_now(),
+ granted_by text COLLATE "C",
+ revoked_at text COLLATE "C",
+ expires_at text COLLATE "C",
+ reason text COLLATE "C",
+ revoked_by text COLLATE "C",
+ PRIMARY KEY (client_id, capability, audience)
+);
+
+CREATE TABLE principal_usage (
+ principal text COLLATE "C" NOT NULL,
+ route text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ auth text COLLATE "C" NOT NULL,
+ allowed bigint NOT NULL,
+ code text COLLATE "C" NOT NULL DEFAULT '',
+ count bigint NOT NULL DEFAULT 0,
+ first_at text COLLATE "C" DEFAULT ov_now(),
+ last_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (principal, route, auth, allowed, code)
+);
+
+CREATE TABLE principal_grant_changes (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ client_id text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ audience text COLLATE "C" NOT NULL,
+ change text COLLATE "C" NOT NULL CHECK (change IN ('granted', 'updated', 'revoked', 'expired')),
+ namespaces text COLLATE "C" NOT NULL DEFAULT '[]',
+ expires_at text COLLATE "C",
+ reason text COLLATE "C",
+ actor text COLLATE "C",
+ event_id text COLLATE "C",
+ at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
+);
+
+CREATE TABLE user_modules (
+ subject_id text COLLATE "C" NOT NULL,
+ namespace text COLLATE "C" NOT NULL,
+ version bigint NOT NULL,
+ revision bigint NOT NULL DEFAULT 0,
+ data text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ updated_by text COLLATE "C",
+ PRIMARY KEY (subject_id, namespace)
+);
+
+CREATE TABLE user_module_revisions (
+ subject_id text COLLATE "C" NOT NULL,
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ PRIMARY KEY (subject_id, namespace)
+);
+
+CREATE TABLE user_module_retirements (
+ namespace text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ retired_seen_at bigint NOT NULL
+);
+
+
+CREATE TABLE dev_projects (
+ id text COLLATE "C" PRIMARY KEY,
+ owner_subject text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ environment_policy text COLLATE "C" NOT NULL DEFAULT 'sandbox' CHECK (environment_policy IN ('sandbox', 'sandbox+production')),
+ allowance text COLLATE "C" NOT NULL DEFAULT '[]',
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ archived_at text COLLATE "C",
+ archived_by text COLLATE "C"
+);
+
+CREATE TABLE dev_project_members (
+ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
+ subject_id text COLLATE "C" NOT NULL,
+ role text COLLATE "C" NOT NULL CHECK (role IN ('owner', 'admin', 'developer', 'viewer')),
+ added_at text COLLATE "C" NOT NULL,
+ added_by text COLLATE "C" NOT NULL,
+ PRIMARY KEY (project_id, subject_id)
+);
+
+CREATE TABLE dev_apps (
+ id text COLLATE "C" PRIMARY KEY,
+ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
+ name text COLLATE "C" NOT NULL,
+ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
+ oauth_client_id text COLLATE "C" NOT NULL UNIQUE,
+ client_type text COLLATE "C" NOT NULL CHECK (client_type IN ('confidential', 'public')),
+ redirect_uris text COLLATE "C" NOT NULL DEFAULT '[]',
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C",
+ revoked_by text COLLATE "C"
+);
+
+CREATE TABLE dev_credentials (
+ id text COLLATE "C" PRIMARY KEY,
+ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
+ secret_hash text COLLATE "C" NOT NULL UNIQUE,
+ hint text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C",
+ revoked_at text COLLATE "C",
+ revoked_by text COLLATE "C",
+ last_used_at text COLLATE "C"
+);
+
+CREATE TABLE dev_grants (
+ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
+ capability text COLLATE "C" NOT NULL,
+ audience text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL CHECK (status IN ('requested', 'approved', 'denied', 'revoked')),
+ requested_by text COLLATE "C" NOT NULL,
+ requested_at text COLLATE "C" NOT NULL,
+ decided_by text COLLATE "C",
+ decided_at text COLLATE "C",
+ PRIMARY KEY (app_id, capability)
+);
+
+CREATE TABLE dev_quotas (
+ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
+ capability text COLLATE "C" NOT NULL,
+ limit_value bigint NOT NULL CHECK (limit_value >= 0),
+ quota_window text COLLATE "C" NOT NULL CHECK (quota_window IN ('minute', 'hour', 'day', 'month', 'total')),
+ unit text COLLATE "C" NOT NULL DEFAULT 'requests',
+ updated_at text COLLATE "C" NOT NULL,
+ updated_by text COLLATE "C" NOT NULL,
+ PRIMARY KEY (project_id, capability)
+);
+
+CREATE TABLE dev_auth_codes (
+ code_hash text COLLATE "C" PRIMARY KEY,
+ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
+ user_subject text COLLATE "C" NOT NULL,
+ redirect_uri text COLLATE "C" NOT NULL,
+ scope text COLLATE "C" NOT NULL DEFAULT '',
+ code_challenge text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ used bigint NOT NULL DEFAULT 0
+);
+
+CREATE TABLE dev_audit (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at text COLLATE "C" NOT NULL,
+ project_id text COLLATE "C",
+ actor text COLLATE "C" NOT NULL,
+ action text COLLATE "C" NOT NULL,
+ target text COLLATE "C",
+ detail text COLLATE "C" NOT NULL DEFAULT '{}',
+ request_id text COLLATE "C",
+ event_type text COLLATE "C",
+ event text COLLATE "C"
+);
+
+CREATE TABLE dev_usage_windows (
+ project_id text COLLATE "C" NOT NULL,
+ env text COLLATE "C" NOT NULL CHECK (env IN ('sandbox', 'production')),
+ service text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ dimension text COLLATE "C" NOT NULL DEFAULT '',
+ unit text COLLATE "C" NOT NULL,
+ window_start text COLLATE "C" NOT NULL,
+ window_end text COLLATE "C" NOT NULL,
+ quantity bigint NOT NULL,
+ errors bigint NOT NULL,
+ error_codes text COLLATE "C" NOT NULL DEFAULT '{}',
+ revision bigint NOT NULL DEFAULT 1,
+ event_id text COLLATE "C" NOT NULL,
+ recorded_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (project_id, env, service, capability, dimension, unit, window_start)
+);
+
+CREATE TABLE dev_usage_daily (
+ project_id text COLLATE "C" NOT NULL,
+ env text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ dimension text COLLATE "C" NOT NULL DEFAULT '',
+ unit text COLLATE "C" NOT NULL,
+ day text COLLATE "C" NOT NULL,
+ quantity bigint NOT NULL DEFAULT 0,
+ errors bigint NOT NULL DEFAULT 0,
+ error_codes text COLLATE "C" NOT NULL DEFAULT '{}',
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (project_id, env, service, capability, dimension, unit, day)
+);
+
+CREATE TABLE dev_usage_errors (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ project_id text COLLATE "C" NOT NULL,
+ env text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ at text COLLATE "C" NOT NULL,
+ code text COLLATE "C" NOT NULL,
+ status bigint,
+ trace_id text COLLATE "C",
+ ref text COLLATE "C" NOT NULL DEFAULT '',
+ event_id text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE user_blocks (
+ blocker_subject text COLLATE "C" NOT NULL,
+ blocked_subject text COLLATE "C" NOT NULL,
+ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ PRIMARY KEY (blocker_subject, blocked_subject),
+ CHECK (blocker_subject <> blocked_subject)
+);
+
+CREATE TABLE user_follows (
+ follower_subject text COLLATE "C" NOT NULL,
+ target_type text COLLATE "C" NOT NULL CHECK (target_type IN ('channel')),
+ target_id text COLLATE "C" NOT NULL,
+ active bigint NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
+ notify_email bigint NOT NULL DEFAULT 1 CHECK (notify_email IN (0, 1)),
+ notify_push bigint NOT NULL DEFAULT 1 CHECK (notify_push IN (0, 1)),
+ revision bigint NOT NULL DEFAULT 1 CHECK (revision >= 1),
+ source text COLLATE "C" NOT NULL DEFAULT 'network',
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
+ PRIMARY KEY (follower_subject, target_type, target_id),
+ CHECK (follower_subject <> target_id)
+);
+
+CREATE TABLE follow_import_holds (
+ source text COLLATE "C" NOT NULL,
+ follower_ref text COLLATE "C" NOT NULL,
+ target_ref text COLLATE "C" NOT NULL,
+ reason text COLLATE "C" NOT NULL,
+ seen_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (source, follower_ref, target_ref)
+);
+
+CREATE TABLE status_incidents (
+ id text COLLATE "C" PRIMARY KEY,
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('incident', 'maintenance')),
+ title text COLLATE "C" NOT NULL,
+ severity text COLLATE "C" CHECK (severity IS NULL OR severity IN ('minor', 'major', 'critical')),
+ state text COLLATE "C" NOT NULL,
+ services text COLLATE "C" NOT NULL DEFAULT '[]',
+ starts_at text COLLATE "C" NOT NULL,
+ ends_at text COLLATE "C",
+ created_by text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE status_incident_updates (
+ incident_id text COLLATE "C" NOT NULL REFERENCES status_incidents(id),
+ at text COLLATE "C" NOT NULL,
+ state text COLLATE "C" NOT NULL,
+ message text COLLATE "C" NOT NULL,
+ author text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE creator_streams (
+ stream_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ creator_subject text COLLATE "C" NOT NULL,
+ title text COLLATE "C",
+ category text COLLATE "C",
+ started_at text COLLATE "C" NOT NULL,
+ ended_at text COLLATE "C" NOT NULL,
+ duration_seconds bigint NOT NULL DEFAULT 0,
+ peak_viewers bigint,
+ avg_viewers double precision,
+ unique_chatters bigint,
+ messages bigint,
+ watch_minutes bigint,
+ received_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE platform_nodes (
+ id text COLLATE "C" PRIMARY KEY,
+ source text COLLATE "C" NOT NULL,
+ doc text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL,
+ reported_at text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE username_history (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ old_username text COLLATE "C" NOT NULL,
+ new_username text COLLATE "C" NOT NULL,
+ changed_by bigint,
+ changed_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE user_profile_changes (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ changed text COLLATE "C" NOT NULL,
+ at bigint NOT NULL
+);
+
+CREATE TABLE subject_aliases (
+ alias_id text COLLATE "C" PRIMARY KEY,
+ subject_id text COLLATE "C" NOT NULL,
+ merge_id text COLLATE "C" NOT NULL,
+ merged_at text COLLATE "C" NOT NULL,
+ merged_by text COLLATE "C" NOT NULL
+);
+
+CREATE TABLE account_merges (
+ id text COLLATE "C" PRIMARY KEY,
+ from_subject text COLLATE "C" NOT NULL UNIQUE,
+ into_subject text COLLATE "C" NOT NULL,
+ from_user_id bigint NOT NULL,
+ into_user_id bigint NOT NULL,
+ initiated_by text COLLATE "C" NOT NULL CHECK (initiated_by IN ('person', 'staff')),
+ actor_subject text COLLATE "C" NOT NULL,
+ reason text COLLATE "C",
+ moved text COLLATE "C" NOT NULL,
+ pre_state text COLLATE "C",
+ merged_at text COLLATE "C" NOT NULL,
+ split_until text COLLATE "C" NOT NULL,
+ reduced_at text COLLATE "C"
+);
+
+CREATE TABLE account_merge_intents (
+ id text COLLATE "C" PRIMARY KEY,
+ into_user_id bigint NOT NULL,
+ created_at bigint NOT NULL,
+ expires_at bigint NOT NULL,
+ used_at bigint
+);
+
+CREATE TABLE account_exports (
+ id text COLLATE "C" PRIMARY KEY,
+ user_id bigint NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),
+ expected text COLLATE "C" NOT NULL DEFAULT '[]',
+ requested_at text COLLATE "C" NOT NULL,
+ deadline text COLLATE "C" NOT NULL,
+ ready_at text COLLATE "C",
+ expires_at text COLLATE "C",
+ size_bytes bigint,
+ services text COLLATE "C",
+ error text COLLATE "C"
+);
+
+CREATE TABLE account_export_parts (
+ export_id text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ received_at text COLLATE "C" NOT NULL,
+ files bigint NOT NULL,
+ bytes bigint NOT NULL,
+ body text COLLATE "C",
+ PRIMARY KEY (export_id, service)
+);
+
+CREATE TABLE account_deletions (
+ id text COLLATE "C" PRIMARY KEY,
+ user_id bigint NOT NULL,
+ subject text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),
+ requested_at text COLLATE "C" NOT NULL,
+ delete_after text COLLATE "C" NOT NULL,
+ cancelled_at text COLLATE "C",
+ cancelled_by text COLLATE "C",
+ deleted_at text COLLATE "C",
+ expected text COLLATE "C",
+ erased text COLLATE "C"
+);
+
+CREATE TABLE account_deletion_confirmations (
+ deletion_id text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ received_at text COLLATE "C" NOT NULL,
+ completed_at text COLLATE "C" NOT NULL,
+ erased text COLLATE "C" NOT NULL,
+ retained text COLLATE "C" NOT NULL,
+ PRIMARY KEY (deletion_id, service)
+);
+
+CREATE TABLE mod_principals (
+ mod_id text COLLATE "C" PRIMARY KEY,
+ owner text COLLATE "C" NOT NULL,
+ runtime text COLLATE "C" NOT NULL,
+ name text COLLATE "C",
+ version text COLLATE "C",
+ publisher text COLLATE "C",
+ manifest text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
+ revision bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C",
+ updated_at text COLLATE "C" NOT NULL,
+ revoked_at text COLLATE "C",
+ revoked_by text COLLATE "C"
+);
+
+CREATE TABLE mod_principal_grants (
+ mod_id text COLLATE "C" NOT NULL,
+ capability text COLLATE "C" NOT NULL,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'revoked')),
+ changed_at text COLLATE "C" NOT NULL,
+ changed_by text COLLATE "C",
+ PRIMARY KEY (mod_id, capability)
+);
+
+CREATE TABLE stream_live_announcements (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ streamer_key text COLLATE "C" NOT NULL,
+ stream_id text COLLATE "C",
+ sent_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE operator_alerts (
+ source text COLLATE "C" NOT NULL,
+ fingerprint text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ severity text COLLATE "C" NOT NULL,
+ summary text COLLATE "C" NOT NULL,
+ description text COLLATE "C",
+ service text COLLATE "C",
+ state text COLLATE "C" NOT NULL CHECK (state IN ('firing', 'resolved')),
+ started_at text COLLATE "C" NOT NULL,
+ opened_at bigint NOT NULL,
+ last_seen_at bigint NOT NULL,
+ notified_at bigint,
+ resolved_at bigint,
+ notices text COLLATE "C",
+ reopened bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (source, fingerprint)
+);
+
+CREATE TABLE tool_domains (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tool_id text COLLATE "C" NOT NULL,
+ host text COLLATE "C" NOT NULL UNIQUE,
+ role text COLLATE "C" NOT NULL DEFAULT 'alias' CHECK (role IN ('canonical', 'short', 'alias', 'mirror')),
+ enabled bigint NOT NULL DEFAULT 1,
+ note text COLLATE "C",
+ created_by bigint,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE user_history (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ user_id bigint NOT NULL,
+ service text COLLATE "C",
+ sub text COLLATE "C",
+ type text COLLATE "C" NOT NULL DEFAULT 'page',
+ title text COLLATE "C" NOT NULL,
+ url text COLLATE "C" NOT NULL,
+ icon text COLLATE "C",
+ meta text COLLATE "C",
+ hits bigint NOT NULL DEFAULT 1,
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+CREATE TABLE frame_cache (
+ key text COLLATE "C" PRIMARY KEY,
+ value text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" DEFAULT ov_now()
+);
+
+CREATE TABLE frame_hits (
+ day text COLLATE "C" NOT NULL,
+ host text COLLATE "C" NOT NULL,
+ hits bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (day, host)
+);
+
+CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username));
+CREATE INDEX idx_notif_user_read ON notifications(user_id, is_read, created_at DESC);
+CREATE INDEX idx_notif_user_cat ON notifications(user_id, category);
+CREATE INDEX idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL;
+CREATE INDEX idx_email_delivery_created ON email_delivery_log(created_at DESC);
+CREATE INDEX idx_email_delivery_status ON email_delivery_log(status, created_at DESC);
+CREATE INDEX idx_email_delivery_type ON email_delivery_log(email_type, created_at DESC);
+CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id, used_at, expires_at);
+CREATE INDEX idx_password_reset_expires ON password_reset_tokens(expires_at);
+CREATE INDEX idx_sessions_user ON user_sessions(user_id, is_active);
+CREATE INDEX idx_sessions_token ON user_sessions(session_token);
+CREATE INDEX idx_coin_tx_user ON coin_transactions(user_id, created_at DESC);
+CREATE INDEX idx_coin_tx_app ON coin_transactions(app_id, created_at DESC);
+CREATE INDEX idx_vkeys_key ON verification_keys(key);
+CREATE INDEX idx_vkeys_target ON verification_keys(target_username);
+CREATE INDEX idx_vkeys_status ON verification_keys(status);
+CREATE INDEX idx_anon_ip_log_ip ON anon_ip_log(ip);
+CREATE INDEX idx_anon_ip_log_anon ON anon_ip_log(anon_id);
+CREATE INDEX idx_push_sub_user ON push_subscriptions(user_id);
+CREATE INDEX idx_push_sub_endpoint ON push_subscriptions(endpoint);
+CREATE INDEX idx_oauth_tokens_family ON oauth_tokens(family_id);
+CREATE INDEX idx_email_verify_user ON email_verification_tokens(user_id, created_at DESC);
+CREATE UNIQUE INDEX idx_linked_user_service ON linked_accounts(user_id, service);
+CREATE INDEX idx_identity_legacy_subject ON identity_legacy_map(subject_id);
+CREATE UNIQUE INDEX idx_users_subject_id ON users(subject_id);
+CREATE UNIQUE INDEX idx_anon_users_subject_id ON anon_users(subject_id);
+CREATE INDEX idx_grant_changes_client ON principal_grant_changes(client_id, id);
+CREATE INDEX idx_dev_projects_owner ON dev_projects(owner_subject);
+CREATE INDEX idx_dev_members_subject ON dev_project_members(subject_id);
+CREATE INDEX idx_dev_apps_project ON dev_apps(project_id);
+CREATE INDEX idx_dev_credentials_app ON dev_credentials(app_id);
+CREATE INDEX idx_dev_audit_project ON dev_audit(project_id, id);
+CREATE INDEX idx_dev_usage_windows_when ON dev_usage_windows(window_start);
+CREATE INDEX idx_dev_usage_windows_project ON dev_usage_windows(project_id, recorded_at);
+CREATE INDEX idx_dev_usage_daily_day ON dev_usage_daily(project_id, day);
+CREATE UNIQUE INDEX idx_dev_usage_errors_once ON dev_usage_errors(project_id, env, service, capability, at, code, ref);
+CREATE INDEX idx_dev_usage_errors_recent ON dev_usage_errors(project_id, at);
+CREATE INDEX idx_user_blocks_blocked ON user_blocks(blocked_subject, active);
+CREATE INDEX idx_user_follows_target ON user_follows(target_type, target_id, active, created_at);
+CREATE INDEX idx_status_incident_updates ON status_incident_updates(incident_id, at);
+CREATE INDEX idx_creator_streams_creator ON creator_streams(creator_subject, started_at);
+CREATE INDEX idx_username_history_old ON username_history(lower(old_username));
+CREATE INDEX idx_username_history_user ON username_history(user_id);
+CREATE INDEX idx_user_profile_changes_user ON user_profile_changes(user_id);
+CREATE INDEX idx_subject_aliases_subject ON subject_aliases(subject_id);
+CREATE INDEX idx_account_exports_user ON account_exports(user_id, requested_at);
+CREATE UNIQUE INDEX idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';
+CREATE INDEX idx_sla_key ON stream_live_announcements(streamer_key, sent_at DESC);
+CREATE INDEX idx_tool_domains_tool ON tool_domains(tool_id, role, enabled);
+CREATE INDEX idx_user_history_user_time ON user_history(user_id, updated_at DESC);
+CREATE INDEX idx_user_history_user_url ON user_history(user_id, url);
+
+CREATE FUNCTION user_modules_guard_users_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'; END IF; RETURN OLD; END $$;
+CREATE TRIGGER user_modules_guard_users_delete BEFORE DELETE ON users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_users_delete();
+
+CREATE FUNCTION user_modules_guard_users_rekey() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND NEW.subject_id IS DISTINCT FROM OLD.subject_id AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectMerged first'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER user_modules_guard_users_rekey BEFORE UPDATE OF subject_id ON users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_users_rekey();
+
+CREATE FUNCTION user_modules_guard_anon_users_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectRemoved or onSubjectMerged first'; END IF; RETURN OLD; END $$;
+CREATE TRIGGER user_modules_guard_anon_users_delete BEFORE DELETE ON anon_users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_anon_users_delete();
+
+CREATE FUNCTION user_modules_guard_anon_users_rekey() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.subject_id IS NOT NULL AND NEW.subject_id IS DISTINCT FROM OLD.subject_id AND EXISTS (SELECT 1 FROM user_modules WHERE subject_id = OLD.subject_id) THEN RAISE EXCEPTION 'user_modules rows remain for this subject: call modules.onSubjectMerged first'; END IF; RETURN NEW; END $$;
+CREATE TRIGGER user_modules_guard_anon_users_rekey BEFORE UPDATE OF subject_id ON anon_users FOR EACH ROW EXECUTE FUNCTION user_modules_guard_anon_users_rekey();
+
+CREATE FUNCTION dev_audit_append_only_update() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'dev_audit is append-only'; RETURN NEW; END $$;
+CREATE TRIGGER dev_audit_append_only_update BEFORE UPDATE ON dev_audit FOR EACH ROW EXECUTE FUNCTION dev_audit_append_only_update();
+
+CREATE FUNCTION dev_audit_append_only_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'dev_audit is append-only'; RETURN OLD; END $$;
+CREATE TRIGGER dev_audit_append_only_delete BEFORE DELETE ON dev_audit FOR EACH ROW EXECUTE FUNCTION dev_audit_append_only_delete();
+
+CREATE FUNCTION users_profile_changed() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF COALESCE(NEW.is_anon, 0) = 0 AND (OLD.username IS DISTINCT FROM NEW.username OR OLD.display_name IS DISTINCT FROM NEW.display_name OR OLD.avatar_url IS DISTINCT FROM NEW.avatar_url OR OLD.profile_color IS DISTINCT FROM NEW.profile_color OR OLD.role IS DISTINCT FROM NEW.role OR OLD.is_banned IS DISTINCT FROM NEW.is_banned) THEN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, trim((CASE WHEN OLD.username IS DISTINCT FROM NEW.username THEN 'username ' ELSE '' END) || (CASE WHEN OLD.display_name IS DISTINCT FROM NEW.display_name THEN 'display_name ' ELSE '' END) || (CASE WHEN OLD.avatar_url IS DISTINCT FROM NEW.avatar_url THEN 'avatar_url ' ELSE '' END) || (CASE WHEN OLD.profile_color IS DISTINCT FROM NEW.profile_color THEN 'profile_color ' ELSE '' END) || (CASE WHEN OLD.role IS DISTINCT FROM NEW.role THEN 'role ' ELSE '' END) || (CASE WHEN OLD.is_banned IS DISTINCT FROM NEW.is_banned THEN 'banned ' ELSE '' END)), (extract(epoch FROM statement_timestamp()) * 1000)::bigint); END IF; RETURN NULL; END $$;
+CREATE TRIGGER users_profile_changed AFTER UPDATE OF username, display_name, avatar_url, profile_color, role, is_banned ON users FOR EACH ROW EXECUTE FUNCTION users_profile_changed();
+
+CREATE FUNCTION users_profile_created() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF COALESCE(NEW.is_anon, 0) = 0 THEN INSERT INTO user_profile_changes (user_id, changed, at) VALUES (NEW.id, 'created', (extract(epoch FROM statement_timestamp()) * 1000)::bigint); END IF; RETURN NULL; END $$;
+CREATE TRIGGER users_profile_created AFTER INSERT ON users FOR EACH ROW EXECUTE FUNCTION users_profile_created();
+
+-- The rows the SQLite boot seeded (built-in themes, default settings, the first-party OAuth clients with their
+-- per-install generated secrets, the platform's default principal grants) are not frozen here: server/db/database.js
+-- seeds them idempotently at boot, exactly as it did on SQLite, so each install generates its own client secrets.
+
+-- openvibe-sdk/events inbox: one receipt per (consumer, event) handled
+CREATE TABLE IF NOT EXISTS network_event_inbox (
+ consumer text NOT NULL,
+ event_id text NOT NULL,
+ processed_at bigint NOT NULL,
+ PRIMARY KEY (consumer, event_id)
+);
+
+-- openvibe-sdk/events PostgreSQL outbox
+CREATE TABLE IF NOT EXISTS network_event_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
+CREATE INDEX IF NOT EXISTS network_event_outbox_due ON network_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
+CREATE INDEX IF NOT EXISTS network_event_outbox_sent ON network_event_outbox (sent_at) WHERE sent_at IS NOT NULL;
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+-- phase: expand
+-- The five request-analytics tables (openvibe-shared/analytics/pg.js analyticsSchema()), so the runtime role creates none.
+-- Behavioural deltas from the SQLite tracker are in that module's header; the privacy bounds (DNT/GPC, path templating) are unchanged.
+CREATE TABLE IF NOT EXISTS analytics_events (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ service text NOT NULL,
+ event_type text NOT NULL DEFAULT 'pageview',
+ path text,
+ method text DEFAULT 'GET',
+ status_code integer,
+ response_time_ms integer,
+ user_id bigint,
+ session_id text,
+ ip text,
+ country text,
+ city text,
+ user_agent text,
+ referer text,
+ is_bot integer DEFAULT 0,
+ bot_type text,
+ device_type text,
+ browser text,
+ os text,
+ authenticated integer NOT NULL DEFAULT 0,
+ created_at text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS analytics_hourly (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ service text NOT NULL, hour text NOT NULL,
+ pageviews bigint DEFAULT 0, api_calls bigint DEFAULT 0, unique_visitors bigint DEFAULT 0, unique_users bigint DEFAULT 0,
+ bot_hits bigint DEFAULT 0, avg_response_ms integer DEFAULT 0, error_count bigint DEFAULT 0, bandwidth_bytes bigint DEFAULT 0,
+ top_paths text, top_referers text,
+ UNIQUE (service, hour)
+);
+CREATE TABLE IF NOT EXISTS analytics_daily (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ service text NOT NULL, date text NOT NULL,
+ pageviews bigint DEFAULT 0, api_calls bigint DEFAULT 0, unique_visitors bigint DEFAULT 0, unique_users bigint DEFAULT 0,
+ new_users bigint DEFAULT 0, bot_hits bigint DEFAULT 0, avg_response_ms integer DEFAULT 0, error_count bigint DEFAULT 0,
+ top_paths text, top_referers text, top_countries text, device_breakdown text, browser_breakdown text,
+ UNIQUE (service, date)
+);
+CREATE TABLE IF NOT EXISTS analytics_visitor_days (
+ service text NOT NULL, day text NOT NULL, hour text NOT NULL, vhash text NOT NULL,
+ authenticated integer NOT NULL DEFAULT 0,
+ PRIMARY KEY (service, hour, vhash)
+);
+CREATE TABLE IF NOT EXISTS analytics_day_salts (
+ service text NOT NULL, day text NOT NULL, salt text NOT NULL,
+ PRIMARY KEY (service, day)
+);
+CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events (created_at);
+CREATE INDEX IF NOT EXISTS idx_analytics_events_service ON analytics_events (service, created_at);
+CREATE INDEX IF NOT EXISTS idx_analytics_events_path ON analytics_events (service, path, created_at);
+CREATE INDEX IF NOT EXISTS idx_analytics_events_bot ON analytics_events (is_bot, created_at);
+CREATE INDEX IF NOT EXISTS idx_analytics_visitor_days_day ON analytics_visitor_days (service, day);
@@ -18,13 +18,18 @@
@@ -18,13 +18,18 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
+ "pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
},
},
"node_modules/@discordjs/builders": {
"node_modules/@discordjs/builders": {
@@ -157,6 +162,19 @@
@@ -157,6 +162,19 @@
"url": "https://github.com/discordjs/discord.js?sponsor"
"url": "https://github.com/discordjs/discord.js?sponsor"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
"node_modules/@sapphire/async-queue": {
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -489,6 +507,15 @@
@@ -489,6 +507,15 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/concat-stream": {
"node_modules/concat-stream": {
"version": "1.6.2",
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -609,6 +636,15 @@
@@ -609,6 +636,15 @@
"node": ">=4.0.0"
"node": ">=4.0.0"
}
}
},
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/depd": {
"node_modules/depd": {
"version": "2.0.0",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1126,6 +1162,49 @@
@@ -1126,6 +1162,49 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/ipaddr.js": {
"node_modules/ipaddr.js": {
"version": "1.9.1",
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1202,12 +1281,24 @@
@@ -1202,12 +1281,24 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"node_modules/lodash.includes": {
"version": "4.3.0",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.isboolean": {
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -1481,12 +1572,32 @@
@@ -1481,12 +1572,32 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.12.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
- "integrity": "sha512-iG1h905O1f7y/PnP8xLA6GCVzXEBDfSaBj2tc1ucNreWg2sT/AJWosb8NW0WRMK5WS4sIQ6tMsprmDLj/fjjeQ==",
+ "version": "0.25.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
+ "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
@@ -1513,6 +1624,134 @@
@@ -1513,6 +1624,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prebuild-install": {
"node_modules/prebuild-install": {
"version": "7.1.3",
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1645,6 +1884,27 @@
@@ -1645,6 +1884,27 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/require-from-string": {
"node_modules/require-from-string": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1860,6 +2120,21 @@
@@ -1860,6 +2120,21 @@
"simple-concat": "^1.0.0"
"simple-concat": "^1.0.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -8,7 +8,10 @@
@@ -8,7 +8,10 @@
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/nodes.test.js && node test/follows.test.js && node test/follow-notify.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/mod-principals.test.js && node test/actor-limits.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/home-empty-catalog.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"test": "node test/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",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
- "grant-admin": "node server/grant-admin.js"
+ "grant-admin": "node server/grant-admin.js",
+ "test:pg": "NETWORK_TEST_STORE=pg node test/run.js",
+ "migrate-to-postgres": "node scripts/migrate-to-postgres.js",
+ "pg-preflight": "node scripts/pg-preflight-collisions.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
@@ -21,12 +24,17 @@
@@ -21,12 +24,17 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
+ "pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The one-time move of Network's SQLite database (DB_PATH) into its PostgreSQL schema (plan T2, ADR-035; the
+ * procedure is openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by runSqliteMigration).
+ * Opus runs it at the cutover, after scripts/pg-preflight-collisions.js.
+ *
+ * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json]
+ *
+ * --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.
+ * --json the report as JSON.
+ *
+ * Without --pglite it applies migrations/ as the owner on DATABASE_DIRECT_URL (the serving DATABASE_URL goes
+ * through PgBouncer in transaction mode, which cannot hold the import's transaction) and copies into emptied
+ * tables, so a rehearsal against a scratch DATABASE_URL is repeatable. Exit 0 only if every table verified.
+ *
+ * The legacy integer-keyed `follows` table is imported like every other: server/auth/routes.js and
+ * server/internal/routes.js still read it (the subject-keyed user_follows is the forward model).
+ */
+require('dotenv').config();
+const path = require('path');
+const { runSqliteMigration } = require('openvibe-sdk/db');
+const config = require('../server/config');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+
+// importSqlite's per-table options. Empty: every PostgreSQL column is fed by the same-named SQLite one.
+const TABLES = {};
+
+// SQLite tables the import leaves behind. Empty: even the SDK's SQLite outbox/inbox rows are carried over
+// (unsent events keep their place in the PostgreSQL outbox).
+const SKIP_SOURCE = [];
+
+if (require.main === module) {
+ runSqliteMigration({ service: 'network', sqlite: config.db.path, 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Before the PostgreSQL cutover (plan T2, ADR-035): SQLite's `COLLATE NOCASE` made some columns compare
+ * case-insensitively, and username uniqueness inherited it, so `Alex` and `alex` cannot both exist here.
+ * PostgreSQL compares text case-sensitively; the port keeps the behaviour with a unique index on
+ * lower(username) and every lookup rewritten to lower(). That only works if the existing data has no two
+ * rows that fold together — this lists them, so they are fixed on the SQLite side before the import.
+ *
+ * node scripts/pg-preflight-collisions.js [--sqlite path/to/network.db] [--json]
+ *
+ * Exit 0 = no collisions; 1 = at least one (the cutover must wait); 2 = unusable input. Read-only: the
+ * SQLite file is opened in read-only mode and never written. Reports, for each case-insensitive column that
+ * must stay unique, the folding keys with more than one distinct spelling.
+ */
+require('dotenv').config();
+const fs = require('fs');
+const config = require('../server/config');
+
+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);
+const asJson = args.includes('--json');
+
+/** [table, column, the WHERE the uniqueness applies under, why it must not fold]. */
+const CHECKS = [
+ ['users', 'username', '1 = 1', 'users.username is UNIQUE: two spellings folding together would make the column ambiguous'],
+ ['verification_keys', 'target_username', "status = 'active'", 'an active reserved-username claim would match two people'],
+ ['username_history', 'old_username', '1 = 1', 'a former name would resolve to two accounts'],
+];
+
+function main() {
+ if (!fs.existsSync(file)) { console.error(`no such SQLite database: ${file}`); return 2; }
+ let Database;
+ try { Database = require('better-sqlite3'); } catch { console.error('better-sqlite3 is not installed; run npm install'); return 2; }
+ let db;
+ try { db = new Database(file, { readonly: true, fileMustExist: true }); } catch (e) { console.error(`cannot open ${file}: ${e.message}`); return 2; }
+
+ const report = { sqlite: file, checked: [], collisions: [] };
+ for (const [table, column, where, why] of CHECKS) {
+ const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
+ if (!exists) { report.checked.push({ table, column, rows: 0, note: 'table absent' }); continue; }
+ const rows = db.prepare(`SELECT lower(${column}) AS folded, COUNT(DISTINCT ${column}) AS spellings,
+ group_concat(DISTINCT ${column}) AS names, COUNT(*) AS rows
+ FROM ${table} WHERE ${where} GROUP BY folded HAVING COUNT(DISTINCT ${column}) > 1
+ ORDER BY folded`).all();
+ report.checked.push({ table, column, rows: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE ${where}`).get().n });
+ for (const r of rows) report.collisions.push({ table, column, folded: r.folded, spellings: r.spellings, names: r.names, rows: r.rows, why });
+ }
+ db.close();
+
+ if (asJson) { console.log(JSON.stringify(report, null, 2)); return report.collisions.length ? 1 : 0; }
+
+ console.log(`pg-preflight-collisions: ${file}`);
+ for (const c of report.checked) console.log(` ${c.table}.${c.column}: ${c.rows} row(s)${c.note ? ` (${c.note})` : ''}`);
+ if (!report.collisions.length) { console.log('no case-fold collisions: safe to import'); return 0; }
+ console.log(`\n${report.collisions.length} case-fold collision(s) — resolve these on the SQLite side first:`);
+ for (const c of report.collisions) {
+ console.log(` ${c.table}.${c.column}: ${c.names} (fold "${c.folded}", ${c.rows} rows)`);
+ console.log(` ${c.why}`);
+ }
+ return 1;
+}
+
+if (require.main === module) process.exit(main());
+module.exports = { CHECKS };
@@ -53,9 +53,24 @@ module.exports = {
@@ -53,9 +53,24 @@ module.exports = {
// scripts/subscribe-events.js.
// scripts/subscribe-events.js.
eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',
eventsWebhookSecrets: process.env.NETWORK_EVENTS_SECRET || '',
- // Database
+ // 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.
db: {
db: {
path: process.env.DB_PATH || './data/network.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',
+ },
+
+ // 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 || '',
},
},
// Admin auto-creation
// Admin auto-creation
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+// Loaded with `node --import` into every test process (test/run.js): one migrated database for the process
+// (plan T2, ADR-035) — PGlite by default, or, with NETWORK_TEST_STORE=pg (npm run test:pg), the PostgreSQL +
+// PgBouncer containers with roles and a schema of its own. server/db/database.js initDb() adopts it, so test
+// files open no database themselves. The process ends with the test (a container schema is dropped by
+// `scripts/test-services.sh down`).
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const require = createRequire(import.meta.url);
+const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+const { createTestDb } = require('openvibe-sdk/testing');
+const t = await createTestDb({ migrations: path.join(root, 'migrations'), store: process.env.NETWORK_TEST_STORE || 'pglite', service: 'network', max: 4 });
+// Tests insert rows with small explicit ids. A PostgreSQL identity would continue after them, so generated ids
+// are set to start at 100000 here, clear of every id a test names.
+for (const r of await t.db.prepare(`SELECT pg_get_serial_sequence(quote_ident(table_name), column_name) AS seq FROM information_schema.columns
+ WHERE table_schema = current_schema() AND is_identity = 'YES'`).all()) {
+ if (r.seq) await t.db.prepare('SELECT setval(?::regclass, 100000)').get(r.seq);
+}
+globalThis.__ovNetworkTestDb = t.db;
+globalThis.__ovNetworkTestDbClose = t.close;
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — one at a time, each in its own process, and fails if
+ * any of them fails (openvibe-shared/test-runner).
+ *
+ * npm test # everything
+ * npm test -- usernames follows # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ *
+ * Each test gets a migrated PostgreSQL database of its own (PGlite; the containers under npm run test:pg) and
+ * local servers on random ports. A test that cannot run something prints `<label>: skipped (<why>)` and is
+ * listed with ○, not counted as passed.
+ */
+'use strict';
+const { pathToFileURL } = require('url');
+// Every test process starts with a migrated database of its own (test/helpers/pg-preload.mjs; plan T2, ADR-035).
+const preload = pathToFileURL(require('path').join(__dirname, 'helpers', 'pg-preload.mjs')).href;
+require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 300000, pad: 40, parallel: 1, hide: /^\[DB\] /, nodeArgs: ['--import', preload] });