- JavaScript 93.4%
- HTML 4%
- CSS 1.3%
- PLpgSQL 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| deploy | ||
| docs | ||
| migrations | ||
| public | ||
| scripts | ||
| server | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| .gitleaks.toml | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Network
Purpose
Identity and account service for the OpenVibe network. Manages user accounts, OAuth2 authorization, theme preferences, notifications, email alerts, anonymous identities, the network-wide OpenCoins wallet, and internal APIs that connect all OpenVibe services together.
Part of the OpenVibe network — https://openvibe.network
What it does
- SSO Identity Provider — "One Account. All of OpenVibe." Central registration and login, OAuth2 Authorization Code flow for OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media.
- Refresh tokens — stored only as a SHA-256, rotated on every use as the next
generationof the sign-in'sfamily_id; presenting an already rotated token again (after a 10-second grace for two tabs refreshing at once) revokes the whole family and is audited (oauth_refresh_reuse). Rows from before this change still work: a raw row is hashed the first time it is presented.server/auth/refresh-tokens.js. - OpenID Connect — discovery at the issuer's root,
/.well-known/openid-configuration(also RFC 8414's/.well-known/oauth-authorization-server; the older/oauth/...alias was retired in plan T2). With scopeopenidthe code grant adds an RS256id_token(aud = client, the authorizenonceechoed,sub= the Network id as a string,subject_id= the canonical usr_ id);/oauth/userinforeturns the same claims for an access token (server/auth/oidc.js). - Unified Notification System — Cross-service notifications with priority levels, category filtering, toast popups, bell badge, sounds, and rich content (buttons, inputs, media). All services push notifications to the central API; clients poll every 15 seconds.
- Email Alerts — Critical notifications (moderation actions, system alerts) can be delivered by the built-in email service. Configuration is managed through the admin panel.
- Anonymous Users — Browse and interact without an account. Anon users receive a unique number, can accumulate stats, and optionally link to a registered account later.
- Multi-Account Switching — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode.
- Theme Catalog — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- Admin Panel — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- Internal API — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, docs/retirement.md) and opens nothing.
- Developer projects (foundation) — projects, members, apps (OAuth clients with
app_subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (GET /api/v1/projects/:project/usage) and an append-only audit at/api/v1/projects. Its events can be relayed to OpenVibe.Events through an outbox whenOV_EVENTS_INTERNAL_URLis set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See docs/developer-projects.md. - Public discovery —
/.well-known/openvibe,/api/v1/registry/*and/contracts/*.json(services, capabilities, contract schemas) answer any origin withAccess-Control-Allow-Origin: *, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (server/public-cors.js). - Public profiles —
/@<username>: one page per person with their picture, bio, when they joined, what they wear and the items they own in OpenVibe.Inventory (plan T21). The same data is atGET /api/v1/profiles/:username, open to every origin. An old username answers 301 to the current one. A person can hide their profile from Edit Profile (users.profile_public); a hidden one shows only the name and picture and is not indexed. Items are read with the Network's own service token and kept 60 s per person (server/profile/public.js). - OpenCoins Wallet — Network-wide currency. User balance/history at
/api/coins/*; atomic credit/debit/transfer for services at/internal/coins/*with idempotency-key dedupe. - User modules — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts
manifests/namespaces); see User modules.
Owns
- accounts, sessions, anonymous identities, canonical subjects (
usr_…,gst_…) and account merges - OAuth2/OIDC (the network's token issuer and its signing key), service principals and their grants, developer projects, apps, credentials, grants and quotas (ADR-014), mod principals
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow graph (ADR-030), creator analytics (ADR-021), the OpenCoins wallet
- node principals: a person's paired machine (
nod_…) with a one-time pairing code, a scoped credential and a token; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announced asnetwork.node.revoked@1 - the service registry (
/api/v1/registry/*,/.well-known/openvibe), the status page, the network changelog proxy, and the files at/shared/*(the pinned openvibe-shared release)
Does not own
- money (OpenVibe.Billing), channel points and streams (OpenVibe.Live), each service's data (a user module's namespace belongs to the service that owns it)
- the contracts it serves (OpenVibe.Contracts) and the Frame's code (OpenVibe.Shared)
Depends on
openvibe-contractsv0.216.0 (registry manifests, capability checks),openvibe-sdkv0.65.0 (PostgreSQL and Valkey adapters, events, per-actor limits, universal telemetry),openvibe-sharedv4.15.2, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the
network.site_copyworkflow), OpenVibe.Blog (the changelog feed it proxies) - email (Resend), Discord and GitHub when their secrets are set (Provider secrets)
Capabilities
Implemented here (the service manifest's capabilities, audience openvibe.network):
identity.subject.resolve, mods.grant.manage, network.account.deletion.confirm,
network.account.export.contribute, network.analytics.creator.read, network.blocks.read,
network.coins.credit|debit|transfer, network.follows.read|write, network.integration.github.read,
network.modules.read|write, network.notifications.push, network.operator.alert,
network.project.manage|read, network.secret.use, network.staff.read and network.status.incident. The grants other
services hold are seeded in server/identity/principals.js.
Called elsewhere: Network is the issuer, so it signs its own svc:network tokens: events.event.publish
(and events.delivery.admin for the admin Events views) at OpenVibe.Events, and ai.run.create /
ai.run.read at OpenVibe.AI for the footer's site copy.
Architecture
openvibe.network (port 4000)
├── server/
│ ├── index.js # Express app, CORS, service init, periodic tasks
│ ├── config.js # Port, JWT, DB, OAuth, connected services
│ ├── auth/
│ │ ├── routes.js # Register, login, profile, anon sessions, multi-account, follows
│ │ └── oauth-routes.js # OAuth2 authorize, token, OIDC discovery
│ ├── notifications/
│ │ ├── notification-service.js # CRUD, preferences, email queue, cleanup
│ │ ├── routes.js # REST API for notification UI
│ │ └── email-service.js # Resend email service with HTML templates
│ ├── admin/
│ │ └── routes.js # Admin panel API (email settings, users, broadcast, health)
│ ├── themes/
│ │ └── routes.js # Theme CRUD, user preferences
│ ├── internal/
│ │ └── routes.js # Server-to-server API (verify-token, user sync, notif push)
│ └── db/
│ └── database.js # PostgreSQL handle (openvibe-sdk/db), migrations/, boot seeding
├── public/
│ ├── index.html # Landing page (hero + service cards)
│ ├── login.html # Animated login/register page
│ └── my.html # Account management (profile, sessions, notifications)
├── deploy/
│ ├── nginx/ # openvibe.network.conf; network-subdomains.conf (status/themes/admin/auth/api. redirects); parked-domains.conf
│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`)
│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env), .socket + socket.conf drop-in
└── .env.example
Setup
# 1. Install dependencies
cd OpenVibe.Network && npm install
# 2. Generate RSA keys for JWT signing
mkdir -p data/keys
openssl genrsa -out data/keys/private.pem 2048
openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
# 3. Configure environment
cp .env.example .env
# Edit .env — set ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE.
# In production also set DATABASE_URL (the runtime role through PgBouncer), DATABASE_DIRECT_URL (the owner
# role on a direct connection, for migrations) and VALKEY_URL (shared limit counters). Without
# DATABASE_URL, development uses an embedded PGlite database in data/pglite; production refuses to boot.
# 4. Run
npm start
After the first run the service will seed the URL registry with safe defaults and create an admin account from environment values. Visit `/api/setup/status` for setup health and use `/api/setup/bootstrap` to apply registry profiles manually.
The server applies migrations/NNNN_*.sql at boot (as the owner on DATABASE_DIRECT_URL) and seeds OAuth2 clients and site settings on first run into PostgreSQL. Client secrets are logged to console once on creation — copy them to the consuming services' env files (/etc/openvibe/<svc>.env). The tests run on PGlite (npm test) or the PostgreSQL + PgBouncer containers (npm run test:pg, after eval "$(openvibe-sdk scripts/test-services.sh up)").
To rotate a service principal, load Network's environment before the script name and pass the target service's file with --write-env:
sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate <id> --write-env /etc/openvibe/<id>.env
The script requires the database named ov_network (or an explicit --database-name <name> override). Do not use --env-file after the script name: Node loads it as environment and can select the target service's database.
Deploying
sudo /opt/openvibe.network/deploy/scripts/deploy.sh runs ovhost deploy network --install-units
(OpenVibe.Host, strategy git-checkout; roadmap WS-N task 11): a fast-forward pull as the checkout owner,
npm install --omit=dev when the lockfile or dependency fields changed, every dependency checked before
anything restarts, deploy/systemd/openvibe-network.service installed when it differs (daemon-reload), the
restart, /api/ready polled, an automatic rollback if it does not come up, and the release announced.
--restart, --wait-idle, --force and --rollback (ovhost rollback network) are passed on; DRY_RUN=1
prints ovhost plan network. If ovhost is missing, the wrapper exits with an error.
The unit is openvibe-network.service (runs as ubuntu from /opt/openvibe.network) on
127.0.0.1:4000, the env file /etc/openvibe/network.env. The listener belongs to openvibe-network.socket
(with the openvibe-network.service.d/socket.conf drop-in), so connections that arrive during a restart wait
for the new process instead of getting 502s; ovhost restarts the service, never the socket. Check that systemd
holds it with sudo ss -ltnp | grep :4000 (systemd listed, the process on fd 3). Rollback: automatic when /api/ready does not
come up; afterwards sudo ovhost rollback network --to <sha>. Two blockers: chrome_cache/chrome_hits
became frame_cache/frame_hits on 2026-09-24 (an older release starts with empty caches), and
tool_domains was rebuilt once for the mirror role (an older release does not know mirror rows).
TLS / Certbot Deploy Support
openvibe.network includes a built-in deploy subsystem under server/deploy/ that can manage Let’s Encrypt certificates and preview Nginx config.
What it does
- wraps local
certbotcalls viaserver/deploy/cert-manager.js - supports
cloudflaremode usingcertbot-dns-cloudflare - supports
manualDNS-01 issuance for wildcard certificates - stores certs under
/etc/letsencrypt/live/<domain>/ - generates Nginx config templates that reference
/etc/letsencrypt/live/.../fullchain.pemandprivkey.pem
Requirements
certbotinstalled and available inPATH- for Cloudflare mode:
certbot-dns-cloudflareplugin installed /etc/letsencryptwritable by the service usernginxinstalled if you want preview/apply support
Deploy configuration keys
Use the network deploy setup API or URL registry to configure:
DEPLOY_ACME_EMAIL— ACME registration emailDEPLOY_CERT_MODE—cloudflare,manual, ornoneDEPLOY_CLOUDFLARE_TOKEN— Cloudflare API token for DNS-01 challengeDEPLOY_DOMAINS— array of domain objects, e.g.[{domain:'openvibe.network',wildcard:false,certName:'openvibe.network',services:['network']}]DEPLOY_NGINX_MODE—preview,apply, ordisabledDEPLOY_NGINX_SITES_PATH— path where Nginx site files should be writtenDEPLOY_NGINX_BACKUP_PATH— backup directory for generated Nginx configsDEPLOY_SERVICE_MAP— optional service mapping override
Admin deploy endpoints
The deploy subsystem exposes admin-only APIs at /api/admin/deploy:
GET /api/admin/deploy/prerequisites— checkcertbot, plugin,/etc/letsencrypt, andnginxGET /api/admin/deploy/config— read current deploy configPUT /api/admin/deploy/config— save deploy config valuesGET /api/admin/deploy/certs— list certificates known to certbotPOST /api/admin/deploy/certs/issue-cloudflare— issue a wildcard cert via Cloudflare DNS-01POST /api/admin/deploy/certs/manual-info— get manual DNS challenge instructionsPOST /api/admin/deploy/certs/issue-manual— run manual DNS issuance after TXT records are in placePOST /api/admin/deploy/certs/renew— renew all certbot certificatesGET /api/admin/deploy/nginx/preview— preview generated Nginx configs
How to use it
- Install certbot:
sudo apt update
sudo apt install certbot python3-certbot-dns-cloudflare
-
Ensure
nginxis installed if you want config preview/apply support. -
Set deploy registry values through the setup API or admin config.
-
For Cloudflare mode, provide a valid Cloudflare token. The wrapper writes
/etc/letsencrypt/cloudflare.ini. -
For manual mode, create the required DNS TXT records for
_acme-challenge.<domain>and*. <domain>as instructed by the service. -
After issuance, Nginx configs reference certs at
/etc/letsencrypt/live/<domain>/fullchain.pemand/etc/letsencrypt/live/<domain>/privkey.pem. -
Use
POST /api/admin/deploy/certs/renewto renew existing certs.
Notes
- The deploy module does not replace a full deployment toolchain; it is a built-in helper for cert issuance and Nginx preview.
- If
DEPLOY_CERT_MODEis set tonone, the service will still generate preview Nginx configs but will leave SSL certificate paths commented out. certbotmust be installed on the host running openvibe.network, not just in Docker or another container.
Notification System
How it works
- Any service pushes notifications to openvibe.network via
POST /internal/notifications/push - openvibe.network stores them in PostgreSQL with priority, category, and optional rich content
- Clients poll
GET /api/notificationsevery 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see Realtime badge below) - Critical notifications are queued for email delivery via the built-in email service (Resend by default).
Priorities
| Priority | Behavior |
|---|---|
low |
Silent — badge only, no toast or sound |
normal |
Standard toast with subtle notification sound |
high |
Persistent toast with attention sound, auto-dismiss 8s |
critical |
Sticky toast (must dismiss), urgent sound, triggers email |
Categories
social, chat, game, stream, economy, achievement, moderation, system, service, admin
Users can toggle each category's enabled/sound/toast/email preferences at openvibe.network/my → Notifications tab.
Cross-Service Push
Other services push notifications to openvibe.network via the internal API:
curl -X POST http://127.0.0.1:4000/internal/notifications/push \
-H "Authorization: Bearer <service token with network.notifications.push>" \
-H "Content-Type: application/json" \
-d '{"userId": 42, "type": "new_follower", "data": {"actorName": "someone"}}'
Notifications from Events
POST /internal/events (server/notifications/events-consumer.js) turns OpenVibe.Events deliveries
into inbox notifications for the person they name, through the same NotificationService.create()
as everything else, so a person who turned the service category off gets nothing and email follows
their per-category email choice.
| Event type | From | Person | Notification |
|---|---|---|---|
deals.watch.matched |
Deals (source: deals) |
payload.recipient (usr_…) |
DEAL_WATCH_MATCH, service, normal |
trade.alert.triggered |
Trade (source: trade) |
subject.id (subject.type: user) |
TRADE_ALERT, service, high |
live.stream.started |
Live (source: live) |
every follower of payload.channel.subject, plus everyone with stream_live_all on |
STREAM_LIVE, stream, high; the Discord live alert after commit |
Go-lives. The followers are Network's own follow graph (see "Follow graph"): the active
user_follows rows for ('channel', payload.channel.subject), read in the same transaction as the
notifications. Live is never asked. A channel whose subject has no Network account is ignored:channel;
a follower whose subject has no account is counted as unresolved; the streamer never hears about
themself; the stream category's mute and email choice apply. A start more
than 30 minutes old (a replay, or Events catching up) announces nothing. The per-streamer window (one
announcement an hour, eight a day; stream_live_cooldown_min, stream_live_daily_cap) is kept by
server/notifications/stream-live.js. This consumer is the only go-live path (compatibility register C-85): Live
publishes live.stream.started and makes no direct call. Follows have been Network's since 2026-09-26, and the
cutover flag that held the consumer back is gone.
Project usage. tools.usage.recorded and events.usage.recorded (hourly rollups of a developer
project's use, common.usage-recorded@1) are kept per project and day (server/developer/usage.js,
tables dev_usage_windows, dev_usage_daily, dev_usage_errors) for GET /api/v1/projects/:project/usage
and the project's usage page on openvibe.services; they notify nobody. See
docs/developer-projects.md.
Coupons publishes no watch event yet: its merchant watches never leave Coupons, and its coupons.*
events name no person. Every other type is acknowledged and ignored. Deliveries are v2-signed only
(openvibe-sdk parseDelivery with requireV2, ±300 s), and each event_id is handled once
(openvibe-sdk inbox, table network_event_inbox, consumer network-notifications). Links point at a
site only when its domain serves the service (the exposure overlay above); until then the URL is kept
in rich_content.context.planned_url.
The route is inert until the operator does both of these:
-
Generate the signing secret and give it to Network: add
NETWORK_EVENTS_SECRET=<openssl rand -hex 32>to/etc/openvibe/network.env(comma-separate a second value to rotate), and make sureOV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300is set. Restartopenvibe-network; the log says[Events consumer] on. -
Create the subscriptions in Events (consumer
network, which Events takes from the token'ssvc:network; endpointhttp://127.0.0.1:4000/internal/events; topic patternsdeals.watch.matched,trade.alert.triggeredandlive.stream.started; secret = the firstNETWORK_EVENTS_SECRET). On a host that already has the other two, addlive.stream.startedalone with--topic live.stream.started(and the project usage rollups with--topic tools.usage.recorded --topic events.usage.recorded):cd /opt/openvibe.network sudo node --env-file=/etc/openvibe/network.env scripts/subscribe-events.js --dry-run sudo node --env-file=/etc/openvibe/network.env scripts/subscribe-events.jsThe script signs its own 5-minute service token (Network is the issuer:
sub svc:network,aud openvibe.events,cap events.subscription.manage) and hands the secret to Events in the subscription body, the way Deals, News and Tips do with theirs. It prints the subscription ids, never the secret, and reports an existing identical subscription instead of duplicating it.
Each go-live's outcome is logged, with counts only:
[Events consumer] live.stream.started evt_…: notified {"followers":3,"unresolved":0,"targets":3,"notified":3}
The consumer is the only go-live path (compatibility register C-85, closed 2026-10-11): Live publishes live.stream.started and makes no direct call, and POST /internal/events/stream-live is gone. A reconnect inside the per-streamer window logs skipped:cooldown.
Realtime badge (network.notification.created)
Every notification NotificationService.create() stores is announced as network.notification.created (Contracts 0.61.0; ADR-005 amendment 2). The envelope goes into network_event_outbox in the same transaction as the notification: both exist or neither. The relay then publishes it to Events.
- Envelope: subject
{ type: user, id: <recipient usr_> }, visibilitysubject(Events streams it to that person only), actorsystem:network. - Payload:
notification_id,type,category,priority,service,created_atand the recipient'sunread_count. Never the title, message, link or sender. - Nothing announced: a muted category, a blocked sender or a go-live dedupe stores nothing, so it announces nothing. Guests and accounts without a
usr_subject get no event.
The badge on other sites cannot use a cookie of openvibe.events, so it asks here for a realtime ticket:
POST /api/v1/realtime/ticket (Bearer Network JWT, or the ov_token cookie here; 60/min per IP)
→ 200 { ticket, expires_at, expires_in: 120, stream_url, topics: ["network.notification.*"], subject }
It then opens ${stream_url}?topics=network.notification.*&ticket=…[&last_event_id=…].
- The ticket (
server/auth/realtime-ticket.js,identity.realtime-ticket-claims@1) is an RS256 JWT:iss <issuer>/realtime,sub <usr_>,aud [openvibe.events],typandpurposerealtime, 120 s,jti rtk_…. Events accepts each one once. - Never a session: its issuer,
typand audience each rule that out, and this session guard refuses it too. - Not stored, logged or audited: it grants the person only their own stream.
- Refusals: a guest gets 403
realtime.guest.REALTIME_TICKETS=offin/etc/openvibe/network.envanswers 503realtime.disabled, and every badge stays on polling.OV_EVENTS_PUBLIC_URLoverrides the stream origin (defaulthttps://openvibe.events). - Client: openvibe-shared
notification-live.js, on where a site setsnotificationsRealtime: true.
A digest (a daily or weekly summary instead of one alert per notification) is designed in docs/notification-digest.md for a later release.
Email Setup
Email is optional — the notification system works without it. Email is only sent for CRITICAL priority notifications.
Quick setup
- Create a domain or sender identity at https://resend.com/domains.
- Add the required SPF, DKIM, and MX records to your DNS provider.
- Configure the Resend API key and from-addresses in the admin panel.
- Verify your domain on Resend before sending production email.
Admin panel configuration
Configure email at runtime via the admin panel (the API key only when RESEND_API_KEY is not set; see below):
GET /api/admin/email— view current email config (api_key_source:env,databaseorunset)PUT /api/admin/email— update API key, default from address, and per-service from addressesPOST /api/admin/email/test— send a test email
Provider secrets
Each provider secret has an environment variable, read first; the site_settings row is only a
fallback (server/secrets.js, roadmap §18.2(12)). While a variable is set, the admin panel shows the
secret as "set in the environment" and never saves one into the database. GET /api/admin/secrets
(owner) and the boot line [Secrets] resend_api_key=env … say which source each one uses, never a value.
| Setting | Variable | Used for |
|---|---|---|
resend_api_key |
RESEND_API_KEY |
email delivery |
resend_webhook_secret |
RESEND_WEBHOOK_SECRET |
Resend delivery webhooks (Svix whsec_…) |
discord_bot_token |
DISCORD_BOT_TOKEN |
Discord bot alerts |
discord_oauth_client_secret |
DISCORD_OAUTH_CLIENT_SECRET |
Discord account linking |
vapid_private_key |
VAPID_PRIVATE_KEY |
web push (the public key is derived from it; VAPID_PUBLIC_KEY optional) |
net.ipinfo_token, net.globalping_token and the old ses_* keys are never read by Network (the Tools
gateway reads NET_IPINFO_TOKEN / NET_GLOBALPING_TOKEN from its own env file).
Provider secrets are read from the environment only (/etc/openvibe/network.env): a site_settings row of the same
key is ignored and no admin form stores one. The database fallback and its copy-out script were retired on 2026-10-10,
once production held no database copy of any secret.
Project secrets (ADR-056)
A project keeps its own secrets here, per environment, written once and used by name (server/developer/secrets.js).
These are the project's, not Network's provider secrets above.
- People (Bearer user tokens on
/api/v1/projects/:project/secrets):- admins and owners set a value (
PUT …/:env/:name { value }; setting it again adds one to its version), delete it, and grant it to a service (PUT …/:name/grants/:service { purposes, origins }); - members and staff read metadata only (
network.project-secret@1): name, version, a keyed fingerprint and grants. No route answers a value to a person.
- admins and owners set a value (
- Services (
POST /internal/secrets/use,network.secret.use): a service the secret is granted to either signs a message with it (sign: HMAC-SHA256, the value never leaves Network) or receives it for an https origin the grant lists (reveal). A missing secret, grant, purpose or origin, and an archived project, all answer 404secret.not_found. Actor holds the capability by default, to sign webhook deliveries with a task'ssecret_ref. - Storage: AES-256-GCM under a key derived from
NETWORK_SECRETS_KEY, with the row's identity as authenticated data, so a ciphertext moved onto another row does not open. Without the key the feature is off (503), never plaintext. Archiving a project, or deleting its owner's account with no heir, shreds its secrets. - Audit (
dev_audit): sets, deletions, grant changes, and the first use per secret, service, purpose and day. Values and messages are never logged.
Anonymous Users
Anonymous users can browse and interact without creating an account:
- Each anon user gets a unique number (e.g., "Anonymous #42")
- Stats and preferences are tracked via a session token
- Fingerprint matching reconnects returning anonymous visitors
- Anon identities can be linked to a registered account at any time, merging stats; the guest's user modules move to the account (the account's own record wins in a namespace where it has one)
- Multi-account switcher includes a "Continue as Anonymous" option
API
POST /api/auth/anon-session— create anonymous sessionGET /api/auth/anon/:token— get anon user infoPUT /api/auth/anon/:token/preferences— update anon preferencesPOST /api/auth/anon/:token/link— link anon identity to registered account
User modules
One JSON record per (person, namespace), for portable preferences and summaries (never domain truth).
Namespaces, schemas, writers, public fields and quotas come from openvibe-contracts
(manifests/namespaces); server/identity/modules.js enforces them.
- People:
GET /api/modules(export),GET|PUT|DELETE /api/modules/:ns(If-Match: <revision>on PUT). Anyone:GET /api/modules/:ns/public/:subject(public fields only). - Services:
GET|PUT|DELETE /internal/modules/:ns/:subject, service token only (network.modules.read/network.modules.writewith the namespace in the grant); only the namespace's owner writes.chat.preferencesis owned by Chat since the Wave 6 cutover (OWNER_HANDOFFS, until Contracts says so). - Every change emits
network.module.updatedthroughnetwork_event_outbox(relayed to OpenVibe.Events whenOV_EVENTS_INTERNAL_URLis set; rows wait there otherwise). The payload names the owner subject, namespace, schema version, revision, change, reason and the changed keys; values only for fields the namespace declares public. Revisions only grow for a (person, namespace), deletes included. - Accounts:
onSubjectRemoved/onSubjectMergeddelete or re-key a subject's records with events; triggers refuse deleting ausers/anon_usersrow, or changing itssubject_id, while records remain. - Owning service retired (its manifest
status: retired): the namespace turns read-only;delete-after-retentionnamespaces are emptiedretentionDaysafter Network first saw it (daily sweep).
Platform blocks
A person blocks someone once, on the network, and every product honours it (roadmap WS-E task 5,
Contracts 0.49.0; server/identity/blocks.js). user_blocks is keyed by subjects, one row per
(blocker, blocked) with active and a per-pair revision that grows on every change.
- People:
GET /api/v1/me/blocks(who I blocked, with names and avatars),PUT|DELETE /api/v1/me/blocks/:subjectOrUsername. Nobody blocks themselves; guests neither block nor are blocked. Staff can be blocked, but a block never hides a staff action. The account page (Security → Blocked people) lists blocks with unblock buttons. - Services:
GET /internal/blocks?subject=usr_…→{ subject, blocks, blocked_by }, service token withnetwork.blocks.readonly (Chat and Community hold it by default; never the shared key). - Every change writes
network.block.changedintonetwork_event_outboxin the same transaction. Chat (DMs, mentions) and Community (replies) keep projections from their own Events subscriptions. - Network's notifications: nothing is created from a person the recipient blocked (
sender_idas a Network id, oractor_subject); moderation, system and admin notices always are.
Follow graph (ADR-030)
Network owns follows, keyed by subjects (server/identity/follows.js; roadmap WS-E task 4, Contracts 0.65.0).
A target is a Live channel today (channel, named by its owner's subject), and other kinds join as products need them.
- Public:
GET /api/v1/follows/:type/:targetgives the follower count, and for a signed-in caller whether they follow and how they are notified. - People:
GET /api/v1/me/followslists what the caller follows.PUT|DELETE /api/v1/me/follows/:type/:targetfollow and unfollow (:targetis a subject or a username). Both are idempotent, and the PUT body can carry{ notify_email, notify_push }. - Who follows a target:
GET /api/v1/follows/:type/:target/followersis open to the target's owner, and to services withnetwork.follows.read(service token only). Lists are never public. - Events: every change writes
network.follow.createdornetwork.follow.deleted(subject visibility, growing per-pair revision) to the outbox in the same transaction. Live keeps a projection from them. - Products writing for a person (ADR-030 step 4):
PUT|DELETE /internal/follows/:type/:targetneedsnetwork.follows.write, a service token andnetwork.follow-write-request@1. It is granted to Live, whose buttons use it. - Go-live source: go-live notifications read the followers here, and only here (plan T2); there is no switch back to Live.
Multi-Account Switching
Google-style account management supporting up to 5 accounts:
- Accounts stored client-side in
localStorage(openvibe_accounts) - Server tracks active sessions via
user_sessionstable - Switch accounts instantly without re-authentication
- Includes anonymous mode as a switchable identity
- Shared
account-switcher.jscomponent renders the switcher UI on all services
API
GET /api/auth/sessions— list active sessionsPOST /api/auth/sessions— create session recordDELETE /api/auth/sessions/:id— revoke single sessionDELETE /api/auth/sessions— revoke all sessions
OAuth2 Flow
- Service redirects user to
https://openvibe.network/oauth/authorize?client_id=...&redirect_uri=... - If user has
ov_tokencookie → issue authorization code → redirect to service - If no cookie → redirect to
openvibe.network→ user signs in → cookie set → resume flow - Service exchanges code for access + refresh tokens via
POST /token - Access tokens are RS256 JWT (24h) verified by any service with the public key
- Refresh tokens rotate on use (30d)
Observability and status
GET /api/ready: named checks fromopenvibe-shared/ready, each withstatus,required,latency_msandchecked_at.dbandsigning_keyare required (signing_keymeans an RS256 keypair that signs and verifies a probe token; it is only optional outside production).registry_pollanddiscord_botare optional. The answer is 503 only when a required check fails. Otherwise it is 200, withstatus: "degraded"when an optional check has failed.GET /metrics: Prometheus text for direct loopback callers only. Any proxied request gets a 404, anddeploy/nginxblocks the path too. Besides the HTTP golden signals by route template, it exports process metrics,release_infoandrelease_client_updates_total{outcome,reason}(what open tabs report toPOST /release-metrics). Network's own counters arenetwork_tokens_issued_total{grant_type},network_token_failures_total{grant_type,error}andnetwork_principal_token_failures_total{code}.- Universal telemetry: requests are aggregated per
route|method|status_classinto a singleplatform.telemetry-sample@1per key per flush (count, sum, max and p95 inextra, mean inlatency_ms), plus the HTTP autoscaling gauges (active requests, p95, event-loop lag) once per flush; health, readiness, metrics, the OpenVibe Frame and static/shared assets are skipped. Buffered byopenvibe-sdk/service's telemetry kit and written into the analytics store astelemetry.*events (server/telemetry-sink.js);gracefulStopflushes it once at shutdown. GET /release.jsonis Network's release manifest (ADR-016,registry.release-manifest@1, fromopenvibe-shared/release'srelease.mount): the deployed commit, the library versions and, since openvibe-contracts 0.32.0, the 1.1.0 fields. No components are declared, so every release still prompts open tabs to reload.GET /statusis the operator page: server-rendered, no JavaScript needed,noindex.GET /api/v1/statusreturns the same data as JSON. Each OpenVibe service shows as up, degraded, down, not running (placeholder or no runtime) or unknown, with its release, boot time andchecked_at. The data comes from the ecosystem registry poll (server/registry/ecosystem.js), which reads each service's readiness endpoint and/release.jsonabout once a minute. A service not checked yet, or whose last check is more than three intervals old, shows as unknown.- Where each service can be reached is Network's exposure overlay (
server/registry/exposure.js), not the manifest's maturity label:live(its public domain serves it),internal(loopback only, the domain still serves a placeholder page),library(released package),repository(code with CI, nothing to run) orplaceholder(planned). The registry addsexposureto each service, the descriptor gives anoriginonly toliveservices (planned_originotherwise),/statusreads "up (loopback only)" for an internal service, and the Frame's nav lists onlylivesites. Change a row when a domain stops serving its placeholder;test/registry-exposure.test.jspins the states. - The registry also answers
GET /api/v1/registry/topics(every event type from the manifests'eventsProduced/eventsConsumedand the payload contracts, with producers, consumers, the matching consumer pattern and the payload contract; filtersservice,producer,consumer,prefix),/topics/:topic,/releases(each running service's/release.jsonfrom the same loopback poll, with its installed openvibe-contracts/sdk/shared versions and drift against the libraries' current releases),/healthand/search?q=. A type a service's code publishes before its manifest lists it is added fromserver/registry/topics.jsOBSERVED (markedobservedon the service; empty since openvibe-contracts 0.32.0 lists Live's, Media's and Network's), and Network's consumed topics come from its own Events consumer. Payload schemas are served at their$id(/contracts/events/payloads/<type>.v1.json). GET /api/v1/registry/categories[/:id]groups every service by what it is, each with its rule:site(in the network's site list,server/frame/sites.js),platform(runs, but other services call it),library,repository,planned.GET /api/v1/registry/featuredlists the open sites whose last check was up or degraded, in the navigation's usage order (7-day page views plus 14-day signed-in history, recounted every 30 minutes), withranked_atandstale; the hub is not listed and nothing is featured by hand.- Loopback addresses for the health poll: the built-in ports in
server/registry/ecosystem.js, each overridable byOV_<ID>_INTERNAL_URL(loopback URLs only; anything else is ignored and logged). Site hosts come from each manifest'spublicOrigin. node scripts/contracts-drift.jswarns about services that pin an older openvibe-contracts than the latest tag, or a tag that was never published (--dir ~/OpenVibersfor local checkouts,--registry https://openvibe.networkfor what is running,--package allfor sdk and shared too). It exits 0 unless--strict; CI runs it as a warning-only step.GET /api/v1/status/sloreturns the proposed SLO categories, as does docs/slo.md / docs/slo.json.
Analytics (ADR-021)
Creator analytics (roadmap WS-E task 6, Contracts 0.68.0; server/analytics/creators.js) are built from
events, not from Live's tables. Every live.stream.ended becomes one creator_streams row, with the stream's
totals from the event's stats: counts only, never who watched or chatted, and kept 400 days.
GET /api/v1/creators/:creator/analytics?days= shows everyone streams, minutes and peak viewers. The creator,
and services with network.analytics.creator.read (Live's dashboards), also get average viewers, chatters,
messages and watch minutes.
Network records one row per finished request in the analytics_* tables of its PostgreSQL database and rolls
them up hourly and daily for the admin analytics pages (/api/admin/analytics, which also gathers
Live, Tools, Games and Media). What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts
docs/adr/ADR-021-analytics.md). The module is openvibe-shared/analytics (the same one Live and Tools
use), on the service database through its PostgreSQL tracker (AnalyticsTrackerPg). server/analytics/network.js
holds only Network's wiring: its path options (paramPrefixes, pathRules) and the prune job.
- Stored: event type, service, route template (the matched Express route, else the path
without its query and with ids, usernames and tokens replaced by
:id/:param), method, status, response time, a rotating session id, country (CDN header), user-agent class (chrome/windows/desktop,bot:curl), referer origin only, bot flags, a signed-in flag. - Never stored: IP address, user or subject id, city, the user-agent string, full referer URLs.
The
ip,user_idandcitycolumns stay for compatibility and are always NULL. Per-IP counters for the bot rate check live in memory only. Unique visitors come from a daily-salted hash kept only until that day's final rollup. "New vs returning visitors" is no longer measured. - Opt-out: a request with
Sec-GPC: 1orDNT: 1is not recorded at all (no raw row, visitor hash, session id or rate counter), so it is also missing from the rollups. - Admin panel: the bot tables list user-agent classes and high-volume session ids, not IPs.
- Connection: the tracker buffers and flushes into the service's own PostgreSQL handle (the same database the identity tables live in), so there is no second connection or file of its own.
- Retention: raw events older than 30 days are deleted every night in batches of 5000 (the
analytics-prunejob: first run 5 minutes after boot, then every 24 h). Rollups are kept. - Operator CLI:
scripts/analytics-prune.js(overopenvibe-shared/analytics/pg'spruneRawEventsPg) runs a dry run by default and changes nothing;--applydeletes raw events older than--days(1..30, default 30). Run it where Network runs, withDATABASE_URLset.
Shared Client Libraries
The openvibe-shared package (OpenVibers/OpenVibe.Shared, pinned in package.json) provides drop-in vanilla JS components served at /shared/ (and /shared/v1/). Only the package's browser files are served; a ?v= equal to a file's content hash is cached for a year, anything else for five minutes:
| File | Purpose |
|---|---|
navbar.js |
Universal top bar with service links, notification bell mount, user dropdown |
nav-icons.js |
Navbar glyphs, loaded by navbar.js |
notification-ui.js |
Toast popups, bell badge, notification panel with category tabs |
account-switcher.js |
Multi-account switcher panel with anonymous mode |
user-card.js |
Right-click context menu + user profile card with name effects |
notifications.js |
Shared constants (priorities, categories, types) — also used server-side |
Include them in any service page:
<script src="https://openvibe.network/shared/navbar.js"></script>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="https://openvibe.network/shared/user-card.js"></script>
Boost (plan T11). /, /status and /updates load /shared/boost.js (data-main="#main"): a same-site link between two pages with the same external scripts swaps only <main id="main"> instead of loading the page. Today that is the /updates?site= filter links between /updates views; /, /status and /updates each load other scripts (the footer, the mark, the shipped log), so a move between them is a normal page load until their script sets match. Each carries <meta name="ov-boost" content="network@<release>">, the release id /release.json serves (server/boost.js), so a page from another deploy is a normal page load too. On / the static hero, intent picker and constellation stay outside #main, and the release id is part of the page cache key, so a deploy changes the / ETag. Pages without the marker (/my, /login, /admin/*, /verify-email, /@:username, the legal pages) are always a normal page load, by design. Page script that lives outside #main and wires something inside it re-wires on ov:boost:load (the shipped log on /updates, the shipped strip on /).
Admin Panel
Accessible to users with role = 'admin'. All endpoints under /api/admin/.
| Endpoint | Description |
|---|---|
GET /api/admin/email |
Email configuration status |
PUT /api/admin/email |
Update API key and email settings |
POST /api/admin/email/test |
Send test email |
GET /api/admin/settings |
All site settings (secrets masked) |
PUT /api/admin/settings |
Update site settings |
PUT /api/admin/users/:id/role |
Change user role |
PUT /api/admin/users/:id/ban |
Ban/unban user (sends notification) |
POST /api/admin/broadcast |
Send notification to all non-banned users |
GET /api/admin/health |
System health (counts, memory, uptime, email status) |
GET /api/admin/audit |
Audit log |
Connected Services
| Service | Port | OAuth Client ID |
|---|---|---|
| OpenVibe.Live | 3000 | live |
| OpenVibe.Tools | 4001 | tools |
| OpenVibe.Games | 8000 | games |
| OpenVibe.Media | 4100 | media |
Database Tables
| Table | Purpose |
|---|---|
users |
Accounts (username, email, password hash, role, bio, effects) |
oauth_clients |
Registered OAuth2 applications |
oauth_codes |
Authorization codes (5-min TTL) |
refresh_tokens |
Rotating refresh tokens (30-day) |
linked_accounts |
Connected external service accounts |
themes |
Theme catalog entries |
user_theme_prefs |
Per-user theme selections |
site_settings |
Key-value admin settings (email config, etc.) |
audit_log |
Admin action audit trail |
notifications |
All user notifications (UUID PK) |
notification_preferences |
Per-user category preferences |
anon_users |
Anonymous user identities |
user_sessions |
Active session tracking for multi-account |
user_effects |
Equipped name/particle effects |
follows |
User follow relationships |
wallets |
OpenCoins balance per Network user |
coin_transactions |
OpenCoins ledger (idempotency-key deduped) |
dev_projects, dev_project_members |
Developer projects and member roles |
dev_apps, dev_credentials, dev_auth_codes |
Developer apps, hashed client secrets, app authorization codes |
dev_grants, dev_quotas |
App capability grants; per-project quotas (enforced by the owning service) |
dev_audit |
Append-only developer audit; rows that are platform events carry an event envelope |
dev_usage_windows, dev_usage_daily, dev_usage_errors |
Developer projects' usage from the services' hourly rollups (35 days), per day (400 days), sampled failures (30 days) |
analytics_events, analytics_hourly, analytics_daily |
Request analytics (raw ≤ 30 days) and rollups, within ADR-021; see Analytics |
user_modules, user_module_revisions, user_module_retirements |
User-module records, the last revision issued per (subject, namespace), retired namespace owners |
network_event_outbox |
Network's events on their way to OpenVibe.Events (developer projects, user modules, blocks) |
user_blocks |
Platform blocks by subject (blocker, blocked, active, per-pair revision) |
user_follows |
The follow graph by subject (follower, target type and id, active, notify flags, per-pair revision, source) |
analytics_visitor_days, analytics_day_salts |
The current day's salted visitor hashes and salt, deleted after the day's final rollup |
Acceptance
npm test runs the 74 test files listed in package.json (temp databases, no network). They
cover OAuth with PKCE, OIDC discovery, refresh-token rotation and reuse, SSO and FedCM, subjects,
principals and grants, account merge, export and deletion, developer projects, export tokens and usage,
user modules, blocks, follows and their authority switch, analytics privacy, the registry and its
exposure, notifications and events, status and incidents, operator alerts, per-actor limits, and the
security suites (security-session, security-redirects, security-secrets, security-private,
security-idor, security-ssrf), plus the deploy wrapper.
Security
Reporting a vulnerability: SECURITY.md. The rules the code keeps:
- Tokens. Network signs every token with its RS256 key (
JWT_PRIVATE_KEYordata/keys); refresh tokens are stored as SHA-256 and a reused one revokes its family;token_valid_aftercutoffs are published so services refuse older tokens; client secrets are stored hashed and shown once. - Redirects. Post-sign-in targets (
/login?return=,/sso/fanout?next=) never runjavascript:URLs or leave for a host OpenVibe does not own (test/security-redirects.test.js); OAuth redirects must be one of the client's registered URIs. - Private data. Analytics keep no personal data beyond a day's salted hash; account export and deletion follow ADR-033; the internal routes take a capability-scoped service token only (the shared internal key was retired in plan T2).
- CORS. Only discovery and the registry are open to any origin; every other route uses the first-party allow-list.
- Secrets. Provider secrets come from the environment first, never printed;
SETUP_TOKEN,ADMIN_PASSWORD,NETWORK_EVENTS_SECRET, the webhook secrets andGITHUB_TOKENlive in/etc/openvibe/network.env(0600), by name only.
License
MIT (license in package.json).
- openvibe-contracts: v0.216.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2