Repositories openvibers openvibe.network official
openvibers/openvibe.network
One OpenVibe account for every site: sign-in, profiles, follows, OpenCoins and your machines.
Get it
Get this repository
A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.
git clone https://git.openvibe.codes/openvibers/openvibe.network.git
OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.
Clone to my computerA Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.
Open in CodesREADME.md
View fileOpenVibe.Network
One account for every OpenVibe site — openvibe.network. Sign-in, profiles, follows, OpenCoins and your machines.
Live site: https://openvibe.network · API docs: https://openvibe.services · For AI: https://openvibe.network/llms.txt
Run it: npm ci && npm run dev · Tests: npm test
Part of the OpenVibe network; help, the SDK and the developer API.
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.
- Per-site theme overrides — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight").
GET /api/themes/me/active?service=<id>answers that service's theme (override: true, with theservicenamed) or the person's global theme (override: false); a missing or unknown service answers exactly as before. The person owns their own list atGET /api/themes/me/overrides,PUT /api/themes/me/overrides/:serviceandDELETE /api/themes/me/overrides/:service(the theme and the custom colours are validated asPUT /api/themes/medoes), at most 50 per person, and every change is kept in the theme history with the service recorded. - Admin Panel — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- Setup (
/admin#setup, owner only) — one checklist of everything only the owner can give the network: keys, accounts, caps and a few registrations. It lists every field a service's config namespace marksx-owner-input(openvibe-shared 4.125.0), reached through the owner settings proxy (server/admin/ai-config-proxy.js, seventeen services), Needed first, then Not working, Connected and Optional, with a counted summary. A key is written once through the proxy (fresh sign-in, the service's vault, audit, alert) and never shown again; Test asks the service to try it and keeps the one-line answer; Network's own to-dos (legal registrations, DNSSEC at the registrar, a top-up) are itsnetwork.setupnamespace with Mark done (server/admin/setup.js). The AI tab and every admin page explain their words;/admin#glossarylists them. - Reviewer queue (
/admin/review) — one staff-only page for every Wiki page, Blog draft, AI brief and Trade company page waiting for a person, grouped by site with the publishing gate's noindex reason in words. Approve (indexable) or keep hidden calls the owning service's own review route and writes its own review 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](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](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. - Online status — each person has a presence of online, away, do not disturb or invisible, with an optional status line and one emoji, carried across every site.
POST /api/v1/me/presence/heartbeatkeeps it fresh (the shared navbar sends it every 60 s while the page is visible),PUT /api/v1/me/presencesets the chosen state, andGET /api/v1/presence?subjects=…answers only for people the caller follows or has a DM with (and themselves), never for a stranger and never for an invisible person; last seen is never returned.network.presence@1andnetwork.presence.changed@1, contracts 0.318.0,server/presence/. Chat reads it throughGET /internal/presence(network.presence.read) and asks back for the DM half of the rule (chat.dm.peers.read). Live streaming makeslive: true. Every row is in the person's export and erased with the account (ADR-033). - The Dock's state — the little floating surface every OpenVibe site draws (your agent, your chats, your activity) follows the person across every domain, because
localStorageis per site and the state lives here instead.GET /api/v1/me/dockandPUT /api/v1/me/dock(network.dock-state@1,network.dock-result@1, contracts 0.251.0;server/dock/). See [docs/dock.md](docs/dock.md).
Your account ID
Every person on OpenVibe has one canonical id, usr_…: the account ID. It is what apps,
robots and services ask for when they ask for "your OpenVibe account ID", and it is the id every
OpenVibe service keys a person's data by.
A person sees their own on their account page (https://openvibe.network/my, Profile → Account
ID): the id in a chip with a Copy button. It is nobody else's — the API answers it for the
signed-in person only.
API
GET /api/auth/me— the signed-in person's own account.user.subject_idis the account ID
(usr_…); an account that has none gets one minted on its first call.
curl -s https://openvibe.network/api/auth/me \
-H "Authorization: Bearer $OV_TOKEN" | jq -r '.user.subject_id'
OV_TOKEN is that person's own access token (the ov_token cookie, an OAuth access token, or an
Authorization: Bearer header). The answer carries the caller's own id and nobody else's.
With openvibe-sdk
The SDK has no client for GET /api/auth/me, so a service that already holds the person's token
reads the id from the token's claims:
const { verifyUserToken } = require('openvibe-sdk/auth');
const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' });
claims.subject_id; // 'usr_…' — the account ID
openvibe-sdk/identity goes the other way: resolve({ subjectId }) turns an account ID into the
person's username and picture (a service token, identity.subject.resolve).
Owns
- accounts, sessions, anonymous identities, canonical subjects (
usr_…,gst_…) - 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 (
lnk_…) 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.link.revoked@1 - the machines hub (
/api/v1/machines, plan L34): a person's machines, their roles and what they do right now.
Bot stays the registry underneath — Network calls it as the person, so Bot checks the machine is that person's
own — and Network publishes what changes (network.machine.online,.offline,.paired,.roles_changed,
.revoked). A token that acts for the person (an app's, or a run's) may read the machines, rename one and
revoke one; pairing a machine and choosing its roles come from the person's own session only, because a
machine paired that way would carry the person's jobs and their desks. - 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.317.0 (registry manifests, capability checks),openvibe-sdkv0.158.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry),openvibe-sharedv4.223.1, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the
network.site_copy
workflow), OpenVibe.Blog (the changelog feed it proxies) - email (Resend), Discord and GitHub when their secrets are set (Provider secrets)
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.presence.read, 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](server/identity/principals.js).
The hub's own two, network.machines.read and network.machines.manage (Contracts 0.261.0), are checked by name
here, next to the token and the consent: the check is the same one the contracts' own guard makes, and this one also
holds the app's consent and the person's own session (see [server/registry/machines.js](server/registry/machines.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, ai.run.create /
ai.run.read at OpenVibe.AI for the footer's site copy, and bot.machine.manage at OpenVibe.Bot for the
machines hub.
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: Link 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).
- Quiet hours and digests can hold normal email and browser push until a window ends, or replace per-alert email with an hourly/daily/weekly summary; critical notifications always break through.
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.
Quiet hours and digests
The bell's tabs (All · Mentions · Live · Deals & alerts · System · Agents) are defined once in server/notifications/tabs.js: GET /api/notifications?tab=mentions|live|alerts|system|agents lists a tab (it combines with unread_only, q and paging; an unknown tab is All), and GET /api/notifications/unread-by-tab answers { tabs: { all, mentions, live, alerts, system, agents } } for the counts on the tabs.
A person can set a do-not-disturb window with GET/PUT /api/notifications/quiet-hours — { enabled, start: 'HH:MM', end: 'HH:MM', tz }, default off at 22:00–07:00 UTC; the GET also reports in_quiet. Normal notification email and browser push wait until the window ends; critical notifications still go at once. The notification itself is always stored and appears in the bell.
Each category can choose digest: off (the default, NULL), hourly, daily or weekly, through PUT /api/notifications/preferences. A digested category is not emailed one by one: runDigests() sends one summary email after the period is due (daily and weekly at 08:00 or later in the person's quiet-hours zone, never during quiet hours), lists up to 20 items and says "and N more", then marks them emailed. It runs every 15 minutes in server/index.js and uses the same daily caps and verified/bounced-address checks as the email queue.
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"}}'
The Action Center
GET /api/v1/me/actions (server/actions/, capability network.actions.read, contracts 0.229.0) is the ✦ beside the bell:
the signed-in person's agent work in five buckets (running, waiting, scheduled, done, failed) as
network.me-actions-result@1. It merges their pending confirmations (server/developer/confirmations.js) with
Actor's, Codes' and AI's GET /api/v1/actions?subject=usr_… (actor.actions.read, codes.actions.read,
ai.actions.read; Network signs its own token for each, server/identity/self-token.js; OV_ACTOR_INTERNAL_URL,
OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL). Each source has 2.5 s; a slow or failing one is named in sources
and the answer is partial, and a service that has not deployed the route is not_connected. Items that are not
common.action-item@1, or claim another service, are dropped. The answer is cached 10 s per person.
POST /api/v1/me/actions/:service/:id/approve|decline decides a Network confirmation, or an Actor approval through
Actor's POST /api/v1/approvals/:id/decide (actor.approval.decide, X-OV-Subject); anything else is opened on its
own page.
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 |
| today.event.scheduled | Today (source: today) | every follower of subject.id, for a public or followers-only stream 15 min to 14 days ahead; at most 3 announced schedules per streamer a day | STREAM_SCHEDULED, stream, low (the bell, no toast); the time in the stream's own zone |
| today.event.cancelled | Today | everyone told by the matching today.event.scheduled | those notices are dismissed |
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](docs/developer-projects.md#usage).
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's
svc:network; endpointhttp://127.0.0.1:4000/internal/events; topic patterns
deals.watch.matched,trade.alert.triggeredandlive.stream.started; secret = the first
NETWORK_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.js
The 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.
Quiet hours and per-category digests are built (migration 0027_notification_quiet_digest.sql, server/notifications/quiet-hours.js and digest.js); the design note is [docs/notification-digest.md](docs/notification-digest.md).
The Dock's state
The Dock (plan 2026-10-30, K-D1) is one small floating surface on every OpenVibe site: your agent, your chats and
your activity. It is drawn on many domains and localStorage is per site, so the state it follows you with lives in
the Network (server/dock/, migration 0031_dock_states.sql, contracts 0.251.0).
GET /api/v1/me/dockanswersnetwork.dock-result@1: the person'snetwork.dock-state@1(the stored one, or the
default — closed, on the activity tab, not pinned, bottom-right, the site's own size), thecountsthe pill's
status dot is drawn from (runningandwaitingfrom the Action Center's aggregator,unreadfrom the
notification service) andsources_partial, true when an authority behind those counts did not answer.PUT /api/v1/me/docktakes a partial body — any subset of the state's fields — merges it into the stored state,
validates the result againstnetwork.dock-state@1(an invalid body is422with the contract's problem shape)
and answers the newnetwork.dock-result@1.placemerges per device class, so a body naming onlyplace.phone
leaves the desktop where it was.- The subject comes from the caller's token and from nowhere else, so one person never reads or writes another's
state. The caller's own session only: theov_tokencookie on openvibe.network, a Bearer user token elsewhere,
with the CORS allow-list and the CSRF rules the other/api/v1/me/*routes use. - A write is limited to 60 a minute per person (
429 dock.rate_limitedpast that); the page debounces anyway. - The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge
with the surviving account.
The full reference is [docs/dock.md](docs/dock.md).
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. - 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 404
secret.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:
onSubjectRemoveddeletes a subject's records with events; guest links move portable preferences to a registered account; 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 with
network.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/:target
follow 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/:targetneeds
network.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 are
network_tokens_issued_total{grant_type},network_token_failures_total{grant_type,error}and
network_principal_token_failures_total{code}.- Universal telemetry: requests are aggregated per
route|method|status_classinto a single
platform.telemetry-sample@1per key per flush (count, sum, max and p95 inextra, mean in
latency_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 by
openvibe-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, from
openvibe-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 and
checked_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.md) / [docs/slo.json](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.
Theip,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's
pruneRawEventsPg) 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) |
| locker_keys | The Locker key directory: a person's public keys, their fingerprint and their self-signature |
| locker_key_backups | The wrapped private keys, one row per person and kind (passphrase, recovery, passkey); opaque to Network |
| locker_key_history | Every Locker key change, append-only: the fingerprint before and after |
| 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, 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](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.
Sharing
The sharing system (design 2026-11-06) lets a person share an item on one site with another person.
The owning service stays the authority on access; Network keeps the index and the list.
The events
Every share event arrives over OpenVibe.Events at POST /internal/events and is handled in
server/sharing/events.js:
| Event | What Network does |
|-------|-------------------|
| share.granted | the item joins that person's list, and they get the notification share.added |
| share.revoked | the item leaves that person's list |
| share.transfer.requested | the offer waits for the recipient, who gets share.transfer.requested |
| share.transfer.accepted | the recipient owns it now; the old owner gets share.transfer.answered |
| share.transfer.declined | nothing changed; the old owner gets share.transfer.answered |
| share.access.requested | the owner gets share.access_requested |
The payloads carry ids only (a { service, kind, id } ref and a subject), never a title. The name of
an item and its own link come from the owning service's share API, GET /api/v1/<kind>/<id>/share,
asked on the person's behalf; when the service does not answer, the item is shown as its site and its
kind instead.
The routes
| Route | What it answers |
|-------|-----------------|
| GET /shared | the page: every item shared with the signed-in person, on every site |
| GET /api/v1/shared-with-me | the same list as JSON, with the pending transfers and their Accept and Decline links |
| GET /api/v1/share/suggestions | the people this person has shared with (the share picker's suggestions): from their own history only, at most 20 |
| GET /api/v1/share/name-check?name= | one @name check for the share picker, thirty a minute for the person |
The event names and the payload shapes are Contracts share.v1, which is still being written; this code
waits for that release before it ships.
Locker keys
The Locker (design 2026-11-06, LOCKER-DESIGN-2026-11-06.md) is end-to-end encryption for anything on
OpenVibe: a locked item is stored and served as ciphertext, and the keys never reach our servers. Network
is the key directory — it holds a person's public keys, their self-signature and a copy of the
private keys that the browser sealed, and it can never read a private key. The format and the library
are openvibe-sdk/locker and openvibe-sdk/locker/server; the browser seals the private keys, Network
only checks the shape of what it is given.
What Network stores
| Table | What it holds |
|-------|---------------|
| locker_keys | the two P-256 public keys (the ECDH one receives content keys, the ECDSA one signs the fingerprint), the eight-word fingerprint and the self-signature |
| locker_key_backups | the wrapped private keys, one row per kind (passphrase, recovery, passkey), stored exactly as the browser sealed them |
| locker_key_history | every key change, append-only: the fingerprint before and the fingerprint after |
What Network can read: the public keys, the fingerprint, when the keys last changed, and the shape of
a backup (its size, its lock and its PBKDF2 count).
What Network cannot read: a private key, in any form. The private keys are wrapped by a key from the
person's locker passphrase (PBKDF2-SHA-256, 600 000 iterations or more) or by a passkey, and the wrapped
bytes are opaque here. If the passphrase and the recovery kit are both lost, nobody can open the locked
items, including OpenVibe — but the person can start again with a reset (see below).
The proof: a change needs the key the person has now
A session cookie on its own must not be able to change a person's keys: a stolen session that swapped in
somebody else's keys would read everything locked for that person from then on. So a person who already
has keys proves they still hold them: the current signing key signs the change, and the signature
comes as previous_signature (base64, ECDSA P-256 over SHA-256). Without it the write is refused with
403 locker.proof_required.
The bytes to sign are these, as UTF-8 (server/locker/proof.js, exported so the browser signs the same
ones):
openvibe-locker key-change v1|<subject>|<new fingerprint>|<backups digest>
openvibe-locker forget v1|<subject>
<subject>is the person's ownusr_…id.<new fingerprint>is the eight words the new keys produce (fingerprintOf).<backups digest>is the lower-case hex SHA-256 of the canonical JSON of the backups that are
written: the keys of every object sorted, no spaces, one backup per kind:
{"passphrase":{"envelope":"<base64>","format":"locker.envelope.v1"}}
The same proof covers a backups-only update (the fingerprint does not change).
A reset, and forgetting
- A reset is for the person who lost both their passphrase and their recovery kit:
PUT /api/v1/me/keys
with{"reset": true}and no proof, allowed only when the session's sign-in is recent — the session
token'sauth_timewithin the last ten minutes (the OIDC claim Network'ssignTokenputs on every
session; the same rule the account deletion uses). Without it the write is refused with
403 locker.reauth_required. A reset replaces the whole locker: new keys, and the backups that come with
them; the old backups wrapped keys that are gone. It is written down asreset. - Forgetting (
DELETE /api/v1/me/keys, with{"confirm": "forget my locker"}) needs the same proof, over
openvibe-locker forget v1|<subject>, or a recent sign-in.
The routes
| Route | What it answers |
|-------|-----------------|
| PUT /api/v1/me/keys | my public keys, my self-signature and my wrapped backups |
| GET /api/v1/me/keys/backup | my own wrapped private keys, for a new device — never anybody else's |
| DELETE /api/v1/me/keys | forget my locker: the keys and the backups go, the history stays |
| GET /api/v1/people/:subject/keys | a person's public keys and fingerprint, by subject |
| GET /api/v1/people/@:username/keys | the same, by exact name |
The lookups need a signed-in person and are rate-limited to sixty a minute for each caller. Every answer
is private, no-store, and Vary: Authorization, Cookie.
# the public keys and the fingerprint of @maticus
curl -s -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/people/@maticus/keys
{
"subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"ecdh_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"signing_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"fingerprint": "alpha bravo charlie delta · echo foxtrot golf hotel",
"updated_at": "2026-11-06T09:12:00.000Z",
"changed_at": "2026-11-06T09:12:00.000Z"
}
changed_at is the last time the keys changed, so a share sheet can warn before it shares with a key the
people have not seen before. The fingerprint is computed here, never taken from the caller: a caller that
sends one that does not match its own keys is refused, and the self-signature must verify with the
signing key.
A backup is a Locker envelope with a password lock, base64, at most 16 KB, sealed with a PBKDF2 count
between 600 000 and 10 000 000. Network checks that with openvibe-sdk/locker/server and stores the bytes
as they came; it never opens one.
{
"ecdh_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"signing_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
"self_signature": "…",
"previous_signature": "…",
"backups": {
"passphrase": { "format": "locker.envelope.v1", "envelope": "…" }
}
}
Every key change is announced on OpenVibe.Events as locker.keys.changed, with ids and fingerprints
only: no key, no signature, no blob ever leaves this service in an event, in a log line or in an answer.
The event's reason says why it happened: created, rotated (new keys), restored (the same keys, new
backups), reset or forgotten.
Every change also sends the person a Network notification, so a swap nobody asked for is seen:
| Change | What the person is told |
|--------|------------------------|
| created | "Your locker was set up: the things you lock are opened with these keys." |
| rotated | "Your locker keys changed: things locked for your old keys cannot be opened with the new ones." |
| restored | "Your locker backup was restored: the things you lock are opened with the same keys." |
| reset | "Your locker was reset: things locked for your old keys cannot be opened" |
| forgotten | "Your locker was forgotten: your keys and your backups are gone." |
License
MIT (license in [package.json](package.json)).
<!-- versions:start -->
- openvibe-contracts: v0.421.0
- openvibe-sdk: v0.161.0
- openvibe-shared: v4.235.0
<!-- versions:end -->