openvibers/openvibe.network README.md
README.md
| 1 |  |
| 2 | |
| 3 | # OpenVibe.Network |
| 4 | |
| 5 | **One account for every OpenVibe site** — [openvibe.network](https://openvibe.network). Sign-in, profiles, follows, OpenCoins and your machines. |
| 6 | |
| 7 | **Live site:** https://openvibe.network · **API docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt |
| 8 | **Run it:** `npm ci && npm run dev` · **Tests:** `npm test` |
| 9 | |
| 10 | 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). |
| 11 | |
| 12 | ## Purpose |
| 13 | |
| 14 | 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. |
| 15 | |
| 16 | **Part of the [OpenVibe network](https://openvibe.network)** — `https://openvibe.network` |
| 17 | |
| 18 | --- |
| 19 | |
| 20 | ## What it does |
| 21 | |
| 22 | - **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. |
| 23 | - **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`. |
| 24 | - **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`). |
| 25 | - **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. |
| 26 | - **Email Alerts** — Critical notifications (moderation actions, system alerts) can be delivered by the built-in email service. Configuration is managed through the admin panel. |
| 27 | - **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. |
| 28 | - **Multi-Account Switching** — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode. |
| 29 | - **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services. |
| 30 | - **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 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. |
| 31 | - **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log. |
| 32 | - **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. |
| 33 | - **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. |
| 34 | - **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. |
| 35 | - **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). |
| 36 | - **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`). |
| 37 | - **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](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`). |
| 38 | - **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe. |
| 39 | - **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules). |
| 40 | - **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). |
| 41 | - **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). |
| 42 | |
| 43 | ## Your account ID |
| 44 | |
| 45 | Every person on OpenVibe has one canonical id, `usr_…`: the **account ID**. It is what apps, |
| 46 | robots and services ask for when they ask for "your OpenVibe account ID", and it is the id every |
| 47 | OpenVibe service keys a person's data by. |
| 48 | |
| 49 | A person sees their own on their account page (`https://openvibe.network/my`, Profile → Account |
| 50 | ID): the id in a chip with a Copy button. It is nobody else's — the API answers it for the |
| 51 | signed-in person only. |
| 52 | |
| 53 | ### API |
| 54 | |
| 55 | - `GET /api/auth/me` — the signed-in person's own account. `user.subject_id` is the account ID |
| 56 | (`usr_…`); an account that has none gets one minted on its first call. |
| 57 | |
| 58 | ```bash |
| 59 | curl -s https://openvibe.network/api/auth/me \ |
| 60 | -H "Authorization: Bearer $OV_TOKEN" | jq -r '.user.subject_id' |
| 61 | ``` |
| 62 | |
| 63 | `OV_TOKEN` is that person's own access token (the `ov_token` cookie, an OAuth access token, or an |
| 64 | `Authorization: Bearer` header). The answer carries the caller's own id and nobody else's. |
| 65 | |
| 66 | ### With openvibe-sdk |
| 67 | |
| 68 | The SDK has no client for `GET /api/auth/me`, so a service that already holds the person's token |
| 69 | reads the id from the token's claims: |
| 70 | |
| 71 | ```js |
| 72 | const { verifyUserToken } = require('openvibe-sdk/auth'); |
| 73 | const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' }); |
| 74 | claims.subject_id; // 'usr_…' — the account ID |
| 75 | ``` |
| 76 | |
| 77 | `openvibe-sdk/identity` goes the other way: `resolve({ subjectId })` turns an account ID into the |
| 78 | person's username and picture (a service token, `identity.subject.resolve`). |
| 79 | |
| 80 | --- |
| 81 | |
| 82 | ## Owns |
| 83 | |
| 84 | - accounts, sessions, anonymous identities, canonical subjects (`usr_…`, `gst_…`) |
| 85 | - OAuth2/OIDC (the network's token issuer and its signing key), service principals and their grants, |
| 86 | developer projects, apps, credentials, grants and quotas (ADR-014), mod principals |
| 87 | - notifications and email, themes and theme preferences, user modules, platform blocks, the follow |
| 88 | graph (ADR-030), creator analytics (ADR-021), the OpenCoins wallet |
| 89 | - node principals: a person's paired machine (`lnk_…`) with a one-time pairing code, a scoped credential and a |
| 90 | token; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announced |
| 91 | as `network.link.revoked@1` |
| 92 | - the machines hub (`/api/v1/machines`, plan L34): a person's machines, their roles and what they do right now. |
| 93 | Bot stays the registry underneath — Network calls it as the person, so Bot checks the machine is that person's |
| 94 | own — and Network publishes what changes (`network.machine.online`, `.offline`, `.paired`, `.roles_changed`, |
| 95 | `.revoked`). A token that acts for the person (an app's, or a run's) may read the machines, rename one and |
| 96 | revoke one; pairing a machine and choosing its roles come from the person's own session only, because a |
| 97 | machine paired that way would carry the person's jobs and their desks. |
| 98 | - the service registry (`/api/v1/registry/*`, `/.well-known/openvibe`), the status page, the network |
| 99 | changelog proxy, and the files at `/shared/*` (the pinned openvibe-shared release) |
| 100 | |
| 101 | ## Does not own |
| 102 | |
| 103 | - money (OpenVibe.Billing), channel points and streams (OpenVibe.Live), each service's data (a user |
| 104 | module's namespace belongs to the service that owns it) |
| 105 | - the contracts it serves (OpenVibe.Contracts) and the Frame's code (OpenVibe.Shared) |
| 106 | |
| 107 | ## Depends on |
| 108 | |
| 109 | - `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.158.0 (PostgreSQL |
| 110 | and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.223.1, pinned by release tarball |
| 111 | - OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy` |
| 112 | workflow), OpenVibe.Blog (the changelog feed it proxies) |
| 113 | - email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets)) |
| 114 | |
| 115 | ## Capabilities |
| 116 | |
| 117 | Implemented here (the service manifest's `capabilities`, audience `openvibe.network`): |
| 118 | `identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`, |
| 119 | `network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`, |
| 120 | `network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.presence.read`, `network.integration.github.read`, |
| 121 | `network.modules.read|write`, `network.notifications.push`, `network.operator.alert`, |
| 122 | `network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants other |
| 123 | services hold are seeded in [server/identity/principals.js](server/identity/principals.js). |
| 124 | |
| 125 | The hub's own two, `network.machines.read` and `network.machines.manage` (Contracts 0.261.0), are checked by name |
| 126 | here, next to the token and the consent: the check is the same one the contracts' own guard makes, and this one also |
| 127 | holds the app's consent and the person's own session (see [server/registry/machines.js](server/registry/machines.js)). |
| 128 | |
| 129 | Called elsewhere: Network is the issuer, so it signs its own `svc:network` tokens: `events.event.publish` |
| 130 | (and `events.delivery.admin` for the admin Events views) at OpenVibe.Events, `ai.run.create` / |
| 131 | `ai.run.read` at OpenVibe.AI for the footer's site copy, and `bot.machine.manage` at OpenVibe.Bot for the |
| 132 | machines hub. |
| 133 | |
| 134 | ## Architecture |
| 135 | |
| 136 | ``` |
| 137 | openvibe.network (port 4000) |
| 138 | ├── server/ |
| 139 | │ ├── index.js # Express app, CORS, service init, periodic tasks |
| 140 | │ ├── config.js # Port, JWT, DB, OAuth, connected services |
| 141 | │ ├── auth/ |
| 142 | │ │ ├── routes.js # Register, login, profile, anon sessions, multi-account, follows |
| 143 | │ │ └── oauth-routes.js # OAuth2 authorize, token, OIDC discovery |
| 144 | │ ├── notifications/ |
| 145 | │ │ ├── notification-service.js # CRUD, preferences, email queue, cleanup |
| 146 | │ │ ├── routes.js # REST API for notification UI |
| 147 | │ │ └── email-service.js # Resend email service with HTML templates |
| 148 | │ ├── admin/ |
| 149 | │ │ └── routes.js # Admin panel API (email settings, users, broadcast, health) |
| 150 | │ ├── themes/ |
| 151 | │ │ └── routes.js # Theme CRUD, user preferences |
| 152 | │ ├── internal/ |
| 153 | │ │ └── routes.js # Server-to-server API (verify-token, user sync, notif push) |
| 154 | │ └── db/ |
| 155 | │ └── database.js # PostgreSQL handle (openvibe-sdk/db), migrations/, boot seeding |
| 156 | ├── public/ |
| 157 | │ ├── index.html # Landing page (hero + service cards) |
| 158 | │ ├── login.html # Animated login/register page |
| 159 | │ └── my.html # Account management (profile, sessions, notifications) |
| 160 | ├── deploy/ |
| 161 | │ ├── nginx/ # openvibe.network.conf; network-subdomains.conf (status/themes/admin/auth/api. redirects); parked-domains.conf |
| 162 | │ ├── scripts/ # deploy.sh (runs `ovhost deploy network`) |
| 163 | │ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env), .socket + socket.conf drop-in |
| 164 | └── .env.example |
| 165 | ``` |
| 166 | |
| 167 | --- |
| 168 | |
| 169 | ## Setup |
| 170 | |
| 171 | ```bash |
| 172 | # 1. Install dependencies |
| 173 | cd OpenVibe.Network && npm install |
| 174 | |
| 175 | # 2. Generate RSA keys for JWT signing |
| 176 | mkdir -p data/keys |
| 177 | openssl genrsa -out data/keys/private.pem 2048 |
| 178 | openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem |
| 179 | |
| 180 | # 3. Configure environment |
| 181 | cp .env.example .env |
| 182 | # Edit .env — set ADMIN_USERNAME, ADMIN_PASSWORD, and optionally SETUP_TOKEN and BOOTSTRAP_PROFILE. |
| 183 | # In production also set DATABASE_URL (the runtime role through PgBouncer), DATABASE_DIRECT_URL (the owner |
| 184 | # role on a direct connection, for migrations) and VALKEY_URL (shared limit counters). Without |
| 185 | # DATABASE_URL, development uses an embedded PGlite database in data/pglite; production refuses to boot. |
| 186 | |
| 187 | # 4. Run |
| 188 | npm start |
| 189 | |
| 190 | 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. |
| 191 | ``` |
| 192 | |
| 193 | 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)"`). |
| 194 | |
| 195 | To rotate a service principal, load **Network's** environment before the script name and pass the target service's file with `--write-env`: |
| 196 | |
| 197 | ```bash |
| 198 | sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate <id> --write-env /etc/openvibe/<id>.env |
| 199 | ``` |
| 200 | |
| 201 | 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. |
| 202 | |
| 203 | --- |
| 204 | |
| 205 | ## Deploying |
| 206 | |
| 207 | `sudo /opt/openvibe.network/deploy/scripts/deploy.sh` runs `ovhost deploy network --install-units` |
| 208 | (OpenVibe.Host, strategy `git-checkout`; roadmap WS-N task 11): a fast-forward pull as the checkout owner, |
| 209 | `npm install --omit=dev` when the lockfile or dependency fields changed, every dependency checked before |
| 210 | anything restarts, `deploy/systemd/openvibe-network.service` installed when it differs (daemon-reload), the |
| 211 | restart, `/api/ready` polled, an automatic rollback if it does not come up, and the release announced. |
| 212 | `--restart`, `--wait-idle`, `--force` and `--rollback` (`ovhost rollback network`) are passed on; `DRY_RUN=1` |
| 213 | prints `ovhost plan network`. If ovhost is missing, the wrapper exits with an error. |
| 214 | |
| 215 | The unit is `openvibe-network.service` (runs as `ubuntu` from `/opt/openvibe.network`) on |
| 216 | `127.0.0.1:4000`, the env file `/etc/openvibe/network.env`. The listener belongs to `openvibe-network.socket` |
| 217 | (with the `openvibe-network.service.d/socket.conf` drop-in), so connections that arrive during a restart wait |
| 218 | for the new process instead of getting 502s; ovhost restarts the service, never the socket. Check that systemd |
| 219 | holds it with `sudo ss -ltnp | grep :4000` (systemd listed, the process on fd 3). Rollback: automatic when `/api/ready` does not |
| 220 | come up; afterwards `sudo ovhost rollback network --to <sha>`. Two blockers: `chrome_cache`/`chrome_hits` |
| 221 | became `frame_cache`/`frame_hits` on 2026-09-24 (an older release starts with empty caches), and |
| 222 | `tool_domains` was rebuilt once for the mirror role (an older release does not know mirror rows). |
| 223 | |
| 224 | --- |
| 225 | |
| 226 | ## TLS / Certbot Deploy Support |
| 227 | |
| 228 | `openvibe.network` includes a built-in deploy subsystem under `server/deploy/` that can manage Let’s Encrypt certificates and preview Nginx config. |
| 229 | |
| 230 | ### What it does |
| 231 | |
| 232 | - wraps local `certbot` calls via `server/deploy/cert-manager.js` |
| 233 | - supports `cloudflare` mode using `certbot-dns-cloudflare` |
| 234 | - supports `manual` DNS-01 issuance for wildcard certificates |
| 235 | - stores certs under `/etc/letsencrypt/live/<domain>/` |
| 236 | - generates Nginx config templates that reference `/etc/letsencrypt/live/.../fullchain.pem` and `privkey.pem` |
| 237 | |
| 238 | ### Requirements |
| 239 | |
| 240 | - `certbot` installed and available in `PATH` |
| 241 | - for Cloudflare mode: `certbot-dns-cloudflare` plugin installed |
| 242 | - `/etc/letsencrypt` writable by the service user |
| 243 | - `nginx` installed if you want preview/apply support |
| 244 | |
| 245 | ### Deploy configuration keys |
| 246 | |
| 247 | Use the `network` deploy setup API or URL registry to configure: |
| 248 | |
| 249 | - `DEPLOY_ACME_EMAIL` — ACME registration email |
| 250 | - `DEPLOY_CERT_MODE` — `cloudflare`, `manual`, or `none` |
| 251 | - `DEPLOY_CLOUDFLARE_TOKEN` — Cloudflare API token for DNS-01 challenge |
| 252 | - `DEPLOY_DOMAINS` — array of domain objects, e.g. `[{domain:'openvibe.network',wildcard:false,certName:'openvibe.network',services:['network']}]` |
| 253 | - `DEPLOY_NGINX_MODE` — `preview`, `apply`, or `disabled` |
| 254 | - `DEPLOY_NGINX_SITES_PATH` — path where Nginx site files should be written |
| 255 | - `DEPLOY_NGINX_BACKUP_PATH` — backup directory for generated Nginx configs |
| 256 | - `DEPLOY_SERVICE_MAP` — optional service mapping override |
| 257 | |
| 258 | ### Admin deploy endpoints |
| 259 | |
| 260 | The deploy subsystem exposes admin-only APIs at `/api/admin/deploy`: |
| 261 | |
| 262 | - `GET /api/admin/deploy/prerequisites` — check `certbot`, plugin, `/etc/letsencrypt`, and `nginx` |
| 263 | - `GET /api/admin/deploy/config` — read current deploy config |
| 264 | - `PUT /api/admin/deploy/config` — save deploy config values |
| 265 | - `GET /api/admin/deploy/certs` — list certificates known to certbot |
| 266 | - `POST /api/admin/deploy/certs/issue-cloudflare` — issue a wildcard cert via Cloudflare DNS-01 |
| 267 | - `POST /api/admin/deploy/certs/manual-info` — get manual DNS challenge instructions |
| 268 | - `POST /api/admin/deploy/certs/issue-manual` — run manual DNS issuance after TXT records are in place |
| 269 | - `POST /api/admin/deploy/certs/renew` — renew all certbot certificates |
| 270 | - `GET /api/admin/deploy/nginx/preview` — preview generated Nginx configs |
| 271 | |
| 272 | ### How to use it |
| 273 | |
| 274 | 1. Install certbot: |
| 275 | |
| 276 | ```bash |
| 277 | sudo apt update |
| 278 | sudo apt install certbot python3-certbot-dns-cloudflare |
| 279 | ``` |
| 280 | |
| 281 | 2. Ensure `nginx` is installed if you want config preview/apply support. |
| 282 | |
| 283 | 3. Set deploy registry values through the setup API or admin config. |
| 284 | |
| 285 | 4. For Cloudflare mode, provide a valid Cloudflare token. The wrapper writes `/etc/letsencrypt/cloudflare.ini`. |
| 286 | |
| 287 | 5. For manual mode, create the required DNS TXT records for `_acme-challenge.<domain>` and `*. <domain>` as instructed by the service. |
| 288 | |
| 289 | 6. After issuance, Nginx configs reference certs at `/etc/letsencrypt/live/<domain>/fullchain.pem` and `/etc/letsencrypt/live/<domain>/privkey.pem`. |
| 290 | |
| 291 | 7. Use `POST /api/admin/deploy/certs/renew` to renew existing certs. |
| 292 | |
| 293 | ### Notes |
| 294 | |
| 295 | - The deploy module does not replace a full deployment toolchain; it is a built-in helper for cert issuance and Nginx preview. |
| 296 | - If `DEPLOY_CERT_MODE` is set to `none`, the service will still generate preview Nginx configs but will leave SSL certificate paths commented out. |
| 297 | - `certbot` must be installed on the host running openvibe.network, not just in Docker or another container. |
| 298 | |
| 299 | --- |
| 300 | |
| 301 | ## Notification System |
| 302 | |
| 303 | ### How it works |
| 304 | |
| 305 | 1. **Any service** pushes notifications to openvibe.network via `POST /internal/notifications/push` |
| 306 | 2. **openvibe.network** stores them in PostgreSQL with priority, category, and optional rich content |
| 307 | 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) |
| 308 | 4. **Critical notifications** are queued for email delivery via the built-in email service (Resend by default). |
| 309 | 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. |
| 310 | |
| 311 | ### Priorities |
| 312 | |
| 313 | | Priority | Behavior | |
| 314 | |----------|----------| |
| 315 | | `low` | Silent — badge only, no toast or sound | |
| 316 | | `normal` | Standard toast with subtle notification sound | |
| 317 | | `high` | Persistent toast with attention sound, auto-dismiss 8s | |
| 318 | | `critical` | Sticky toast (must dismiss), urgent sound, **triggers email** | |
| 319 | |
| 320 | ### Categories |
| 321 | |
| 322 | `social`, `chat`, `game`, `stream`, `economy`, `achievement`, `moderation`, `system`, `service`, `admin` |
| 323 | |
| 324 | Users can toggle each category's enabled/sound/toast/email preferences at `openvibe.network/my` → Notifications tab. |
| 325 | |
| 326 | ### Quiet hours and digests |
| 327 | |
| 328 | 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. |
| 329 | |
| 330 | 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. |
| 331 | |
| 332 | 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. |
| 333 | |
| 334 | ### Cross-Service Push |
| 335 | |
| 336 | Other services push notifications to openvibe.network via the internal API: |
| 337 | |
| 338 | ```bash |
| 339 | curl -X POST http://127.0.0.1:4000/internal/notifications/push \ |
| 340 | -H "Authorization: Bearer <service token with network.notifications.push>" \ |
| 341 | -H "Content-Type: application/json" \ |
| 342 | -d '{"userId": 42, "type": "new_follower", "data": {"actorName": "someone"}}' |
| 343 | ``` |
| 344 | |
| 345 | ### The Action Center |
| 346 | |
| 347 | `GET /api/v1/me/actions` (`server/actions/`, capability `network.actions.read`, contracts 0.229.0) is the ✦ beside the bell: |
| 348 | the signed-in person's agent work in five buckets (`running`, `waiting`, `scheduled`, `done`, `failed`) as |
| 349 | `network.me-actions-result@1`. It merges their pending confirmations (`server/developer/confirmations.js`) with |
| 350 | Actor's, Codes' and AI's `GET /api/v1/actions?subject=usr_…` (`actor.actions.read`, `codes.actions.read`, |
| 351 | `ai.actions.read`; Network signs its own token for each, `server/identity/self-token.js`; `OV_ACTOR_INTERNAL_URL`, |
| 352 | `OV_CODES_INTERNAL_URL`, `OV_AI_INTERNAL_URL`). Each source has 2.5 s; a slow or failing one is named in `sources` |
| 353 | and the answer is `partial`, and a service that has not deployed the route is `not_connected`. Items that are not |
| 354 | `common.action-item@1`, or claim another service, are dropped. The answer is cached 10 s per person. |
| 355 | `POST /api/v1/me/actions/:service/:id/approve|decline` decides a Network confirmation, or an Actor approval through |
| 356 | Actor's `POST /api/v1/approvals/:id/decide` (`actor.approval.decide`, `X-OV-Subject`); anything else is opened on its |
| 357 | own page. |
| 358 | |
| 359 | ### Notifications from Events |
| 360 | |
| 361 | `POST /internal/events` (`server/notifications/events-consumer.js`) turns OpenVibe.Events deliveries |
| 362 | into inbox notifications for the person they name, through the same `NotificationService.create()` |
| 363 | as everything else, so a person who turned the `service` category off gets nothing and email follows |
| 364 | their per-category email choice. |
| 365 | |
| 366 | | Event type | From | Person | Notification | |
| 367 | |---|---|---|---| |
| 368 | | `deals.watch.matched` | Deals (`source: deals`) | `payload.recipient` (`usr_…`) | `DEAL_WATCH_MATCH`, `service`, normal | |
| 369 | | `trade.alert.triggered` | Trade (`source: trade`) | `subject.id` (`subject.type: user`) | `TRADE_ALERT`, `service`, high | |
| 370 | | `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 | |
| 371 | | `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 | |
| 372 | | `today.event.cancelled` | Today | everyone told by the matching `today.event.scheduled` | those notices are dismissed | |
| 373 | |
| 374 | **Go-lives.** The followers are Network's own follow graph (see "Follow graph"): the active |
| 375 | `user_follows` rows for `('channel', payload.channel.subject)`, read in the same transaction as the |
| 376 | notifications. Live is never asked. A channel whose subject has no Network account is `ignored:channel`; |
| 377 | a follower whose subject has no account is counted as `unresolved`; the streamer never hears about |
| 378 | themself; the `stream` category's mute and email choice apply. A start more |
| 379 | than 30 minutes old (a replay, or Events catching up) announces nothing. The per-streamer window (one |
| 380 | announcement an hour, eight a day; `stream_live_cooldown_min`, `stream_live_daily_cap`) is kept by |
| 381 | `server/notifications/stream-live.js`. This consumer is the only go-live path (compatibility register C-85): Live |
| 382 | publishes `live.stream.started` and makes no direct call. Follows have been Network's since 2026-09-26, and the |
| 383 | cutover flag that held the consumer back is gone. |
| 384 | |
| 385 | **Project usage.** `tools.usage.recorded` and `events.usage.recorded` (hourly rollups of a developer |
| 386 | project's use, `common.usage-recorded@1`) are kept per project and day (`server/developer/usage.js`, |
| 387 | tables `dev_usage_windows`, `dev_usage_daily`, `dev_usage_errors`) for `GET /api/v1/projects/:project/usage` |
| 388 | and the project's usage page on openvibe.services; they notify nobody. See |
| 389 | [docs/developer-projects.md](docs/developer-projects.md#usage). |
| 390 | |
| 391 | Coupons publishes no watch event yet: its merchant watches never leave Coupons, and its `coupons.*` |
| 392 | events name no person. Every other type is acknowledged and ignored. Deliveries are v2-signed only |
| 393 | (`openvibe-sdk` `parseDelivery` with `requireV2`, ±300 s), and each `event_id` is handled once |
| 394 | (openvibe-sdk inbox, table `network_event_inbox`, consumer `network-notifications`). Links point at a |
| 395 | site only when its domain serves the service (the exposure overlay above); until then the URL is kept |
| 396 | in `rich_content.context.planned_url`. |
| 397 | |
| 398 | The route is inert until the operator does both of these: |
| 399 | |
| 400 | 1. Generate the signing secret and give it to Network: add |
| 401 | `NETWORK_EVENTS_SECRET=<openssl rand -hex 32>` to `/etc/openvibe/network.env` (comma-separate a |
| 402 | second value to rotate), and make sure `OV_EVENTS_INTERNAL_URL=http://127.0.0.1:4300` is set. |
| 403 | Restart `openvibe-network`; the log says `[Events consumer] on`. |
| 404 | 2. Create the subscriptions in Events (consumer `network`, which Events takes from the token's |
| 405 | `svc:network`; endpoint `http://127.0.0.1:4000/internal/events`; topic patterns |
| 406 | `deals.watch.matched`, `trade.alert.triggered` and `live.stream.started`; secret = the first |
| 407 | `NETWORK_EVENTS_SECRET`). On a host that already has the other two, add `live.stream.started` alone with |
| 408 | `--topic live.stream.started` (and the project usage rollups with |
| 409 | `--topic tools.usage.recorded --topic events.usage.recorded`): |
| 410 | |
| 411 | ```bash |
| 412 | cd /opt/openvibe.network |
| 413 | sudo node --env-file=/etc/openvibe/network.env scripts/subscribe-events.js --dry-run |
| 414 | sudo node --env-file=/etc/openvibe/network.env scripts/subscribe-events.js |
| 415 | ``` |
| 416 | |
| 417 | The script signs its own 5-minute service token (Network is the issuer: `sub svc:network`, |
| 418 | `aud openvibe.events`, `cap events.subscription.manage`) and hands the secret to Events in the |
| 419 | subscription body, the way Deals, News and Tips do with theirs. It prints the subscription ids, |
| 420 | never the secret, and reports an existing identical subscription instead of duplicating it. |
| 421 | |
| 422 | Each go-live's outcome is logged, with counts only: |
| 423 | |
| 424 | ``` |
| 425 | [Events consumer] live.stream.started evt_…: notified {"followers":3,"unresolved":0,"targets":3,"notified":3} |
| 426 | ``` |
| 427 | |
| 428 | 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`. |
| 429 | |
| 430 | ### Realtime badge (network.notification.created) |
| 431 | |
| 432 | 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. |
| 433 | - **Envelope:** subject `{ type: user, id: <recipient usr_> }`, visibility `subject` (Events streams it to that person only), actor `system:network`. |
| 434 | - **Payload:** `notification_id`, `type`, `category`, `priority`, `service`, `created_at` and the recipient's `unread_count`. Never the title, message, link or sender. |
| 435 | - **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. |
| 436 | |
| 437 | The badge on other sites cannot use a cookie of openvibe.events, so it asks here for a **realtime ticket**: |
| 438 | |
| 439 | ``` |
| 440 | POST /api/v1/realtime/ticket (Bearer Network JWT, or the ov_token cookie here; 60/min per IP) |
| 441 | → 200 { ticket, expires_at, expires_in: 120, stream_url, topics: ["network.notification.*"], subject } |
| 442 | ``` |
| 443 | |
| 444 | It then opens `${stream_url}?topics=network.notification.*&ticket=…[&last_event_id=…]`. |
| 445 | - **The ticket** (`server/auth/realtime-ticket.js`, `identity.realtime-ticket-claims@1`) is an RS256 JWT: `iss <issuer>/realtime`, `sub <usr_>`, `aud [openvibe.events]`, `typ` and `purpose` `realtime`, 120 s, `jti rtk_…`. Events accepts each one once. |
| 446 | - **Never a session:** its issuer, `typ` and audience each rule that out, and this session guard refuses it too. |
| 447 | - **Not stored, logged or audited:** it grants the person only their own stream. |
| 448 | - **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`). |
| 449 | - **Client:** openvibe-shared `notification-live.js`, on where a site sets `notificationsRealtime: true`. |
| 450 | |
| 451 | 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). |
| 452 | |
| 453 | --- |
| 454 | |
| 455 | ## The Dock's state |
| 456 | |
| 457 | The Dock (plan 2026-10-30, K-D1) is one small floating surface on every OpenVibe site: your agent, your chats and |
| 458 | your activity. It is drawn on many domains and `localStorage` is per site, so the state it follows you with lives in |
| 459 | the Network (`server/dock/`, migration `0031_dock_states.sql`, contracts 0.251.0). |
| 460 | |
| 461 | - `GET /api/v1/me/dock` answers `network.dock-result@1`: the person's `network.dock-state@1` (the stored one, or the |
| 462 | default — closed, on the activity tab, not pinned, bottom-right, the site's own size), the `counts` the pill's |
| 463 | status dot is drawn from (`running` and `waiting` from the Action Center's aggregator, `unread` from the |
| 464 | notification service) and `sources_partial`, true when an authority behind those counts did not answer. |
| 465 | - `PUT /api/v1/me/dock` takes a partial body — any subset of the state's fields — merges it into the stored state, |
| 466 | validates the result against `network.dock-state@1` (an invalid body is `422` with the contract's problem shape) |
| 467 | and answers the new `network.dock-result@1`. `place` merges per device class, so a body naming only `place.phone` |
| 468 | leaves the desktop where it was. |
| 469 | - The subject comes from the caller's token and from nowhere else, so one person never reads or writes another's |
| 470 | state. The caller's own session only: the `ov_token` cookie on openvibe.network, a Bearer user token elsewhere, |
| 471 | with the CORS allow-list and the CSRF rules the other `/api/v1/me/*` routes use. |
| 472 | - A write is limited to 60 a minute per person (`429 dock.rate_limited` past that); the page debounces anyway. |
| 473 | - The row is the person's own: it goes into their account export and is erased with them (ADR-033), and a merge |
| 474 | with the surviving account. |
| 475 | |
| 476 | The full reference is [docs/dock.md](docs/dock.md). |
| 477 | |
| 478 | --- |
| 479 | |
| 480 | ## Email Setup |
| 481 | |
| 482 | Email is optional — the notification system works without it. Email is only sent for **CRITICAL** priority notifications. |
| 483 | |
| 484 | ### Quick setup |
| 485 | |
| 486 | 1. Create a domain or sender identity at <https://resend.com/domains>. |
| 487 | 2. Add the required SPF, DKIM, and MX records to your DNS provider. |
| 488 | 3. Configure the Resend API key and from-addresses in the admin panel. |
| 489 | 4. Verify your domain on Resend before sending production email. |
| 490 | |
| 491 | ### Admin panel configuration |
| 492 | |
| 493 | Configure email at runtime via the admin panel (the API key only when `RESEND_API_KEY` is not set; see below): |
| 494 | - `GET /api/admin/email` — view current email config (`api_key_source`: `env`, `database` or `unset`) |
| 495 | - `PUT /api/admin/email` — update API key, default from address, and per-service from addresses |
| 496 | - `POST /api/admin/email/test` — send a test email |
| 497 | |
| 498 | ## Provider secrets |
| 499 | |
| 500 | Each provider secret has an environment variable, read first; the `site_settings` row is only a |
| 501 | fallback (`server/secrets.js`, roadmap §18.2(12)). While a variable is set, the admin panel shows the |
| 502 | secret as "set in the environment" and never saves one into the database. `GET /api/admin/secrets` |
| 503 | (owner) and the boot line `[Secrets] resend_api_key=env …` say which source each one uses, never a value. |
| 504 | |
| 505 | | Setting | Variable | Used for | |
| 506 | | --- | --- | --- | |
| 507 | | `resend_api_key` | `RESEND_API_KEY` | email delivery | |
| 508 | | `resend_webhook_secret` | `RESEND_WEBHOOK_SECRET` | Resend delivery webhooks (Svix `whsec_…`) | |
| 509 | | `discord_bot_token` | `DISCORD_BOT_TOKEN` | Discord bot alerts | |
| 510 | | `discord_oauth_client_secret` | `DISCORD_OAUTH_CLIENT_SECRET` | Discord account linking | |
| 511 | | `vapid_private_key` | `VAPID_PRIVATE_KEY` | web push (the public key is derived from it; `VAPID_PUBLIC_KEY` optional) | |
| 512 | |
| 513 | `net.ipinfo_token`, `net.globalping_token` and the old `ses_*` keys are never read by Network (the Tools |
| 514 | gateway reads `NET_IPINFO_TOKEN` / `NET_GLOBALPING_TOKEN` from its own env file). |
| 515 | |
| 516 | Provider secrets are read from the environment only (`/etc/openvibe/network.env`): a `site_settings` row of the same |
| 517 | key is ignored and no admin form stores one. The database fallback and its copy-out script were retired on 2026-10-10, |
| 518 | once production held no database copy of any secret. |
| 519 | |
| 520 | ## Project secrets (ADR-056) |
| 521 | |
| 522 | A project keeps its own secrets here, per environment, written once and used by name (`server/developer/secrets.js`). |
| 523 | These are the project's, not Network's provider secrets above. |
| 524 | |
| 525 | - **People** (Bearer user tokens on `/api/v1/projects/:project/secrets`): |
| 526 | - admins and owners set a value (`PUT …/:env/:name { value }`; setting it again adds one to its version), delete it, |
| 527 | and grant it to a service (`PUT …/:name/grants/:service { purposes, origins }`); |
| 528 | - members and staff read metadata only (`network.project-secret@1`): name, version, a keyed fingerprint and grants. |
| 529 | No route answers a value to a person. |
| 530 | - **Services** (`POST /internal/secrets/use`, `network.secret.use`): a service the secret is granted to either signs a |
| 531 | message with it (`sign`: HMAC-SHA256, the value never leaves Network) or receives it for an https origin the grant |
| 532 | lists (`reveal`). A missing secret, grant, purpose or origin, and an archived project, all answer 404 |
| 533 | `secret.not_found`. Actor holds the capability by default, to sign webhook deliveries with a task's `secret_ref`. |
| 534 | - **Storage:** AES-256-GCM under a key derived from `NETWORK_SECRETS_KEY`, with the row's identity as authenticated data, |
| 535 | so a ciphertext moved onto another row does not open. Without the key the feature is off (503), never plaintext. |
| 536 | Archiving a project, or deleting its owner's account with no heir, shreds its secrets. |
| 537 | - **Audit (`dev_audit`):** sets, deletions, grant changes, and the first use per secret, service, purpose and day. |
| 538 | Values and messages are never logged. |
| 539 | |
| 540 | --- |
| 541 | |
| 542 | ## Anonymous Users |
| 543 | |
| 544 | Anonymous users can browse and interact without creating an account: |
| 545 | |
| 546 | - Each anon user gets a unique number (e.g., "Anonymous #42") |
| 547 | - Stats and preferences are tracked via a session token |
| 548 | - Fingerprint matching reconnects returning anonymous visitors |
| 549 | - 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) |
| 550 | - Multi-account switcher includes a "Continue as Anonymous" option |
| 551 | |
| 552 | ### API |
| 553 | |
| 554 | - `POST /api/auth/anon-session` — create anonymous session |
| 555 | - `GET /api/auth/anon/:token` — get anon user info |
| 556 | - `PUT /api/auth/anon/:token/preferences` — update anon preferences |
| 557 | - `POST /api/auth/anon/:token/link` — link anon identity to registered account |
| 558 | |
| 559 | --- |
| 560 | |
| 561 | ## User modules |
| 562 | |
| 563 | One JSON record per (person, namespace), for portable preferences and summaries (never domain truth). |
| 564 | Namespaces, schemas, writers, public fields and quotas come from openvibe-contracts |
| 565 | (`manifests/namespaces`); `server/identity/modules.js` enforces them. |
| 566 | |
| 567 | - People: `GET /api/modules` (export), `GET|PUT|DELETE /api/modules/:ns` (`If-Match: <revision>` on PUT). |
| 568 | Anyone: `GET /api/modules/:ns/public/:subject` (public fields only). |
| 569 | - Services: `GET|PUT|DELETE /internal/modules/:ns/:subject`, service token only (`network.modules.read` / |
| 570 | `network.modules.write` with the namespace in the grant); only the namespace's owner writes. |
| 571 | `chat.preferences` is owned by Chat since the Wave 6 cutover (`OWNER_HANDOFFS`, until Contracts says so). |
| 572 | - Every change emits `network.module.updated` through `network_event_outbox` (relayed to OpenVibe.Events |
| 573 | when `OV_EVENTS_INTERNAL_URL` is set; rows wait there otherwise). The payload names the owner subject, |
| 574 | namespace, schema version, revision, change, reason and the changed keys; values only for fields the |
| 575 | namespace declares public. Revisions only grow for a (person, namespace), deletes included. |
| 576 | - Accounts: `onSubjectRemoved` deletes a subject's records with events; guest links move portable preferences to a registered account; triggers |
| 577 | refuse deleting a `users`/`anon_users` row, or changing its `subject_id`, while records remain. |
| 578 | - Owning service retired (its manifest `status: retired`): the namespace turns read-only; |
| 579 | `delete-after-retention` namespaces are emptied `retentionDays` after Network first saw it (daily sweep). |
| 580 | |
| 581 | --- |
| 582 | |
| 583 | ## Platform blocks |
| 584 | |
| 585 | A person blocks someone once, on the network, and every product honours it (roadmap WS-E task 5, |
| 586 | Contracts 0.49.0; `server/identity/blocks.js`). `user_blocks` is keyed by subjects, one row per |
| 587 | (blocker, blocked) with `active` and a per-pair `revision` that grows on every change. |
| 588 | |
| 589 | - People: `GET /api/v1/me/blocks` (who I blocked, with names and avatars), `PUT|DELETE /api/v1/me/blocks/:subjectOrUsername`. |
| 590 | Nobody blocks themselves; guests neither block nor are blocked. Staff can be blocked, but a block never hides a |
| 591 | staff action. The account page (Security → Blocked people) lists blocks with unblock buttons. |
| 592 | - Services: `GET /internal/blocks?subject=usr_…` → `{ subject, blocks, blocked_by }`, service token with |
| 593 | `network.blocks.read` only (Chat and Community hold it by default; never the shared key). |
| 594 | - Every change writes `network.block.changed` into `network_event_outbox` in the same transaction. Chat |
| 595 | (DMs, mentions) and Community (replies) keep projections from their own Events subscriptions. |
| 596 | - Network's notifications: nothing is created from a person the recipient blocked (`sender_id` as a Network |
| 597 | id, or `actor_subject`); moderation, system and admin notices always are. |
| 598 | |
| 599 | --- |
| 600 | |
| 601 | ## Follow graph (ADR-030) |
| 602 | |
| 603 | Network owns follows, keyed by subjects (`server/identity/follows.js`; roadmap WS-E task 4, Contracts 0.65.0). |
| 604 | A target is a Live channel today (`channel`, named by its owner's subject), and other kinds join as products need them. |
| 605 | |
| 606 | - **Public:** `GET /api/v1/follows/:type/:target` gives the follower count, and for a signed-in caller whether |
| 607 | they follow and how they are notified. |
| 608 | - **People:** `GET /api/v1/me/follows` lists what the caller follows. `PUT|DELETE /api/v1/me/follows/:type/:target` |
| 609 | follow and unfollow (`:target` is a subject or a username). Both are idempotent, and the PUT body can carry |
| 610 | `{ notify_email, notify_push }`. |
| 611 | - **Who follows a target:** `GET /api/v1/follows/:type/:target/followers` is open to the target's owner, and to |
| 612 | services with `network.follows.read` (service token only). Lists are never public. |
| 613 | - **Events:** every change writes `network.follow.created` or `network.follow.deleted` (subject visibility, |
| 614 | growing per-pair revision) to the outbox in the same transaction. Live keeps a projection from them. |
| 615 | - **Products writing for a person** (ADR-030 step 4): `PUT|DELETE /internal/follows/:type/:target` needs |
| 616 | `network.follows.write`, a service token and `network.follow-write-request@1`. It is granted to Live, whose |
| 617 | buttons use it. |
| 618 | - **Go-live source:** go-live notifications read the followers here, and only here (plan T2); there is no |
| 619 | switch back to Live. |
| 620 | |
| 621 | ## Multi-Account Switching |
| 622 | |
| 623 | Google-style account management supporting up to 5 accounts: |
| 624 | |
| 625 | - Accounts stored client-side in `localStorage` (`openvibe_accounts`) |
| 626 | - Server tracks active sessions via `user_sessions` table |
| 627 | - Switch accounts instantly without re-authentication |
| 628 | - Includes anonymous mode as a switchable identity |
| 629 | - Shared `account-switcher.js` component renders the switcher UI on all services |
| 630 | |
| 631 | ### API |
| 632 | |
| 633 | - `GET /api/auth/sessions` — list active sessions |
| 634 | - `POST /api/auth/sessions` — create session record |
| 635 | - `DELETE /api/auth/sessions/:id` — revoke single session |
| 636 | - `DELETE /api/auth/sessions` — revoke all sessions |
| 637 | |
| 638 | --- |
| 639 | |
| 640 | ## OAuth2 Flow |
| 641 | |
| 642 | 1. Service redirects user to `https://openvibe.network/oauth/authorize?client_id=...&redirect_uri=...` |
| 643 | 2. If user has `ov_token` cookie → issue authorization code → redirect to service |
| 644 | 3. If no cookie → redirect to `openvibe.network` → user signs in → cookie set → resume flow |
| 645 | 4. Service exchanges code for access + refresh tokens via `POST /token` |
| 646 | 5. Access tokens are RS256 JWT (24h) verified by any service with the public key |
| 647 | 6. Refresh tokens rotate on use (30d) |
| 648 | |
| 649 | --- |
| 650 | |
| 651 | ## Observability and status |
| 652 | |
| 653 | - `GET /api/ready`: named checks from `openvibe-shared/ready`, each with `status`, `required`, |
| 654 | `latency_ms` and `checked_at`. `db` and `signing_key` are required (`signing_key` means an RS256 |
| 655 | keypair that signs and verifies a probe token; it is only optional outside production). |
| 656 | `registry_poll` and `discord_bot` are optional. The answer is 503 only when a required check fails. |
| 657 | Otherwise it is 200, with `status: "degraded"` when an optional check has failed. |
| 658 | - `GET /metrics`: Prometheus text for direct loopback callers only. Any proxied request gets a 404, |
| 659 | and `deploy/nginx` blocks the path too. Besides the HTTP golden signals by route template, it |
| 660 | exports process metrics, `release_info` and `release_client_updates_total{outcome,reason}` (what |
| 661 | open tabs report to `POST /release-metrics`). Network's own counters are |
| 662 | `network_tokens_issued_total{grant_type}`, `network_token_failures_total{grant_type,error}` and |
| 663 | `network_principal_token_failures_total{code}`. |
| 664 | - Universal telemetry: requests are aggregated per `route|method|status_class` into a single |
| 665 | `platform.telemetry-sample@1` per key per flush (count, sum, max and p95 in `extra`, mean in |
| 666 | `latency_ms`), plus the HTTP autoscaling gauges (active requests, p95, event-loop lag) once per flush; |
| 667 | health, readiness, metrics, the OpenVibe Frame and static/shared assets are skipped. Buffered by |
| 668 | `openvibe-sdk/service`'s telemetry kit and written into the analytics store as `telemetry.*` events |
| 669 | (`server/telemetry-sink.js`); `gracefulStop` flushes it once at shutdown. |
| 670 | - `GET /release.json` is Network's release manifest (ADR-016, `registry.release-manifest@1`, from |
| 671 | `openvibe-shared/release`'s `release.mount`): the deployed commit, the library versions and, since |
| 672 | openvibe-contracts 0.32.0, the 1.1.0 fields. No components are declared, so every release still |
| 673 | prompts open tabs to reload. |
| 674 | - `GET /status` is the operator page: server-rendered, no JavaScript needed, `noindex`. |
| 675 | `GET /api/v1/status` returns the same data as JSON. Each OpenVibe service shows as up, degraded, |
| 676 | down, not running (placeholder or no runtime) or unknown, with its release, boot time and |
| 677 | `checked_at`. The data comes from the ecosystem registry poll (`server/registry/ecosystem.js`), |
| 678 | which reads each service's readiness endpoint and `/release.json` about once a minute. A service |
| 679 | not checked yet, or whose last check is more than three intervals old, shows as unknown. |
| 680 | - Where each service can be reached is Network's exposure overlay (`server/registry/exposure.js`), not |
| 681 | the manifest's maturity label: `live` (its public domain serves it), `internal` (loopback only, the |
| 682 | domain still serves a placeholder page), `library` (released package), `repository` (code with CI, |
| 683 | nothing to run) or `placeholder` (planned). The registry adds `exposure` to each service, the |
| 684 | descriptor gives an `origin` only to `live` services (`planned_origin` otherwise), `/status` reads |
| 685 | "up (loopback only)" for an internal service, and the Frame's nav lists only `live` sites. Change a |
| 686 | row when a domain stops serving its placeholder; `test/registry-exposure.test.js` pins the states. |
| 687 | - The registry also answers `GET /api/v1/registry/topics` (every event type from the manifests' |
| 688 | `eventsProduced`/`eventsConsumed` and the payload contracts, with producers, consumers, the matching |
| 689 | consumer pattern and the payload contract; filters `service`, `producer`, `consumer`, `prefix`), |
| 690 | `/topics/:topic`, `/releases` (each running service's `/release.json` from the same loopback poll, |
| 691 | with its installed openvibe-contracts/sdk/shared versions and drift against the libraries' current |
| 692 | releases), `/health` and `/search?q=`. A type a service's code publishes before its manifest lists |
| 693 | it is added from `server/registry/topics.js` OBSERVED (marked `observed` on the service; empty since |
| 694 | openvibe-contracts 0.32.0 lists Live's, Media's and Network's), and Network's consumed topics come |
| 695 | from its own Events consumer. |
| 696 | Payload schemas are served at their `$id` (`/contracts/events/payloads/<type>.v1.json`). |
| 697 | - `GET /api/v1/registry/categories[/:id]` groups every service by what it is, each with its rule: |
| 698 | `site` (in the network's site list, `server/frame/sites.js`), `platform` (runs, but other services call |
| 699 | it), `library`, `repository`, `planned`. `GET /api/v1/registry/featured` lists the open sites whose last |
| 700 | check was up or degraded, in the navigation's usage order (7-day page views plus 14-day signed-in |
| 701 | history, recounted every 30 minutes), with `ranked_at` and `stale`; the hub is not listed and nothing |
| 702 | is featured by hand. |
| 703 | - Loopback addresses for the health poll: the built-in ports in `server/registry/ecosystem.js`, each |
| 704 | overridable by `OV_<ID>_INTERNAL_URL` (loopback URLs only; anything else is ignored and logged). Site |
| 705 | hosts come from each manifest's `publicOrigin`. |
| 706 | - `node scripts/contracts-drift.js` warns about services that pin an older openvibe-contracts than |
| 707 | the latest tag, or a tag that was never published (`--dir ~/OpenVibers` for local checkouts, |
| 708 | `--registry https://openvibe.network` for what is running, `--package all` for sdk and shared too). |
| 709 | It exits 0 unless `--strict`; CI runs it as a warning-only step. |
| 710 | - `GET /api/v1/status/slo` returns the proposed SLO categories, as does |
| 711 | [docs/slo.md](docs/slo.md) / [docs/slo.json](docs/slo.json). |
| 712 | |
| 713 | ## Analytics (ADR-021) |
| 714 | |
| 715 | **Creator analytics** (roadmap WS-E task 6, Contracts 0.68.0; `server/analytics/creators.js`) are built from |
| 716 | events, not from Live's tables. Every `live.stream.ended` becomes one `creator_streams` row, with the stream's |
| 717 | totals from the event's `stats`: counts only, never who watched or chatted, and kept 400 days. |
| 718 | `GET /api/v1/creators/:creator/analytics?days=` shows everyone streams, minutes and peak viewers. The creator, |
| 719 | and services with `network.analytics.creator.read` (Live's dashboards), also get average viewers, chatters, |
| 720 | messages and watch minutes. |
| 721 | |
| 722 | Network records one row per finished request in the `analytics_*` tables of its PostgreSQL database and rolls |
| 723 | them up hourly and daily for the admin analytics pages (`/api/admin/analytics`, which also gathers |
| 724 | Live, Tools, Games and Media). What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts |
| 725 | `docs/adr/ADR-021-analytics.md`). The module is `openvibe-shared/analytics` (the same one Live and Tools |
| 726 | use), on the service database through its PostgreSQL tracker (`AnalyticsTrackerPg`). `server/analytics/network.js` |
| 727 | holds only Network's wiring: its path options (`paramPrefixes`, `pathRules`) and the prune job. |
| 728 | |
| 729 | - **Stored:** event type, service, **route template** (the matched Express route, else the path |
| 730 | without its query and with ids, usernames and tokens replaced by `:id` / `:param`), method, status, |
| 731 | response time, a **rotating session id**, country (CDN header), **user-agent class** |
| 732 | (`chrome/windows/desktop`, `bot:curl`), referer **origin** only, bot flags, a signed-in flag. |
| 733 | - **Never stored:** IP address, user or subject id, city, the user-agent string, full referer URLs. |
| 734 | The `ip`, `user_id` and `city` columns stay for compatibility and are always NULL. Per-IP counters |
| 735 | for the bot rate check live in memory only. Unique visitors come from a daily-salted hash kept only |
| 736 | until that day's final rollup. "New vs returning visitors" is no longer measured. |
| 737 | - **Opt-out:** a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all (no raw row, visitor |
| 738 | hash, session id or rate counter), so it is also missing from the rollups. |
| 739 | - **Admin panel:** the bot tables list user-agent classes and high-volume session ids, not IPs. |
| 740 | - **Connection:** the tracker buffers and flushes into the service's own PostgreSQL handle (the same |
| 741 | database the identity tables live in), so there is no second connection or file of its own. |
| 742 | - **Retention:** raw events older than 30 days are deleted every night in batches of 5000 (the |
| 743 | `analytics-prune` job: first run 5 minutes after boot, then every 24 h). Rollups are kept. |
| 744 | - **Operator CLI:** `scripts/analytics-prune.js` (over `openvibe-shared/analytics/pg`'s |
| 745 | `pruneRawEventsPg`) runs a dry run by default and changes nothing; `--apply` deletes raw events older |
| 746 | than `--days` (1..30, default 30). Run it where Network runs, with `DATABASE_URL` set. |
| 747 | |
| 748 | ## Shared Client Libraries |
| 749 | |
| 750 | 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: |
| 751 | |
| 752 | | File | Purpose | |
| 753 | |------|---------| |
| 754 | | `navbar.js` | Universal top bar with service links, notification bell mount, user dropdown | |
| 755 | | `nav-icons.js` | Navbar glyphs, loaded by navbar.js | |
| 756 | | `notification-ui.js` | Toast popups, bell badge, notification panel with category tabs | |
| 757 | | `account-switcher.js` | Multi-account switcher panel with anonymous mode | |
| 758 | | `user-card.js` | Right-click context menu + user profile card with name effects | |
| 759 | | `notifications.js` | Shared constants (priorities, categories, types) — also used server-side | |
| 760 | |
| 761 | Include them in any service page: |
| 762 | ```html |
| 763 | <script src="https://openvibe.network/shared/navbar.js"></script> |
| 764 | <script src="https://openvibe.network/shared/notification-ui.js"></script> |
| 765 | <script src="https://openvibe.network/shared/account-switcher.js"></script> |
| 766 | <script src="https://openvibe.network/shared/user-card.js"></script> |
| 767 | ``` |
| 768 | |
| 769 | **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 `/`). |
| 770 | |
| 771 | --- |
| 772 | |
| 773 | ## Admin Panel |
| 774 | |
| 775 | Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`. |
| 776 | |
| 777 | | Endpoint | Description | |
| 778 | |----------|-------------| |
| 779 | | `GET /api/admin/email` | Email configuration status | |
| 780 | | `PUT /api/admin/email` | Update API key and email settings | |
| 781 | | `POST /api/admin/email/test` | Send test email | |
| 782 | | `GET /api/admin/settings` | All site settings (secrets masked) | |
| 783 | | `PUT /api/admin/settings` | Update site settings | |
| 784 | | `PUT /api/admin/users/:id/role` | Change user role | |
| 785 | | `PUT /api/admin/users/:id/ban` | Ban/unban user (sends notification) | |
| 786 | | `POST /api/admin/broadcast` | Send notification to all non-banned users | |
| 787 | | `GET /api/admin/health` | System health (counts, memory, uptime, email status) | |
| 788 | | `GET /api/admin/audit` | Audit log | |
| 789 | |
| 790 | --- |
| 791 | |
| 792 | ## Connected Services |
| 793 | |
| 794 | | Service | Port | OAuth Client ID | |
| 795 | |---------|------|-----------------| |
| 796 | | OpenVibe.Live | 3000 | `live` | |
| 797 | | OpenVibe.Tools | 4001 | `tools` | |
| 798 | | OpenVibe.Games | 8000 | `games` | |
| 799 | | OpenVibe.Media | 4100 | `media` | |
| 800 | |
| 801 | --- |
| 802 | |
| 803 | ## Database Tables |
| 804 | |
| 805 | | Table | Purpose | |
| 806 | |-------|---------| |
| 807 | | `users` | Accounts (username, email, password hash, role, bio, effects) | |
| 808 | | `oauth_clients` | Registered OAuth2 applications | |
| 809 | | `oauth_codes` | Authorization codes (5-min TTL) | |
| 810 | | `refresh_tokens` | Rotating refresh tokens (30-day) | |
| 811 | | `linked_accounts` | Connected external service accounts | |
| 812 | | `themes` | Theme catalog entries | |
| 813 | | `user_theme_prefs` | Per-user theme selections | |
| 814 | | `site_settings` | Key-value admin settings (email config, etc.) | |
| 815 | | `audit_log` | Admin action audit trail | |
| 816 | | `notifications` | All user notifications (UUID PK) | |
| 817 | | `notification_preferences` | Per-user category preferences | |
| 818 | | `anon_users` | Anonymous user identities | |
| 819 | | `user_sessions` | Active session tracking for multi-account | |
| 820 | | `user_effects` | Equipped name/particle effects | |
| 821 | | `follows` | User follow relationships | |
| 822 | | `wallets` | OpenCoins balance per Network user | |
| 823 | | `coin_transactions` | OpenCoins ledger (idempotency-key deduped) | |
| 824 | | `dev_projects`, `dev_project_members` | Developer projects and member roles | |
| 825 | | `dev_apps`, `dev_credentials`, `dev_auth_codes` | Developer apps, hashed client secrets, app authorization codes | |
| 826 | | `dev_grants`, `dev_quotas` | App capability grants; per-project quotas (enforced by the owning service) | |
| 827 | | `dev_audit` | Append-only developer audit; rows that are platform events carry an event envelope | |
| 828 | | `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) | |
| 829 | | `analytics_events`, `analytics_hourly`, `analytics_daily` | Request analytics (raw ≤ 30 days) and rollups, within ADR-021; see [Analytics](#analytics-adr-021) | |
| 830 | | `user_modules`, `user_module_revisions`, `user_module_retirements` | User-module records, the last revision issued per (subject, namespace), retired namespace owners | |
| 831 | | `network_event_outbox` | Network's events on their way to OpenVibe.Events (developer projects, user modules, blocks) | |
| 832 | | `user_blocks` | Platform blocks by subject (blocker, blocked, active, per-pair revision) | |
| 833 | | `locker_keys` | The Locker key directory: a person's public keys, their fingerprint and their self-signature | |
| 834 | | `locker_key_backups` | The wrapped private keys, one row per person and kind (`passphrase`, `recovery`, `passkey`); opaque to Network | |
| 835 | | `locker_key_history` | Every Locker key change, append-only: the fingerprint before and after | |
| 836 | | `user_follows` | The follow graph by subject (follower, target type and id, active, notify flags, per-pair revision, source) | |
| 837 | | `analytics_visitor_days`, `analytics_day_salts` | The current day's salted visitor hashes and salt, deleted after the day's final rollup | |
| 838 | |
| 839 | --- |
| 840 | |
| 841 | ## Acceptance |
| 842 | |
| 843 | `npm test` runs the 74 test files listed in `package.json` (temp databases, no network). They |
| 844 | cover OAuth with PKCE, OIDC discovery, refresh-token rotation and reuse, SSO and FedCM, subjects, |
| 845 | principals and grants, export and deletion, developer projects, export tokens and usage, |
| 846 | user modules, blocks, follows and their authority switch, analytics privacy, the registry and its |
| 847 | exposure, notifications and events, status and incidents, operator alerts, per-actor limits, and the |
| 848 | security suites (`security-session`, `security-redirects`, `security-secrets`, `security-private`, |
| 849 | `security-idor`, `security-ssrf`), plus the deploy wrapper. |
| 850 | |
| 851 | ## Security |
| 852 | |
| 853 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps: |
| 854 | |
| 855 | - **Tokens.** Network signs every token with its RS256 key (`JWT_PRIVATE_KEY` or `data/keys`); refresh |
| 856 | tokens are stored as SHA-256 and a reused one revokes its family; `token_valid_after` cutoffs are |
| 857 | published so services refuse older tokens; client secrets are stored hashed and shown once. |
| 858 | - **Redirects.** Post-sign-in targets (`/login?return=`, `/sso/fanout?next=`) never run `javascript:` |
| 859 | URLs or leave for a host OpenVibe does not own (`test/security-redirects.test.js`); OAuth redirects |
| 860 | must be one of the client's registered URIs. |
| 861 | - **Private data.** Analytics keep no personal data beyond a day's salted hash; account export and |
| 862 | deletion follow ADR-033; the internal routes take a capability-scoped service token only (the shared |
| 863 | internal key was retired in plan T2). |
| 864 | - **CORS.** Only discovery and the registry are open to any origin; every other route uses the |
| 865 | first-party allow-list. |
| 866 | - **Secrets.** Provider secrets come from the environment first, never printed; `SETUP_TOKEN`, |
| 867 | `ADMIN_PASSWORD`, `NETWORK_EVENTS_SECRET`, the webhook secrets and `GITHUB_TOKEN` live in |
| 868 | `/etc/openvibe/network.env` (0600), by name only. |
| 869 | |
| 870 | ## Sharing |
| 871 | |
| 872 | The sharing system (design 2026-11-06) lets a person share an item on one site with another person. |
| 873 | The owning service stays the authority on access; Network keeps the index and the list. |
| 874 | |
| 875 | ### The events |
| 876 | |
| 877 | Every share event arrives over OpenVibe.Events at `POST /internal/events` and is handled in |
| 878 | `server/sharing/events.js`: |
| 879 | |
| 880 | | Event | What Network does | |
| 881 | |-------|-------------------| |
| 882 | | `share.granted` | the item joins that person's list, and they get the notification `share.added` | |
| 883 | | `share.revoked` | the item leaves that person's list | |
| 884 | | `share.transfer.requested` | the offer waits for the recipient, who gets `share.transfer.requested` | |
| 885 | | `share.transfer.accepted` | the recipient owns it now; the old owner gets `share.transfer.answered` | |
| 886 | | `share.transfer.declined` | nothing changed; the old owner gets `share.transfer.answered` | |
| 887 | | `share.access.requested` | the owner gets `share.access_requested` | |
| 888 | |
| 889 | The payloads carry ids only (a `{ service, kind, id }` ref and a subject), never a title. The name of |
| 890 | an item and its own link come from the owning service's share API, `GET /api/v1/<kind>/<id>/share`, |
| 891 | asked on the person's behalf; when the service does not answer, the item is shown as its site and its |
| 892 | kind instead. |
| 893 | |
| 894 | ### The routes |
| 895 | |
| 896 | | Route | What it answers | |
| 897 | |-------|-----------------| |
| 898 | | `GET /shared` | the page: every item shared with the signed-in person, on every site | |
| 899 | | `GET /api/v1/shared-with-me` | the same list as JSON, with the pending transfers and their Accept and Decline links | |
| 900 | | `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 | |
| 901 | | `GET /api/v1/share/name-check?name=` | one @name check for the share picker, thirty a minute for the person | |
| 902 | |
| 903 | The event names and the payload shapes are Contracts share.v1, which is still being written; this code |
| 904 | waits for that release before it ships. |
| 905 | |
| 906 | ## Locker keys |
| 907 | |
| 908 | The Locker (design 2026-11-06, `LOCKER-DESIGN-2026-11-06.md`) is end-to-end encryption for anything on |
| 909 | OpenVibe: a locked item is stored and served as ciphertext, and the keys never reach our servers. Network |
| 910 | is the key directory — it holds a person's **public** keys, their self-signature and a copy of the |
| 911 | **private** keys that the browser sealed, and it can never read a private key. The format and the library |
| 912 | are `openvibe-sdk/locker` and `openvibe-sdk/locker/server`; the browser seals the private keys, Network |
| 913 | only checks the shape of what it is given. |
| 914 | |
| 915 | ### What Network stores |
| 916 | |
| 917 | | Table | What it holds | |
| 918 | |-------|---------------| |
| 919 | | `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 | |
| 920 | | `locker_key_backups` | the wrapped private keys, one row per kind (`passphrase`, `recovery`, `passkey`), stored exactly as the browser sealed them | |
| 921 | | `locker_key_history` | every key change, append-only: the fingerprint before and the fingerprint after | |
| 922 | |
| 923 | **What Network can read:** the public keys, the fingerprint, when the keys last changed, and the shape of |
| 924 | a backup (its size, its lock and its PBKDF2 count). |
| 925 | |
| 926 | **What Network cannot read:** a private key, in any form. The private keys are wrapped by a key from the |
| 927 | person's locker passphrase (PBKDF2-SHA-256, 600 000 iterations or more) or by a passkey, and the wrapped |
| 928 | bytes are opaque here. If the passphrase and the recovery kit are both lost, nobody can open the locked |
| 929 | items, including OpenVibe — but the person can start again with a **reset** (see below). |
| 930 | |
| 931 | ### The proof: a change needs the key the person has now |
| 932 | |
| 933 | A session cookie on its own must not be able to change a person's keys: a stolen session that swapped in |
| 934 | somebody else's keys would read everything locked for that person from then on. So a person who **already |
| 935 | has keys** proves they still hold them: the **current** signing key signs the change, and the signature |
| 936 | comes as `previous_signature` (base64, ECDSA P-256 over SHA-256). Without it the write is refused with |
| 937 | `403 locker.proof_required`. |
| 938 | |
| 939 | The bytes to sign are these, as UTF-8 (`server/locker/proof.js`, exported so the browser signs the same |
| 940 | ones): |
| 941 | |
| 942 | ``` |
| 943 | openvibe-locker key-change v1|<subject>|<new fingerprint>|<backups digest> |
| 944 | openvibe-locker forget v1|<subject> |
| 945 | ``` |
| 946 | |
| 947 | - `<subject>` is the person's own `usr_…` id. |
| 948 | - `<new fingerprint>` is the eight words the **new** keys produce (`fingerprintOf`). |
| 949 | - `<backups digest>` is the lower-case hex SHA-256 of the **canonical JSON** of the backups that are |
| 950 | written: the keys of every object sorted, no spaces, one backup per kind: |
| 951 | |
| 952 | ```json |
| 953 | {"passphrase":{"envelope":"<base64>","format":"locker.envelope.v1"}} |
| 954 | ``` |
| 955 | |
| 956 | The same proof covers a backups-only update (the fingerprint does not change). |
| 957 | |
| 958 | ### A reset, and forgetting |
| 959 | |
| 960 | - **A reset** is for the person who lost both their passphrase and their recovery kit: `PUT /api/v1/me/keys` |
| 961 | with `{"reset": true}` and no proof, allowed only when the session's sign-in is recent — the session |
| 962 | token's `auth_time` within the last ten minutes (the OIDC claim Network's `signToken` puts on every |
| 963 | session; the same rule the account deletion uses). Without it the write is refused with |
| 964 | `403 locker.reauth_required`. A reset replaces the whole locker: new keys, and the backups that come with |
| 965 | them; the old backups wrapped keys that are gone. It is written down as `reset`. |
| 966 | - **Forgetting** (`DELETE /api/v1/me/keys`, with `{"confirm": "forget my locker"}`) needs the same proof, over |
| 967 | `openvibe-locker forget v1|<subject>`, or a recent sign-in. |
| 968 | |
| 969 | ### The routes |
| 970 | |
| 971 | | Route | What it answers | |
| 972 | |-------|-----------------| |
| 973 | | `PUT /api/v1/me/keys` | my public keys, my self-signature and my wrapped backups | |
| 974 | | `GET /api/v1/me/keys/backup` | my own wrapped private keys, for a new device — never anybody else's | |
| 975 | | `DELETE /api/v1/me/keys` | forget my locker: the keys and the backups go, the history stays | |
| 976 | | `GET /api/v1/people/:subject/keys` | a person's public keys and fingerprint, by subject | |
| 977 | | `GET /api/v1/people/@:username/keys` | the same, by exact name | |
| 978 | |
| 979 | The lookups need a signed-in person and are rate-limited to sixty a minute for each caller. Every answer |
| 980 | is `private, no-store`, and `Vary: Authorization, Cookie`. |
| 981 | |
| 982 | ```bash |
| 983 | # the public keys and the fingerprint of @maticus |
| 984 | curl -s -H "Authorization: Bearer $TOKEN" https://openvibe.network/api/v1/people/@maticus/keys |
| 985 | ``` |
| 986 | |
| 987 | ```json |
| 988 | { |
| 989 | "subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", |
| 990 | "ecdh_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, |
| 991 | "signing_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, |
| 992 | "fingerprint": "alpha bravo charlie delta · echo foxtrot golf hotel", |
| 993 | "updated_at": "2026-11-06T09:12:00.000Z", |
| 994 | "changed_at": "2026-11-06T09:12:00.000Z" |
| 995 | } |
| 996 | ``` |
| 997 | |
| 998 | `changed_at` is the last time the keys changed, so a share sheet can warn before it shares with a key the |
| 999 | people have not seen before. The fingerprint is computed here, never taken from the caller: a caller that |
| 1000 | sends one that does not match its own keys is refused, and the self-signature must verify with the |
| 1001 | signing key. |
| 1002 | |
| 1003 | A backup is a Locker envelope with a password lock, base64, at most 16 KB, sealed with a PBKDF2 count |
| 1004 | between 600 000 and 10 000 000. Network checks that with `openvibe-sdk/locker/server` and stores the bytes |
| 1005 | as they came; it never opens one. |
| 1006 | |
| 1007 | ```json |
| 1008 | { |
| 1009 | "ecdh_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, |
| 1010 | "signing_public": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }, |
| 1011 | "self_signature": "…", |
| 1012 | "previous_signature": "…", |
| 1013 | "backups": { |
| 1014 | "passphrase": { "format": "locker.envelope.v1", "envelope": "…" } |
| 1015 | } |
| 1016 | } |
| 1017 | ``` |
| 1018 | |
| 1019 | Every key change is announced on OpenVibe.Events as `locker.keys.changed`, with ids and fingerprints |
| 1020 | only: no key, no signature, no blob ever leaves this service in an event, in a log line or in an answer. |
| 1021 | The event's reason says why it happened: `created`, `rotated` (new keys), `restored` (the same keys, new |
| 1022 | backups), `reset` or `forgotten`. |
| 1023 | |
| 1024 | Every change also sends the person a Network notification, so a swap nobody asked for is seen: |
| 1025 | |
| 1026 | | Change | What the person is told | |
| 1027 | |--------|------------------------| |
| 1028 | | `created` | "Your locker was set up: the things you lock are opened with these keys." | |
| 1029 | | `rotated` | "Your locker keys changed: things locked for your old keys cannot be opened with the new ones." | |
| 1030 | | `restored` | "Your locker backup was restored: the things you lock are opened with the same keys." | |
| 1031 | | `reset` | "Your locker was reset: things locked for your old keys cannot be opened" | |
| 1032 | | `forgotten` | "Your locker was forgotten: your keys and your backups are gone." | |
| 1033 | |
| 1034 | ## License |
| 1035 | |
| 1036 | MIT (`license` in [package.json](package.json)). |
| 1037 | |
| 1038 | <!-- versions:start --> |
| 1039 | - openvibe-contracts: v0.421.0 |
| 1040 | - openvibe-sdk: v0.161.0 |
| 1041 | - openvibe-shared: v4.237.0 |
| 1042 | <!-- versions:end --> |
| 1043 |