![OpenVibe.Network](https://openvibe.network/shared/social/network.jpg) # OpenVibe.Network **One account for every OpenVibe site** — [openvibe.network](https://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](https://openvibe.network); [help](https://openvibe.help), the [SDK](https://github.com/OpenVibers/OpenVibe.SDK) and the [developer API](https://openvibe.services). ## 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)** — `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 `generation` of the sign-in's `family_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 scope `openid` the code grant adds an RS256 `id_token` (aud = client, the authorize `nonce` echoed, `sub` = the Network id as a string, `subject_id` = the canonical usr_ id); `/oauth/userinfo` returns 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=` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), 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 marks `x-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 its `network.setup` namespace with **Mark done** (`server/admin/setup.js`). The AI tab and every admin page explain their words; `/admin#glossary` lists 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 when `OV_EVENTS_INTERNAL_URL` is 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 with `Access-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** — `/@`: one page per person with their picture, bio, when they joined, what they wear and the items they own in [OpenVibe.Inventory](https://inventory.openvibe.network) (plan T21). The same data is at `GET /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](#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/heartbeat` keeps it fresh (the shared navbar sends it every 60 s while the page is visible), `PUT /api/v1/me/presence` sets the chosen state, and `GET /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@1` and `network.presence.changed@1`, contracts 0.318.0, `server/presence/`. Chat reads it through `GET /internal/presence` (`network.presence.read`) and asks back for the DM half of the rule (`chat.dm.peers.read`). Live streaming makes `live: 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 `localStorage` is per site and the state lives here instead. `GET /api/v1/me/dock` and `PUT /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_id` is the account ID (`usr_…`); an account that has none gets one minted on its first call. ```bash 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: ```js 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 as `network.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-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.158.0 (PostgreSQL and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.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](#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 ```bash # 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/.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`: ```bash sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate --write-env /etc/openvibe/.env ``` The script requires the database named `ov_network` (or an explicit `--database-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 `. 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 `certbot` calls via `server/deploy/cert-manager.js` - supports `cloudflare` mode using `certbot-dns-cloudflare` - supports `manual` DNS-01 issuance for wildcard certificates - stores certs under `/etc/letsencrypt/live//` - generates Nginx config templates that reference `/etc/letsencrypt/live/.../fullchain.pem` and `privkey.pem` ### Requirements - `certbot` installed and available in `PATH` - for Cloudflare mode: `certbot-dns-cloudflare` plugin installed - `/etc/letsencrypt` writable by the service user - `nginx` installed 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 email - `DEPLOY_CERT_MODE` — `cloudflare`, `manual`, or `none` - `DEPLOY_CLOUDFLARE_TOKEN` — Cloudflare API token for DNS-01 challenge - `DEPLOY_DOMAINS` — array of domain objects, e.g. `[{domain:'openvibe.network',wildcard:false,certName:'openvibe.network',services:['network']}]` - `DEPLOY_NGINX_MODE` — `preview`, `apply`, or `disabled` - `DEPLOY_NGINX_SITES_PATH` — path where Nginx site files should be written - `DEPLOY_NGINX_BACKUP_PATH` — backup directory for generated Nginx configs - `DEPLOY_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` — check `certbot`, plugin, `/etc/letsencrypt`, and `nginx` - `GET /api/admin/deploy/config` — read current deploy config - `PUT /api/admin/deploy/config` — save deploy config values - `GET /api/admin/deploy/certs` — list certificates known to certbot - `POST /api/admin/deploy/certs/issue-cloudflare` — issue a wildcard cert via Cloudflare DNS-01 - `POST /api/admin/deploy/certs/manual-info` — get manual DNS challenge instructions - `POST /api/admin/deploy/certs/issue-manual` — run manual DNS issuance after TXT records are in place - `POST /api/admin/deploy/certs/renew` — renew all certbot certificates - `GET /api/admin/deploy/nginx/preview` — preview generated Nginx configs ### How to use it 1. Install certbot: ```bash sudo apt update sudo apt install certbot python3-certbot-dns-cloudflare ``` 2. Ensure `nginx` is installed if you want config preview/apply support. 3. Set deploy registry values through the setup API or admin config. 4. For Cloudflare mode, provide a valid Cloudflare token. The wrapper writes `/etc/letsencrypt/cloudflare.ini`. 5. For manual mode, create the required DNS TXT records for `_acme-challenge.` and `*. ` as instructed by the service. 6. After issuance, Nginx configs reference certs at `/etc/letsencrypt/live//fullchain.pem` and `/etc/letsencrypt/live//privkey.pem`. 7. Use `POST /api/admin/deploy/certs/renew` to 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_MODE` is set to `none`, the service will still generate preview Nginx configs but will leave SSL certificate paths commented out. - `certbot` must be installed on the host running openvibe.network, not just in Docker or another container. --- ## Notification System ### How it works 1. **Any service** pushes notifications to openvibe.network via `POST /internal/notifications/push` 2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content 3. **Clients** poll `GET /api/notifications` every 15 seconds, rendering toasts and updating the bell badge (or, where a site turned realtime on, hear each new notification over OpenVibe.Events and poll every 2 minutes as a safety net: see *Realtime badge* below) 4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default). 5. **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: ```bash curl -X POST http://127.0.0.1:4000/internal/notifications/push \ -H "Authorization: Bearer " \ -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: 1. Generate the signing secret and give it to Network: add `NETWORK_EVENTS_SECRET=` to `/etc/openvibe/network.env` (comma-separate a second value to rotate), and make sure `OV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300` is set. Restart `openvibe-network`; the log says `[Events consumer] on`. 2. Create the subscriptions in Events (consumer `network`, which Events takes from the token's `svc:network`; endpoint `http://127.0.0.1:4000/internal/events`; topic patterns `deals.watch.matched`, `trade.alert.triggered` and `live.stream.started`; secret = the first `NETWORK_EVENTS_SECRET`). On a host that already has the other two, add `live.stream.started` alone with `--topic live.stream.started` (and the project usage rollups with `--topic tools.usage.recorded --topic events.usage.recorded`): ```bash 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: }`, visibility `subject` (Events streams it to that person only), actor `system:network`. - **Payload:** `notification_id`, `type`, `category`, `priority`, `service`, `created_at` and the recipient's `unread_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 /realtime`, `sub `, `aud [openvibe.events]`, `typ` and `purpose` `realtime`, 120 s, `jti rtk_…`. Events accepts each one once. - **Never a session:** its issuer, `typ` and 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=off` in `/etc/openvibe/network.env` answers 503 `realtime.disabled`, and every badge stays on polling. `OV_EVENTS_PUBLIC_URL` overrides the stream origin (default `https://openvibe.events`). - **Client:** openvibe-shared `notification-live.js`, on where a site sets `notificationsRealtime: 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/dock` answers `network.dock-result@1`: the person's `network.dock-state@1` (the stored one, or the default — closed, on the activity tab, not pinned, bottom-right, the site's own size), the `counts` the pill's status dot is drawn from (`running` and `waiting` from the Action Center's aggregator, `unread` from the notification service) and `sources_partial`, true when an authority behind those counts did not answer. - `PUT /api/v1/me/dock` takes a partial body — any subset of the state's fields — merges it into the stored state, validates the result against `network.dock-state@1` (an invalid body is `422` with the contract's problem shape) and answers the new `network.dock-result@1`. `place` merges per device class, so a body naming only `place.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: the `ov_token` cookie 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_limited` past 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 1. Create a domain or sender identity at . 2. Add the required SPF, DKIM, and MX records to your DNS provider. 3. Configure the Resend API key and from-addresses in the admin panel. 4. 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`, `database` or `unset`) - `PUT /api/admin/email` — update API key, default from address, and per-service from addresses - `POST /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's `secret_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 session - `GET /api/auth/anon/:token` — get anon user info - `PUT /api/auth/anon/:token/preferences` — update anon preferences - `POST /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: ` 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.write` with the namespace in the grant); only the namespace's owner writes. `chat.preferences` is owned by Chat since the Wave 6 cutover (`OWNER_HANDOFFS`, until Contracts says so). - Every change emits `network.module.updated` through `network_event_outbox` (relayed to OpenVibe.Events when `OV_EVENTS_INTERNAL_URL` is 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` deletes a subject's records with events; guest links move portable preferences to a registered account; triggers refuse deleting a `users`/`anon_users` row, or changing its `subject_id`, while records remain. - Owning service retired (its manifest `status: retired`): the namespace turns read-only; `delete-after-retention` namespaces are emptied `retentionDays` after 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.read` only (Chat and Community hold it by default; never the shared key). - Every change writes `network.block.changed` into `network_event_outbox` in 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_id` as a Network id, or `actor_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/:target` gives the follower count, and for a signed-in caller whether they follow and how they are notified. - **People:** `GET /api/v1/me/follows` lists what the caller follows. `PUT|DELETE /api/v1/me/follows/:type/:target` follow and unfollow (`:target` is 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/followers` is open to the target's owner, and to services with `network.follows.read` (service token only). Lists are never public. - **Events:** every change writes `network.follow.created` or `network.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/:target` needs `network.follows.write`, a service token and `network.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_sessions` table - Switch accounts instantly without re-authentication - Includes anonymous mode as a switchable identity - Shared `account-switcher.js` component renders the switcher UI on all services ### API - `GET /api/auth/sessions` — list active sessions - `POST /api/auth/sessions` — create session record - `DELETE /api/auth/sessions/:id` — revoke single session - `DELETE /api/auth/sessions` — revoke all sessions --- ## OAuth2 Flow 1. Service redirects user to `https://openvibe.network/oauth/authorize?client_id=...&redirect_uri=...` 2. If user has `ov_token` cookie → issue authorization code → redirect to service 3. If no cookie → redirect to `openvibe.network` → user signs in → cookie set → resume flow 4. Service exchanges code for access + refresh tokens via `POST /token` 5. Access tokens are RS256 JWT (24h) verified by any service with the public key 6. Refresh tokens rotate on use (30d) --- ## Observability and status - `GET /api/ready`: named checks from `openvibe-shared/ready`, each with `status`, `required`, `latency_ms` and `checked_at`. `db` and `signing_key` are required (`signing_key` means an RS256 keypair that signs and verifies a probe token; it is only optional outside production). `registry_poll` and `discord_bot` are optional. The answer is 503 only when a required check fails. Otherwise it is 200, with `status: "degraded"` when an optional check has failed. - `GET /metrics`: Prometheus text for direct loopback callers only. Any proxied request gets a 404, and `deploy/nginx` blocks the path too. Besides the HTTP golden signals by route template, it exports process metrics, `release_info` and `release_client_updates_total{outcome,reason}` (what open tabs report to `POST /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_class` into a single `platform.telemetry-sample@1` per key per flush (count, sum, max and p95 in `extra`, 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 as `telemetry.*` events (`server/telemetry-sink.js`); `gracefulStop` flushes it once at shutdown. - `GET /release.json` is Network's release manifest (ADR-016, `registry.release-manifest@1`, from `openvibe-shared/release`'s `release.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 /status` is the operator page: server-rendered, no JavaScript needed, `noindex`. `GET /api/v1/status` returns 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.json` about 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) or `placeholder` (planned). The registry adds `exposure` to each service, the descriptor gives an `origin` only to `live` services (`planned_origin` otherwise), `/status` reads "up (loopback only)" for an internal service, and the Frame's nav lists only `live` sites. Change a row when a domain stops serving its placeholder; `test/registry-exposure.test.js` pins the states. - The registry also answers `GET /api/v1/registry/topics` (every event type from the manifests' `eventsProduced`/`eventsConsumed` and the payload contracts, with producers, consumers, the matching consumer pattern and the payload contract; filters `service`, `producer`, `consumer`, `prefix`), `/topics/:topic`, `/releases` (each running service's `/release.json` from the same loopback poll, with its installed openvibe-contracts/sdk/shared versions and drift against the libraries' current releases), `/health` and `/search?q=`. A type a service's code publishes before its manifest lists it is added from `server/registry/topics.js` OBSERVED (marked `observed` on 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/.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/featured` lists 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), with `ranked_at` and `stale`; 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 by `OV__INTERNAL_URL` (loopback URLs only; anything else is ignored and logged). Site hosts come from each manifest's `publicOrigin`. - `node scripts/contracts-drift.js` warns about services that pin an older openvibe-contracts than the latest tag, or a tag that was never published (`--dir ~/OpenVibers` for local checkouts, `--registry https://openvibe.network` for what is running, `--package all` for sdk and shared too). It exits 0 unless `--strict`; CI runs it as a warning-only step. - `GET /api/v1/status/slo` returns 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. The `ip`, `user_id` and `city` columns 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: 1` or `DNT: 1` is not recorded at all (no raw row, visitor hash, session id or rate counter), so it is also missing from the rollups. - **Admin panel:** the bot tables list user-agent classes and high-volume session ids, not IPs. - **Connection:** the tracker buffers and flushes into the service's own PostgreSQL handle (the same database the identity tables live in), so there is no second connection or file of its own. - **Retention:** raw events older than 30 days are deleted every night in batches of 5000 (the `analytics-prune` job: first run 5 minutes after boot, then every 24 h). Rollups are kept. - **Operator CLI:** `scripts/analytics-prune.js` (over `openvibe-shared/analytics/pg`'s `pruneRawEventsPg`) runs a dry run by default and changes nothing; `--apply` deletes raw events older than `--days` (1..30, default 30). Run it where Network runs, with `DATABASE_URL` set. ## Shared Client Libraries The `openvibe-shared` package ([OpenVibers/OpenVibe.Shared](https://github.com/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: ```html ``` **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 `
` 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 ``, 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](#analytics-adr-021) | | `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_KEY` or `data/keys`); refresh tokens are stored as SHA-256 and a reused one revokes its family; `token_valid_after` cutoffs 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 run `javascript:` 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 and `GITHUB_TOKEN` live 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///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||| openvibe-locker forget v1| ``` - `` is the person's own `usr_…` id. - `` is the eight words the **new** keys produce (`fingerprintOf`). - `` 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: ```json {"passphrase":{"envelope":"","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's `auth_time` within the last ten minutes (the OIDC claim Network's `signToken` puts 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 as `reset`. - **Forgetting** (`DELETE /api/v1/me/keys`, with `{"confirm": "forget my locker"}`) needs the same proof, over `openvibe-locker forget v1|`, 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`. ```bash # the public keys and the fingerprint of @maticus curl -s -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/people/@maticus/keys ``` ```json { "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. ```json { "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)). - openvibe-contracts: v0.421.0 - openvibe-sdk: v0.161.0 - openvibe-shared: v4.237.0