openvibers/openvibe.network Commits

Integrate: D11/D12 — API keys, QR sign-in, owner settings, legacy secrets, project memberships, wallet (#559)

OP OpenVibers committed 9 days ago · 3e22739537 · 56 files, +7232 −57

* Developer API keys and per-project budgets (AI Exchange P3), behind NETWORK_DEV_API_KEYS

A project-bound key on one of the project's apps, shown once (only sha256 is stored), with
scopes (ai.inference -> ai.inference.create, D8) and an environment matching its app (live
-> production, test -> sandbox; admin+ for a live key); rotate, revoke (and revoked with its
app), audit with ids and scopes only. Services exchange a key at POST /internal/api-keys/exchange
(network.project.read) for a short-lived ordinary app token: the receiver verifies it offline
as any app token. The exchange fails closed: a scope whose capability the installed contracts
do not know, or that the app was not granted, is dropped. Per-project monthly budgets in nano-USD
strings (D1), read by AI with its service token (AI gains network.project.read by default).
openvibe-contracts v0.284.0 (was v0.280.0). Tests: shown once, hash only, revoked at once,
scope and audience enforced, budget read and write, no key in any log.

* Document the contracts pin and the grant narrowing in developer-api-keys.md

* D8': the ai.inference scope exchanges for ai.app.run, the public capability apps already call AI with (ADR-014)

* Developer API keys: with the flag off, a stranger gets the project's 404, not 503

The feature check ran before the project access check, so anyone could read 503 api_keys.disabled on a private
project's /api-keys and /budget (security-private-projects wants 404 for every private read). The access check
now runs first; only a member sees that the feature is off.

* Developer API keys: name the scope's AI capability 'grants', so contracts-check does not read it as Network's

openvibe-contracts-check treats any capability: '<id>' property as a capability this service enforces, and
ai.app.run is AI's. The scope map's key is now grants (the capability the exchanged token carries to
openvibe.ai); nothing else changes.

* Sign in on your phone: QR sign-in behind NETWORK_QR_SIGNIN

Desktop dialog, /q phone page, hashed secret/code/nonce, creating-session approval, one-session status race, audit and new-sign-in notice, device row, rate limits and sweep.

* Sign in on your phone: show the OpenVibe mark when the account has no avatar

* Sign in on another device: any signed-in device can show the code

Device-neutral dialog and /q words, source/target naming, the QR on every screen size with the dialog stacking QR-first, and a phone-sized source start/approve test.

* Sign in on another device: design review pass

Focus the dialog title on open, step states after the scan, tighter confirm spacing and stacked buttons below 420 px, inline target glyphs, sentence-case labels, a themed light dialog, and a phone page with the number first, an initial avatar and the Shared mark in Nunito.

* Owner settings: Platform AI tab and the /internal/config proxy (D11 S3)

Pin openvibe-contracts 0.288.0; seed Network's <svc>.config.manage grants, ai.provider.manage and Host's ai.usage.read; add the owner-only /api/admin/ai/config proxy with fresh sign-in, X-OV-Fresh, operator alerts, audit, no secret storage and a 60-write/hour limit; add the stacked Platform AI tab with ov-forms controls, write-only keys, schema-drawn cards, pins, danger confirmations, history and rollback, and the vault state.

* QR sign-in: inherit the source session's auth_time

A QR-created session is not a fresh sign-in, so the merge and owner key/money fresh checks still require a real re-authentication. Store source_auth_time on the transfer, pass it through signToken, and share the merge fresh check as isFreshSignIn.

* Platform AI review fixes: switches, vault states, money, pins, labels, quiet buttons

* Platform AI: no gap above an untitled card; a service with no settings says so

* D11: secrets leave Network's site_settings for the sealed config store

* D11: blank a legacy secret only when the store holds that exact value

* Wallet: Balance, Vibes and OpenCoins in one place, and the Vibes Pay sheet

* Projects: list a person's memberships for D12 Balance

* Wallet: one activity row per transaction, project names link out, and activity reads with the person's own token

* Projects: include the project name in a person's memberships

* Merge #553: Developer API keys and per-project budgets (AI Exchange P3)

* Merge #554: Sign in on another device (QR sign-in behind NETWORK_QR_SIGNIN)

Conflict: #553 and #554 both added migration 0035. #554's 0035_qr_signin.sql is
renumbered 0036_qr_signin.sql (the later merge in the integration order), and the
migration lists in test/agent-schema.test.js and test/user-owned-trust.test.js
now name both.

* Merge #555: Owner settings: Platform AI tab and the /internal/config proxy (D11 S3)

Conflict: package.json pins — openvibe-contracts takes v0.288.0, the highest tag among
the merged branches (#553 had v0.284.0). package-lock.json is regenerated once after the
last merge.

* Merge #556: D11: secrets leave Network's site_settings for the sealed config store

Conflict: package-lock.json only; openvibe-shared takes v4.110.0, the highest tag among
the merged branches (#556 had v4.110.0, the rest v4.109.2). Regenerated after the last merge.

* Merge #558: Projects: list a person's memberships for D12 Balance

* Merge #557: Wallet: Balance, Vibes and OpenCoins, and the Vibes Pay sheet

* Owner settings: reuse account-merge's isFreshSignIn for the fresh-sign-in rule

#554 and #555 each had a fresh-sign-in check. Keep one: the AI settings proxy
delegates to isFreshSignIn in server/identity/account-merge.js, so a renewal or
a QR session carrying an old source auth_time is refused in both places.

* Regenerate package-lock for the merged pins

openvibe-contracts v0.288.0 (highest among the branches, #555/#556) and
openvibe-shared v4.110.0 (#556); openvibe-sdk stays v0.82.0.

Changes

ddiff --git a/.env.example b/.env.example
iindex 3d97138f..0d1d554c 100644
---- a/.env.example
++++ b/.env.example
@@@ -22,6 +22,10 @@ INDEXNOW_KEY=
#NETWORK_LIMITS_MINUTE=120
#NETWORK_LIMITS_HOUR=3000
++# Sign in on another device (server/auth/qr-signin.js): a QR on one device, a scan on another and a two-digit match.
++# Off unless this is explicitly on; off means /auth/qr/* and /q are not mounted.
++# NETWORK_QR_SIGNIN=1
++
# Security — CHANGE THESE IN PRODUCTION
# /internal/* takes capability-scoped service tokens; the shared internal key is retired (plan T2).
SETUP_TOKEN=change-me-to-a-second-random-secret
ddiff --git a/README.md b/README.md
iindex 3711c8f5..d685d722 100644
---- a/README.md
++++ b/README.md
@@@ -57,7 +57,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Depends on
--- `openvibe-contracts` v0.222.0 (registry manifests, capability checks), `openvibe-sdk` v0.82.0 (PostgreSQL
++- `openvibe-contracts` v0.288.0 (registry manifests, capability checks), `openvibe-sdk` v0.82.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, 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)
@@@ -820,7 +820,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
--- openvibe-contracts: v0.280.0
++- openvibe-contracts: v0.288.0
- openvibe-sdk: v0.82.0
--- openvibe-shared: v4.109.2
++- openvibe-shared: v4.110.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 4e1d6dd8..b7e74541 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,9 +6,9 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.280.0",
++ "contracts": "openvibe-contracts v0.288.0",
"sdk": "openvibe-sdk v0.82.0",
-- "shared": "openvibe-shared v4.109.2",
++ "shared": "openvibe-shared v4.110.0",
"capabilities": [
"identity.subject.resolve",
"mods.grant.manage",
ddiff --git a/docs/developer-api-keys.md b/docs/developer-api-keys.md
nnew file mode 100644
iindex 00000000..4f09922b
---- /dev/null
++++ b/docs/developer-api-keys.md
@@@ -0,0 +1,171 @@
++# Developer API keys and per-project budgets
++
++Plan G-P3 (AI Exchange). Network stays the identity authority: it owns the developer projects, the
++apps in them, the grants those apps hold, and — since this document — the **developer API keys** and
++the **per-project monthly budget**. OpenVibe.AI is the service that spends for a project; it reads
++the budget and authenticates the keys with its own service token, and it never stores a secret.
++
++Everything new here is behind `NETWORK_DEV_API_KEYS=1`. While the flag is off, every route below
++answers `503 api_keys.disabled` and nothing is written.
++
++## What exists today (before this change)
++
++| Thing | Where | Notes |
++|---|---|---|
++| Developer projects | `migrations/0001_initial.sql:436` (`dev_projects`), `server/developer/store.js` | `prj_<ULID>`, one owner, members with roles, an allowance, an environment policy, an archive. |
++| Project members and roles | `server/developer/store.js` (`addMember`, `updateMember`, `removeMember`) | owner / admin / developer / viewer. |
++| Developer apps | `migrations/0001_initial.sql:457` (`dev_apps`), `server/developer/store.js` (`createApp`) | An OAuth client: `client_id` **is** its app id (`app_<ULID>`). Environment `sandbox` or `production`, type `confidential` or `public`. |
++| App credentials (client secrets) | `migrations/0001_initial.sql:471` (`dev_credentials`), `server/developer/store.js` (`insertCredential`, `rotateCredential`, `revokeCredential`, `matchSecret`) | `ovsec_…`, 256 random bits, **only sha256(secret) is stored**; the secret is shown once; rotation keeps the old secret valid for an overlap; revocation is immediate. |
++| Scoped grants | `migrations/0001_initial.sql:484` (`dev_grants`), `server/developer/store.js` (`requestGrant`, `decideGrant`) | One capability per app, `requested` / `approved` / `denied` / `revoked`, never outside the project's allowance. |
++| Client-credentials tokens | `server/developer/tokens.js` (`handleTokenRequest`, `mint`), `server/auth/oauth-routes.js` (`POST /token`) | `/oauth/token` with `grant_type=client_credentials` for a confidential app: a 5-minute RS256 token, `sub app:app_<ULID>`, `cap` = approved grants ∩ allowance ∩ grantable, `ns [project_id, app.<project_id>.*]`, `project_id`, `env sandbox|production`. |
++| Revocation | `server/developer/store.js` (`revokeAppTx`, `revokeCredential`), `server/developer/tokens.js` (`usableApp`) | A revoked credential fails at once; a revoked app or a revoked grant stops new tokens at once; issued tokens live at most 5 minutes. |
++| The append-only audit | `migrations/0001_initial.sql:518` (`dev_audit`), `server/developer/store.js` (`audit`, `listAudit`) | One row per change, with ids and scopes — never a secret. |
++| Project quotas (not money) | `migrations/0001_initial.sql:496` (`dev_quotas`), `server/developer/store.js` (`setQuota`) | A limit, a window and a unit. Network records them; the owning service enforces them. **Not a budget.** |
++| Project secrets (ADR-056) | `server/developer/secrets.js`, `migrations/0023_project_secrets.sql` | Named, encrypted values a service may sign with or reveal. Not an API key. |
++| Service-side project read | `server/internal/routes.js` (`GET /internal/projects/:project_id`), `server/developer/store.js` (`internalProjectView`) | `network.project.read` on a service token; the view carries the tenancy, the allowance, the quotas, the apps and their grants. |
++| How OpenVibe.AI authenticates app tokens today | `OpenVibe.AI/server/auth.js` (`verify`, `requireCap`, `appOrCap`) | An app token (`sub app:app_<ULID>`, `actor_type app`) is refused on every first-party route and judged only on `ai.app.run` on the routes that take one (`appOrCap`). The token is verified offline against Network's JWKS, so a Network-minted token needs no change on AI's side. |
++
++Network now pins `openvibe-contracts` **0.284.0** (it was 0.280.0), the release that registers the
++public capability `ai.app.run` (ADR-014; D8') and the `billing.credit-*` schemas (D9). The pin is
++expand-only, so nothing existing changes.
++
++### There is no `hbt_` system
++
++No `hbt_` prefix, no API-token table and no bearer-API-key route exist in this repository (the
++`hbt_` naming in the plan is not implemented anywhere). The only API-key-like things are the
++operator's own secrets (`RESEND_API_KEY`, `resend_api_key` in `server/secrets.js`) and the app
++client secrets above. Both are first-party configuration, not developer credentials.
++
++### The gap
++
++- **No developer API key.** A developer who wants to call OpenVibe.AI from their own backend must
++ keep an app client secret and run the OAuth client-credentials exchange themselves. There is no
++ credential that is shown once, bound to a project, scoped, environment-separated, rotatable and
++ revocable — and none that a service can authenticate directly from a bearer header.
++- **No per-project money ceiling.** `dev_quotas` counts requests, bytes and tokens; it is not
++ money and it is not a monthly ceiling. Nothing in Network told OpenVibe.AI how much a project may
++ spend in a month (decision D1: integer nano-USD, never a float).
++- **No scoped opaque exchange.** The plan's P3 asks for "hashing (and/or scoped opaque exchange to
++ Network token)". Only the client-secret exchange existed; there was no way to present a project
++ credential to a service and get a short-lived, scoped Network token back.
++
++## What this change adds
++
++### The key (`server/developer/api-keys.js`, table `dev_api_keys`)
++
++A key is bound to **a project and one of that project's apps**, so the token it exchanges for is an
++ordinary app token with the project's tenancy. The secret is `ovk_` plus 32 random bytes, base64url.
++
++| Method and path | Who | Body / result |
++|---|---|---|
++| `GET /api/v1/projects/:project/api-keys` | viewer+, staff | `{ keys: [...] }` — metadata only, never a secret |
++| `POST /api/v1/projects/:project/api-keys` | developer+ (admin+ for `live`) | `{ name, app_id, scopes, environment }` → the key plus `api_key` **once** (`shown_once: true`) |
++| `POST /api/v1/projects/:project/api-keys/:key/rotate` | as the key's app needs | the same key id with a new secret, shown once; the old secret fails at once |
++| `POST /api/v1/projects/:project/api-keys/:key/revoke` | as the key's app needs, staff | the key stops authenticating at once |
++
++- **Stored:** `sha256(secret)` only (`key_hash`), the last four characters (`hint`), the scopes, the
++ environment, the state and the dates. The secret is never stored, never logged, never audited.
++- **Environment:** `test` or `live`. A `test` key belongs to a sandbox app and exchanges for a
++ sandbox token (`env: sandbox`); a `live` key belongs to a production app and exchanges for a
++ production token (`env: production`). A key that does not match its app's environment is refused.
++- **Scopes:** Network's own vocabulary. `ai.inference` is the only scope today: it maps to the
++ capability `ai.app.run` (the existing public capability, ADR-014; AI Exchange D8') and
++ to the audience `openvibe.ai`. The scope is narrowed by the app: the app must hold the scope's
++ capability as an approved grant, so a key can never do more than the app it belongs to.
++- **Audit:** `api_key.created`, `api_key.rotated`, `api_key.revoked` and `api_key.exchanged` rows in
++ `dev_audit`, with ids, scopes and the environment — never a secret.
++- **Cascades:** revoking the app revokes its keys with it (`server/developer/store.js`,
++ `revokeAppTx` → `apiKeys.revokeForApp`); archiving the project does the same through the app.
++
++### The exchange (`POST /internal/api-keys/exchange`)
++
++A developer shows their key to a service (OpenVibe.AI); the service sends it to Network with its
++own service token and Network answers with the facts and a short-lived scoped token.
++
++```
++POST /internal/api-keys/exchange
++Authorization: Bearer <the service's token for openvibe.network> (network.project.read)
++{ "api_key": "ovk_…", "audience": "openvibe.ai" }
++```
++
++```json
++{
++ "access_token": "<a 5-minute RS256 app token>",
++ "token_type": "Bearer", "expires_in": 300,
++ "project_id": "prj_…", "app_id": "app_…", "subject": "app:app_…",
++ "environment": "test", "token_env": "sandbox",
++ "scopes": ["ai.inference"], "capability": ["ai.app.run"],
++ "jti": "tok_…"
++}
++```
++
++- The token is signed with Network's key and shaped by `identity.service-token-claims@1`, so a
++ receiver verifies it offline exactly as it verifies an app token today
++ (`sub app:app_<ULID>`, `actor_type app`, `aud [openvibe.ai]`, `cap [ai.app.run]`,
++ `ns [project_id, app.<project_id>.*]`, `project_id`, `env`).
++- **The scope is enforced by Network.** A key may only be exchanged for an audience its scopes
++ cover: `403 api_key.scope_denied` otherwise. The key's scopes, narrowed by the app's approved
++ grants, decide the `cap` claim.
++- A revoked key, an unknown key and a malformed key are the same `401 api_key.invalid`: a guess
++ learns nothing, and a revoked key stops working at once.
++- The key, its hash and the issued token never reach a log or an error message.
++
++### The budget (`monthly_budget_nano_usd`)
++
++The project's own monthly ceiling, an **integer number of nano-USD** (decision D1: 1e-9 USD, never
++a float), kept as a decimal string so the value is exact at any size. `null` means no budget was
++set.
++
++| Method and path | Who | Body / result |
++|---|---|---|
++| `GET /api/v1/projects/:project/budget` | admin+, staff | `{ project_id, monthly_budget_nano_usd, currency: "nano_usd", updated_at }` |
++| `PUT /api/v1/projects/:project/budget` | admin+, staff | `{ monthly_budget_nano_usd: "5000000000" }` (or `null` to clear) |
++| `GET /internal/projects/:project_id/budget` | a service token with `network.project.read` | the same shape, `Cache-Control: no-store` |
++
++The project view (`GET /api/v1/projects/:project`) carries `monthly_budget_nano_usd` too. Every
++change is audited (`project.budget_set`) with the old and the new value, never anything else.
++
++**OpenVibe.AI holds `network.project.read` by default** (`server/identity/principals.js`,
++`DEFAULT_GRANTS`), so it reads the budget with its service token. The capability is first-party and
++is never grantable to an app.
++
++### Money stays in Billing
++
++This budget is the **project's own ceiling**, not an account balance and not a hold. Billing owns
++the money (the credit subledger, decision D2/D9): it reserves, settles and reports. Network stores
++and publishes the ceiling; OpenVibe.AI reads it and refuses to start a run above it. No float ever
++touches either side.
++
++## Configuration
++
++| Env var | Default | Meaning |
++|---|---|---|
++| `NETWORK_DEV_API_KEYS` | unset (off) | `1` turns on the key lifecycle, the exchange and the budget routes |
++
++## What is not here yet
++
++- **The scope vocabulary is Network's own.** It is not the contracts capability catalog: the key
++ holds `ai.inference`, and the exchange maps it to `ai.app.run`. When the contracts
++ catalog grows the scope list, the mapping moves into it.
++- **OpenVibe.AI does not read `ovk_` keys yet.** It authenticates app tokens today
++ (`OpenVibe.AI/server/auth.js`). The exchange is what lets AI accept a developer's key: AI calls
++ `/internal/api-keys/exchange` and uses the token it gets back, unchanged.
++- **No public exchange route.** The exchange is internal: a third party cannot mint a token
++ without a first-party service token. A public `POST /oauth/api-key/token` for developer CLIs is
++ a later slice.
++- **The budget is a ceiling, not a meter.** What a project actually spent comes from Billing's
++ settlements; Network only holds the number the project set.
++
++## Tests
++
++`test/developer-api-keys.test.js` proves, with every log line captured:
++
++- the key is shown once and only `sha256(secret)` is stored (the secret is nowhere in the row);
++- a revoked key is refused at once, and rotating it invalidates the old secret at once;
++- the scope is enforced: an audience the key's scopes do not cover is `403 api_key.scope_denied`,
++ the exchanged token carries `cap: ["ai.app.run"]` (D8), and revoking the app's grant
++ takes the scope with it;
++- the budget is read and written as a nano-USD string, a fraction is refused, and OpenVibe.AI reads
++ it with its service token (`no-store`);
++- the key never reaches a log line, an audit row or an error message;
++- every route answers `503 api_keys.disabled` while the flag is off.
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 6a1cdbdd..6bc19f76 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -131,6 +131,10 @@ Production apps still need staff: the project's environment policy (`sandbox+pro
This is ADR-014's acceptance rule: "a revoked credential fails everywhere within one token lifetime (5 minutes)". Receivers verify tokens offline, so Network cannot recall a token it has already issued.
++## Developer API keys and the monthly budget
++
++A developer API key (a project-bound bearer credential, shown once, stored hashed, scoped, rotatable and revocable) and the project's own monthly budget in nano-USD are described in [developer-api-keys.md](developer-api-keys.md). Both are behind `NETWORK_DEV_API_KEYS=1`.
++
## Quotas
A quota says how much of one capability a project may use: for example, `media.object.upload` with limit 1073741824, window `total`, unit `bytes`. Network records quotas and shows them to members. **Network does not enforce them.** The service that owns the capability does (`enforced_by` in the response is that audience). An owning service reads a project's quotas through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Until that service enforces what it reads, a quota is a recorded limit and not a guarantee. What a quota's current window has used is measured from the services' usage rollups ([Usage](#usage)).
@@@ -255,6 +259,9 @@ A first-party service acting for a person (`X-OV-Subject`) may name one project
- **Route.** `GET /internal/projects/:project_id/members/:subject` with a service token holding
`network.project.member.read` for `openvibe.network` (first-party, never granted to apps). Run and Watch hold it by
default: Run checks before every delegated job, and Watch checks when a watch names a project.
++- **List route.** `GET /internal/projects?subject=usr_…` with the same capability returns
++ `{ subject, projects: [{ project_id, name, role, archived, added_at }] }`, so a service that acts for a person finds the
++ projects they administer without holding the person's token. Billing holds it for a person's Balance (D12).
- **Result** (`Cache-Control: no-store`, `network.project-member-result@1`): `{ project_id, subject, role, archived,
added_at }`. No other member, app or secret.
- **The decision is the receiver's:** delegation accepts only an `admin` or `owner` of a project that is not archived,
ddiff --git a/migrations/0035_dev_api_keys.sql b/migrations/0035_dev_api_keys.sql
nnew file mode 100644
iindex 00000000..947ef6ad
---- /dev/null
++++ b/migrations/0035_dev_api_keys.sql
@@@ -0,0 +1,40 @@
++-- phase: expand
++-- Developer API keys and per-project budgets (plan G-P3, Network stays the identity authority).
++--
++-- A developer API key is a project-bound credential a person shows once and then uses as a bearer
++-- credential against OpenVibe.AI. Only sha256(secret) is stored; the secret itself is never stored,
++-- never logged and never written to the audit. The key is bound to a project AND to one of the
++-- project's apps, so the token it exchanges for is an ordinary app token (sub app:app_<ULID>) with
++-- the project's tenancy (project_id, ns) and the key's own environment (live -> production,
++-- test -> sandbox).
++--
++-- The per-project monthly budget is an integer number of nano-USD (decision D1: 1e-9 USD, never a
++-- float). It is kept as a decimal string so the value is exact whatever it is, and the column is
++-- NULL when the project set no budget (no limit). Billing owns the money; this is the project's own
++-- ceiling, which OpenVibe.AI reads with its service token.
++--
++-- Expand only: an older release never reads these tables and never reads the new columns.
++CREATE TABLE IF NOT EXISTS dev_api_keys (
++ id text COLLATE "C" PRIMARY KEY,
++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
++ app_id text COLLATE "C" NOT NULL REFERENCES dev_apps(id),
++ name text COLLATE "C" NOT NULL,
++ key_hash text COLLATE "C" NOT NULL UNIQUE,
++ hint text COLLATE "C" NOT NULL,
++ scopes text COLLATE "C" NOT NULL DEFAULT '[]',
++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('live', 'test')),
++ status text COLLATE "C" NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ rotated_at text COLLATE "C",
++ revoked_at text COLLATE "C",
++ revoked_by text COLLATE "C",
++ last_used_at text COLLATE "C"
++);
++CREATE INDEX IF NOT EXISTS idx_dev_api_keys_project ON dev_api_keys(project_id);
++CREATE INDEX IF NOT EXISTS idx_dev_api_keys_app ON dev_api_keys(app_id);
++
++-- The project's own monthly ceiling, in nano-USD (D1). NULL = no budget set.
++ALTER TABLE dev_projects ADD COLUMN IF NOT EXISTS monthly_budget_nano_usd text COLLATE "C";
++ALTER TABLE dev_projects ADD COLUMN IF NOT EXISTS budget_updated_at text COLLATE "C";
++ALTER TABLE dev_projects ADD COLUMN IF NOT EXISTS budget_updated_by text COLLATE "C";
ddiff --git a/migrations/0036_qr_signin.sql b/migrations/0036_qr_signin.sql
nnew file mode 100644
iindex 00000000..38e91cc6
---- /dev/null
++++ b/migrations/0036_qr_signin.sql
@@@ -0,0 +1,37 @@
++-- phase: expand
++-- Sign in on another device (owner, 2026-11-04): the source device shows a QR code, the target scans it, the source
++-- confirms the two-digit code the target shows, and the target gets an ordinary Network session.
++--
++-- The secret is never stored, only its SHA-256; the match code and the target's ov_qr nonce are hashed
++-- the same way. expires_at is an ISO-8601 UTC string so comparisons stay lexical on PostgreSQL and
++-- PGlite. Rows are swept once expired (server/auth/qr-signin.js sweep, server/index.js timer); the
++-- audit_log rows remain.
++CREATE TABLE qr_signin_transfers (
++ id text COLLATE "C" PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ secret_hash text COLLATE "C" NOT NULL,
++ creator_jti text COLLATE "C" NOT NULL,
++ source_auth_time integer,
++ status text COLLATE "C" NOT NULL DEFAULT 'waiting'
++ CHECK (status IN ('waiting', 'scanned', 'approved', 'denied', 'used')),
++ code_hash text COLLATE "C",
++ attempts integer NOT NULL DEFAULT 0,
++ target_nonce_hash text COLLATE "C" UNIQUE,
++ device_summary text COLLATE "C",
++ scanned_at text COLLATE "C",
++ approved_at text COLLATE "C",
++ used_at text COLLATE "C",
++ expires_at text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++CREATE INDEX qr_signin_transfers_user_live ON qr_signin_transfers (user_id, status, expires_at);
++CREATE INDEX qr_signin_transfers_expiry ON qr_signin_transfers (expires_at);
++
++-- The 10-starts-per-person-per-hour budget, counted across processes. Rows live two hours and are
++-- swept with the transfers; audit_log separately records each start.
++CREATE TABLE qr_signin_starts (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
++);
++CREATE INDEX qr_signin_starts_user_time ON qr_signin_starts (user_id, created_at);
ddiff --git a/package-lock.json b/package-lock.json
iindex 097c55bf..a9a72acd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,10 +20,11 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.280.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0",
++ "qrcode": "1.5.4",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
},
@@@ -296,6 +297,30 @@
}
}
},
++ "node_modules/ansi-regex": {
++ "version": "5.0.1",
++ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
++ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/ansi-styles": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
++ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
++ "license": "MIT",
++ "dependencies": {
++ "color-convert": "^2.0.1"
++ },
++ "engines": {
++ "node": ">=8"
++ },
++ "funding": {
++ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
++ }
++ },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@@ -430,6 +455,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/camelcase": {
++ "version": "5.3.1",
++ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
++ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/cliui": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
++ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
++ "license": "ISC",
++ "dependencies": {
++ "string-width": "^4.2.0",
++ "strip-ansi": "^6.0.0",
++ "wrap-ansi": "^6.2.0"
++ }
++ },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@@ -439,6 +484,24 @@
"node": ">=0.10.0"
}
},
++ "node_modules/color-convert": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
++ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
++ "license": "MIT",
++ "dependencies": {
++ "color-name": "~1.1.4"
++ },
++ "engines": {
++ "node": ">=7.0.0"
++ }
++ },
++ "node_modules/color-name": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
++ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
++ "license": "MIT"
++ },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@@ -568,6 +631,15 @@
"ms": "2.0.0"
}
},
++ "node_modules/decamelize": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
++ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@@ -596,6 +668,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/dijkstrajs": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
++ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
++ "license": "MIT"
++ },
"node_modules/discord-api-types": {
"version": "0.38.53",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
@@@ -740,6 +818,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
++ "node_modules/emoji-regex": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
++ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
++ "license": "MIT"
++ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@@ -907,6 +991,19 @@
"node": ">= 0.8"
}
},
++ "node_modules/find-up": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
++ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
++ "license": "MIT",
++ "dependencies": {
++ "locate-path": "^5.0.0",
++ "path-exists": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@@ -934,6 +1031,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/get-caller-file": {
++ "version": "2.0.5",
++ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
++ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
++ "license": "ISC",
++ "engines": {
++ "node": "6.* || 8.* || >= 10.*"
++ }
++ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@@ -1258,6 +1364,15 @@
"node": ">= 0.10"
}
},
++ "node_modules/is-fullwidth-code-point": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
++ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@@ -1343,6 +1458,18 @@
}
}
},
++ "node_modules/locate-path": {
++ "version": "5.0.0",
++ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
++ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
++ "license": "MIT",
++ "dependencies": {
++ "p-locate": "^4.1.0"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@@ -1595,9 +1722,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.280.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.280.0",
-- "integrity": "sha512-OOU5z5gwXZkXl+eWiRJHsbZoNC+nDL3LK+kXHo8TcMlgijTkGyvLIUlKzRDqYXthk0KnYK91dS0VuzRNkVLPIw==",
++ "version": "0.288.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1639,9 +1766,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.109.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
++ "version": "4.110.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
@@@ -1651,6 +1778,42 @@
"openvibe-build-assets": "scripts/build-assets.js"
}
},
++ "node_modules/p-limit": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
++ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
++ "license": "MIT",
++ "dependencies": {
++ "p-try": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/p-locate": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
++ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
++ "license": "MIT",
++ "dependencies": {
++ "p-limit": "^2.2.0"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/p-try": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
++ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=6"
++ }
++ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@@ -1660,6 +1823,15 @@
"node": ">= 0.8"
}
},
++ "node_modules/path-exists": {
++ "version": "4.0.0",
++ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
++ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@@ -1755,6 +1927,15 @@
"split2": "^4.1.0"
}
},
++ "node_modules/pngjs": {
++ "version": "5.0.0",
++ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
++ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10.13.0"
++ }
++ },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@@ -1817,6 +1998,23 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/qrcode": {
++ "version": "1.5.4",
++ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
++ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
++ "license": "MIT",
++ "dependencies": {
++ "dijkstrajs": "^1.0.1",
++ "pngjs": "^5.0.0",
++ "yargs": "^15.3.1"
++ },
++ "bin": {
++ "qrcode": "bin/qrcode"
++ },
++ "engines": {
++ "node": ">=10.13.0"
++ }
++ },
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@@ -1899,6 +2097,15 @@
"node": ">=4"
}
},
++ "node_modules/require-directory": {
++ "version": "2.1.1",
++ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
++ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@@ -1908,6 +2115,12 @@
"node": ">=0.10.0"
}
},
++ "node_modules/require-main-filename": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
++ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
++ "license": "ISC"
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@@ -1991,6 +2204,12 @@
"node": ">= 0.8.0"
}
},
++ "node_modules/set-blocking": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
++ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
++ "license": "ISC"
++ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@@ -2116,6 +2335,32 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
++ "node_modules/string-width": {
++ "version": "4.2.3",
++ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
++ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
++ "license": "MIT",
++ "dependencies": {
++ "emoji-regex": "^8.0.0",
++ "is-fullwidth-code-point": "^3.0.0",
++ "strip-ansi": "^6.0.1"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/strip-ansi": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
++ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
++ "license": "MIT",
++ "dependencies": {
++ "ansi-regex": "^5.0.1"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -2242,6 +2487,26 @@
"node": ">= 16"
}
},
++ "node_modules/which-module": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
++ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
++ "license": "ISC"
++ },
++ "node_modules/wrap-ansi": {
++ "version": "6.2.0",
++ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
++ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
++ "license": "MIT",
++ "dependencies": {
++ "ansi-styles": "^4.0.0",
++ "string-width": "^4.1.0",
++ "strip-ansi": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@@@ -2271,6 +2536,47 @@
"engines": {
"node": ">=0.4"
}
++ },
++ "node_modules/y18n": {
++ "version": "4.0.3",
++ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
++ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
++ "license": "ISC"
++ },
++ "node_modules/yargs": {
++ "version": "15.4.1",
++ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
++ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
++ "license": "MIT",
++ "dependencies": {
++ "cliui": "^6.0.0",
++ "decamelize": "^1.2.0",
++ "find-up": "^4.1.0",
++ "get-caller-file": "^2.0.1",
++ "require-directory": "^2.1.1",
++ "require-main-filename": "^2.0.0",
++ "set-blocking": "^2.0.0",
++ "string-width": "^4.2.0",
++ "which-module": "^2.0.0",
++ "y18n": "^4.0.0",
++ "yargs-parser": "^18.1.2"
++ },
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/yargs-parser": {
++ "version": "18.1.3",
++ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
++ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
++ "license": "ISC",
++ "dependencies": {
++ "camelcase": "^5.0.0",
++ "decamelize": "^1.2.0"
++ },
++ "engines": {
++ "node": ">=6"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex 6299b542..aeaebfd4 100644
---- a/package.json
++++ b/package.json
@@@ -24,10 +24,11 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.280.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.0",
++ "qrcode": "1.5.4",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
},
ddiff --git a/public/admin.html b/public/admin.html
iindex 2d6b1dd2..c64d3f10 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -27,6 +27,8 @@
})();
</script>
<link rel="stylesheet" href="/css/admin.css">
++<!-- OpenVibe's own form controls (4.109.0): every select in Platform AI is one, with data-badge/data-desc/data-state. -->
++<link rel="stylesheet" href="/shared/ov-forms.css">
</head>
<body>
@@@ -178,6 +180,8 @@
<!-- AI: configure the vision/text provider + usage cost breakdown -->
<div class="section" id="sec-ai">
++ <!-- Platform AI (AI Exchange D11 S3, owner only): one long page, loaded by js/admin/ai.js. -->
++ <div id="platform-ai" class="platform-ai pai" hidden></div>
<div id="ai-staff-room"></div>
<div id="ai-content"><div class="loading">Loading...</div></div>
</div>
@@@ -633,6 +637,7 @@
<script src="/shared/navbar.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
++<script src="/shared/ov-forms.js" defer></script>
<script src="/js/admin/core.js" defer></script>
<script src="/js/admin/dashboard.js" defer></script>
ddiff --git a/public/css/admin.css b/public/css/admin.css
iindex 0d300a8d..e564e253 100644
---- a/public/css/admin.css
++++ b/public/css/admin.css
@@@ -1,10 +1,10 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
-- --bg:#0d0d14;--bg-secondary:#151520;--bg-card:#1a1a26;--bg-input:#1c1c28;--bg-tertiary:#222233;
++ --bg:var(--bg-primary,#0d0d14);--bg-secondary:#151520;--bg-card:#1a1a26;--bg-input:#1c1c28;--bg-tertiary:#222233;
--border:#2a2a3a;--border-focus:#8b5cf6;
--accent:#3b82f6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#1d4ed8;--accent-light:#60a5fa;
-- --text:#e0e0e0;--text-secondary:#b0b0b8;--text-muted:#707080;
++ --text:var(--text-primary,#e0e0e0);--text-secondary:#b0b0b8;--text-muted:#707080;
--live-red:#ef4444;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444;
--shadow:0 4px 20px rgba(0,0,0,0.3);
}
@@@ -160,3 +160,122 @@ a.btn{text-decoration:none}
#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
@media (prefers-reduced-motion: reduce) { .section, .toast { animation: none; } }
++
++/* ── Platform AI (AI Exchange D11 S3, owner only): one long page, sticky index, no inner tabs ───────────────────── */
++.platform-ai{max-width:1100px;min-width:0;overflow-wrap:anywhere}
++.pai-head{margin-bottom:14px}
++.pai-head h2{font-size:20px;font-weight:800;display:flex;align-items:center;gap:9px;margin-bottom:6px}
++.pai-head p{max-width:820px;font-size:13px;line-height:1.55;color:var(--text-muted)}
++.pai-index{position:sticky;top:0;z-index:30;display:flex;flex-wrap:wrap;gap:4px;padding:7px;margin:0 0 18px;background:color-mix(in srgb,var(--bg-secondary) 92%,transparent);border:1px solid var(--border);border-radius:10px;backdrop-filter:blur(9px)}
++.pai-index a{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:5px 10px;border-radius:8px;color:var(--text-secondary);text-decoration:none;font-size:12px;font-weight:600;white-space:nowrap}
++.pai-index a:hover,.pai-index a:focus-visible{background:var(--bg-card);color:var(--text)}
++.pai-count{font-size:10px;font-weight:700;line-height:1;padding:4px 7px;border-radius:999px;background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent-light)}
++.pai-section{scroll-margin-top:64px;margin-bottom:30px}
++.pai-section>h3{font-size:15px;font-weight:800;margin-bottom:5px}
++.pai-section>p{font-size:12.5px;line-height:1.5;margin-bottom:12px}
++.pai-section-head{display:block;margin-bottom:10px}
++.pai-section-head h3{font-size:15px;font-weight:800;margin-bottom:3px}
++.pai-section-head p{max-width:820px;font-size:12.5px;line-height:1.5;margin-top:2px}
++.pai-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
++.pai-fresh[hidden]{display:none}
++.pai-fresh{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 16px;padding:12px 14px;border:1px solid color-mix(in srgb,var(--warning) 45%,var(--border));border-radius:10px;background:color-mix(in srgb,var(--warning) 12%,var(--bg-card));font-size:13px;color:var(--text)}
++.pai-warning{margin:0 0 16px;padding:12px 14px;border:1px solid color-mix(in srgb,var(--warning) 40%,var(--border));border-radius:10px;background:color-mix(in srgb,var(--warning) 8%,var(--bg-card));font-size:12.5px;line-height:1.55}
++.pai-warning-row{margin-top:6px;color:var(--text-muted)}
++.pai-error{padding:12px 14px;border:1px solid color-mix(in srgb,var(--danger) 45%,var(--border));border-radius:10px;background:color-mix(in srgb,var(--danger) 9%,var(--bg-card));color:var(--text);font-size:13px}
++.pai-rows{display:grid;gap:8px}
++.pai-more{margin-top:8px}
++.pai-more>summary{cursor:pointer;font-size:12px;font-weight:600;color:var(--accent-light);min-height:34px;display:flex;align-items:center}
++.pai-provider{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 16px;align-items:center;padding:12px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px}
++.pai-provider-main{min-width:0}
++.pai-provider-name{font-size:13.5px;font-weight:700;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
++.pai-kind{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:2px 7px;border-radius:999px;background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border)}
++.pai-provider-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px;margin-top:5px;font-size:11.5px;color:var(--text-muted)}
++.pai-provider-meta code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}
++.pai-access{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}
++.pai-access.is-own_deployment{color:var(--success);background:color-mix(in srgb,var(--success) 15%,transparent);border-color:color-mix(in srgb,var(--success) 35%,transparent)}
++.pai-access.is-authorized_wholesale{color:var(--accent-light);background:color-mix(in srgb,var(--accent) 16%,transparent);border-color:color-mix(in srgb,var(--accent) 38%,transparent)}
++.pai-access.is-customer_byok{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 17%,transparent);border-color:color-mix(in srgb,#8b5cf6 40%,transparent)}
++.pai-access.is-restricted{color:var(--warning);background:color-mix(in srgb,var(--warning) 14%,transparent);border-color:color-mix(in srgb,var(--warning) 38%,transparent)}
++.pai-access.is-unverified{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent);border-color:color-mix(in srgb,var(--text-muted) 32%,transparent)}
++.pai-billing{font-size:11px;color:var(--text-secondary)}
++.pai-keystate{margin-top:5px;font-size:11.5px;font-family:ui-monospace,Menlo,Consolas,monospace}
++.pai-keystate.is-ok{color:var(--success)}
++.pai-keystate.is-muted{color:var(--text-muted)}
++.pai-keystate.is-bad{color:var(--danger)}
++.pai-provider-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:6px;max-width:430px}
++.pai .btn.pai-quiet{background:transparent;border:1px solid var(--border);color:var(--text-secondary)}
++.pai .btn.pai-quiet:hover{border-color:var(--accent);color:var(--text)}
++.pai .btn.pai-quiet.pai-quiet-danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--border));background:transparent}
++.pai .btn.pai-quiet.pai-quiet-danger:hover{background:color-mix(in srgb,var(--danger) 10%,transparent);border-color:var(--danger);color:var(--danger)}
++.pai-switch{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary);min-height:34px;padding-right:4px;cursor:pointer;user-select:none}
++.pai-switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
++.pai-track{position:relative;display:inline-block;width:36px;height:20px;flex:0 0 36px;border-radius:10px;background:var(--border);border:1px solid var(--border);transition:background-color .16s ease,border-color .16s ease}
++.pai-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.35);transition:transform .16s ease}
++.pai-switch input:checked + .pai-track{background:var(--accent);border-color:var(--accent)}
++.pai-switch input:checked + .pai-track .pai-knob{transform:translateX(16px)}
++.pai-switch input:focus-visible + .pai-track{outline:2px solid var(--accent);outline-offset:2px}
++.pai-switch input:disabled + .pai-track{opacity:.55;cursor:not-allowed}
++.pai-switch input:disabled ~ .pai-switch-text{color:var(--text-muted)}
++.pai-subhead{font-size:13px;font-weight:600;color:var(--text-secondary);margin:16px 0 8px}
++.pai-group{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin:16px 0 8px}
++.pai-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px}
++.pai-card-head h5{font-size:14px;font-weight:800;margin-bottom:3px}
++.pai-card-head p{font-size:12px;line-height:1.5}
++.pai-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px 18px;margin-top:14px}
++.pai-card>.pai-fields:first-child{margin-top:0}
++.pai-field{min-width:0;border-top:1px solid var(--border);padding-top:10px}
++.pai-field-label{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:12.5px;font-weight:600}
++.pai-field-label label{font-weight:600;display:flex;align-items:center;gap:6px;flex-wrap:wrap;cursor:pointer}
++.pai-field-label code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px}
++.pai-field-desc{font-size:11px;line-height:1.45;margin-top:3px}
++.pai-control{margin-top:7px;display:flex;flex-direction:column;gap:6px}
++.pai-control input[type=text],.pai-control input[type=number],.pai-control input[type=url],.pai-control input:not([type]),.pai-control textarea,.pai-control select,.pai-list-add input{width:100%;min-height:36px;padding:7px 9px;background:var(--bg-input);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:13px;font-family:inherit}
++.pai-field-desc+.pai-control{margin-top:6px}
++.pai-chip-lock,.pai-chip-danger,.pai-chip-secret{font-size:10px;font-weight:700;padding:2px 7px;border-radius:999px;border:1px solid transparent}
++.pai-chip-lock{color:var(--text-muted);background:var(--bg-tertiary);border-color:var(--border)}
++.pai-chip-danger{color:var(--warning);background:color-mix(in srgb,var(--warning) 15%,transparent);border-color:color-mix(in srgb,var(--warning) 40%,transparent)}
++.pai-chip-secret{color:#a78bfa;background:color-mix(in srgb,#8b5cf6 14%,transparent);border-color:color-mix(in srgb,#8b5cf6 36%,transparent)}
++.pai-pin-line{font-size:11px;color:var(--text-muted);margin-top:2px}
++.pai-marker{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;color:var(--success);padding:7px 0;display:inline-block}
++.pai-secret{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
++.pai-chips{display:flex;flex-wrap:wrap;gap:5px;min-height:26px}
++.pai-chip{display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:999px;background:var(--bg-tertiary);border:1px solid var(--border);font-size:11.5px;color:var(--text-secondary);max-width:100%}
++.pai-chip>span{overflow-wrap:anywhere}
++.pai-chip button{border:0;background:none;color:var(--text-muted);cursor:pointer;font-size:14px;line-height:1;padding:2px 3px}
++.pai-chip button:hover{color:var(--danger)}
++.pai-list-add{display:flex;gap:6px;align-items:center}
++.pai-list-add input{flex:1;min-width:0}
++.pai-money-input,.pai-unit-input{display:flex;align-items:center;gap:0;position:relative}
++.pai-money-input .pai-prefix{position:absolute;left:10px;color:var(--text-muted);font-size:13px;pointer-events:none}
++.pai-control .pai-money-input input{padding-left:24px}
++.pai-unit{position:absolute;right:10px;color:var(--text-muted);font-size:11px;pointer-events:none}
++.pai-unit-input input{padding-right:44px}
++.pai-card-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin-top:16px;padding-top:10px;border-top:1px solid var(--border)}
++.pai-revision{font-size:11.5px;color:var(--text-muted)}
++.pai-card-actions{display:flex;gap:6px;flex-wrap:wrap}
++.pai-vault-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:9px}
++.pai-vault-name{font-size:13px;font-weight:700;min-width:140px}
++.pai-state{font-size:11.5px;font-weight:700;padding:2px 8px;border-radius:999px}
++.pai-state.is-ok{color:var(--success);background:color-mix(in srgb,var(--success) 15%,transparent)}
++.pai-state.is-bad{color:var(--danger);background:color-mix(in srgb,var(--danger) 15%,transparent)}
++.pai-state.is-muted{color:var(--text-muted);background:color-mix(in srgb,var(--text-muted) 14%,transparent)}
++.pai-vault-row code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px;color:var(--text-secondary)}
++.pai-hint{flex-basis:100%;font-size:11px;color:var(--text-muted)}
++.pai-dialog{width:min(560px,calc(100vw - 24px));max-height:min(86vh,760px);overflow:auto;border:1px solid var(--border);border-radius:14px;background:var(--bg-card);color:var(--text);padding:0;box-shadow:var(--shadow)}
++.pai-dialog-wide{width:min(820px,calc(100vw - 24px))}
++.pai-dialog::backdrop{background:rgba(0,0,0,.55);backdrop-filter:blur(2px)}
++.pai-form{padding:18px;display:grid;gap:11px;min-width:0}
++.pai-form h3{font-size:15px;font-weight:800}
++.pai-form .ov-field{display:grid;gap:4px;min-width:0}
++.pai-form .ov-field label{font-size:12px;font-weight:600;color:var(--text-secondary)}
++.pai-form .ov-field input,.pai-form .ov-field select{width:100%;min-height:36px;padding:7px 9px;background:var(--bg-input);border:1px solid var(--border);border-radius:7px;color:var(--text);font-size:13px}
++.pai-form-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;min-width:0}
++.pai-form .ov-field-hint{font-size:10.5px;color:var(--text-muted);line-height:1.4}
++.pai-dialog-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:6px}
++.pai-confirm-line{font-size:13px;line-height:1.5;color:var(--text-secondary);padding:8px 10px;background:var(--bg-secondary);border-radius:8px;border:1px solid var(--border)}
++.pai-table-wrap{overflow:auto;max-width:100%}
++.pai-table{width:100%;border-collapse:collapse;font-size:12px}
++.pai-table th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);padding:6px 8px;border-bottom:1px solid var(--border);white-space:nowrap}
++.pai-table td{padding:7px 8px;border-bottom:1px solid var(--border);vertical-align:top;overflow-wrap:anywhere}
++@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
++@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
ddiff --git a/public/css/my.css b/public/css/my.css
iindex bac861c3..b21fd13e 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -417,3 +417,87 @@ button.td-stat:hover span { color: var(--accent-light); }
.label-pref-seg button.is-on { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #111a2c); box-shadow: 0 1px 3px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #60a5fa) 55%, transparent); }
.label-prefs-status.is-bad { color: var(--danger-text, #f87171); }
@media (min-width: 640px) { .label-pref { grid-template-columns: minmax(0, 1fr) 260px; align-items: center; } }
++
++/* Sign in on another device (server/auth/qr-signin.js): a <dialog> with the QR on a white tile on the left and
++ the three steps on the right; below 600 px it stacks, QR first. The tile stays white in every theme: cameras read it. */
++.qr-entry-button { min-height: 44px; margin-top: 12px; }
++.qr-entry[hidden], .qr-dialog [hidden] { display: none !important; }
++.qr-dialog {
++ position: fixed;
++ inset: 0;
++ margin: auto;
++ width: min(760px, calc(100vw - 24px));
++ max-height: calc(100vh - 32px);
++ overflow: auto;
++ padding: 0;
++ color: var(--text-primary, #e8e6e3);
++ background: var(--bg-card, #1a1a26);
++ border: 1px solid var(--border, #2a2a3a);
++ border-radius: 20px;
++ box-shadow: 0 30px 90px rgba(0, 0, 0, .55);
++}
++.qr-dialog::backdrop { background: rgba(0, 0, 0, .62); }
++.qr-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 20px 0; }
++.qr-dialog-head h2 { font-size: 18px; line-height: 1.25; letter-spacing: -.2px; }
++.qr-dialog-head h2:focus { outline: none; }
++.qr-close { all: unset; box-sizing: border-box; width: 44px; height: 44px; display: grid; place-items: center; border-radius: 12px; cursor: pointer; font-size: 26px; line-height: 1; color: var(--text-muted, #8b93ad); }
++.qr-close:hover { color: var(--text-primary, #e8e6e3); background: var(--bg-input, rgba(255,255,255,.06)); }
++.qr-close:focus-visible { outline: 2px solid var(--accent, #60a5fa); outline-offset: 2px; }
++.qr-grid { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr); gap: 26px; padding: 18px 20px 24px; align-items: start; }
++.qr-left { display: flex; flex-direction: column; align-items: center; min-width: 0; }
++#qr-confirm-view, #qr-done-view { width: 100%; }
++.qr-tile { width: 232px; height: 232px; border-radius: 16px; padding: 20px; background: #fff; position: relative; display: grid; place-items: center; flex: none; box-shadow: 0 2px 12px rgba(0,0,0,.18); }
++.qr-code { width: 192px; height: 192px; }
++.qr-code svg { display: block; width: 100%; height: 100%; }
++.qr-expired { position: absolute; inset: 0; border-radius: 16px; background: rgba(255,255,255,.94); color: #181a22; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; text-align: center; }
++.qr-expired p { font-size: 15px; font-weight: 700; }
++.qr-tile.is-expired .qr-code { opacity: .16; }
++.qr-bar { width: 232px; height: 4px; margin-top: 14px; border-radius: 4px; overflow: hidden; background: color-mix(in srgb, var(--text-primary, #e8e6e3) 12%, transparent); }
++#qr-bar-fill { display: block; height: 100%; width: 100%; border-radius: 4px; background: var(--accent, #60a5fa); }
++.qr-timer { min-height: 16px; margin-top: 6px; font-size: 12px; color: var(--text-muted, #8b93ad); }
++.qr-device { display: flex; align-items: center; gap: 10px; width: 100%; padding: 12px 14px; border: 1px solid var(--border, #2a2a3a); border-radius: 12px; background: var(--bg-secondary, #151520); font-size: 14px; font-weight: 650; overflow-wrap: anywhere; }
++.qr-device-icon { display: grid; place-items: center; width: 18px; height: 18px; flex: none; color: var(--accent, #60a5fa); }
++.qr-device-icon svg { display: block; width: 18px; height: 18px; }
++.qr-confirm-label { margin: 12px 0 8px; font-size: 15px; font-weight: 500; color: var(--text-secondary, #a8b3c7); text-align: center; }
++.qr-digits { display: flex; gap: 12px; justify-content: center; }
++.qr-digit { width: 66px; height: 76px; padding: 0; text-align: center; font-size: 36px; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8e6e3); background: var(--bg-input, #1c1c28); border: 1.5px solid var(--border, #2a2a3a); border-radius: 14px; }
++.qr-digit:focus { outline: none; border-color: var(--accent, #60a5fa); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #60a5fa) 25%, transparent); }
++.qr-error { margin-top: 8px; text-align: center; font-size: 13px; line-height: 1.4; color: var(--danger-text, #ef4444); }
++.qr-actions { display: grid; grid-template-columns: repeat(2, minmax(140px, 1fr)); gap: 12px; justify-content: center; margin-top: 20px; }
++.qr-dialog .btn { min-height: 44px; }
++.qr-actions .btn { min-height: 44px; }
++.btn-quiet { color: var(--text-secondary, #a8b3c7); background: transparent; border: 1px solid var(--border, #2a2a3a); }
++.btn-quiet:hover { color: var(--text-primary, #e8e6e3); }
++.qr-done-check { width: 64px; height: 64px; margin: 12px auto 14px; border-radius: 50%; display: grid; place-items: center; background: var(--success, #22c55e); color: #fff; font-size: 30px; font-weight: 800; }
++.qr-done-text { text-align: center; font-size: 18px; font-weight: 750; line-height: 1.3; overflow-wrap: anywhere; }
++.qr-right { min-width: 0; }
++.qr-steps { list-style: none; display: grid; gap: 16px; margin: 6px 0 0; padding: 0; }
++.qr-steps li { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 12px; font-size: 14px; line-height: 1.45; }
++.qr-step-text { color: var(--text-secondary, #a8b3c7); }
++.qr-step-n { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; border: 1px solid var(--border, #2a2a3a); font-size: 13px; font-weight: 700; color: var(--text-secondary, #a8b3c7); transition: background .2s, border-color .2s, color .2s; }
++.qr-steps li.is-done .qr-step-n { border-color: var(--success, #22c55e); background: var(--success, #22c55e); color: #fff; font-size: 16px; }
++.qr-steps li.is-done .qr-step-text { color: var(--text-muted, #8b93ad); }
++.qr-steps li.is-current .qr-step-n { border-color: var(--accent, #60a5fa); background: var(--accent, #60a5fa); color: var(--on-accent, #fff); }
++.qr-steps li.is-current .qr-step-text { color: var(--text-primary, #e8e6e3); }
++.qr-note { margin-top: 22px; font-size: 12px; line-height: 1.55; color: var(--text-muted, #8b93ad); }
++@keyframes qr-shake {
++ 0%, 100% { transform: translateX(0); }
++ 20% { transform: translateX(-7px); }
++ 40% { transform: translateX(7px); }
++ 60% { transform: translateX(-5px); }
++ 80% { transform: translateX(5px); }
++}
++.qr-shake { animation: qr-shake .36s ease-in-out 1; }
++@media (prefers-reduced-motion: reduce) { .qr-shake { animation: none; } }
++@media (max-width: 419px) {
++ .qr-actions { grid-template-columns: minmax(0, 1fr); }
++ .qr-actions .btn { width: 100%; }
++}
++@media (max-width: 600px) {
++ .qr-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 14px 16px 20px; }
++ .qr-right { order: 2; }
++ .qr-steps { gap: 12px; }
++ .qr-steps li { font-size: 13px; }
++ .qr-note { margin-top: 14px; }
++ .qr-dialog { border-radius: 16px; }
++}
ddiff --git a/public/css/wallet.css b/public/css/wallet.css
nnew file mode 100644
iindex 00000000..4f545af5
---- /dev/null
++++ b/public/css/wallet.css
@@@ -0,0 +1,248 @@
++/* openvibe.network/wallet and /pay/:id — the one wallet and the Vibes Pay sheet (MONEY.md, D12).
++ *
++ * The shared kit's classes (ovk-page, ovk-btn, ovk-sheet) carry the controls; this file is the wallet's own shape:
++ * three cards that sit in a row at 1280 px, rows that fold at 320 px, and no sideways scroll. Colours are the site's
++ * theme tokens, so both themes and every custom theme work; money figures use tabular numerals. Nothing spins.
++ */
++
++* { box-sizing: border-box; }
++[hidden] { display: none !important; }
++html, body { margin: 0; min-height: 100%; }
++body {
++ background: var(--bg-primary, #0b0f17);
++ color: var(--text-primary, #e8ecf3);
++ font-family: var(--ov-font, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
++ -webkit-font-smoothing: antialiased;
++}
++a { color: var(--accent, #3b82f6); }
++:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++
++.w-page { padding-top: 18px; padding-bottom: 48px; }
++.w-head { margin: 4px auto 20px; }
++.w-head h1 { margin: 0; font-size: clamp(1.7rem, 3.4vw, 2.15rem); font-weight: 800; letter-spacing: -.025em; }
++.w-head p { margin: 6px 0 0; color: var(--text-secondary, #a8b3c4); font-size: .95rem; line-height: 1.5; }
++
++.w-banner {
++ margin: 0 0 14px; padding: 10px 14px; border-radius: 12px; font-size: .9rem; line-height: 1.5;
++ color: var(--text-primary, #e8ecf3);
++ background: color-mix(in srgb, var(--accent, #3b82f6) 10%, var(--bg-card, #121826));
++ border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 32%, var(--border, rgba(255, 255, 255, .12)));
++}
++.w-linkish { padding: 0; border: 0; background: none; color: var(--accent, #3b82f6); font: inherit; font-weight: 700; text-decoration: underline; cursor: pointer; }
++
++/* ── The three cards ── */
++.w-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
++@media (max-width: 899px) { .w-cards { grid-template-columns: minmax(0, 1fr); } }
++
++.w-card {
++ position: relative; display: flex; flex-direction: column; min-width: 0;
++ padding: 22px 22px 20px; border: 1px solid var(--border, rgba(255, 255, 255, .12)); border-radius: 18px;
++ background: var(--bg-card, #121826);
++ box-shadow: 0 16px 40px -34px rgba(0, 0, 0, .9);
++ overflow: hidden;
++}
++.w-card::before { content: ''; position: absolute; inset: 0 0 auto; height: 3px; background: var(--accent, #3b82f6); opacity: .9; }
++.w-card--vibes::before { background: var(--success-text, var(--success, #22c55e)); }
++.w-card--coins::before { background: var(--warning-text, var(--warning, #f59e0b)); }
++
++.w-card-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
++.w-card-top h2 { margin: 0; font-size: 1rem; font-weight: 750; letter-spacing: -.01em; }
++.w-mark {
++ flex: none; display: inline-grid; place-items: center; width: 36px; height: 36px; border-radius: 12px;
++ font-weight: 800; font-size: 1.15rem; line-height: 1;
++ background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent);
++ color: var(--accent-light, var(--accent, #60a5fa));
++}
++.w-mark--vibes { background: color-mix(in srgb, var(--success, #22c55e) 15%, transparent); color: var(--success-text, var(--success, #22c55e)); }
++.w-mark--coins { background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent); color: var(--warning-text, var(--warning, #f59e0b)); }
++.w-mark .w-icon { width: 21px; height: 21px; }
++
++.w-amount {
++ margin: 18px 0 2px; font-size: clamp(1.9rem, 4vw, 2.6rem); line-height: 1.05; font-weight: 800;
++ letter-spacing: -.035em; font-variant-numeric: tabular-nums; color: var(--text-primary, #e8ecf3);
++ overflow-wrap: anywhere;
++}
++.w-line { margin: 4px 0 0; color: var(--text-secondary, #a8b3c4); font-size: .88rem; line-height: 1.45; }
++.w-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 18px; }
++.w-actions .ovk-btn { flex: 1 1 140px; }
++.w-note { margin: 10px 0 0; font-size: .78rem; line-height: 1.45; color: var(--text-muted, #7d8aa0); }
++.w-note.is-warn { color: var(--warning-text, var(--warning, #b45309)); }
++
++/* ── Sections under the cards ── */
++.w-sec { margin-top: 30px; min-width: 0; }
++.w-sec-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-bottom: 10px; }
++.w-sec-head h2 { margin: 0; font-size: 1.08rem; font-weight: 750; letter-spacing: -.015em; }
++.w-sec-sub { margin: 3px 0 0; color: var(--text-muted, #7d8aa0); font-size: .82rem; line-height: 1.4; }
++.w-sec-head--activity { align-items: center; }
++
++.w-empty { margin: 10px 0 0; color: var(--text-muted, #7d8aa0); font-size: .86rem; line-height: 1.5; }
++
++/* Projects */
++.w-projects {
++ list-style: none; margin: 0; padding: 0; overflow: hidden;
++ border: 1px solid var(--border, rgba(255, 255, 255, .12)); border-radius: 16px;
++ background: var(--bg-card, #121826);
++}
++.w-project { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 11px 16px; }
++.w-project + .w-project { border-top: 1px solid var(--border, rgba(255, 255, 255, .12)); }
++.w-project-main { flex: 1 1 auto; min-width: 0; }
++.w-project-link { display: flex; align-items: center; gap: 4px; color: inherit; text-decoration: none; min-width: 0; }
++.w-project-link:hover b { color: var(--accent-light, var(--accent, #60a5fa)); }
++.w-project-link b { font-size: .93rem; font-weight: 650; overflow-wrap: anywhere; }
++.w-project-ext { color: var(--text-muted, #7d8aa0); font-size: .74rem; line-height: 1; }
++.w-project-sub { margin-top: 2px; color: var(--text-muted, #7d8aa0); font-size: .74rem; font-variant-numeric: tabular-nums; }
++.w-project-side { flex: none; display: flex; align-items: center; gap: 10px; }
++.w-project-bal { font-variant-numeric: tabular-nums; font-weight: 750; font-size: .95rem; white-space: nowrap; }
++@media (max-width: 359px) {
++ .w-project { flex-wrap: wrap; padding: 12px 14px; }
++ .w-project-main { flex: 1 1 100%; }
++ .w-project-side { width: 100%; justify-content: space-between; }
++}
++
++/* Activity */
++.w-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.w-chip {
++ min-height: 36px; padding: 6px 14px; border-radius: 999px; cursor: pointer;
++ border: 1px solid var(--border, rgba(255, 255, 255, .12)); background: transparent;
++ color: var(--text-secondary, #a8b3c4); font: inherit; font-size: .82rem; font-weight: 650;
++}
++.w-chip:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, transparent)); color: var(--text-primary, #e8ecf3); }
++.w-chip[aria-pressed="true"] {
++ background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent);
++ border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent);
++ color: var(--text-primary, #e8ecf3);
++}
++@media (pointer: coarse), (max-width: 759px) { .w-chip { min-height: 44px; } }
++
++.w-activity {
++ list-style: none; margin: 12px 0 0; padding: 0; overflow: hidden;
++ border: 1px solid var(--border, rgba(255, 255, 255, .12)); border-radius: 16px;
++ background: var(--bg-card, #121826);
++}
++.w-activity:empty { display: none; }
++.w-act { display: grid; grid-template-columns: 92px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 11px 16px; }
++.w-act + .w-act { border-top: 1px solid var(--border, rgba(255, 255, 255, .12)); }
++.w-act-date { color: var(--text-muted, #7d8aa0); font-size: .78rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.w-act-main { display: grid; gap: 1px; min-width: 0; }
++.w-act-label { font-size: .92rem; font-weight: 650; overflow-wrap: anywhere; }
++.w-act-detail { color: var(--text-muted, #7d8aa0); font-size: .78rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.w-act-amt { display: flex; flex-direction: column; align-items: flex-end; justify-self: end; color: var(--text-primary, #e8ecf3); font-size: .95rem; font-weight: 750; font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; line-height: 1.25; }
++.w-act-amt--stack { gap: 1px; }
++.w-act-amt small { margin-left: 2px; color: var(--text-muted, #7d8aa0); font-size: .7rem; font-weight: 600; }
++.w-act-part.is-pos { color: var(--success-text, var(--success, #22c55e)); }
++@media (max-width: 600px) { .w-act-detail { white-space: normal; } }
++@media (max-width: 359px) {
++ .w-act { grid-template-columns: 78px minmax(0, 1fr); row-gap: 3px; padding: 11px 13px; }
++ .w-act-amt { grid-column: 2; justify-self: start; align-items: flex-start; text-align: left; font-size: .86rem; }
++}
++
++/* Where each works: one quiet line of chips per currency */
++.w-where { margin-top: 34px; padding-top: 20px; border-top: 1px solid var(--border, rgba(255, 255, 255, .12)); }
++.w-where h2 { margin: 0 0 12px; color: var(--text-muted, #7d8aa0); font-size: .74rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
++.w-where-list { display: grid; gap: 9px; margin: 0; }
++.w-where-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
++.w-where-row dt { min-width: 76px; color: var(--text-secondary, #a8b3c4); font-size: .8rem; font-weight: 700; }
++.w-where-row dd { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }
++.w-tag {
++ padding: 3px 10px; border: 1px solid var(--border, rgba(255, 255, 255, .12)); border-radius: 999px;
++ background: var(--bg-secondary, #101828); color: var(--text-secondary, #a8b3c4); font-size: .74rem; line-height: 1.5;
++}
++
++/* ── Sheets (Move to a project, Add money, Get Vibes) ── */
++.w-sheet { color: var(--text-primary, #e8ecf3); }
++.w-sheet .ovk-sheet-body { display: grid; gap: 12px; }
++.w-sheet .ovk-sheet-head h2 { overflow-wrap: anywhere; }
++.w-sheet-lede { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: .9rem; line-height: 1.55; }
++.w-sheet-note { margin: 0; color: var(--text-muted, #7d8aa0); font-size: .82rem; line-height: 1.5; }
++.w-sheet-go { width: 100%; }
++.w-sheet-cancel { width: 100%; }
++.w-quiet {
++ display: inline-flex; align-items: center; justify-content: center; min-height: 44px;
++ padding: 8px 12px; border: 0; background: none; color: var(--text-muted, #7d8aa0);
++ font: inherit; font-size: .88rem; font-weight: 650; text-decoration: none; cursor: pointer; border-radius: 10px;
++}
++.w-quiet:hover { color: var(--text-primary, #e8ecf3); }
++
++.w-tabs { display: flex; gap: 4px; padding: 4px; border-radius: 12px; background: var(--bg-secondary, #101828); }
++.w-tab {
++ flex: 1 1 0; min-width: 0; min-height: 44px; padding: 8px 10px; border: 0; border-radius: 9px;
++ background: transparent; color: var(--text-muted, #7d8aa0); font: inherit; font-size: .88rem;
++ font-weight: 700; cursor: pointer;
++}
++.w-tab[aria-selected="true"] { background: var(--bg-card, #121826); color: var(--text-primary, #e8ecf3); }
++
++.w-field { display: grid; gap: 5px; min-width: 0; }
++.w-field > span { color: var(--text-muted, #7d8aa0); font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
++.w-field input, .w-field select {
++ width: 100%; min-height: 46px; padding: 10px 12px; border-radius: 11px;
++ border: 1px solid var(--border, rgba(255, 255, 255, .12)); background: var(--bg-input, #0f1522);
++ color: var(--text-primary, #e8ecf3); font: inherit; font-size: 1rem; font-weight: 500;
++}
++.w-field input:focus, .w-field select:focus {
++ border-color: var(--accent, #3b82f6);
++ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent);
++ outline: 0;
++}
++.w-field input::placeholder { color: var(--text-muted, #7d8aa0); opacity: 1; }
++.w-cost-line { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: .9rem; line-height: 1.5; }
++.w-cost-line b { color: var(--text-primary, #e8ecf3); font-variant-numeric: tabular-nums; }
++
++.w-packs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
++.w-pack {
++ display: flex; flex-direction: column; gap: 2px; min-height: 44px; padding: 12px 13px;
++ border: 1px solid var(--border, rgba(255, 255, 255, .12)); border-radius: 12px;
++ background: var(--bg-card, #121826); color: var(--text-primary, #e8ecf3); text-decoration: none;
++}
++.w-pack:hover { border-color: var(--accent, #3b82f6); }
++.w-pack b { font-size: .92rem; font-weight: 700; }
++.w-pack small { color: var(--text-muted, #7d8aa0); font-size: .78rem; }
++@media (max-width: 360px) { .w-packs { grid-template-columns: 1fr; } }
++
++/* ── Vibes Pay (/pay/:id): one centred card, 420 px at most ── */
++.w-pay-page { display: flex; align-items: flex-start; justify-content: center; min-height: 66vh; padding: 9vh 16px 48px; }
++@media (max-height: 720px) { .w-pay-page { padding-top: 28px; } }
++.w-pay-card { width: 100%; max-width: 420px; min-width: 0; margin: 0 auto; }
++.w-pay {
++ display: flex; flex-direction: column; gap: 10px; min-width: 0;
++ padding: 26px 24px 22px; border: 1px solid var(--border, rgba(255, 255, 255, .12)); border-radius: 22px;
++ background: var(--bg-card, #121826);
++ box-shadow: 0 28px 70px -46px rgba(0, 0, 0, .95);
++}
++.w-pay-app { display: flex; align-items: center; gap: 12px; min-width: 0; }
++.w-pay-mark {
++ flex: none; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden;
++ border-radius: 14px; background: color-mix(in srgb, var(--accent, #3b82f6) 16%, var(--bg-secondary, #101828));
++ color: var(--accent-light, var(--accent, #60a5fa)); font-size: 1.2rem; font-weight: 800;
++}
++.w-pay-mark img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.w-pay-app-who { display: grid; gap: 1px; min-width: 0; }
++.w-pay-app-who b { font-size: 1rem; font-weight: 750; overflow-wrap: anywhere; }
++.w-pay-app-who span { color: var(--text-muted, #7d8aa0); font-size: .76rem; }
++.w-pay-item { margin: 6px 0 0; color: var(--text-secondary, #a8b3c4); font-size: .95rem; line-height: 1.5; overflow-wrap: anywhere; }
++.w-pay-price {
++ margin: 2px 0; font-size: clamp(2rem, 9vw, 2.6rem); line-height: 1.05; font-weight: 800;
++ letter-spacing: -.035em; font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
++}
++.w-pay-have { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: .9rem; line-height: 1.45; font-variant-numeric: tabular-nums; }
++.w-pay-need { margin: 2px 0 2px; color: var(--warning-text, var(--warning, #b45309)); font-size: .95rem; font-weight: 700; line-height: 1.45; }
++.w-pay-go { width: 100%; margin-top: 6px; }
++.w-pay-cancel { width: 100%; align-self: center; }
++.w-pay-title { margin: 0; font-size: 1.55rem; font-weight: 800; letter-spacing: -.02em; }
++.w-pay-line { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: .95rem; line-height: 1.5; }
++.w-pay-check {
++ display: grid; place-items: center; width: 58px; height: 58px; margin: 4px 0 2px; border-radius: 50%;
++ background: color-mix(in srgb, var(--success, #22c55e) 16%, transparent);
++ color: var(--success-text, var(--success, #22c55e));
++}
++.w-pay-sentence { margin: 0; color: var(--text-secondary, #a8b3c4); font-size: 1rem; line-height: 1.6; }
++.w-pay-loading { margin: 0; color: var(--text-muted, #7d8aa0); font-size: .95rem; }
++.w-pay-error { margin: 0; color: var(--danger-text, var(--danger, #ef4444)); font-size: .86rem; line-height: 1.45; }
++@media (max-width: 380px) {
++ .w-pay { padding: 20px 18px 18px; border-radius: 18px; }
++ .w-pay-page { padding-left: 12px; padding-right: 12px; }
++}
++
++/* Reduced motion: nothing here moves, and a person who asks for calm gets no transitions either. */
++@media (prefers-reduced-motion: reduce) {
++ *, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
++}
ddiff --git a/public/index.html b/public/index.html
iindex 12a1e680..48ab78cd 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -292,7 +292,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
function initNavbar(user) {
if (typeof OpenVibeNavbar !== 'undefined') {
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: token || null, user: user || null, links: [{ label: 'My Account', href: '/my' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: token || null, user: user || null, menu: { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] }, links: [{ label: 'My Account', href: '/my' }, { label: 'Wallet', href: '/wallet' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });
}
if (user) {
document.getElementById('hero-guest-ctas').style.display = 'none';
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
iindex e95cff11..3bea6de1 100644
---- a/public/js/admin/ai.js
++++ b/public/js/admin/ai.js
@@@ -49,6 +49,7 @@ async function loadStaffRoom() {
}
async function loadAi() {
++ loadPlatformAi();
loadStaffRoom();
const c = document.getElementById('ai-content');
c.innerHTML = '<div class="loading">Loading...</div>';
@@@ -555,3 +556,792 @@ async function testTTSVoice() {
toast(`Testing ${result.voiceName || voiceId}`, 'info');
} catch (e) { toast(e.message || 'Could not play the test clip', 'error'); }
}
++
++// ═══════════════════════════════════════════════════════════════
++// Platform AI (AI Exchange D11 S3, owner only): every service's openvibe-shared/config namespaces and AI's provider
++// registry, in one long stacked page (no inner tabs, nothing display:none) with a sticky in-page index. The server
++// proxy is /api/admin/ai/config (server/admin/ai-config-proxy.js); it holds no value, and neither does this page:
++// a secret is only ever written, never rendered. Network's admin helpers (api, esc, toast, timeAgo) are above.
++// ═══════════════════════════════════════════════════════════════
++const PAI_NAMES = {
++ network: 'OpenVibe.Network', ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
++ bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
++ actor: 'OpenVibe.Actor',
++};
++const PAI_ACCESS = ['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED'];
++const PAI_BILLING_LABEL = { metered: 'Metered', subscription: 'Subscription', payg: 'Pay as you go', free: 'Free', byok: 'Customer BYOK' };
++const PAI_ACCESS_LABEL = {
++ OWN_DEPLOYMENT: 'Own deployment', AUTHORIZED_WHOLESALE: 'Authorized wholesale', CUSTOMER_BYOK: 'Customer BYOK',
++ RESTRICTED: 'Restricted', UNVERIFIED: 'Unverified',
++};
++const PAI = {
++ providers: [], services: [], namespaces: [], warnings: [], vault: {}, serviceState: {}, loading: false, addProviderError: null,
++};
++
++function paiLabel(service) {
++ return PAI_NAMES[service] || ('OpenVibe.' + String(service || '').replace(/^./, (c) => c.toUpperCase()));
++}
++
++function paiApi(path, opts = {}) {
++ return api('/api/admin/ai/config' + path, opts).catch((err) => {
++ if (err && err.status === 403 && err.body && err.body.code === 'config.fresh_required') {
++ paiShowFresh(err.body.sign_in_url);
++ err.freshRequired = true;
++ }
++ throw err;
++ });
++}
++
++function paiShowFresh(url) {
++ const el = document.getElementById('pai-fresh');
++ if (!el) return;
++ const link = el.querySelector('a');
++ if (link && url) link.href = url;
++ el.hidden = false;
++}
++
++function paiFriendly(service, err) {
++ if (err && err.freshRequired) return "Confirm it's you to change keys and money settings.";
++ if (err && (err.status === 502 || err.status === 503 || /did not answer|not configured/i.test(err.message || ''))) {
++ return paiLabel(service) + ' did not answer.';
++ }
++ const message = String((err && err.message) || 'The request failed').split('\n')[0];
++ return message.length > 240 ? message.slice(0, 240) + '…' : message;
++}
++
++/** Dollar text ↔ nano-USD, exactly: BigInt integer maths, no float, at most 9 decimals. */
++function paiUsd(nano) {
++ let n;
++ try { n = BigInt(String(nano == null ? 0 : nano)); } catch { return '0'; }
++ const negative = n < 0n;
++ if (negative) n = -n;
++ const whole = n / 1000000000n;
++ const frac = (n % 1000000000n).toString().padStart(9, '0').replace(/0+$/, '');
++ return (negative ? '-' : '') + whole.toString() + (frac ? '.' + frac : '');
++}
++
++function paiParseUsd(text) {
++ const value = String(text == null ? '' : text).trim();
++ if (!/^(?:0|[1-9][0-9]{0,15})(?:\.[0-9]{0,9})?$/.test(value)) return { error: 'Enter dollars with at most 9 decimals, e.g. 12.50' };
++ const [whole, frac = ''] = value.split('.');
++ const nano = BigInt(whole) * 1000000000n + BigInt((frac + '000000000').slice(0, 9));
++ if (nano > BigInt(Number.MAX_SAFE_INTEGER)) return { error: 'That amount is too large' };
++ return { nano: Number(nano) };
++}
++
++function paiHost(url) {
++ try { return new URL(url).host; } catch { return String(url || '—'); }
++}
++
++function paiWhen(iso) {
++ if (!iso) return '';
++ try { return timeAgo(iso); } catch { return ''; }
++}
++
++function paiMarker(value) {
++ return value && typeof value === 'object' && value.redacted === true ? value : null;
++}
++
++function paiFold(items, render) {
++ const head = items.slice(0, 8).map(render).join('');
++ if (items.length <= 8) return '<div class="pai-rows">' + head + '</div>';
++ return '<div class="pai-rows">' + head + '</div><details class="pai-more"><summary>More (' + (items.length - 8) + ')</summary><div class="pai-rows">' + items.slice(8).map(render).join('') + '</div></details>';
++}
++
++async function loadPlatformAi() {
++ const root = document.getElementById('platform-ai');
++ if (!root) return;
++ if (!currentUser || !currentUser.is_owner) { root.hidden = true; return; }
++ root.hidden = false;
++ if (PAI.loading) return;
++ PAI.loading = true;
++ try {
++ const [servicesRes, providersRes] = await Promise.all([
++ paiApi('/services'),
++ paiApi('/providers').catch((err) => ({ error: err })),
++ ]);
++ PAI.services = servicesRes.services || [];
++ PAI.providers = (providersRes && providersRes.providers) || [];
++ PAI.addProviderError = providersRes && providersRes.error ? paiFriendly('ai', providersRes.error) : null;
++ const answers = await Promise.all(PAI.services.map((s) => paiApi('/service/' + encodeURIComponent(s.service)).then((r) => ({ service: s.service, ...r })).catch((err) => ({ service: s.service, error: err }))));
++ PAI.namespaces = [];
++ PAI.warnings = [];
++ PAI.vault = {};
++ PAI.serviceState = {};
++ for (const answer of answers) {
++ if (answer.error) {
++ PAI.warnings.push({ service: answer.service, namespace: null, reason: 'unavailable', detail: paiFriendly(answer.service, answer.error) });
++ PAI.vault[answer.service] = { sealed: null, kek: null };
++ PAI.serviceState[answer.service] = { answered: false, sealed: null, kek: null };
++ continue;
++ }
++ PAI.namespaces.push(...(answer.namespaces || []));
++ PAI.warnings.push(...(answer.warnings || []));
++ PAI.vault[answer.service] = answer.vault || { sealed: null, kek: null };
++ PAI.serviceState[answer.service] = { answered: true, sealed: answer.vault ? answer.vault.sealed : null, kek: answer.vault ? answer.vault.kek : null };
++ }
++ paiRender(root);
++ } catch (err) {
++ root.innerHTML = paiShell('<p class="pai-error">' + esc(paiFriendly('network', err)) + '</p>');
++ } finally {
++ PAI.loading = false;
++ }
++}
++
++function paiShell(inner) {
++ return '<div class="pai-head"><h2><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Platform AI</h2>'
++ + '<p class="muted">Every service\'s AI settings in one place. Keys are sealed and write-only: once saved, nothing here can show one again. Money and key changes ask you to confirm it is you.</p></div>'
++ + '<div id="pai-fresh" class="pai-fresh" hidden><i class="fa-solid fa-lock" aria-hidden="true"></i> <span>Confirm it\'s you to change keys and money settings.</span> <a class="btn btn-primary" href="/login?return=%2Fadmin%23ai">Sign in again</a></div>'
++ + inner;
++}
++
++function paiRender(root) {
++ const exchange = PAI.namespaces.filter((n) => n.service === 'ai' && !n.namespace.endsWith('.provider_keys'));
++ const money = PAI.namespaces.filter((n) => n.service === 'billing');
++ const rest = PAI.namespaces.filter((n) => n.service !== 'ai' && n.service !== 'billing' && !n.namespace.endsWith('.provider_keys'));
++ const count = (n) => n ? ' <span class="pai-count">' + n + '</span>' : '';
++ root.innerHTML = paiShell(
++ '<nav class="pai-index" aria-label="Platform AI sections">'
++ + '<a href="#pai-providers">Providers and accounts' + count(PAI.providers.length) + '</a>'
++ + '<a href="#pai-exchange">AI Exchange' + count(exchange.length) + '</a>'
++ + '<a href="#pai-money">Money' + count(money.length) + '</a>'
++ + '<a href="#pai-services">Services' + count(rest.length) + '</a>'
++ + '<a href="#pai-vault">Vault' + count(PAI.services.length) + '</a>'
++ + '</nav>'
++ + '<div id="pai-warnings"></div>'
++ + '<section id="pai-providers" class="pai-section"><div class="pai-section-head"><h3>Providers and accounts</h3><p class="muted">The providers OpenVibe.AI may call, their billing profile and their key. A subscription account runs on a shared pool; a metered one spends Credits.</p></div>'
++ + '<div class="pai-toolbar"><button type="button" class="btn btn-primary" id="pai-add-provider"><i class="fa-solid fa-plus" aria-hidden="true"></i> Add provider</button></div>'
++ + '<div id="pai-providers-list"></div></section>'
++ + '<section id="pai-exchange" class="pai-section"><h3>AI Exchange</h3><p class="muted">What OpenVibe.AI routes, which models it offers and what it may spend.</p><div id="pai-cards-exchange" data-pai-cards></div></section>'
++ + '<section id="pai-money" class="pai-section"><h3>Money</h3><p class="muted">Credits, top-ups and the sandbox. Every money switch says exactly what it turns on.</p><div id="pai-cards-money" data-pai-cards></div></section>'
++ + '<section id="pai-services" class="pai-section"><h3>Services</h3><p class="muted">The settings of every other service, drawn from each namespace\'s schema.</p><div id="pai-cards-services" data-pai-cards></div></section>'
++ + '<section id="pai-vault" class="pai-section"><h3>Vault</h3><p class="muted">Secrets are sealed with the service\'s own key-encryption key (KEK); without one the store keeps its pre-D11 plaintext behaviour and says so.</p><div id="pai-vault-list"></div></section>'
++ );
++ paiRenderWarnings();
++ paiRenderProviders();
++ paiRenderCards();
++ paiRenderVault();
++ const add = document.getElementById('pai-add-provider');
++ if (add) add.addEventListener('click', () => paiProviderDialog(null));
++ const list = document.getElementById('pai-providers-list');
++ if (list) {
++ list.addEventListener('click', paiProviderClick);
++ list.addEventListener('change', paiProviderSwitch);
++ }
++ for (const box of document.querySelectorAll('#platform-ai [data-pai-cards]')) {
++ box.addEventListener('click', paiCardClick);
++ box.addEventListener('change', (ev) => {
++ const input = ev.target.closest('input[data-pai-control][data-type="boolean"]');
++ if (!input) return;
++ const text = input.closest('.pai-switch') && input.closest('.pai-switch').querySelector('.pai-switch-text');
++ if (text) text.textContent = input.checked ? 'On' : 'Off';
++ });
++ }
++ if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(root);
++}
++
++function paiRenderWarnings() {
++ const box = document.getElementById('pai-warnings');
++ if (!box) return;
++ if (!PAI.warnings.length) { box.innerHTML = ''; return; }
++ box.innerHTML = '<div class="pai-warning"><strong>Some answers were dropped.</strong> The service answered with something that is not a settings summary, so it is not shown.'
++ + PAI.warnings.map((w) => '<div class="pai-warning-row">' + esc(paiLabel(w.service)) + (w.namespace ? ' · <code>' + esc(w.namespace) + '</code>' : '') + ' — ' + esc(w.reason === 'invalid-summary' ? 'not a settings summary' : (w.reason || 'not available')) + '</div>').join('')
++ + '</div>';
++}
++
++function paiKeyState(p) {
++ const ref = String(p.secret_ref || '');
++ const vault = ref.startsWith('vault:');
++ const env = ref.startsWith('env:');
++ const hint = p.key_hint || p.hint || p.secret_hint || '';
++ const bound = p.bound_to || paiHost(p.base_url);
++ const required = p.auth_mode !== 'none' && p.credentials !== 'not_required';
++ if (!required) return { tone: 'muted', text: 'Not required', required: false };
++ if (vault && p.credentials !== 'missing') return { tone: 'ok', text: 'Sealed' + (hint ? ' ••••' + esc(hint) : '') + ' · bound to ' + esc(paiHost(bound)), required: true };
++ if (env) return { tone: 'muted', text: 'From env ' + esc(ref.slice(4)), env: ref.slice(4), required: true };
++ if (p.credentials === 'missing' || !ref) return { tone: 'bad', text: 'No key', required: true };
++ return { tone: 'ok', text: 'Sealed · bound to ' + esc(paiHost(bound)), required: true };
++}
++
++function paiProviderRow(p) {
++ const access = PAI_ACCESS.includes(String(p.provider_access || '').toUpperCase()) ? String(p.provider_access).toUpperCase() : 'UNVERIFIED';
++ const billing = p.billing_profile === 'subscription' ? 'Subscription · ' + esc(p.pool_key || p.key) : esc(PAI_BILLING_LABEL[p.billing_profile] || PAI_BILLING_LABEL.metered);
++ const key = paiKeyState(p);
++ const sealed = String(p.secret_ref || '').startsWith('vault:') && p.credentials !== 'missing';
++ const actions = '<button type="button" class="btn btn-sm pai-quiet" data-act="edit">Edit</button> '
++ + (key.required && key.env ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">Move into the vault</button> ' : '')
++ + (key.required ? '<button type="button" class="btn btn-sm pai-quiet" data-act="key">' + (sealed ? 'Replace key' : 'Add key') + '</button> ' : '')
++ + (key.required && sealed ? '<button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="remove-key">Remove key</button>' : '');
++ return '<div class="pai-provider" data-pai-provider="' + esc(p.key) + '">'
++ + '<div class="pai-provider-main"><div class="pai-provider-name">' + esc(p.display_name || p.key) + ' <span class="pai-kind">' + esc(p.kind || '—') + '</span></div>'
++ + '<div class="pai-provider-meta"><code>' + esc(paiHost(p.base_url)) + '</code> <span class="pai-access is-' + access.toLowerCase() + '">' + esc(PAI_ACCESS_LABEL[access]) + '</span> <span class="pai-billing">' + billing + '</span></div>'
++ + '<div class="pai-keystate is-' + key.tone + '">' + key.text + '</div></div>'
++ + '<div class="pai-provider-actions"><label class="pai-switch"><input type="checkbox" role="switch" data-pai-enabled data-ov-native ' + (p.status === 'active' ? 'checked' : '') + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (p.status === 'active' ? 'Enabled' : 'Disabled') + '</span></label>' + actions + '</div></div>';
++}
++
++function paiRenderProviders() {
++ const box = document.getElementById('pai-providers-list');
++ if (!box) return;
++ if (PAI.addProviderError) {
++ box.innerHTML = '<p class="pai-error">' + esc(PAI.addProviderError) + '</p>';
++ return;
++ }
++ if (!PAI.providers.length) {
++ box.innerHTML = '<p class="muted">No provider is configured yet. Add one to let OpenVibe.AI answer.</p>';
++ return;
++ }
++ const subscription = PAI.providers.filter((p) => p.billing_profile === 'subscription');
++ const other = PAI.providers.filter((p) => p.billing_profile !== 'subscription');
++ box.innerHTML = paiFold(other, paiProviderRow)
++ + (subscription.length ? '<h4 class="pai-subhead">Subscription accounts (Claude, Codex, …)</h4>' + paiFold(subscription, paiProviderRow) : '');
++}
++
++function paiProviderClick(ev) {
++ const button = ev.target.closest('button[data-act]');
++ if (!button) return;
++ const row = button.closest('[data-pai-provider]');
++ if (!row) return;
++ const provider = PAI.providers.find((p) => p.key === row.dataset.paiProvider);
++ if (!provider) return;
++ if (button.dataset.act === 'edit') paiProviderDialog(provider);
++ if (button.dataset.act === 'key') paiKeyDialog(provider);
++ if (button.dataset.act === 'remove-key') paiRemoveKey(provider);
++}
++
++async function paiProviderSwitch(ev) {
++ const input = ev.target.closest('input[data-pai-enabled]');
++ if (!input) return;
++ const row = input.closest('[data-pai-provider]');
++ const provider = PAI.providers.find((p) => p.key === row.dataset.paiProvider);
++ if (!provider) return;
++ input.disabled = true;
++ try {
++ await paiApi('/providers/' + encodeURIComponent(provider.key), { method: 'PATCH', body: { status: input.checked ? 'active' : 'disabled' } });
++ provider.status = input.checked ? 'active' : 'disabled';
++ const label = input.closest('.pai-switch').querySelector('.pai-switch-text');
++ if (label) label.textContent = input.checked ? 'Enabled' : 'Disabled';
++ toast(provider.display_name + (input.checked ? ' enabled' : ' disabled'), 'success');
++ } catch (err) {
++ input.checked = !input.checked;
++ const label = input.closest('.pai-switch').querySelector('.pai-switch-text');
++ if (label) label.textContent = input.checked ? 'Enabled' : 'Disabled';
++ if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');
++ } finally {
++ input.disabled = false;
++ }
++}
++
++function paiProviderDialog(provider) {
++ const editing = !!provider;
++ const p = provider || { billing_profile: 'metered', kind: 'openai', priority: 100, provider_access: 'UNVERIFIED' };
++ const access = PAI_ACCESS.includes(String(p.provider_access || '').toUpperCase()) ? String(p.provider_access).toUpperCase() : 'UNVERIFIED';
++ const kind = p.kind || 'openai';
++ const billing = p.billing_profile || 'metered';
++ const dlg = document.createElement('dialog');
++ dlg.className = 'pai-dialog';
++ const options = (items, current) => items.map((item) => '<option value="' + esc(item.value) + '"' + (item.value === current ? ' selected' : '') + ' data-desc="' + esc(item.desc || '') + '" data-state="' + (item.state || 'off') + '">' + esc(item.label) + '</option>').join('');
++ const kindOptions = [
++ { value: 'openai', label: 'OpenAI-compatible', desc: 'Chat/completions with a bearer key', state: 'ok' },
++ { value: 'anthropic', label: 'Anthropic Messages', desc: 'Claude\'s own messages API', state: 'ok' },
++ { value: 'http', label: 'Plain HTTP seam', desc: 'A custom endpoint that speaks JSON', state: 'off' },
++ { value: 'stub', label: 'Deterministic stub', desc: 'Synthetic answers for tests', state: 'off' },
++ { value: 'whisper', label: 'whisper.cpp', desc: 'Local speech-to-text', state: 'off' },
++ ];
++ const billingOptions = [
++ { value: 'metered', label: 'Metered', desc: 'Every request spends Credits', state: 'ok' },
++ { value: 'subscription', label: 'Subscription', desc: 'Runs on a shared plan pool', state: 'ok' },
++ { value: 'payg', label: 'Pay as you go', desc: 'A prepaid provider balance', state: 'off' },
++ { value: 'free', label: 'Free', desc: 'No provider charge', state: 'off' },
++ { value: 'byok', label: 'Customer BYOK', desc: 'A customer\'s own key pays the provider', state: 'new' },
++ ];
++ dlg.innerHTML = '<form method="dialog" class="pai-form">'
++ + '<h3>' + (editing ? 'Edit provider' : 'Add provider') + '</h3>'
++ + '<div class="ov-field"><label for="pai-p-key">Key</label><input id="pai-p-key" name="key" required pattern="[a-z][a-z0-9_-]*([.][a-z0-9_-]+)*" ' + (editing ? 'readonly' : '') + ' value="' + esc(p.key || '') + '"><p class="ov-field-hint">Lowercase, dotted or dashed: deepseek, claude, codex. It cannot be renamed.</p></div>'
++ + '<div class="ov-field"><label for="pai-p-name">Display name</label><input id="pai-p-name" name="display_name" value="' + esc(p.display_name || '') + '"></div>'
++ + '<div class="ov-field"><label for="pai-p-kind">Kind</label><select id="pai-p-kind" name="kind">'
++ + options(kindOptions, kind)
++ + '</select></div>'
++ + '<div class="ov-field"><label for="pai-p-url">Base URL</label><input id="pai-p-url" name="base_url" type="url" placeholder="https://api.example.com" value="' + esc(p.base_url || '') + '"><p class="ov-field-hint">A sealed key is bound to this address: changing it means entering the key again.</p></div>'
++ + '<div class="ov-field"><label for="pai-p-access">Provider access</label><select id="pai-p-access" name="provider_access" data-ov-title="Provider access">'
++ + PAI_ACCESS.map((a) => '<option value="' + a + '"' + (a === access ? ' selected' : '') + ' data-badge="' + esc(PAI_ACCESS_LABEL[a]) + '" data-desc="' + esc(paiAccessDesc(a)) + '" data-state="' + (a === 'OWN_DEPLOYMENT' || a === 'AUTHORIZED_WHOLESALE' ? 'ok' : a === 'RESTRICTED' ? 'warn' : a === 'UNVERIFIED' ? 'off' : 'new') + '">' + esc(PAI_ACCESS_LABEL[a]) + '</option>').join('')
++ + '</select></div>'
++ + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-billing">Billing profile</label><select id="pai-p-billing" name="billing_profile">'
++ + options(billingOptions, billing)
++ + '</select></div><div class="ov-field"><label for="pai-p-pool">Pool</label><input id="pai-p-pool" name="pool_key" placeholder="claude-max" value="' + esc(p.pool_key || '') + '"><p class="ov-field-hint">Subscription pools only; a name, never a secret.</p></div></div>'
++ + '<div class="pai-form-row"><div class="ov-field"><label for="pai-p-priority">Priority</label><input id="pai-p-priority" name="priority" type="number" step="1" value="' + esc(p.priority == null ? 100 : p.priority) + '"></div>'
++ + '<div class="ov-field"><label for="pai-p-model">Default model</label><input id="pai-p-model" name="default_model" value="' + esc(p.default_model || '') + '"></div></div>'
++ + '<div id="pai-p-key-field"></div>'
++ + '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">' + (editing ? 'Save' : 'Add provider') + '</button></div>'
++ + '</form>';
++ // The key field is built as a DOM node and is never part of an innerHTML string.
++ const keyField = document.createElement('div');
++ keyField.className = 'ov-field';
++ const keyLabel = document.createElement('label');
++ keyLabel.htmlFor = 'pai-p-value';
++ keyLabel.textContent = editing ? 'Replace key (write-only)' : 'Key (write-only)';
++ const keyInput = document.createElement('input');
++ keyInput.id = 'pai-p-value';
++ keyInput.type = 'password';
++ keyInput.autocomplete = 'off';
++ keyInput.spellcheck = false;
++ keyInput.placeholder = editing ? 'Leave empty to keep the key' : 'Paste the provider key';
++ const keyHint = document.createElement('p');
++ keyHint.className = 'ov-field-hint';
++ keyHint.textContent = 'Sent once and sealed by OpenVibe.AI; it is never shown again.';
++ keyField.append(keyLabel, keyInput, keyHint);
++ dlg.querySelector('#pai-p-key-field').append(keyField);
++ document.body.appendChild(dlg);
++ dlg.addEventListener('click', (ev) => { if (ev.target.closest('[data-act="cancel"]')) { dlg.close(); dlg.remove(); } });
++ dlg.addEventListener('close', () => dlg.remove());
++ const form = dlg.querySelector('form');
++ form.addEventListener('submit', async (ev) => {
++ ev.preventDefault();
++ const data = new FormData(form);
++ const body = {
++ key: String(data.get('key') || '').trim(),
++ display_name: String(data.get('display_name') || '').trim() || null,
++ kind: data.get('kind'),
++ base_url: String(data.get('base_url') || '').trim() || null,
++ auth_mode: data.get('kind') === 'anthropic' ? 'x-api-key' : (data.get('kind') === 'stub' || data.get('kind') === 'whisper' ? 'none' : 'bearer'),
++ billing_profile: data.get('billing_profile'),
++ pool_key: String(data.get('pool_key') || '').trim() || null,
++ priority: Number(data.get('priority') || 100),
++ default_model: String(data.get('default_model') || '').trim() || null,
++ metadata: { ...(p.metadata || {}), provider_access: data.get('provider_access') },
++ };
++ if (editing && p.base_url && body.base_url !== p.base_url && String(p.secret_ref || '').startsWith('vault:')) {
++ const ok = await paiConfirm('Change the base URL?', ['Its sealed key is bound to the old address and will stop working until you enter it again.']);
++ if (!ok) return;
++ }
++ const save = form.querySelector('[data-act="save"]');
++ save.disabled = true;
++ save.textContent = 'Saving…';
++ try {
++ let created;
++ if (editing) created = await paiApi('/providers/' + encodeURIComponent(p.key), { method: 'PATCH', body });
++ else created = await paiApi('/providers', { method: 'POST', body });
++ const key = body.key || p.key;
++ if (keyInput.value) {
++ await paiApi('/providers/' + encodeURIComponent(key) + '/key', { method: 'PUT', body: { value: keyInput.value, reason: editing ? 'Replaced from the admin panel' : 'Added from the admin panel' } });
++ }
++ keyInput.value = '';
++ dlg.close();
++ toast(editing ? 'Provider saved' : 'Provider added', 'success');
++ await loadPlatformAi();
++ } catch (err) {
++ if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');
++ save.disabled = false;
++ save.textContent = editing ? 'Save' : 'Add provider';
++ } finally {
++ keyInput.value = '';
++ }
++ });
++ dlg.showModal();
++ if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(dlg);
++}
++
++function paiAccessDesc(access) {
++ return {
++ OWN_DEPLOYMENT: 'A model on OpenVibe\'s own machine: no provider terms limit it.',
++ AUTHORIZED_WHOLESALE: 'A wholesale agreement permits resale; check the agreement covers this model.',
++ CUSTOMER_BYOK: 'The customer\'s own key: they pay the provider and the terms are theirs.',
++ RESTRICTED: 'The provider\'s terms do not permit resale: do not sell this capacity.',
++ UNVERIFIED: 'Not reviewed yet: treat it as restricted until someone checks the terms.',
++ }[access] || '';
++}
++
++async function paiKeyDialog(provider) {
++ const dlg = document.createElement('dialog');
++ dlg.className = 'pai-dialog';
++ const state = paiKeyState(provider);
++ dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>Key for ' + esc(provider.display_name || provider.key) + '</h3>'
++ + '<p class="muted">' + state.text + '</p>'
++ + '<div id="pai-key-field"></div>'
++ + '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';
++ const field = document.createElement('div');
++ field.className = 'ov-field';
++ const label = document.createElement('label');
++ label.htmlFor = 'pai-key-value';
++ label.textContent = 'Provider key (write-only)';
++ const input = document.createElement('input');
++ input.id = 'pai-key-value';
++ input.type = 'password';
++ input.autocomplete = 'off';
++ input.spellcheck = false;
++ input.required = true;
++ const hint = document.createElement('p');
++ hint.className = 'ov-field-hint';
++ hint.textContent = 'Sent once, sealed by OpenVibe.AI; the box is cleared and removed right after saving.';
++ field.append(label, input, hint);
++ dlg.querySelector('#pai-key-field').append(field);
++ document.body.appendChild(dlg);
++ dlg.addEventListener('click', (ev) => { if (ev.target.closest('[data-act="cancel"]')) { dlg.close(); dlg.remove(); } });
++ dlg.addEventListener('close', () => { input.value = ''; dlg.remove(); });
++ const form = dlg.querySelector('form');
++ let saving = false;
++ form.addEventListener('submit', async (ev) => {
++ ev.preventDefault();
++ if (saving) return;
++ saving = true;
++ const value = input.value;
++ const save = form.querySelector('[data-act="save"]');
++ save.disabled = true;
++ save.textContent = 'Saving…';
++ try {
++ await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'PUT', body: { value, reason: 'Replaced from the admin panel' } });
++ toast('Key sealed', 'success');
++ dlg.close();
++ await loadPlatformAi();
++ } catch (err) {
++ if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');
++ save.disabled = false;
++ save.textContent = 'Save key';
++ } finally {
++ // Clear and remove the input even when the request failed: a secret must not stay in the page.
++ input.value = '';
++ input.remove();
++ if (dlg.open) dlg.close();
++ saving = false;
++ }
++ });
++ dlg.showModal();
++ if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(dlg);
++}
++
++async function paiRemoveKey(provider) {
++ const ok = await paiConfirm('Remove the key?', [paiLabel('ai') + ' will stop calling ' + (provider.display_name || provider.key) + ' until a key is entered again.']);
++ if (!ok) return;
++ try {
++ await paiApi('/providers/' + encodeURIComponent(provider.key) + '/key', { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });
++ toast('Key removed', 'success');
++ await loadPlatformAi();
++ } catch (err) {
++ if (!err.freshRequired) toast(paiFriendly('ai', err), 'error');
++ }
++}
++
++function paiRenderCards() {
++ paiFillCards('exchange', PAI.namespaces.filter((n) => n.service === 'ai' && !n.namespace.endsWith('.provider_keys')), 'OpenVibe.AI has not published the ai.exchange namespace yet.', 'AI Exchange');
++ paiFillCards('money', PAI.namespaces.filter((n) => n.service === 'billing'), 'OpenVibe.Billing has not published a settings namespace yet.', 'Money');
++ paiFillCards('services', PAI.namespaces.filter((n) => n.service !== 'ai' && n.service !== 'billing' && !n.namespace.endsWith('.provider_keys')), 'No other service has published settings yet.', 'Services');
++}
++
++function paiFillCards(group, list, empty, sectionTitle) {
++ const box = document.getElementById('pai-cards-' + group);
++ if (!box) return;
++ if (!list.length) { box.innerHTML = '<p class="muted">' + empty + '</p>'; return; }
++ const groups = new Map();
++ for (const n of list) {
++ const name = (n.schema && n.schema['x-group']) || 'General';
++ if (!groups.has(name)) groups.set(name, []);
++ groups.get(name).push(n);
++ }
++ // One group (or no x-group) needs no heading: the section's own title already says it.
++ const showGroups = groups.size > 1;
++ const single = list.length === 1;
++ box.innerHTML = [...groups.entries()].map(([name, items]) => (showGroups ? '<h4 class="pai-group">' + esc(name) + '</h4>' : '') + items.map((n) => paiCard(n, { hideTitle: single && (n.schema && (n.schema.title || n.namespace)) === sectionTitle })).join('')).join('');
++}
++
++function paiCard(n, opts = {}) {
++ const schema = n.schema || {};
++ const props = schema.properties || {};
++ const pins = Array.isArray(n.pins) ? n.pins : (n.pins && typeof n.pins === 'object' ? Object.keys(n.pins) : []);
++ const pinEnv = n.pin_env || {};
++ const fields = Object.entries(props).map(([key, prop]) => paiField(n, key, prop, pins.includes(key), pinEnv[key])).join('');
++ const actor = n.active && n.active.activated_by && n.active.activated_by.id ? n.active.activated_by.id : (n.active && n.active.created_by && n.active.created_by.id ? n.active.created_by.id : null);
++ const who = actor ? (currentUser && actor === currentUser.subject_id ? '@' + currentUser.username : actor) : '';
++ const at = n.active && (n.active.activated_at || n.active.created_at);
++ const revised = n.revision == null ? 'Not published' : 'Revision ' + esc(n.revision);
++ const title = opts.hideTitle ? '' : '<h5>' + esc(schema.title || n.namespace) + '</h5>';
++ const description = schema.description ? '<p class="muted">' + esc(schema.description) + '</p>' : '';
++ const head = title || description ? '<header class="pai-card-head">' + title + description + '</header>' : '';
++ return '<article class="pai-card" data-pai-card data-service="' + esc(n.service) + '" data-namespace="' + esc(n.namespace) + '">'
++ + head
++ + '<div class="pai-fields">' + fields + '</div>'
++ + '<footer class="pai-card-foot"><span class="pai-revision">' + revised + (who ? ' · ' + esc(who) : '') + (at ? ' · ' + esc(paiWhen(at)) : '') + '</span>'
++ + '<span class="pai-card-actions"><button type="button" class="btn btn-sm pai-quiet" data-act="history">History</button><button type="button" class="btn btn-sm btn-primary" data-act="save">Save</button></span></footer>'
++ + '</article>';
++}
++
++function paiField(n, key, prop, pinned, pinName) {
++ const value = n.values ? n.values[key] : undefined;
++ const cls = (n.classification && n.classification[key]) || 'internal';
++ const danger = !!prop['x-danger'];
++ const title = prop.title || key;
++ const description = prop.description || '';
++ const types = Array.isArray(prop.type) ? prop.type : [prop.type];
++ const type = types.find((t) => t !== 'null') || 'string';
++ const nullable = types.includes('null');
++ const chips = (pinned ? '<span class="pai-chip-lock">🔒 Pinned</span>' : '') + (danger ? '<span class="pai-chip-danger">' + esc(/money|credit|spend|pay|price|usd/i.test(title + ' ' + description + ' ' + key) ? 'Money' : 'Danger') + '</span>' : '') + (cls === 'secret' ? '<span class="pai-chip-secret">Secret</span>' : '');
++ const dis = pinned ? ' disabled' : '';
++ let control;
++ if (cls === 'secret') {
++ const marker = paiMarker(value);
++ control = '<div class="pai-secret"><span class="pai-marker">' + (marker ? 'Sealed ••••' + esc(marker.hint || '') : 'Not set') + '</span>'
++ + (pinned ? '' : ' <button type="button" class="btn btn-sm pai-quiet" data-act="secret" data-key="' + esc(key) + '">' + (marker ? 'Replace key' : 'Set key') + '</button>')
++ + (!pinned && marker ? ' <button type="button" class="btn btn-sm pai-quiet pai-quiet-danger" data-act="secret-remove" data-key="' + esc(key) + '">Remove</button>' : '') + '</div>';
++ } else if (prop.enum && Array.isArray(prop.enum)) {
++ control = '<select data-pai-control data-type="enum" data-key="' + esc(key) + '" aria-label="' + esc(title) + '"' + (pinned ? ' data-ov-native' : '') + dis + '>' + prop.enum.map((v) => '<option value="' + esc(v) + '"' + (String(value) === String(v) ? ' selected' : '') + '>' + esc(v) + '</option>').join('') + '</select>';
++ } else if (type === 'boolean') {
++ control = '<label class="pai-switch"><input type="checkbox" role="switch" data-ov-native data-pai-control data-type="boolean" data-key="' + esc(key) + '"' + (value ? ' checked' : '') + dis + '><span class="pai-track" aria-hidden="true"><span class="pai-knob"></span></span><span class="pai-switch-text">' + (value ? 'On' : 'Off') + '</span></label>';
++ } else if (type === 'array') {
++ const items = Array.isArray(value) ? value : [];
++ control = '<div class="pai-chips" data-pai-chips>' + items.map((it) => paiChip(it, !pinned)).join('') + '</div>'
++ + (pinned ? '' : '<span class="pai-list-add"><input data-pai-list-input placeholder="Add…" aria-label="Add to ' + esc(title) + '"><button type="button" class="btn btn-sm pai-quiet" data-act="list-add">Add</button></span>');
++ } else if (type === 'object') {
++ control = '<textarea data-pai-control data-type="object" data-key="' + esc(key) + '" rows="3" spellcheck="false" aria-label="' + esc(title) + '"' + dis + '>' + esc(JSON.stringify(value == null ? {} : value, null, 2)) + '</textarea>';
++ } else if (prop['x-unit'] === 'nano_usd') {
++ control = '<span class="pai-money-input"><span class="pai-prefix">$</span><input data-pai-control data-type="nano_usd" data-key="' + esc(key) + '" inputmode="decimal" value="' + esc(paiUsd(value)) + '" aria-label="' + esc(title) + '"' + dis + '></span>';
++ } else if (type === 'integer') {
++ control = '<span class="pai-unit-input"><input data-pai-control data-type="integer" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="1" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '><span class="pai-unit">' + esc(prop['x-unit'] || '') + '</span></span>';
++ } else if (type === 'number') {
++ control = '<input data-pai-control data-type="number" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' type="number" step="any" value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';
++ } else {
++ control = '<input data-pai-control data-type="string" data-key="' + esc(key) + '"' + (nullable ? ' data-nullable="1"' : '') + ' value="' + esc(value == null ? '' : value) + '" aria-label="' + esc(title) + '"' + dis + '>';
++ }
++ const pinLine = pinned ? '<p class="pai-pin-line">Pinned by ' + esc(pinName || (prop['x-env'] || 'the environment')) + ' in the env file</p>' : '';
++ return '<div class="pai-field" data-pai-field data-key="' + esc(key) + '" data-secret="' + (cls === 'secret' ? '1' : '0') + '" data-pinned="' + (pinned ? '1' : '0') + '">'
++ + '<div class="pai-field-label"><label>' + esc(title) + ' <code>' + esc(key) + '</code></label>' + chips + '</div>'
++ + (description ? '<p class="pai-field-desc muted">' + esc(description) + '</p>' : '')
++ + '<div class="pai-control">' + control + pinLine + '</div></div>';
++}
++
++function paiChip(value, removable = true) {
++ const label = typeof value === 'string' ? value : JSON.stringify(value);
++ return '<span class="pai-chip" data-value="' + esc(JSON.stringify(value)) + '"><span>' + esc(label) + '</span>' + (removable ? '<button type="button" data-act="list-remove" aria-label="Remove ' + esc(label) + '">×</button>' : '') + '</span>';
++}
++
++async function paiCardClick(ev) {
++ const button = ev.target.closest('button[data-act]');
++ if (!button) return;
++ const card = button.closest('[data-pai-card]');
++ if (!card) return;
++ const n = PAI.namespaces.find((x) => x.service === card.dataset.service && x.namespace === card.dataset.namespace);
++ if (!n) return;
++ const act = button.dataset.act;
++ if (act === 'save') return await paiSaveCard(card, n);
++ if (act === 'history') return await paiHistoryDialog(n);
++ if (act === 'secret') return paiSecretDialog(n, button.dataset.key);
++ if (act === 'secret-remove') return paiRemoveSecret(n, button.dataset.key);
++ if (act === 'list-add') {
++ const field = button.closest('[data-pai-field]');
++ const input = field.querySelector('[data-pai-list-input]');
++ const text = String(input.value || '').trim();
++ if (!text) return;
++ const chips = field.querySelector('[data-pai-chips]');
++ chips.insertAdjacentHTML('beforeend', paiChip(text));
++ input.value = '';
++ return;
++ }
++ if (act === 'list-remove') {
++ const chip = button.closest('.pai-chip');
++ if (chip) chip.remove();
++ }
++}
++
++function paiCollect(card) {
++ const values = {};
++ for (const field of card.querySelectorAll('[data-pai-field]')) {
++ if (field.dataset.pinned === '1' || field.dataset.secret === '1') continue;
++ const control = field.querySelector('[data-pai-control]');
++ if (!control) continue;
++ const key = field.dataset.key;
++ if (!key) continue;
++ if (control.dataset.type === 'boolean') values[key] = control.checked;
++ else if (control.dataset.nullable === '1' && control.value === '') values[key] = null;
++ else if (control.dataset.type === 'nano_usd') {
++ const parsed = paiParseUsd(control.value);
++ if (parsed.error) return { error: control.dataset.key + ': ' + parsed.error };
++ values[key] = parsed.nano;
++ } else if (control.dataset.type === 'integer') {
++ const number = Number(control.value);
++ if (!Number.isInteger(number)) return { error: key + ': enter a whole number' };
++ values[key] = number;
++ } else if (control.dataset.type === 'number') {
++ const number = Number(control.value);
++ if (!Number.isFinite(number)) return { error: key + ': enter a number' };
++ values[key] = number;
++ } else if (control.dataset.type === 'enum' || control.dataset.type === 'string') values[key] = control.value;
++ else if (control.dataset.type === 'object') {
++ try { values[key] = JSON.parse(control.value); } catch { return { error: key + ': the JSON is not valid' }; }
++ }
++ }
++ for (const field of card.querySelectorAll('[data-pai-field]')) {
++ if (field.querySelector('[data-pai-chips]')) {
++ values[field.dataset.key] = [...field.querySelectorAll('.pai-chip')].map((chip) => JSON.parse(chip.dataset.value));
++ }
++ }
++ return { values };
++}
++
++async function paiSaveCard(card, n) {
++ const collected = paiCollect(card);
++ if (collected.error) return toast(collected.error, 'error');
++ const changed = {};
++ for (const [key, value] of Object.entries(collected.values)) {
++ if (JSON.stringify(value) !== JSON.stringify(n.values ? n.values[key] : undefined)) changed[key] = value;
++ }
++ if (!Object.keys(changed).length) return toast('Nothing to save', 'info');
++ const props = (n.schema && n.schema.properties) || {};
++ const danger = Object.keys(changed).filter((key) => props[key] && props[key]['x-danger']);
++ if (danger.length) {
++ const lines = danger.map((key) => (props[key].title || key) + ': ' + (props[key].description || 'This change takes effect at once.') + ' Now: ' + (changed[key] ? 'on' : 'off') + '.');
++ const ok = await paiConfirm('Save this money setting?', lines);
++ if (!ok) return;
++ }
++ const save = card.querySelector('[data-act="save"]');
++ save.disabled = true;
++ save.textContent = 'Saving…';
++ try {
++ await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace), { method: 'POST', body: { values: changed, reason: 'Changed in the admin panel', merge: true } });
++ toast('Saved', 'success');
++ await loadPlatformAi();
++ } catch (err) {
++ if (!err.freshRequired) toast(paiFriendly(n.service, err), 'error');
++ } finally {
++ save.disabled = false;
++ save.textContent = 'Save';
++ }
++}
++
++function paiSecretDialog(n, key) {
++ const dlg = document.createElement('dialog');
++ dlg.className = 'pai-dialog';
++ dlg.innerHTML = '<form method="dialog" class="pai-form"><h3>' + esc(key) + '</h3><p class="muted">The value is written once, sealed by ' + esc(paiLabel(n.service)) + '; it is never shown again.</p>'
++ + '<div id="pai-secret-field"></div><div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="submit" class="btn btn-primary" data-act="save">Save key</button></div></form>';
++ const field = document.createElement('div');
++ field.className = 'ov-field';
++ const label = document.createElement('label');
++ label.htmlFor = 'pai-secret-value';
++ label.textContent = 'New value (write-only)';
++ const input = document.createElement('input');
++ input.id = 'pai-secret-value';
++ input.type = 'password';
++ input.autocomplete = 'off';
++ input.spellcheck = false;
++ input.required = true;
++ const hint = document.createElement('p');
++ hint.className = 'ov-field-hint';
++ hint.textContent = 'Cleared and removed from the page right after saving.';
++ field.append(label, input, hint);
++ dlg.querySelector('#pai-secret-field').append(field);
++ document.body.appendChild(dlg);
++ dlg.addEventListener('click', (ev) => { if (ev.target.closest('[data-act="cancel"]')) { dlg.close(); dlg.remove(); } });
++ dlg.addEventListener('close', () => { input.value = ''; dlg.remove(); });
++ const form = dlg.querySelector('form');
++ form.addEventListener('submit', async (ev) => {
++ ev.preventDefault();
++ const save = form.querySelector('[data-act="save"]');
++ save.disabled = true;
++ save.textContent = 'Saving…';
++ try {
++ await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/secrets/' + encodeURIComponent(key), { method: 'PUT', body: { value: input.value, reason: 'Changed in the admin panel' } });
++ toast('Key saved', 'success');
++ dlg.close();
++ await loadPlatformAi();
++ } catch (err) {
++ if (!err.freshRequired) toast(paiFriendly(n.service, err), 'error');
++ save.disabled = false;
++ save.textContent = 'Save key';
++ } finally {
++ input.value = '';
++ input.remove();
++ if (dlg.open) dlg.close();
++ }
++ });
++ dlg.showModal();
++ if (window.OVForms && window.OVForms.enhance) window.OVForms.enhance(dlg);
++}
++
++async function paiRemoveSecret(n, key) {
++ const ok = await paiConfirm('Remove ' + key + '?', [paiLabel(n.service) + ' will not have this value until it is entered again.']);
++ if (!ok) return;
++ try {
++ await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/secrets/' + encodeURIComponent(key), { method: 'DELETE', body: { reason: 'Removed from the admin panel' } });
++ toast('Key removed', 'success');
++ await loadPlatformAi();
++ } catch (err) {
++ if (!err.freshRequired) toast(paiFriendly(n.service, err), 'error');
++ }
++}
++
++async function paiHistoryDialog(n) {
++ let data;
++ try {
++ data = await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/history?limit=50');
++ } catch (err) {
++ return toast(paiFriendly(n.service, err), 'error');
++ }
++ const snapshots = (data && data.snapshots) || [];
++ const dlg = document.createElement('dialog');
++ dlg.className = 'pai-dialog pai-dialog-wide';
++ const rows = snapshots.map((s) => {
++ const actor = s.activated_by && s.activated_by.id ? s.activated_by.id : (s.created_by && s.created_by.id ? s.created_by.id : '');
++ const current = s.state === 'active' || s.revision === n.revision;
++ return '<tr><td>' + esc(s.revision) + '</td><td>' + esc(paiWhen(s.activated_at || s.created_at)) + '</td><td>' + esc(actor || '—') + '</td><td>' + esc(s.reason || '—') + '</td><td>' + (current ? '<span class="muted">Current</span>' : '<button type="button" class="btn btn-sm" data-rollback="' + esc(s.revision) + '">Roll back</button>') + '</td></tr>';
++ }).join('');
++ dlg.innerHTML = '<div class="pai-form"><h3>History · ' + esc(n.namespace) + '</h3>'
++ + (snapshots.length ? '<div class="pai-table-wrap"><table class="pai-table"><thead><tr><th>Revision</th><th>When</th><th>By</th><th>Reason</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>' : '<p class="muted">No revisions yet.</p>')
++ + '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="close">Close</button></div></div>';
++ document.body.appendChild(dlg);
++ dlg.addEventListener('click', async (ev) => {
++ if (ev.target.closest('[data-act="close"]')) { dlg.close(); return; }
++ const rollback = ev.target.closest('[data-rollback]');
++ if (!rollback) return;
++ const to = Number(rollback.dataset.rollback);
++ const ok = await paiConfirm('Roll back to revision ' + to + '?', ['The namespace returns to that revision. Nothing is deleted; the rollback is recorded as a new revision.']);
++ if (!ok) return;
++ rollback.disabled = true;
++ try {
++ await paiApi('/' + encodeURIComponent(n.service) + '/' + encodeURIComponent(n.namespace) + '/rollback', { method: 'POST', body: { to, reason: 'Rolled back from the admin panel' } });
++ dlg.close();
++ toast('Rolled back', 'success');
++ await loadPlatformAi();
++ } catch (err) {
++ rollback.disabled = false;
++ if (!err.freshRequired) toast(paiFriendly(n.service, err), 'error');
++ }
++ });
++ dlg.addEventListener('close', () => dlg.remove());
++ dlg.showModal();
++}
++
++function paiConfirm(title, lines) {
++ return new Promise((resolve) => {
++ const dlg = document.createElement('dialog');
++ dlg.className = 'pai-dialog';
++ dlg.innerHTML = '<div class="pai-form"><h3>' + esc(title) + '</h3>' + (lines || []).map((line) => '<p class="pai-confirm-line">' + esc(line) + '</p>').join('')
++ + '<div class="pai-dialog-actions"><button type="button" class="btn" data-act="cancel">Cancel</button><button type="button" class="btn btn-primary" data-act="confirm">Confirm</button></div></div>';
++ let answer = false;
++ document.body.appendChild(dlg);
++ dlg.addEventListener('click', (ev) => {
++ if (ev.target.closest('[data-act="cancel"]')) { answer = false; dlg.close(); }
++ if (ev.target.closest('[data-act="confirm"]')) { answer = true; dlg.close(); }
++ });
++ dlg.addEventListener('close', () => { dlg.remove(); resolve(answer); });
++ dlg.showModal();
++ });
++}
++
++function paiRenderVault() {
++ const box = document.getElementById('pai-vault-list');
++ if (!box) return;
++ const rows = PAI.services.map((s) => {
++ const state = PAI.serviceState[s.service] || { answered: false, sealed: null, kek: null };
++ const name = esc(s.name || paiLabel(s.service));
++ let status;
++ let hint = '';
++ if (state.answered && state.sealed === true) {
++ status = '<span class="pai-state is-ok">Sealed' + (state.kek ? ' · ' + esc(state.kek) : '') + '</span>';
++ } else if (state.answered && state.sealed === false) {
++ status = '<span class="pai-state is-bad">Not sealed</span>';
++ hint = '<div class="pai-hint">the service needs openvibe-shared 4.110 or later and OV_CONFIG_KEKS</div>';
++ } else if (state.answered) {
++ status = '<span class="pai-state is-muted">No settings yet</span>';
++ } else {
++ status = '<span class="pai-state is-muted">Did not answer</span>';
++ }
++ return '<div class="pai-vault-row"><span class="pai-vault-name">' + name + '</span>' + status + hint + '</div>';
++ });
++ box.innerHTML = paiFold(rows, (row) => row);
++}
ddiff --git a/public/js/admin/core.js b/public/js/admin/core.js
iindex c35fbac3..072d80a4 100644
---- a/public/js/admin/core.js
++++ b/public/js/admin/core.js
@@@ -155,6 +155,10 @@ function routeFromUrl() {
let rest = location.pathname.replace(/^\/admin\/?/, '');
let parts = rest.split('/').filter(Boolean);
let search = location.search;
++ // A sign-in that returns to /admin#ai opens that tab (the Platform AI fresh-sign-in link), just as
++ // /admin/ai does; the hash wins over the remembered last-visited tab.
++ const hashTab = location.hash.replace(/^#\/?/, '').split(/[/?]/)[0];
++ if (hashTab && tabLoaders[hashTab]) parts = [hashTab];
// Bare /admin → restore last-visited location (a real slug always wins).
if (parts.length === 0) {
const last = localStorage.getItem('openvibe_admin_last_path');
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 2a43cd76..870fd9e2 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -4,6 +4,10 @@
my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
my/boot.js (loaded last), which starts the page. No ES modules.
═══════════════════════════════════════════════════════════════ */
++// The wallet (MONEY.md D12) in the account menu on Network's own pages: Shared's navbar needs the same item on every
++// site (the report says so); here Network can pass it in.
++const OV_WALLET_MENU = { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] };
++
// Load user data
async function loadUser() {
// Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
@@@ -31,10 +35,11 @@ async function loadUser() {
applyAnonModeUI(currentUser);
loadAccounts();
loadAnonInfo();
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser, menu: OV_WALLET_MENU });
} else {
const data = await apiFetch('/api/auth/me');
currentUser = data.user;
++ loadQrSignin(data.features);
renderHeader(data.user);
renderProfile(data.user);
loadThemes(); loadThemeSubmissions();
@@@ -54,7 +59,7 @@ async function loadUser() {
loadRecentTools();
// Init navbar
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser, menu: OV_WALLET_MENU });
const bellMount = OpenVibeNavbar.getBellMount();
if (bellMount && window.OpenVibeNotifications && authToken) {
OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
iindex 5b46ff0b..8e8b7916 100644
---- a/public/js/my/security.js
++++ b/public/js/my/security.js
@@@ -469,3 +469,292 @@ document.getElementById('password-form').addEventListener('submit', async (e) =>
document.getElementById('password-form').reset();
} catch (err) { alert('Error: ' + err.message); }
});
++
++/* ── Sign in on another device ──────────────────────────────────────────────────────────────
++ The source-device half of server/auth/qr-signin.js: start a transfer, show the QR for 120 s, poll
++ every 2 s, then approve the two-digit number the target device shows. The target opens /q; nothing
++ here holds the QR's secret after the start response. */
++const QR_PHONE_ICON = '<svg viewBox="0 0 24 24" fill="none"><rect x="7" y="2.5" width="10" height="19" rx="2.6" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 18.5h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
++const QR_LAPTOP_ICON = '<svg viewBox="0 0 24 24" fill="none"><rect x="3.5" y="5" width="17" height="11" rx="1.6" stroke="currentColor" stroke-width="1.8"/><path d="M2 18.5h20" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
++let qrTransfer = null;
++let qrPollTimer = null;
++let qrTickTimer = null;
++let qrExpireTimer = null;
++
++function loadQrSignin(features) {
++ const card = document.getElementById('qr-signin-card');
++ if (card) card.hidden = !(features && features.qr_signin);
++}
++
++function qrPost(path, body) {
++ const headers = { 'Content-Type': 'application/json' };
++ const token = getAuthToken();
++ if (token) headers.Authorization = 'Bearer ' + token;
++ return fetch(API + path, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(body || {}) })
++ .then(async (res) => {
++ let data = {};
++ try { data = await res.json(); } catch { /* empty answer */ }
++ return { ok: res.ok, status: res.status, data };
++ });
++}
++
++function qrSetView(which) {
++ const scan = document.getElementById('qr-scan-view');
++ const confirm = document.getElementById('qr-confirm-view');
++ const done = document.getElementById('qr-done-view');
++ if (!scan || !confirm || !done) return;
++ scan.hidden = which !== 'scan';
++ confirm.hidden = which !== 'confirm';
++ done.hidden = which !== 'done';
++ qrSetSteps(which);
++}
++
++function qrSetSteps(phase) {
++ document.querySelectorAll('#qr-steps li').forEach((li, index) => {
++ const n = index + 1;
++ const done = phase === 'done' || (phase === 'confirm' && n < 3);
++ const current = (phase === 'scan' && n === 1) || (phase === 'confirm' && n === 3);
++ li.classList.toggle('is-done', done);
++ li.classList.toggle('is-current', current);
++ const marker = li.querySelector('.qr-step-n');
++ if (marker) { marker.textContent = done ? '✓' : String(n); marker.setAttribute('aria-hidden', 'true'); }
++ });
++}
++
++function qrSetDevice(device) {
++ const label = device || 'Your other device';
++ const name = document.getElementById('qr-device-name');
++ if (name) name.textContent = label;
++ const target = String(label).split(' · ')[0];
++ const phone = /iPhone|Android|Phone/i.test(target) && !/iPad|Tablet/i.test(target);
++ const icon = document.getElementById('qr-device-icon');
++ if (icon) icon.innerHTML = phone ? QR_PHONE_ICON : QR_LAPTOP_ICON;
++}
++
++function qrStopTimers() {
++ if (qrPollTimer) { clearTimeout(qrPollTimer); qrPollTimer = null; }
++ if (qrTickTimer) { clearInterval(qrTickTimer); qrTickTimer = null; }
++ if (qrExpireTimer) { clearTimeout(qrExpireTimer); qrExpireTimer = null; }
++}
++
++function qrReset() {
++ qrStopTimers();
++ qrTransfer = null;
++ const code = document.getElementById('qr-code');
++ if (code) code.replaceChildren();
++ const expired = document.getElementById('qr-expired');
++ if (expired) { expired.hidden = true; const p = expired.querySelector('p'); if (p) p.textContent = 'This code expired'; }
++ document.getElementById('qr-tile')?.classList.remove('is-expired');
++ const err = document.getElementById('qr-error');
++ if (err) { err.hidden = true; err.textContent = ''; }
++ for (const id of ['qr-digit-1', 'qr-digit-2']) { const el = document.getElementById(id); if (el) el.value = ''; }
++ const fill = document.getElementById('qr-bar-fill');
++ if (fill) { fill.style.transition = 'none'; fill.style.width = '100%'; }
++ const timer = document.getElementById('qr-timer');
++ if (timer) timer.textContent = 'Code expires in 2:00';
++ qrSetView('scan');
++}
++
++function qrSigninClose() {
++ const dialog = document.getElementById('qr-dialog');
++ qrStopTimers();
++ if (dialog && dialog.open) dialog.close();
++}
++
++function qrSigninOpen() {
++ const dialog = document.getElementById('qr-dialog');
++ if (!dialog) return;
++ qrReset();
++ if (!dialog.open) dialog.showModal();
++ const title = document.getElementById('qr-title');
++ if (title) title.focus({ preventScroll: true });
++ qrSigninStart();
++}
++
++function qrSigninNewCode() { qrSigninStart(); }
++
++async function qrSigninStart() {
++ qrReset();
++ const timer = document.getElementById('qr-timer');
++ const started = await qrPost('/auth/qr/start', {});
++ if (!started.ok) {
++ if (started.status === 404) { // the flag is off after all: no entry point draws a code
++ qrSigninClose();
++ const card = document.getElementById('qr-signin-card');
++ if (card) card.hidden = true;
++ return;
++ }
++ if (timer) timer.textContent = started.status === 429
++ ? 'Finish or cancel the code you already have first.'
++ : 'Could not make a code. Try again.';
++ return;
++ }
++ qrTransfer = { id: started.data.id, expiresAt: Date.parse(started.data.expires_at), device: null };
++ document.getElementById('qr-code').innerHTML = started.data.qr_svg; // rendered by Network's qrcode, never a third party
++ qrDrain(started.data.expires_at);
++ qrPoll();
++}
++
++function qrDrain(expiresAt) {
++ const left = Math.max(0, Date.parse(expiresAt) - Date.now());
++ const fill = document.getElementById('qr-bar-fill');
++ if (fill) {
++ fill.style.transition = 'none';
++ fill.style.width = '100%';
++ void fill.offsetWidth; // start the drain from full
++ fill.style.transition = `width ${left}ms linear`;
++ fill.style.width = '0%';
++ }
++ const timer = document.getElementById('qr-timer');
++ const tick = () => {
++ if (!qrTransfer) return;
++ const ms = Math.max(0, qrTransfer.expiresAt - Date.now());
++ const total = Math.ceil(ms / 1000);
++ if (timer) timer.textContent = ms > 0 ? `Code expires in ${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` : 'Code expired';
++ };
++ tick();
++ qrTickTimer = setInterval(tick, 1000);
++ qrExpireTimer = setTimeout(qrExpire, left);
++}
++
++function qrExpire() {
++ if (!qrTransfer) return;
++ qrStopTimers();
++ qrSetView('scan');
++ const expired = document.getElementById('qr-expired');
++ if (expired) { expired.hidden = false; const p = expired.querySelector('p'); if (p) p.textContent = 'This code expired'; }
++ document.getElementById('qr-tile')?.classList.add('is-expired');
++ const timer = document.getElementById('qr-timer');
++ if (timer) timer.textContent = 'Code expired';
++}
++
++function qrStopped(title) {
++ qrStopTimers();
++ qrSetView('scan');
++ const expired = document.getElementById('qr-expired');
++ if (expired) { expired.hidden = false; const p = expired.querySelector('p'); if (p) p.textContent = title; }
++ document.getElementById('qr-tile')?.classList.add('is-expired');
++ const timer = document.getElementById('qr-timer');
++ if (timer) timer.textContent = 'Code closed';
++}
++
++async function qrPoll() {
++ if (!qrTransfer) return;
++ const dialog = document.getElementById('qr-dialog');
++ if (!dialog || !dialog.open) return;
++ try {
++ const token = getAuthToken();
++ const res = await fetch(`${API}/auth/qr/${encodeURIComponent(qrTransfer.id)}`, {
++ credentials: 'include', cache: 'no-store', headers: token ? { Authorization: 'Bearer ' + token } : {},
++ });
++ if (res.status === 404) { qrExpire(); return; }
++ if (res.status === 403) { qrSigninClose(); return; }
++ if (!res.ok) throw new Error('poll');
++ const data = await res.json();
++ if (!qrTransfer) return;
++ if (data.state === 'scanned') {
++ qrTransfer.device = data.device || null;
++ qrShowConfirm();
++ } else if (data.state === 'approved') {
++ const timer = document.getElementById('qr-timer');
++ if (timer) timer.textContent = 'Approved — finishing on your other device…';
++ } else if (data.state === 'denied') {
++ qrStopped('This code was denied');
++ return;
++ } else if (data.state === 'used') {
++ qrDone(data.device || qrTransfer.device);
++ return;
++ }
++ } catch { /* one failed poll is fine; keep waiting */ }
++ qrPollTimer = setTimeout(qrPoll, 2000);
++}
++
++function qrShowConfirm() {
++ if (!qrTransfer) return;
++ const confirm = document.getElementById('qr-confirm-view');
++ if (!confirm.hidden) return;
++ qrSetView('confirm');
++ qrSetDevice(qrTransfer.device);
++ const err = document.getElementById('qr-error');
++ if (err) { err.hidden = true; err.textContent = ''; }
++ setTimeout(() => document.getElementById('qr-digit-1')?.focus(), 30);
++}
++
++function qrWrongCode(left) {
++ const box = document.getElementById('qr-confirm-view');
++ if (box) {
++ box.classList.remove('qr-shake');
++ void box.offsetWidth;
++ box.classList.add('qr-shake');
++ setTimeout(() => box.classList.remove('qr-shake'), 420);
++ }
++ const err = document.getElementById('qr-error');
++ if (err) {
++ err.hidden = false;
++ err.textContent = left > 0
++ ? `That number doesn't match. ${left} ${left === 1 ? 'try' : 'tries'} left.`
++ : "That number doesn't match.";
++ }
++ const a = document.getElementById('qr-digit-1');
++ const b = document.getElementById('qr-digit-2');
++ if (a) a.value = '';
++ if (b) b.value = '';
++ if (a) a.focus();
++}
++
++async function qrSigninApprove() {
++ if (!qrTransfer) return;
++ const a = document.getElementById('qr-digit-1')?.value || '';
++ const b = document.getElementById('qr-digit-2')?.value || '';
++ if (!/^[0-9]$/.test(a) || !/^[0-9]$/.test(b)) { qrWrongCode(0); return; }
++ const btn = document.getElementById('qr-approve');
++ if (btn) btn.disabled = true;
++ try {
++ const res = await qrPost(`/auth/qr/${encodeURIComponent(qrTransfer.id)}/approve`, { code: a + b });
++ if (res.ok) {
++ const timer = document.getElementById('qr-timer');
++ if (timer) timer.textContent = 'Approved — finishing on your other device…';
++ return;
++ }
++ if (res.status === 400 && res.data.error === 'code_mismatch') { qrWrongCode(Number(res.data.attempts_left) || 0); return; }
++ if (res.status === 403) { qrStopped('This code was denied'); return; }
++ if (res.status === 404) { qrExpire(); return; }
++ qrWrongCode(0);
++ } catch {
++ const err = document.getElementById('qr-error');
++ if (err) { err.hidden = false; err.textContent = 'Could not check that number. Try again.'; }
++ } finally {
++ if (btn) btn.disabled = false;
++ }
++}
++
++async function qrSigninDeny() {
++ if (qrTransfer) await qrPost(`/auth/qr/${encodeURIComponent(qrTransfer.id)}/deny`, {}).catch(() => {});
++ qrSigninClose();
++}
++
++function qrDone(device) {
++ qrStopTimers();
++ const name = String(device || 'your other device').split(' · ')[0];
++ const text = document.getElementById('qr-done-text');
++ if (text) text.textContent = `Signed in on ${name}`;
++ qrSetView('done');
++}
++
++function qrDigit(input, nextId) {
++ const value = String(input.value || '').replace(/[^0-9]/g, '').slice(-1);
++ input.value = value;
++ if (value && nextId) document.getElementById(nextId)?.focus();
++}
++
++function qrDigitKey(event, input) {
++ if (event.key === 'Backspace' && !input.value) {
++ const previous = input.id === 'qr-digit-2' ? document.getElementById('qr-digit-1') : null;
++ if (previous) { previous.focus(); previous.value = ''; event.preventDefault(); }
++ }
++}
++
++loadQrSignin(null); // hidden until /api/auth/me says the feature is on (profile.js calls loadQrSignin)
++
++
++document.getElementById('qr-dialog')?.addEventListener('close', qrStopTimers);
ddiff --git a/public/js/wallet/common.js b/public/js/wallet/common.js
nnew file mode 100644
iindex 00000000..081f1184
---- /dev/null
++++ b/public/js/wallet/common.js
@@@ -0,0 +1,131 @@
++/* openvibe.network/wallet — what both wallet pages share: the person's token, the API call, the shared navbar, and
++ * the small DOM helpers. Classic script, no modules: /wallet and /pay load it before their own script.
++ */
++(function (root) {
++ 'use strict';
++
++ const API = root.location.origin;
++ const WALLET_MENU = {
++ before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }],
++ };
++
++ function cookieToken() {
++ const m = String(document.cookie || '').match(/(?:^|; )ov_token=([^;]*)/);
++ return m ? decodeURIComponent(m[1]) : null;
++ }
++
++ function storedAccountToken() {
++ try {
++ const id = localStorage.getItem('openvibe_active_account');
++ const list = JSON.parse(localStorage.getItem('openvibe_accounts') || '[]');
++ const a = list.find((x) => x && String(x.id) === String(id) && x.token && !x.is_anon);
++ return a ? a.token : null;
++ } catch { return null; }
++ }
++
++ /** The person's token, kept where every OpenVibe page keeps it. Null for a guest. */
++ function token() {
++ try { if (localStorage.getItem('openvibe_active_account') === 'anon') return null; } catch { /* private mode */ }
++ return localStorage.getItem('ov_token') || storedAccountToken() || cookieToken();
++ }
++
++ function signInReturn() {
++ return `/login?return=${encodeURIComponent(root.location.pathname + root.location.search)}`;
++ }
++
++ /** A JSON call to Network's own API with the person's token. Throws { status, code, detail }. */
++ async function api(path, options = {}) {
++ const headers = Object.assign({ accept: 'application/json' }, options.headers || {});
++ const t = token();
++ if (t) headers.authorization = `Bearer ${t}`;
++ if (options.body !== undefined) headers['content-type'] = 'application/json';
++ const r = await fetch(API + path, {
++ method: options.method || 'GET',
++ headers,
++ credentials: 'include',
++ cache: 'no-store',
++ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
++ });
++ let body = null;
++ try { body = await r.json(); } catch { /* a non-JSON refusal */ }
++ if (!r.ok) {
++ const err = new Error((body && (body.detail || body.error)) || `Request failed (${r.status})`);
++ err.status = r.status;
++ err.code = body && body.error;
++ err.body = body;
++ throw err;
++ }
++ return body;
++ }
++
++ /** The person, from Network's session; sends a guest to sign in and back to this page. */
++ async function person() {
++ if (!token()) { root.location.replace(signInReturn()); return null; }
++ try {
++ const me = await api('/api/auth/me');
++ return me && me.user ? me.user : null;
++ } catch (e) {
++ if (e.status === 401 || e.status === 403) { root.location.replace(signInReturn()); return null; }
++ throw e;
++ }
++ }
++
++ /** The shared navbar, with Wallet in the account menu on Network's own pages. */
++ function navbar(user) {
++ if (!root.OpenVibeNavbar) return;
++ try {
++ root.OpenVibeNavbar.init({
++ service: 'network', notificationsRealtime: true, token: token(), user,
++ menu: WALLET_MENU,
++ links: [{ label: 'Wallet', href: '/wallet' }, { label: 'My Account', href: '/my' }],
++ });
++ } catch { /* the page works without it */ }
++ }
++
++ function el(tag, cls, text) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = text;
++ return n;
++ }
++
++ function icon(pathD, size = 20) {
++ const ns = 'http://www.w3.org/2000/svg';
++ const svg = document.createElementNS(ns, 'svg');
++ svg.setAttribute('viewBox', '0 0 512 512');
++ svg.setAttribute('width', String(size));
++ svg.setAttribute('height', String(size));
++ svg.setAttribute('aria-hidden', 'true');
++ svg.setAttribute('focusable', 'false');
++ svg.setAttribute('class', 'w-icon');
++ const path = document.createElementNS(ns, 'path');
++ path.setAttribute('d', pathD);
++ path.setAttribute('fill', 'currentColor');
++ svg.appendChild(path);
++ return svg;
++ }
++
++ /** A date a screen reader and the eye both read: "Nov 4, 2026", "14:32" in the title. */
++ function when(iso) {
++ const d = new Date(iso);
++ if (Number.isNaN(d.getTime())) return { date: '', time: '' };
++ return {
++ date: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
++ time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
++ };
++ }
++
++ /** Only an https URL, never javascript: or data:. */
++ function httpsUrl(raw) {
++ try {
++ const u = new URL(String(raw || ''));
++ return u.protocol === 'https:' && u.hostname ? u : null;
++ } catch { return null; }
++ }
++
++ function closeOnBackdrop(dialog) {
++ dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); });
++ }
++
++ root.OVWallet = { API, api, person, token, navbar, el, icon, when, httpsUrl, closeOnBackdrop, signInReturn };
++}(window));
ddiff --git a/public/js/wallet/money.js b/public/js/wallet/money.js
nnew file mode 100644
iindex 00000000..0f3050ec
---- /dev/null
++++ b/public/js/wallet/money.js
@@@ -0,0 +1,81 @@
++/* openvibe.network/wallet — money formatting and exact conversion in the browser.
++ *
++ * Money is integer end to end (MONEY.md): Balance is nano-USD, never a float; Vibes are whole numbers. This module is
++ * the only place the page turns those integers into words, and it never uses floating-point arithmetic for them.
++ *
++ * OVWalletMoney.money(nano) "12340000000" → "$12.40"
++ * OVWalletMoney.signedMoney(nano) "-4200000" → { text: "$0.0042", sign: "−" }
++ * OVWalletMoney.dollarsToNano("12.40") → "12400000000" (null when not a valid dollar amount)
++ * OVWalletMoney.priceNano(vibes, perUsd) → "250000000" (the exact cost, rounded up to the nano)
++ * OVWalletMoney.vibes(1250) → "1,250"
++ * Usable in the page (window.OVWalletMoney) and in Node's tests (module.exports).
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVWalletMoney = api;
++}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const NANO = 1000000000n;
++
++ /** Whole number with thousands separators, or "—" when it is not one. */
++ function vibes(n) {
++ const v = Number(n);
++ return Number.isSafeInteger(v) ? v.toLocaleString('en-US') : '—';
++ }
++
++ function group(whole) {
++ return String(whole).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
++ }
++
++ /** Nano-USD as dollars: always at least two decimals, up to nine when the amount is finer than a cent. */
++ function money(nano) {
++ let n;
++ try { n = BigInt(String(nano == null ? 0 : nano)); } catch { return '—'; }
++ const neg = n < 0n;
++ if (neg) n = -n;
++ const whole = n / NANO;
++ const frac = String(n % NANO).padStart(9, '0').replace(/0+$/, '');
++ const cents = frac.length < 2 ? (frac + '00').slice(0, 2) : frac;
++ return `${neg ? '−' : ''}$${group(whole)}.${cents}`;
++ }
++
++ /** For an activity row: the unsigned text and the sign, so the page can paint + green and − in the text colour. */
++ function signedMoney(nano) {
++ let n;
++ try { n = BigInt(String(nano == null ? 0 : nano)); } catch { return { sign: '', text: '—' }; }
++ if (n === 0n) return { sign: '', text: money(0) };
++ return { sign: n < 0n ? '−' : '+', text: money(n < 0n ? -n : n) };
++ }
++
++ /** "$12.40" or "12.4" → the exact integer nano-USD, or null. Never parseFloat. */
++ function dollarsToNano(raw) {
++ const s = String(raw == null ? '' : raw).trim().replace(/^\$/, '').replace(/,/g, '');
++ const m = /^(\d{0,15})(?:\.(\d{0,9}))?$/.exec(s);
++ if (!m || (m[1] === '' && (m[2] === '' || m[2] === undefined))) return null;
++ const whole = m[1] || '0';
++ const frac = (m[2] || '').padEnd(9, '0');
++ const out = BigInt(whole) * NANO + BigInt(frac || '0');
++ return out.toString();
++ }
++
++ /** The exact dollar cost of whole Vibes at a whole bits-per-USD rate: ceil(vibes × 1e9 / rate) nano-USD. */
++ function priceNano(vibesCount, bitsPerUsd) {
++ const v = BigInt(Math.trunc(Number(vibesCount) || 0));
++ let rate;
++ try { rate = BigInt(Math.trunc(Number(bitsPerUsd) || 0)); } catch { return null; }
++ if (v <= 0n || rate <= 0n) return null;
++ return (((v * NANO) + rate - 1n) / rate).toString();
++ }
++
++ /** A whole amount a person typed: 1,250 → 1250; anything else null. */
++ function whole(raw) {
++ const s = String(raw == null ? '' : raw).trim().replace(/,/g, '');
++ if (!/^\d{1,12}$/.test(s)) return null;
++ const n = Number(s);
++ return Number.isSafeInteger(n) && n > 0 ? n : null;
++ }
++
++ return { money, signedMoney, dollarsToNano, priceNano, whole, vibes };
++}));
ddiff --git a/public/js/wallet/page.js b/public/js/wallet/page.js
nnew file mode 100644
iindex 00000000..08f321e0
---- /dev/null
++++ b/public/js/wallet/page.js
@@@ -0,0 +1,375 @@
++/* openvibe.network/wallet — the one wallet (MONEY.md, D12).
++ *
++ * Three cards — Balance (nano-USD, dollars), Vibes (whole numbers) and OpenCoins — then the person's projects and
++ * one activity list over both currencies. Every amount comes from Network's /api/wallet (which forwards the person's
++ * token to Billing) as an integer; this file only formats it. No spinner: loading is a written word.
++ */
++(function () {
++ 'use strict';
++
++ const W = window.OVWallet;
++ const M = window.OVWalletMoney;
++ const S = window.OVWalletSheets;
++ if (!W || !M) return;
++
++ const GLYPH = {
++ vibes: 'M64 43Q37 44 19 62Q1 80 0 107V427Q1 454 19 472Q37 490 64 491H448Q475 490 493 472Q511 454 512 427V203Q511 176 493 158Q475 140 448 139H80Q65 138 64 123Q65 108 80 107H448Q462 107 471 98Q480 89 480 75Q480 61 471 52Q462 43 448 43H64ZM416 283Q430 283 439 292Q448 301 448 315Q448 329 439 338Q430 347 416 347Q402 347 393 338Q384 329 384 315Q384 301 393 292Q402 283 416 283Z',
++ coins: 'M512 91Q511 118 474 139Q428 164 351 170Q346 167 340 165Q279 140 192 139Q180 139 168 140L166 139Q129 118 128 91Q130 57 184 34Q238 12 320 11Q402 12 456 34Q510 57 512 91ZM161 172Q176 171 192 171Q239 171 278 180Q318 188 345 202Q383 223 384 251Q384 257 382 263Q374 283 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q347 298 347 298Q346 299 346 299Q346 299 346 299Q319 314 279 322Q240 331 192 331Q100 330 44 302Q41 300 38 299Q1 278 0 251Q1 224 36 204Q71 184 128 176Q144 173 161 172ZM416 251Q414 218 392 198Q435 191 468 177Q493 167 512 152V187Q511 217 468 238Q446 249 416 256Q416 256 416 256Q416 254 416 251ZM384 347Q383 374 346 395Q344 396 342 397Q341 397 340 398Q284 426 192 427Q144 427 105 418Q65 410 38 395Q1 374 0 347V312Q19 327 44 337Q105 362 192 363Q279 362 340 337Q352 332 363 326Q372 321 380 315Q381 314 383 313Q383 312 384 312V315V321V347ZM416 347V315V347V315V289Q445 283 468 273Q493 263 512 248V283Q512 299 497 314Q471 339 416 352Q416 350 416 347ZM192 459Q279 458 340 433Q365 423 384 408V443Q382 477 328 500Q274 522 192 523Q110 522 56 500Q2 477 0 443V408Q19 423 44 433Q105 458 192 459Z',
++ };
++
++ const $ = (id) => document.getElementById(id);
++ let DATA = null;
++ let filter = 'all';
++ let transfer = { project: null, key: null, fixed: false };
++
++ function randomKey() {
++ try { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); } catch { /* older browser */ }
++ return `wallet-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
++ }
++
++ function banner(text, retry) {
++ const b = $('w-banner');
++ b.replaceChildren();
++ if (!text) { b.hidden = true; return; }
++ b.hidden = false;
++ b.append(document.createTextNode(text));
++ if (retry) {
++ const btn = W.el('button', 'w-linkish', 'Try again');
++ btn.type = 'button';
++ btn.addEventListener('click', () => load());
++ b.append(document.createTextNode(' '), btn);
++ }
++ }
++
++ function note(id, text, tone) {
++ const n = $(id);
++ if (!text) { n.hidden = true; n.textContent = ''; n.className = 'w-note'; return; }
++ n.hidden = false;
++ n.textContent = text;
++ n.className = `w-note${tone ? ` is-${tone}` : ''}`;
++ }
++
++ /** The projects list in the shared shape (Balance's answer may not be open yet). */
++ function projects() {
++ return DATA && Array.isArray(DATA.projects) ? DATA.projects : [];
++ }
++
++ function balanceOpen() { return !!(DATA && DATA.state && DATA.state.balance === 'open' && DATA.balance); }
++
++ function vibesCount() {
++ return DATA && DATA.vibes && Number.isFinite(Number(DATA.vibes.credit)) ? Number(DATA.vibes.credit) : 0;
++ }
++
++ function renderCards() {
++ const personal = DATA && DATA.balance && DATA.balance.personal ? DATA.balance.personal : null;
++ $('w-balance-amount').textContent = personal ? M.money(personal.balance_nano_usd) : (DATA && DATA.state.balance === 'closed' ? M.money(0) : '—');
++ if (DATA && DATA.state.balance === 'closed') {
++ note('w-balance-note', "Balance isn't open yet. When it is, Add money puts dollars here for the AI API and the pro tools.", 'quiet');
++ } else if (DATA && DATA.state.balance === 'unavailable') {
++ note('w-balance-note', "Your Balance couldn't load.", 'warn');
++ } else if (personal && BigInt(String(personal.held_nano_usd || '0')) > 0n) {
++ note('w-balance-note', `${M.money(personal.held_nano_usd)} is held for work that is running now.`, 'quiet');
++ } else {
++ note('w-balance-note', '');
++ }
++
++ $('w-vibes-amount').textContent = DATA && DATA.state.vibes === 'open' ? M.vibes(vibesCount()) : (DATA && DATA.state.vibes === 'closed' ? M.vibes(0) : '—');
++ note('w-vibes-note', DATA && DATA.state.vibes === 'not_authoritative' ? "Vibes aren't in this wallet until Billing takes them over." :
++ DATA && DATA.state.vibes === 'unavailable' ? "Your Vibes couldn't load." : '', 'quiet');
++
++ $('w-coins-amount').textContent = DATA ? M.vibes(DATA.opencoins) : '—';
++ }
++
++ function renderProjects() {
++ const list = $('w-projects');
++ list.replaceChildren();
++ const rows = projects();
++ $('w-projects-empty').hidden = rows.length > 0;
++ for (const p of rows) {
++ const li = W.el('li', 'w-project');
++ const main = W.el('div', 'w-project-main');
++ const a = W.el('a', 'w-project-link');
++ a.href = p.url;
++ a.target = '_blank';
++ a.rel = 'noopener';
++ a.title = `${p.name || 'Project'} on openvibe.services`;
++ a.append(W.el('b', null, p.name || 'Project'), W.el('span', 'w-project-ext', '↗'));
++ main.appendChild(a);
++ let held = 0n;
++ try { held = BigInt(String(p.held_nano_usd || '0')); } catch { held = 0n; }
++ if (held > 0n) main.appendChild(W.el('span', 'w-project-sub', `Available ${M.money(p.available_nano_usd)}`));
++ const side = W.el('div', 'w-project-side');
++ side.appendChild(W.el('span', 'w-project-bal', M.money(p.balance_nano_usd)));
++ const add = W.el('button', 'ovk-btn ovk-btn--quiet ovk-btn--small', 'Add');
++ add.type = 'button';
++ add.disabled = !balanceOpen();
++ add.title = balanceOpen() ? `Move money from your Balance to ${p.name || 'this project'}` : 'Your Balance is not open yet';
++ add.addEventListener('click', () => openTransfer(p.id));
++ side.appendChild(add);
++ li.append(main, side);
++ list.appendChild(li);
++ }
++ }
++
++ function activityParts(row) {
++ if (Array.isArray(row.parts) && row.parts.length) return row.parts;
++ return row.kind ? [{ kind: row.kind, amount: row.amount }] : [];
++ }
++
++ function activityRow(row) {
++ const li = W.el('li', 'w-act');
++ const parts = activityParts(row);
++ const kinds = parts.map((p) => p.kind);
++ li.dataset.kinds = kinds.join(' ');
++ const t = W.when(row.at);
++ const date = W.el('time', 'w-act-date', t.date);
++ date.dateTime = String(row.at || '');
++ if (t.time) date.title = t.time;
++ const main = W.el('div', 'w-act-main');
++ main.appendChild(W.el('b', 'w-act-label', row.label || 'Activity'));
++ const kindNames = [...new Set(kinds.map((k) => (k === 'vibes' ? 'Vibes' : 'Balance')))];
++ const kindName = kindNames.join(' · ');
++ main.appendChild(W.el('span', 'w-act-detail', row.detail ? `${kindName} · ${row.detail}` : kindName));
++ const amt = W.el('span', `w-act-amt${parts.length > 1 ? ' w-act-amt--stack' : ''}`);
++ for (const part of parts) {
++ const signed = part.kind === 'balance'
++ ? M.signedMoney(part.amount)
++ : { sign: Number(part.amount) < 0 ? '−' : '+', text: M.vibes(Math.abs(Number(part.amount))) };
++ const line = W.el('span', `w-act-part ${signed.sign === '+' ? 'is-pos' : 'is-neg'}`);
++ line.append(document.createTextNode(`${signed.sign}${signed.text}`));
++ if (part.kind === 'vibes') line.appendChild(W.el('small', null, 'Vibes'));
++ amt.appendChild(line);
++ }
++ li.append(date, main, amt);
++ return li;
++ }
++
++ function renderActivity() {
++ const list = $('w-activity');
++ list.replaceChildren();
++ const rows = DATA && Array.isArray(DATA.activity) ? DATA.activity : [];
++ for (const row of rows) list.appendChild(activityRow(row));
++ applyFilter();
++ }
++
++ function applyFilter() {
++ const rows = DATA && Array.isArray(DATA.activity) ? DATA.activity : [];
++ const items = $('w-activity').children;
++ let shown = 0;
++ for (const li of items) {
++ const ok = filter === 'all' || String(li.dataset.kinds || '').split(' ').includes(filter);
++ li.hidden = !ok;
++ if (ok) shown++;
++ }
++ const empty = $('w-activity-empty');
++ const total = rows.length;
++ empty.hidden = total > 0;
++ if (total === 0) empty.textContent = 'No activity yet. Add money or get Vibes and it will show here.';
++ else if (shown === 0) { empty.hidden = false; empty.textContent = filter === 'balance' ? 'No Balance activity yet.' : 'No Vibes activity yet.'; }
++ for (const chip of document.querySelectorAll('.w-chip')) chip.setAttribute('aria-pressed', String(chip.dataset.filter === filter));
++ }
++
++ function fillGlyphs() {
++ for (const el of document.querySelectorAll('[data-glyph]')) {
++ const d = GLYPH[el.getAttribute('data-glyph')];
++ if (!d) continue;
++ el.replaceChildren(W.icon(d, 22));
++ }
++ }
++
++ // ── Move money ───────────────────────────────────────────
++ function transferAvailable() {
++ const p = DATA && DATA.balance && DATA.balance.personal;
++ return p ? BigInt(String(p.available_nano_usd || '0')) : 0n;
++ }
++
++ function openTransfer(projectId) {
++ const dialog = $('w-transfer-dialog');
++ transfer = { project: projectId || '', key: randomKey(), fixed: !!projectId };
++ const select = $('w-transfer-project');
++ select.replaceChildren();
++ for (const p of projects()) {
++ const o = document.createElement('option');
++ o.value = p.id;
++ o.textContent = p.name || p.id;
++ select.appendChild(o);
++ }
++ select.value = projectId || (projects()[0] && projects()[0].id) || '';
++ $('w-transfer-project-field').hidden = !!projectId;
++ $('w-transfer-lede').textContent = projectId
++ ? `Move money from your Balance into ${(projects().find((p) => p.id === projectId) || {}).name || 'this project'}.`
++ : "Move money from your Balance into a project's Balance.";
++ $('w-transfer-have').textContent = M.money(transferAvailable());
++ $('w-transfer-amount').value = '';
++ $('w-transfer-go').textContent = 'Move money';
++ $('w-transfer-go').disabled = true;
++ const state = $('w-transfer-state');
++ state.textContent = balanceOpen() ? '' : 'Your Balance is not open yet.';
++ if (!dialog.open) dialog.showModal();
++ setTimeout(() => $('w-transfer-amount').focus(), 0);
++ }
++
++ function updateTransfer() {
++ const nano = M.dollarsToNano($('w-transfer-amount').value);
++ const go = $('w-transfer-go');
++ if (!nano || BigInt(nano) <= 0n) { go.disabled = true; go.textContent = 'Move money'; return; }
++ const available = transferAvailable();
++ const over = BigInt(nano) > available;
++ go.disabled = over || !balanceOpen();
++ go.textContent = over ? 'Not enough Balance' : `Move ${M.money(nano)}`;
++ $('w-transfer-state').textContent = over ? `You have ${M.money(available)} available.` : '';
++ }
++
++ async function submitTransfer() {
++ const nano = M.dollarsToNano($('w-transfer-amount').value);
++ const projectId = transfer.fixed ? transfer.project : $('w-transfer-project').value;
++ if (!nano || !projectId) return;
++ const go = $('w-transfer-go');
++ const state = $('w-transfer-state');
++ go.disabled = true;
++ state.textContent = 'Moving…';
++ try {
++ await W.api('/api/wallet/transfers', { method: 'POST', body: { to_project_id: projectId, amount_nano_usd: nano, idempotency_key: transfer.key } });
++ $('w-transfer-dialog').close();
++ const name = (projects().find((p) => p.id === projectId) || {}).name || 'the project';
++ await load();
++ banner(`Moved ${M.money(nano)} to ${name}.`);
++ } catch (e) {
++ state.textContent = e.status === 402 ? "There isn't enough Balance for that."
++ : e.status === 403 ? "You're not an admin of that project."
++ : e.status === 422 ? 'That amount is not valid.'
++ : 'The money could not be moved. Try again in a moment.';
++ go.disabled = false;
++ }
++ }
++
++ // ── Add money ────────────────────────────────────────────
++ function openAddMoney() {
++ const dialog = $('w-add-dialog');
++ const state = $('w-add-state');
++ state.textContent = balanceOpen() ? '' : "Balance isn't open yet.";
++ $('w-add-amount').value = '';
++ $('w-add-go').disabled = !balanceOpen();
++ // Only the providers that answer with a hosted checkout are offered here; PowerChat is finished in the
++ // product that started the tip, not in a wallet dialog.
++ const providers = DATA && DATA.rates && DATA.rates.providers ? DATA.rates.providers : null;
++ const labels = { stripe: 'A card (Stripe)', paypal: 'PayPal', ccbill: 'CCBill', nowpayments: 'Crypto (NOWPayments)' };
++ const select = $('w-add-provider');
++ select.replaceChildren();
++ for (const key of Object.keys(labels)) {
++ if (providers && providers[key] === false) continue;
++ const o = document.createElement('option');
++ o.value = key;
++ o.textContent = labels[key];
++ select.appendChild(o);
++ }
++ if (!select.options.length) {
++ state.textContent = 'No payment provider is open right now.';
++ $('w-add-go').disabled = true;
++ }
++ if (!dialog.open) dialog.showModal();
++ setTimeout(() => $('w-add-amount').focus(), 0);
++ }
++
++ function updateAdd() {
++ const nano = M.dollarsToNano($('w-add-amount').value);
++ const go = $('w-add-go');
++ if (!nano || BigInt(nano) <= 0n) { go.disabled = true; return; }
++ $('w-add-state').textContent = '';
++ go.disabled = !balanceOpen();
++ }
++
++ async function submitAdd() {
++ const nano = M.dollarsToNano($('w-add-amount').value);
++ const provider = $('w-add-provider').value;
++ const state = $('w-add-state');
++ const go = $('w-add-go');
++ if (!nano) { state.textContent = 'Enter an amount in dollars, like 20.00.'; return; }
++ if (BigInt(nano) % 10000000n !== 0n) { state.textContent = 'Use whole cents, like 20.00 or 20.50.'; return; }
++ const cents = BigInt(nano) / 10000000n;
++ if (cents > 100000000n) { state.textContent = 'That amount is above the single top-up limit.'; return; }
++ go.disabled = true;
++ state.textContent = 'Opening the checkout…';
++ try {
++ const out = await W.api('/api/wallet/topups', { method: 'POST', body: { provider, amount_cents: Number(cents) } });
++ const url = out && out.checkout_url ? W.httpsUrl(out.checkout_url) : null;
++ if (url) { window.location.assign(url.href); return; }
++ state.textContent = 'The checkout did not open. Try again, or add money from OpenVibe.Live.';
++ go.disabled = false;
++ } catch (e) {
++ state.textContent = e.status === 422 ? "That amount isn't offered right now. Try another."
++ : e.status === 409 ? 'That payment provider is not open right now.'
++ : e.status === 404 || e.status === 503 ? "Adding money isn't open yet."
++ : 'The checkout could not open. Try again in a moment.';
++ go.disabled = false;
++ }
++ }
++
++ function openVibes() {
++ const rates = DATA && DATA.rates ? DATA.rates : null;
++ const personal = DATA && DATA.balance && DATA.balance.personal;
++ S.openVibes({
++ rates,
++ bitsPerUsd: DATA && DATA.vibes && DATA.vibes.bits_per_usd ? DATA.vibes.bits_per_usd : (rates && rates.bits_per_usd),
++ balanceNano: personal ? String(personal.available_nano_usd) : null,
++ canBuy: !!(DATA && DATA.state.vibes === 'open'),
++ onDone: () => load(),
++ });
++ }
++
++ // ── Page ─────────────────────────────────────────────────
++ function render() {
++ renderCards();
++ renderProjects();
++ renderActivity();
++ }
++
++ async function load() {
++ banner('');
++ try {
++ DATA = await W.api('/api/wallet');
++ render();
++ for (const id of ['w-add-money', 'w-move', 'w-get-vibes']) $(id).disabled = false;
++ } catch (e) {
++ banner("The wallet couldn't load right now.", true);
++ $('w-balance-amount').textContent = '—';
++ $('w-vibes-amount').textContent = '—';
++ $('w-coins-amount').textContent = '—';
++ }
++ }
++
++ function bind() {
++ fillGlyphs();
++ // Nothing in a card is actionable until its numbers are here (no spinner: a dimmed button is the wait).
++ for (const id of ['w-add-money', 'w-move', 'w-get-vibes']) $(id).disabled = true;
++ for (const chip of document.querySelectorAll('.w-chip')) {
++ chip.addEventListener('click', () => { filter = chip.dataset.filter; applyFilter(); });
++ }
++ for (const close of document.querySelectorAll('[data-close]')) {
++ close.addEventListener('click', () => { const d = close.closest('dialog'); if (d) d.close(); });
++ }
++ for (const dialog of document.querySelectorAll('dialog.ovk-sheet')) W.closeOnBackdrop(dialog);
++ $('w-transfer-project').addEventListener('change', (e) => { transfer.project = e.target.value; });
++ $('w-transfer-amount').addEventListener('input', updateTransfer);
++ $('w-transfer-go').addEventListener('click', submitTransfer);
++ $('w-add-amount').addEventListener('input', updateAdd);
++ $('w-add-go').addEventListener('click', submitAdd);
++ $('w-add-money').addEventListener('click', openAddMoney);
++ $('w-move').addEventListener('click', () => {
++ if (!balanceOpen()) { banner("Your Balance isn't open yet."); return; }
++ if (!projects().length) { banner('Create a project on OpenVibe.Services first, then you can move money to it.'); return; }
++ openTransfer(null);
++ });
++ $('w-get-vibes').addEventListener('click', openVibes);
++ }
++
++ (async function start() {
++ const user = await W.person();
++ if (!user) return;
++ W.navbar(user);
++ bind();
++ await load();
++ })().catch(() => {
++ banner("The wallet couldn't start. Reload the page.", false);
++ });
++}());
ddiff --git a/public/js/wallet/pay.js b/public/js/wallet/pay.js
nnew file mode 100644
iindex 00000000..caba3385
---- /dev/null
++++ b/public/js/wallet/pay.js
@@@ -0,0 +1,348 @@
++/* openvibe.network/pay/:id — the Vibes Pay sheet (MONEY.md, D12).
++ *
++ * A centred card, 420 px at most: the app and "on OpenVibe", the item, one large price in whole Vibes, the person's
++ * Vibes, one primary button (or Get Vibes when they are short), and a quiet Cancel. It never draws a card form: the
++ * Vibes are already bought. Opened as a popup it posts { status } to the opener — only at the origin of the
++ * registered return_url — and closes; as a full page it stays and links back to the app.
++ */
++(function () {
++ 'use strict';
++
++ const W = window.OVWallet;
++ const M = window.OVWalletMoney;
++ const S = window.OVWalletSheets;
++ if (!W || !M) return;
++
++ const root = document.getElementById('w-pay-card');
++ let charge = null;
++ let wallet = null;
++ let state = 'loading';
++
++ function idFromPath() {
++ const parts = String(location.pathname || '').split('/').filter(Boolean);
++ const raw = parts.length ? parts[parts.length - 1] : '';
++ try { return decodeURIComponent(raw); } catch { return raw; }
++ }
++
++ const chargeId = idFromPath();
++
++ function randomKey() {
++ try { if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID(); } catch { /* older browser */ }
++ return `pay-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
++ }
++
++ function card() {
++ const box = W.el('div', 'w-pay');
++ root.replaceChildren();
++ root.appendChild(box);
++ return box;
++ }
++
++ function appOf(out, fallback) {
++ const c = out && out.charge ? out.charge : out;
++ if (!c || typeof c !== 'object' || !c.id) return null;
++ const app = c.app && typeof c.app === 'object' ? c.app : (out && out.app && typeof out.app === 'object' ? out.app : {});
++ const before = fallback || {};
++ const name = app.name || c.app_name || (out && out.app_name) || (before.app && before.app.name) || 'An OpenVibe app';
++ const icon = app.icon || app.icon_url || c.app_icon || (out && out.app_icon) || (before.app && before.app.icon) || null;
++ let amount = Number(c.amount);
++ if (!Number.isSafeInteger(amount) || amount < 0) amount = Number(before.amount);
++ if (!Number.isSafeInteger(amount) || amount < 0) return null;
++ const status = String(c.status || before.status || 'pending');
++ return {
++ id: String(c.id),
++ app: { name: String(name).slice(0, 80), icon },
++ description: String(c.description || before.description || 'A purchase on OpenVibe').slice(0, 200),
++ amount,
++ status,
++ returnUrl: c.return_url || before.returnUrl || null,
++ expiresAt: c.expires_at || before.expiresAt || null,
++ reference: c.reference || before.reference || null,
++ };
++ }
++
++ function creditOf(out) {
++ if (!out) return null;
++ const candidates = [
++ out.vibes && out.vibes.credit, out.credit, out.vibes_credit,
++ out.wallet && out.wallet.credit, out.charge && out.charge.vibes_credit,
++ ];
++ for (const c of candidates) if (c != null && Number.isSafeInteger(Number(c))) return Number(c);
++ return null;
++ }
++
++ function expired(c) {
++ if (!c || !c.expiresAt) return false;
++ const t = new Date(c.expiresAt).getTime();
++ return Number.isFinite(t) && t > 0 && t < Date.now();
++ }
++
++ function creditNow() {
++ if (wallet && wallet.vibes && wallet.vibes.credit != null && Number.isFinite(Number(wallet.vibes.credit))) return Number(wallet.vibes.credit);
++ return null;
++ }
++
++ function safeIcon(raw) {
++ const u = W.httpsUrl(raw);
++ if (!u) return null;
++ const h = u.hostname.toLowerCase();
++ if (h === 'openvibe.media' || h === 'openvibe.live' || u.origin === location.origin) return u.href;
++ return null;
++ }
++
++ function appMark(c) {
++ const wrap = W.el('span', 'w-pay-mark');
++ const src = safeIcon(c.app.icon);
++ if (src) {
++ const img = document.createElement('img');
++ img.src = src;
++ img.alt = '';
++ img.width = 48;
++ img.height = 48;
++ img.addEventListener('error', () => { wrap.replaceChildren(W.el('span', 'w-pay-mark-initial', initial(c))); });
++ wrap.appendChild(img);
++ } else {
++ wrap.appendChild(W.el('span', 'w-pay-mark-initial', initial(c)));
++ }
++ return wrap;
++ }
++
++ function initial(c) {
++ const m = String(c.app.name || 'O').match(/[A-Za-z0-9]/);
++ return (m ? m[0] : 'O').toUpperCase();
++ }
++
++ function appLine(c, small) {
++ const row = W.el('div', 'w-pay-app');
++ row.appendChild(appMark(c));
++ const who = W.el('div', 'w-pay-app-who');
++ who.appendChild(W.el('b', null, c.app.name));
++ who.appendChild(W.el('span', null, small || 'on OpenVibe'));
++ row.appendChild(who);
++ return row;
++ }
++
++ function backLink(c) {
++ const u = W.httpsUrl(c.returnUrl);
++ if (!u) return null;
++ const a = W.el('a', 'ovk-btn ovk-btn--primary w-pay-go', `Back to ${c.app.name}`);
++ a.href = u.href;
++ a.rel = 'noopener';
++ a.addEventListener('click', () => { if (window.opener && !window.opener.closed) setTimeout(() => { try { window.close(); } catch { /* */ } }, 0); });
++ return a;
++ }
++
++ function quietCancel(label, onClick) {
++ const b = W.el('button', 'w-quiet w-pay-cancel', label || 'Cancel');
++ b.type = 'button';
++ if (onClick) b.addEventListener('click', onClick);
++ return b;
++ }
++
++ function quietLink(href, label) {
++ const a = W.el('a', 'w-quiet w-pay-cancel', label);
++ a.href = href;
++ return a;
++ }
++
++ function frame(children) {
++ const box = card();
++ for (const child of children) if (child) box.appendChild(child);
++ return box;
++ }
++
++ function sentence(text) {
++ return frame([W.el('p', 'w-pay-sentence', text)]);
++ }
++
++ // ── The document the opener receives ─────────────────────
++ function tellOpener(status) {
++ const c = charge;
++ const u = c ? W.httpsUrl(c.returnUrl) : null;
++ if (!u || !window.opener || window.opener.closed) return;
++ try { window.opener.postMessage({ type: 'ov-vibes-pay', status, charge_id: c.id }, u.origin); } catch { /* a closed or cross-origin opener */ }
++ }
++
++ function closeAsPopupIfAny(status) {
++ const c = charge;
++ const u = c ? W.httpsUrl(c.returnUrl) : null;
++ if (!u || !window.opener || window.opener.closed) return;
++ tellOpener(status);
++ setTimeout(() => { try { window.close(); } catch { /* not opened by script */ } }, 1400);
++ }
++
++ // ── The states ───────────────────────────────────────────
++ function renderUnavailable() {
++ state = 'unavailable';
++ sentence("This payment link isn't available. It may be old, or for another account.");
++ const box = root.querySelector('.w-pay');
++ box.appendChild(quietLink('/wallet', 'Go to your wallet'));
++ }
++
++ function renderStatusSentence(text) {
++ sentence(text);
++ const box = root.querySelector('.w-pay');
++ const c = charge;
++ const u = c ? W.httpsUrl(c.returnUrl) : null;
++ if (u) box.appendChild(quietLink(u.href, `Back to ${c.app.name}`));
++ else box.appendChild(quietLink('/wallet', 'Go to your wallet'));
++ }
++
++ function renderPaid() {
++ state = 'paid';
++ const c = charge;
++ const box = card();
++ const check = W.el('span', 'w-pay-check');
++ check.setAttribute('aria-hidden', 'true');
++ check.innerHTML = '<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5.2 5L20 6.8"/></svg>';
++ box.appendChild(check);
++ box.appendChild(W.el('h1', 'w-pay-title', 'Paid'));
++ box.appendChild(W.el('p', 'w-pay-line', `${M.vibes(c.amount)} Vibes to ${c.app.name}.`));
++ box.appendChild(appLine(c, 'paid'));
++ const back = backLink(c);
++ if (back) box.appendChild(back);
++ else box.appendChild(quietLink('/wallet', 'Go to your wallet'));
++ if (window.opener && !window.opener.closed) closeAsPopupIfAny('paid');
++ }
++
++ function renderShort(c, credit) {
++ state = 'short';
++ const box = card();
++ box.appendChild(appLine(c));
++ box.appendChild(W.el('p', 'w-pay-item', c.description));
++ box.appendChild(W.el('p', 'w-pay-price', `${M.vibes(c.amount)} Vibes`));
++ box.appendChild(W.el('p', 'w-pay-have', `You have ${M.vibes(credit)} Vibes`));
++ const need = c.amount - credit;
++ box.appendChild(W.el('p', 'w-pay-need', `You need ${M.vibes(need)} more Vibes`));
++ const get = W.el('button', 'ovk-btn ovk-btn--primary w-pay-go', 'Get Vibes');
++ get.type = 'button';
++ get.addEventListener('click', () => S.openVibes({
++ rates: wallet && wallet.rates,
++ bitsPerUsd: wallet && wallet.vibes && wallet.vibes.bits_per_usd,
++ balanceNano: wallet && wallet.balance && wallet.balance.personal ? wallet.balance.personal.available_nano_usd : null,
++ canBuy: !!(wallet && wallet.state && wallet.state.vibes === 'open'),
++ onDone: async () => { await refresh(); },
++ }));
++ box.appendChild(get);
++ box.appendChild(quietCancel('Cancel', cancelCharge));
++ }
++
++ function renderPayable(c, credit) {
++ state = 'payable';
++ const box = card();
++ box.appendChild(appLine(c));
++ box.appendChild(W.el('p', 'w-pay-item', c.description));
++ box.appendChild(W.el('p', 'w-pay-price', `${M.vibes(c.amount)} Vibes`));
++ box.appendChild(W.el('p', 'w-pay-have', `You have ${M.vibes(credit)} Vibes`));
++ const pay = W.el('button', 'ovk-btn ovk-btn--primary w-pay-go', `Pay ${M.vibes(c.amount)} Vibes`);
++ pay.type = 'button';
++ pay.addEventListener('click', () => payCharge(pay));
++ box.appendChild(pay);
++ box.appendChild(quietCancel('Cancel', cancelCharge));
++ }
++
++ function renderError(text) {
++ const box = root.querySelector('.w-pay') || card();
++ let line = box.querySelector('.w-pay-error');
++ if (!line) { line = W.el('p', 'w-pay-error'); box.appendChild(line); }
++ line.textContent = text;
++ }
++
++ function render() {
++ if (!charge) return renderUnavailable();
++ const c = charge;
++ if (c.status === 'paid') return renderPaid();
++ if (c.status === 'refunded') return renderStatusSentence('This payment was refunded to your Vibes.');
++ if (c.status === 'cancelled') {
++ state = 'cancelled';
++ renderStatusSentence('This payment was cancelled.');
++ closeAsPopupIfAny('cancelled');
++ return;
++ }
++ if (c.status === 'expired' || expired(c)) {
++ state = 'expired';
++ renderStatusSentence('This payment link has expired.');
++ return;
++ }
++ const credit = creditNow();
++ if (credit == null) {
++ state = 'unknown';
++ return sentence("Your Vibes aren't available right now. Try again in a moment.");
++ }
++ const canPay = credit >= c.amount;
++ if (!canPay) return renderShort(c, credit);
++ renderPayable(c, credit);
++ }
++
++ // ── Actions ──────────────────────────────────────────────
++ async function payCharge(button) {
++ if (state !== 'payable' || !charge) return;
++ button.disabled = true;
++ button.textContent = 'Paying…';
++ try {
++ const out = await W.api(`/api/wallet/charge/${encodeURIComponent(charge.id)}/pay`, { method: 'POST', body: { idempotency_key: randomKey() } });
++ const next = appOf(out, charge);
++ if (next) charge = next;
++ else charge = Object.assign(charge, { status: 'paid' });
++ // The answer may be the paid charge, or a wrapper { charge, vibes }; take its credit too.
++ const credit = creditOf(out);
++ if (credit != null) wallet = Object.assign(wallet || {}, { vibes: { credit } });
++ renderPaid();
++ } catch (e) {
++ if (e.status === 402) {
++ // Somebody or something moved the Vibes between the read and the pay: show the shortfall and Get Vibes.
++ await refresh();
++ if (state !== 'paid') renderError('You need a few more Vibes for this one.');
++ return;
++ }
++ button.disabled = false;
++ button.textContent = `Pay ${M.vibes(charge.amount)} Vibes`;
++ renderError(e.status === 409 ? 'This payment can no longer be paid.' : 'The payment could not go through. Try again in a moment.');
++ }
++ }
++
++ async function cancelCharge() {
++ if (!charge) return;
++ if (state === 'cancelled' || state === 'paid') return;
++ try {
++ await W.api(`/api/wallet/charge/${encodeURIComponent(charge.id)}/cancel`, { method: 'POST', body: {} });
++ charge.status = 'cancelled';
++ render();
++ } catch {
++ renderError('The payment could not be cancelled.');
++ }
++ }
++
++ async function refresh() {
++ const [out, w] = await Promise.allSettled([
++ W.api(`/api/wallet/charge/${encodeURIComponent(chargeId)}`),
++ W.api('/api/wallet'),
++ ]);
++ if (w.status === 'fulfilled') wallet = w.value;
++ if (out.status === 'fulfilled') {
++ const next = appOf(out.value, charge);
++ if (next) {
++ charge = next;
++ const credit = creditOf(out.value);
++ if (credit != null) wallet = Object.assign(wallet || {}, { vibes: { credit } });
++ render();
++ return;
++ }
++ }
++ if (out.status === 'rejected' && (out.reason.status === 404 || out.reason.status === 400)) { charge = null; renderUnavailable(); return; }
++ if (!root.querySelector('.w-pay')) sentence("This payment link isn't available right now.");
++ else renderError("This payment link isn't available right now.");
++ }
++
++ (async function start() {
++ // The person must be signed in; the sign-in page returns them to this exact link.
++ const user = await W.person();
++ if (!user) return;
++ W.navbar(user);
++ if (!/^vch_[0-9A-HJKMNP-TV-Z]{26}$/.test(chargeId)) { renderUnavailable(); return; }
++ await refresh();
++ document.title = charge ? `Pay ${M.vibes(charge.amount)} Vibes - ${charge.app.name}` : 'Vibes Pay - OpenVibe';
++ })().catch(() => {
++ if (!root.querySelector('.w-pay')) sentence("This payment link isn't available right now.");
++ });
++}());
ddiff --git a/public/js/wallet/sheets.js b/public/js/wallet/sheets.js
nnew file mode 100644
iindex 00000000..f55c0d3f
---- /dev/null
++++ b/public/js/wallet/sheets.js
@@@ -0,0 +1,201 @@
++/* openvibe.network/wallet — the Get Vibes sheet, shared by /wallet and /pay/:id.
++ *
++ * Two tabs, one sheet: "With a card" is the existing packs flow (OpenVibe.Live's Buy Vibes window, which starts a
++ * Billing intent and credits the Vibes here), and "With your Balance" buys Vibes from the person's Balance at the
++ * same pack value rate, with the exact dollar cost shown before the confirm. No card form lives here; the card tab
++ * hands off to the flow that already owns cards. Nothing spins: a static "Getting…" is the whole busy state.
++ */
++(function (root) {
++ 'use strict';
++
++ const W = root.OVWallet;
++ const M = root.OVWalletMoney;
++ if (!W || !M) return;
++
++ const LIVE_VIBES = 'https://openvibe.live/#vibes';
++ let sheet = null;
++ let ctx = null;
++
++ function dollarsCents(cents) {
++ const c = Math.max(0, Math.trunc(Number(cents) || 0));
++ return `$${Math.floor(c / 100)}.${String(c % 100).padStart(2, '0')}`;
++ }
++
++ function randomKey() {
++ try { if (root.crypto && typeof root.crypto.randomUUID === 'function') return root.crypto.randomUUID(); } catch { /* older browser */ }
++ return `wallet-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
++ }
++
++ function build() {
++ if (sheet) return sheet;
++ sheet = W.el('dialog', 'ovk-sheet w-sheet w-vibes-sheet');
++ sheet.id = 'w-vibes-sheet';
++ sheet.setAttribute('aria-labelledby', 'w-vibes-h');
++ sheet.innerHTML = `
++ <div class="ovk-sheet-head">
++ <h2 id="w-vibes-h">Get Vibes</h2>
++ <button type="button" class="ovk-icon-btn" data-close aria-label="Close" title="Close">×</button>
++ </div>
++ <div class="ovk-sheet-body">
++ <p class="w-sheet-lede">For tips, cheers and fun stuff. Bought in whole Vibes, never earned.</p>
++ <div class="w-tabs" role="tablist" aria-label="How to get Vibes">
++ <button type="button" role="tab" class="w-tab" id="w-tab-card" aria-selected="true" aria-controls="w-panel-card" tabindex="0">With a card</button>
++ <button type="button" role="tab" class="w-tab" id="w-tab-balance" aria-selected="false" aria-controls="w-panel-balance" tabindex="-1">With your Balance</button>
++ </div>
++ <section id="w-panel-card" role="tabpanel" aria-labelledby="w-tab-card">
++ <p class="w-sheet-note">Packs with a card are bought on <b>OpenVibe.Live</b>. Your Vibes land in this wallet, ready for any site.</p>
++ <div class="w-packs" id="w-packs"></div>
++ <a class="ovk-btn ovk-btn--primary w-sheet-go" id="w-card-go" href="${LIVE_VIBES}" target="_blank" rel="noopener">Open the packs</a>
++ </section>
++ <section id="w-panel-balance" role="tabpanel" aria-labelledby="w-tab-balance" hidden>
++ <label class="w-field" for="w-vibes-input"><span>Vibes</span>
++ <input id="w-vibes-input" type="number" inputmode="numeric" min="100" step="1" placeholder="1,000" autocomplete="off">
++ </label>
++ <p class="w-cost-line">Costs <b id="w-vibes-cost">—</b> from your Balance</p>
++ <p class="w-sheet-note" id="w-vibes-have"></p>
++ <button type="button" class="ovk-btn ovk-btn--primary w-sheet-go" id="w-vibes-buy" disabled>Get Vibes</button>
++ <p class="w-sheet-note" id="w-vibes-state" role="status" aria-live="polite"></p>
++ </section>
++ <button type="button" class="w-quiet w-sheet-cancel" data-close>Cancel</button>
++ </div>`;
++ document.body.appendChild(sheet);
++ W.closeOnBackdrop(sheet);
++ sheet.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => sheet.close()));
++
++ const tabs = [sheet.querySelector('#w-tab-card'), sheet.querySelector('#w-tab-balance')];
++ const panels = { card: sheet.querySelector('#w-panel-card'), balance: sheet.querySelector('#w-panel-balance') };
++ function select(which) {
++ for (const t of tabs) {
++ const on = t.id === `w-tab-${which}`;
++ t.setAttribute('aria-selected', String(on));
++ t.tabIndex = on ? 0 : -1;
++ }
++ panels.card.hidden = which !== 'card';
++ panels.balance.hidden = which !== 'balance';
++ if (which === 'balance') setTimeout(() => sheet.querySelector('#w-vibes-input').focus(), 0);
++ }
++ tabs.forEach((t) => {
++ t.addEventListener('click', () => select(t.id.replace('w-tab-', '')));
++ t.addEventListener('keydown', (e) => {
++ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
++ e.preventDefault();
++ const which = t.id === 'w-tab-card' ? 'balance' : 'card';
++ select(which);
++ tabs[which === 'card' ? 0 : 1].focus();
++ });
++ });
++
++ sheet.querySelector('#w-vibes-input').addEventListener('input', preview);
++ sheet.querySelector('#w-vibes-buy').addEventListener('click', buy);
++ return sheet;
++ }
++
++ function cost() {
++ const input = sheet.querySelector('#w-vibes-input');
++ const n = M.whole(input.value);
++ const rate = ctx && ctx.bitsPerUsd;
++ if (!n || !rate) return null;
++ const nano = M.priceNano(n, rate);
++ return nano ? { vibes: n, nano } : null;
++ }
++
++ function preview() {
++ const buyBtn = sheet.querySelector('#w-vibes-buy');
++ const state = sheet.querySelector('#w-vibes-state');
++ const costEl = sheet.querySelector('#w-vibes-cost');
++ const have = sheet.querySelector('#w-vibes-have');
++ const c = cost();
++ if (!c) {
++ costEl.textContent = '—';
++ buyBtn.disabled = true;
++ buyBtn.textContent = 'Get Vibes';
++ have.textContent = ctx && ctx.balanceNano != null ? `You have ${M.money(ctx.balanceNano)} in Balance.` : '';
++ return;
++ }
++ costEl.textContent = M.money(c.nano);
++ buyBtn.textContent = `Get ${M.vibes(c.vibes)} Vibes`;
++ const short = ctx.balanceNano != null && BigInt(ctx.balanceNano) < BigInt(c.nano);
++ buyBtn.disabled = !!short || !ctx.canBuy || state.dataset.busy === '1';
++ buyBtn.title = short ? 'Not enough Balance' : '';
++ if (ctx.balanceNano != null) have.textContent = short
++ ? `You need ${M.money(BigInt(c.nano) - BigInt(ctx.balanceNano))} more Balance.`
++ : `You have ${M.money(ctx.balanceNano)} in Balance.`;
++ else have.textContent = '';
++ }
++
++ async function buy() {
++ const c = cost();
++ const state = sheet.querySelector('#w-vibes-state');
++ const buyBtn = sheet.querySelector('#w-vibes-buy');
++ if (!c || buyBtn.disabled) return;
++ state.dataset.busy = '1';
++ state.textContent = 'Getting…';
++ buyBtn.disabled = true;
++ try {
++ const out = await W.api('/api/wallet/vibes/from-balance', { method: 'POST', body: { vibes: c.vibes, idempotency_key: randomKey() } });
++ const credit = out && Number.isFinite(Number(out.vibes_credit)) ? Number(out.vibes_credit) : null;
++ if (credit != null && ctx) ctx.balanceNano = null;
++ state.textContent = `${M.vibes(c.vibes)} Vibes added to your wallet.`;
++ if (ctx && typeof ctx.onDone === 'function') {
++ try { await ctx.onDone(out); } catch { /* the next visit to the sheet still shows the new number */ }
++ }
++ sheet.querySelector('#w-vibes-input').value = '';
++ preview();
++ } catch (e) {
++ state.textContent = e.status === 402 ? 'Not enough Balance for that many Vibes.'
++ : e.status === 404 ? 'Getting Vibes with Balance is not open yet.'
++ : e.status === 422 ? 'That amount is not offered right now.'
++ : 'The Vibes could not be added. Try again in a moment.';
++ } finally {
++ state.dataset.busy = '0';
++ buyBtn.disabled = false;
++ preview();
++ }
++ }
++
++ function renderPacks(rates) {
++ const box = sheet.querySelector('#w-packs');
++ box.replaceChildren();
++ const packs = rates && Array.isArray(rates.packages) ? rates.packages : [];
++ for (const p of packs) {
++ const a = W.el('a', 'w-pack');
++ a.href = LIVE_VIBES;
++ a.target = '_blank';
++ a.rel = 'noopener';
++ a.append(W.el('b', null, `${M.vibes(p.bits)} Vibes`), W.el('small', null, dollarsCents(p.price_cents)));
++ box.appendChild(a);
++ }
++ const min = rates && rates.min_purchase_bits;
++ const input = sheet.querySelector('#w-vibes-input');
++ if (min) input.min = String(min);
++ if (min) input.placeholder = M.vibes(min);
++ }
++
++ /**
++ * Open the sheet.
++ * { bitsPerUsd, minPurchaseBits, packages, balanceNano, canBuy, onDone(result), onAddMoney() }
++ */
++ async function open(options = {}) {
++ build();
++ const rates = options.rates || null;
++ ctx = {
++ bitsPerUsd: Number(options.bitsPerUsd || (rates && rates.bits_per_usd) || 100),
++ balanceNano: options.balanceNano == null ? null : String(options.balanceNano),
++ canBuy: options.canBuy !== false,
++ onDone: options.onDone,
++ onAddMoney: options.onAddMoney,
++ };
++ renderPacks(rates);
++ const costEl = sheet.querySelector('#w-vibes-cost');
++ const state = sheet.querySelector('#w-vibes-state');
++ costEl.textContent = '—';
++ state.textContent = options.canBuy === false ? 'Getting Vibes with Balance is not open yet.' : '';
++ sheet.querySelector('#w-vibes-input').value = '';
++ preview();
++ // The first tab is the card flow, as in the design; the balance tab keeps the exact price live once chosen.
++ sheet.querySelector('#w-tab-card').click();
++ if (!sheet.open) sheet.showModal();
++ }
++
++ root.OVWalletSheets = { openVibes: open, close() { if (sheet && sheet.open) sheet.close(); } };
++}(window));
ddiff --git a/public/my.html b/public/my.html
iindex 6d4a35fe..0f0d87a4 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -82,7 +82,7 @@
<div class="td-tries" id="td-try" hidden></div>
</section>
<section class="td-box td-money" id="coins" hidden aria-labelledby="td-money-h">
-- <div class="td-h"><h2 id="td-money-h">Your money</h2><a class="td-more" href="https://openvibe.live/#vibes">Add Vibes</a></div>
++ <div class="td-h"><h2 id="td-money-h">Your money</h2><a class="td-more" href="/wallet">Wallet</a></div>
<div class="td-bal"><span class="td-coin" aria-hidden="true"></span><b id="td-coins">0</b><span>OpenCoins</span></div>
<div class="td-bal" id="td-vibes-row" hidden><span class="td-coin td-coin-v" aria-hidden="true"></span><b id="td-vibes">0</b><span>Vibes</span></div>
<p class="td-sub" id="td-money-last" hidden></p>
@@@ -389,6 +389,11 @@
</div>
<div class="section" id="sec-security">
++ <div class="card qr-entry" id="qr-signin-card" hidden>
++ <h3><span class="icon"><i class="fa-solid fa-mobile-screen-button"></i></span> Sign in on another device</h3>
++ <p>Show a code on this device, scan it with the device you want to sign in, then confirm the number it shows.</p>
++ <button class="btn btn-primary qr-entry-button" type="button" onclick="qrSigninOpen()"><i class="fa-solid fa-mobile-screen-button"></i> Sign in on another device</button>
++ </div>
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-lock"></i></span> Change Password</h3>
<form id="password-form">
@@@ -453,6 +458,57 @@
</div>
</div>
++<!-- Sign in on another device (server/auth/qr-signin.js; entry revealed only when /api/auth/me says the feature is on). -->
++<dialog class="qr-dialog" id="qr-dialog" aria-labelledby="qr-title">
++ <div class="qr-dialog-head">
++ <h2 id="qr-title" tabindex="-1">Sign in on another device</h2>
++ <button class="qr-close" type="button" aria-label="Close" onclick="qrSigninClose()">×</button>
++ </div>
++ <div class="qr-grid">
++ <div class="qr-left">
++ <div id="qr-scan-view">
++ <div class="qr-tile" id="qr-tile">
++ <div class="qr-code" id="qr-code" aria-label="QR code for your other device"></div>
++ <div class="qr-expired" id="qr-expired" hidden>
++ <p>This code expired</p>
++ <button class="btn btn-primary" type="button" onclick="qrSigninNewCode()">New code</button>
++ </div>
++ </div>
++ <div class="qr-bar" aria-hidden="true"><span id="qr-bar-fill"></span></div>
++ <p class="qr-timer" id="qr-timer" aria-live="off">Code expires in 2:00</p>
++ </div>
++
++ <div id="qr-confirm-view" hidden>
++ <div class="qr-device" id="qr-device"><span class="qr-device-icon" id="qr-device-icon" aria-hidden="true"></span><span id="qr-device-name">Your other device</span></div>
++ <p class="qr-confirm-label">Type the number it shows</p>
++ <div class="qr-digits">
++ <input class="qr-digit" id="qr-digit-1" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="one-time-code" aria-label="First digit" oninput="qrDigit(this, 'qr-digit-2')" onkeydown="qrDigitKey(event, this)">
++ <input class="qr-digit" id="qr-digit-2" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" autocomplete="off" aria-label="Second digit" oninput="qrDigit(this, null)" onkeydown="qrDigitKey(event, this)">
++ </div>
++ <p class="qr-error" id="qr-error" role="alert" hidden></p>
++ <div class="qr-actions">
++ <button class="btn btn-primary" id="qr-approve" type="button" onclick="qrSigninApprove()">Sign in</button>
++ <button class="btn btn-quiet" id="qr-deny" type="button" onclick="qrSigninDeny()">That's not me</button>
++ </div>
++ </div>
++
++ <div id="qr-done-view" hidden>
++ <div class="qr-done-check" aria-hidden="true">✓</div>
++ <p class="qr-done-text" id="qr-done-text">Signed in on your other device</p>
++ </div>
++ </div>
++
++ <div class="qr-right">
++ <ol class="qr-steps" id="qr-steps">
++ <li><span class="qr-step-n" aria-hidden="true">1</span><span class="qr-step-text">Open the camera on the device you want to sign in</span></li>
++ <li><span class="qr-step-n" aria-hidden="true">2</span><span class="qr-step-text">Point it at this code</span></li>
++ <li><span class="qr-step-n" aria-hidden="true">3</span><span class="qr-step-text">Type the number it shows</span></li>
++ </ol>
++ <p class="qr-note">Only the device that scanned the code gets the sign-in, and only this device can approve it.</p>
++ </div>
++ </div>
++</dialog>
++
<!-- Shared scripts -->
<script src="/shared/theme-loader.js"></script>
<script src="/shared/navbar.js"></script>
ddiff --git a/public/pay.html b/public/pay.html
nnew file mode 100644
iindex 00000000..6a6544df
---- /dev/null
++++ b/public/pay.html
@@@ -0,0 +1,41 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<meta http-equiv="X-UA-Compatible" content="IE=edge">
++<title>Vibes Pay - OpenVibe</title>
++<link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2216%22%20fill%3D%22%230d131d%22%2F%3E%3Cpath%20d%3D%22M18%2026h28M18%2032h28M18%2038h18%22%20stroke%3D%22%2360a5fa%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E">
++<meta name="theme-color" content="#0d131d">
++<meta name="color-scheme" content="dark light">
++<link rel="apple-touch-icon" href="/assets/logo-192.png">
++<meta name="description" content="Pay an OpenVibe app with Vibes.">
++<meta name="robots" content="noindex, nofollow">
++<meta property="og:type" content="website">
++<meta property="og:title" content="Vibes Pay - OpenVibe">
++<meta property="og:site_name" content="OpenVibe">
++<meta property="og:image" content="https://openvibe.network/shared/social/network.jpg">
++<meta name="twitter:card" content="summary_large_image">
++<meta name="twitter:image" content="https://openvibe.network/shared/social/network.jpg">
++<!--ov:look-boot-->
++<link rel="stylesheet" href="/shared/kit.css">
++<link rel="stylesheet" href="/css/wallet.css">
++</head>
++<body>
++<div id="navbar-mount"></div>
++
++<main class="w-pay-page" id="pay-page">
++ <div class="w-pay-card" id="w-pay-card" aria-live="polite">
++ <p class="w-pay-loading">Loading the payment…</p>
++ </div>
++</main>
++
++<script src="/shared/theme-loader.js"></script>
++<script src="/shared/navbar.js"></script>
++<script src="/shared/kit-web.js" defer></script>
++<script src="/js/wallet/money.js" defer></script>
++<script src="/js/wallet/common.js" defer></script>
++<script src="/js/wallet/sheets.js" defer></script>
++<script src="/js/wallet/pay.js" defer></script>
++</body>
++</html>
ddiff --git a/public/wallet.html b/public/wallet.html
nnew file mode 100644
iindex 00000000..01376080
---- /dev/null
++++ b/public/wallet.html
@@@ -0,0 +1,181 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<meta http-equiv="X-UA-Compatible" content="IE=edge">
++<title>Wallet - OpenVibe</title>
++<link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2216%22%20fill%3D%22%230d131d%22%2F%3E%3Cpath%20d%3D%22M18%2026h28M18%2032h28M18%2038h18%22%20stroke%3D%22%2360a5fa%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E">
++<meta name="theme-color" content="#0d131d">
++<meta name="color-scheme" content="dark light">
++<link rel="apple-touch-icon" href="/assets/logo-192.png">
++<link rel="manifest" href="/manifest.webmanifest">
++<meta name="description" content="Your OpenVibe wallet: Balance, Vibes and OpenCoins in one place.">
++<meta name="robots" content="noindex, nofollow">
++<meta property="og:type" content="website">
++<meta property="og:url" content="https://openvibe.network/wallet">
++<meta property="og:title" content="Wallet - OpenVibe">
++<meta property="og:description" content="Balance, Vibes and OpenCoins in one place.">
++<meta property="og:site_name" content="OpenVibe">
++<meta property="og:image" content="https://openvibe.network/shared/social/network.jpg">
++<meta property="og:image:alt" content="OpenVibe.Network: one account for every OpenVibe site">
++<meta name="twitter:card" content="summary_large_image">
++<meta name="twitter:image" content="https://openvibe.network/shared/social/network.jpg">
++<link rel="canonical" href="https://openvibe.network/wallet">
++<!--ov:look-boot-->
++<link rel="stylesheet" href="/shared/kit.css">
++<link rel="stylesheet" href="/css/wallet.css">
++</head>
++<body>
++<div id="navbar-mount"></div>
++
++<main class="ovk-page ovk-page--wide w-page" id="wallet-page">
++ <header class="w-head">
++ <h1>Wallet</h1>
++ <p>Everything you hold on OpenVibe, and where each one works.</p>
++ </header>
++
++ <p class="w-banner" id="w-banner" role="status" aria-live="polite" hidden></p>
++
++ <div class="w-cards" id="w-cards">
++ <section class="w-card w-card--balance" aria-labelledby="w-balance-h">
++ <div class="w-card-top">
++ <h2 id="w-balance-h">Balance</h2>
++ <span class="w-mark w-mark--balance" aria-hidden="true">$</span>
++ </div>
++ <p class="w-amount" id="w-balance-amount">—</p>
++ <p class="w-line">For AI, the API and pro tools</p>
++ <div class="w-actions">
++ <button type="button" class="ovk-btn ovk-btn--primary" id="w-add-money">Add money</button>
++ <button type="button" class="ovk-btn ovk-btn--quiet" id="w-move">Move to a project</button>
++ </div>
++ <p class="w-note" id="w-balance-note" hidden></p>
++ </section>
++
++ <section class="w-card w-card--vibes" aria-labelledby="w-vibes-h">
++ <div class="w-card-top">
++ <h2 id="w-vibes-h">Vibes</h2>
++ <span class="w-mark w-mark--vibes" data-glyph="vibes" aria-hidden="true"></span>
++ </div>
++ <p class="w-amount" id="w-vibes-amount">—</p>
++ <p class="w-line">For tips, cheers and fun stuff</p>
++ <div class="w-actions">
++ <button type="button" class="ovk-btn ovk-btn--primary" id="w-get-vibes">Get Vibes</button>
++ </div>
++ <p class="w-note" id="w-vibes-note" hidden></p>
++ </section>
++
++ <section class="w-card w-card--coins" aria-labelledby="w-coins-h">
++ <div class="w-card-top">
++ <h2 id="w-coins-h">OpenCoins</h2>
++ <span class="w-mark w-mark--coins" data-glyph="coins" aria-hidden="true"></span>
++ </div>
++ <p class="w-amount" id="w-coins-amount">—</p>
++ <p class="w-line">Earned by watching and chatting</p>
++ <div class="w-actions">
++ <a class="ovk-btn ovk-btn--quiet" href="https://inventory.openvibe.network/" target="_blank" rel="noopener">Spend your OpenCoins</a>
++ </div>
++ </section>
++ </div>
++
++ <section class="w-sec" aria-labelledby="w-projects-h">
++ <div class="w-sec-head">
++ <div>
++ <h2 id="w-projects-h">Your projects</h2>
++ <p class="w-sec-sub">Each project's own Balance, for its apps and API keys.</p>
++ </div>
++ </div>
++ <ul class="w-projects" id="w-projects"></ul>
++ <p class="w-empty" id="w-projects-empty" hidden>No projects yet. <a href="https://openvibe.services/" target="_blank" rel="noopener">Create one on OpenVibe.Services</a>.</p>
++ </section>
++
++ <section class="w-sec" aria-labelledby="w-activity-h">
++ <div class="w-sec-head w-sec-head--activity">
++ <div>
++ <h2 id="w-activity-h">Activity</h2>
++ <p class="w-sec-sub">Every movement in your Balance and Vibes.</p>
++ </div>
++ <div class="w-chips" role="group" aria-label="Filter activity">
++ <button type="button" class="w-chip" data-filter="all" aria-pressed="true">All</button>
++ <button type="button" class="w-chip" data-filter="balance" aria-pressed="false">Balance</button>
++ <button type="button" class="w-chip" data-filter="vibes" aria-pressed="false">Vibes</button>
++ </div>
++ </div>
++ <ul class="w-activity" id="w-activity" aria-live="polite"></ul>
++ <p class="w-empty" id="w-activity-empty" hidden>No activity yet. Add money or get Vibes and it will show here.</p>
++ </section>
++
++ <section class="w-sec w-where" aria-labelledby="w-where-h">
++ <h2 id="w-where-h">Where each works</h2>
++ <dl class="w-where-list">
++ <div class="w-where-row">
++ <dt>Balance</dt>
++ <dd><span class="w-tag">AI API</span><span class="w-tag">Exchange</span><span class="w-tag">Developer tools</span><span class="w-tag">Hosting</span><span class="w-tag">Enterprise plans</span></dd>
++ </div>
++ <div class="w-where-row">
++ <dt>Vibes</dt>
++ <dd><span class="w-tag">Tips</span><span class="w-tag">Cheers</span><span class="w-tag">Paid messages</span><span class="w-tag">Consumer AI</span><span class="w-tag">App items</span></dd>
++ </div>
++ <div class="w-where-row">
++ <dt>OpenCoins</dt>
++ <dd><span class="w-tag">Emotes</span><span class="w-tag">Themes</span><span class="w-tag">Cosmetics</span><span class="w-tag">Sounds</span></dd>
++ </div>
++ </dl>
++ </section>
++</main>
++
++<!-- Move money: from the Balance card (any project) or a project's Add (that project). -->
++<dialog class="ovk-sheet w-sheet" id="w-transfer-dialog" aria-labelledby="w-transfer-h">
++ <div class="ovk-sheet-head">
++ <h2 id="w-transfer-h">Move to a project</h2>
++ <button type="button" class="ovk-icon-btn" data-close aria-label="Close" title="Close">×</button>
++ </div>
++ <div class="ovk-sheet-body">
++ <p class="w-sheet-lede" id="w-transfer-lede">Move money from your Balance into a project's Balance.</p>
++ <label class="w-field" id="w-transfer-project-field" for="w-transfer-project"><span>Project</span>
++ <select id="w-transfer-project"></select>
++ </label>
++ <label class="w-field" for="w-transfer-amount"><span>Amount in dollars</span>
++ <input id="w-transfer-amount" type="text" inputmode="decimal" placeholder="5.00" autocomplete="off">
++ </label>
++ <p class="w-cost-line">Your Balance: <b id="w-transfer-have">—</b></p>
++ <button type="button" class="ovk-btn ovk-btn--primary w-sheet-go" id="w-transfer-go" disabled>Move money</button>
++ <p class="w-sheet-note" id="w-transfer-state" role="status" aria-live="polite"></p>
++ <button type="button" class="w-quiet w-sheet-cancel" data-close>Cancel</button>
++ </div>
++</dialog>
++
++<!-- Add money: a hosted checkout with a card or another provider. Never a card form here. -->
++<dialog class="ovk-sheet w-sheet" id="w-add-dialog" aria-labelledby="w-add-h">
++ <div class="ovk-sheet-head">
++ <h2 id="w-add-h">Add money</h2>
++ <button type="button" class="ovk-icon-btn" data-close aria-label="Close" title="Close">×</button>
++ </div>
++ <div class="ovk-sheet-body">
++ <p class="w-sheet-lede">You go to the provider's own secure checkout. Your Balance is credited when the payment confirms.</p>
++ <label class="w-field" for="w-add-amount"><span>Amount in dollars</span>
++ <input id="w-add-amount" type="text" inputmode="decimal" placeholder="20.00" autocomplete="off">
++ </label>
++ <label class="w-field" for="w-add-provider"><span>Pay with</span>
++ <select id="w-add-provider">
++ <option value="stripe">A card (Stripe)</option>
++ <option value="paypal">PayPal</option>
++ <option value="ccbill">CCBill</option>
++ <option value="nowpayments">Crypto (NOWPayments)</option>
++ </select>
++ </label>
++ <button type="button" class="ovk-btn ovk-btn--primary w-sheet-go" id="w-add-go">Continue to payment</button>
++ <p class="w-sheet-note" id="w-add-state" role="status" aria-live="polite"></p>
++ <button type="button" class="w-quiet w-sheet-cancel" data-close>Cancel</button>
++ </div>
++</dialog>
++
++<script src="/shared/theme-loader.js"></script>
++<script src="/shared/navbar.js"></script>
++<script src="/shared/kit-web.js" defer></script>
++<script src="/js/wallet/money.js" defer></script>
++<script src="/js/wallet/common.js" defer></script>
++<script src="/js/wallet/sheets.js" defer></script>
++<script src="/js/wallet/page.js" defer></script>
++</body>
++</html>
ddiff --git a/server/admin/ai-config-proxy.js b/server/admin/ai-config-proxy.js
nnew file mode 100644
iindex 00000000..d324b873
---- /dev/null
++++ b/server/admin/ai-config-proxy.js
@@@ -0,0 +1,518 @@
++'use strict';
++/**
++ * The owner's platform settings proxy (AI Exchange D11 S3; /home/workstation/openvibe/plan-state-2026-10-22/
++ * ai-exchange/OWNER-SETTINGS.md): the admin panel's AI tab reads and changes every service's openvibe-shared/config
++ * namespaces from here, and AI's provider registry through AI's own admin API.
++ *
++ * GET /api/admin/ai/config/services the services and their internal URLs
++ * GET /api/admin/ai/config/:service every config namespace of that service
++ * GET /api/admin/ai/config/:service/:namespace one namespace (common.config-namespace@1)
++ * GET /api/admin/ai/config/:service/:namespace/history its revisions
++ * POST /api/admin/ai/config/:service/:namespace apply common.config-change@1
++ * POST /api/admin/ai/config/:service/:namespace/rollback roll back to a revision
++ * PUT /api/admin/ai/config/:service/:namespace/secrets/:key set a secret (common.config-secret-put@1)
++ * DELETE /api/admin/ai/config/:service/:namespace/secrets/:key remove a secret
++ * GET|POST /api/admin/ai/config/providers AI's provider registry (ai.provider.manage)
++ * PATCH /api/admin/ai/config/providers/:key one provider
++ * PUT|DELETE /api/admin/ai/config/providers/:key/key its write-only key (bound to the provider origin)
++ *
++ * Every route is owner only. Each call goes to the service's /internal/config with Network's own service token for
++ * openvibe.<service> and the matching <service>.config.manage capability; the owner's usr_ id travels in
++ * X-OV-Subject, the way Network already vouches for a person to Bot (server/registry/machines.js). A secret write, or
++ * a change to a key whose schema says x-danger, needs the session's auth_time within ten minutes; otherwise the answer
++ * is 403 config.fresh_required and the panel sends the owner through sign-in and back to /admin#ai. When fresh,
++ * X-OV-Fresh: 1 goes to the service.
++ *
++ * Network keeps no value: the body is forwarded and dropped, never written to a table or a log, and the audit row
++ * names only the service, namespace, key, action and revision. Every secret write and every danger change raises an
++ * operator alert, again with names and never a value.
++ *
++ * test/ai-config-proxy.test.js
++ */
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++const { requireOwner } = require('../auth/owner-guard');
++const alerts = require('../operator/alerts');
++const { isFreshSignIn } = require('../identity/account-merge');
++
++const SERVICES = ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor'];
++const LABELS = {
++ ai: 'OpenVibe.AI', billing: 'OpenVibe.Billing', services: 'OpenVibe.Services', watch: 'OpenVibe.Watch',
++ bench: 'OpenVibe.Bench', report: 'OpenVibe.Report', host: 'OpenVibe.Host', codes: 'OpenVibe.Codes',
++ actor: 'OpenVibe.Actor',
++};
++const FRESH_S = 10 * 60;
++const WRITES_PER_HOUR = 60;
++const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
++const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ACCESS = new Set(['OWN_DEPLOYMENT', 'AUTHORIZED_WHOLESALE', 'CUSTOMER_BYOK', 'RESTRICTED', 'UNVERIFIED']);
++const SECRET_FIELDS = new Set(['api_key', 'secret', 'token', 'password', 'client_secret', 'access_token', 'authorization']);
++
++/** A refusal this proxy owns (the upstream's own refusals are relayed with their body). */
++class ProxyError extends Error {
++ constructor(status, code, detail, extra = {}) {
++ super(detail);
++ this.status = status;
++ this.code = code;
++ this.extra = extra;
++ }
++}
++
++const label = (service) => LABELS[service] || `OpenVibe.${String(service || '').replace(/^./, (c) => c.toUpperCase())}`;
++const jsonType = (type) => /^application\/([a-z.+-]+\+)?json\b/i.test(String(type || ''));
++
++function signInUrl(config = {}) {
++ const base = String(config.baseUrl || config.loginUrl || 'https://openvibe.network').replace(/\/+$/, '');
++ const login = String(config.loginUrl || base).replace(/\/+$/, '') + '/login';
++ return `${login}?return=${encodeURIComponent(`${base}/admin#ai`)}`;
++}
++
++/** The session's real sign-in time, the OIDC auth_time Network's signToken puts on every session token. The rule is
++ * account-merge's isFreshSignIn (auth_time within ten minutes, never a renewal and never a QR session carrying an
++ * old source time), so the owner settings and the account merge cannot drift apart. */
++function isFresh(req, now = Date.now()) {
++ return isFreshSignIn(req.tokenClaims, now);
++}
++
++/** The x-danger annotation of a key, from the namespace schema. */
++function dangerOf(summary, key) {
++ const props = summary && summary.schema && summary.schema.properties;
++ return !!(props && props[key] && props[key]['x-danger']);
++}
++
++/** A safe provider row: no value-looking field ever leaves this proxy, even if AI one day returns one by mistake. */
++function sanitizeProvider(p) {
++ if (!p || typeof p !== 'object') return p;
++ const out = {};
++ for (const [k, v] of Object.entries(p)) {
++ if (SECRET_FIELDS.has(k.toLowerCase())) continue;
++ out[k] = v;
++ }
++ const access = String(out.provider_access || (out.metadata && out.metadata.provider_access) || 'UNVERIFIED').toUpperCase();
++ out.provider_access = ACCESS.has(access) ? access : 'UNVERIFIED';
++ return out;
++}
++
++/** Replace a secret value anywhere in a string-shaped answer (a service must never echo it; if one does, it stops here). */
++function redact(value, secret) {
++ if (!secret || String(secret).length < 4) return value;
++ if (typeof value === 'string') return value.split(String(secret)).join('[redacted]');
++ if (Array.isArray(value)) return value.map((v) => redact(v, secret));
++ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, secret)]));
++ return value;
++}
++
++/**
++ * A service's summary as common.config-namespace@1 reads it. The pinned-keys member is accepted as the object the
++ * first 4.110 builds returned and normalised to the contract's array; `kek` and `secrets` are carried beside the
++ * validated summary (the tab's vault row needs the KEK id) but are not part of it.
++ */
++function normaliseSummary(raw) {
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { summary: raw, extra: {} };
++ const summary = { ...raw };
++ const extra = {};
++ if (summary.pins && !Array.isArray(summary.pins) && typeof summary.pins === 'object') {
++ extra.pin_env = Object.fromEntries(Object.entries(summary.pins).map(([k, p]) => [k, p && (p.env || p.name) || null]));
++ summary.pins = Object.keys(summary.pins);
++ }
++ if (summary.kek !== undefined) { extra.kek = summary.kek; delete summary.kek; }
++ if (summary.secrets !== undefined) { extra.secrets = summary.secrets; delete summary.secrets; }
++ // A response from before 4.110 has no sealed/pins/schema. It is not this contract's answer: keeping the tab empty
++ // is the honest state, and the UI says the service has not adopted the settings store yet.
++ return { summary, extra };
++}
++
++function createAiConfigProxy(opts = {}) {
++ const r = express.Router();
++ const log = opts.log || console;
++ const now = opts.now || (() => Date.now());
++ const writesPerHour = opts.writesPerHour || Number(process.env.AI_CONFIG_WRITES_PER_HOUR) || WRITES_PER_HOUR;
++
++ // Owner only on every route, even if a caller mounts this router without its own guard.
++ r.use(requireOwner);
++
++ // 60 writes an hour for the owner: a person cannot accidentally (or maliciously) rewrite the platform's settings
++ // faster than that. Reads are not limited; the response says how long to wait.
++ r.use(rateLimit({
++ windowMs: 60 * 60 * 1000,
++ limit: writesPerHour,
++ standardHeaders: true,
++ legacyHeaders: false,
++ skip: (req) => !['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method),
++ keyGenerator: (req) => `ai-config:${req.user && req.user.id != null ? req.user.id : req.ip}`,
++ handler: (_req, res) => send(res, 429, { ok: false, error: 'Too many settings changes this hour. Try again later.', code: 'config.rate_limited' }),
++ }));
++
++ const state = (req) => ({
++ db: opts.db || req.app.locals.db,
++ services: opts.services || (req.app.locals.config && req.app.locals.config.services) || {},
++ selfToken: opts.selfToken || req.app.locals.selfToken,
++ notificationService: opts.notificationService || req.app.locals.notificationService,
++ config: opts.config || req.app.locals.config || {},
++ });
++
++ /** The one outbound call site: a service internal URL from operator config, a fixed /internal or /api path. */
++ async function callService(req, service, pathName, { method = 'GET', body = null, cap = `${service}.config.manage`, fresh = false, secret = null, timeout = 8000 } = {}) {
++ const st = state(req);
++ const entry = st.services && st.services[service];
++ const base = entry && entry.internalUrl;
++ if (!base) throw new ProxyError(503, 'config.service_unavailable', `${label(service)} is not configured`);
++ let token = null;
++ try { token = typeof st.selfToken === 'function' ? st.selfToken(`openvibe.${service}`, [cap]) : null; } catch { token = null; }
++ if (!token) throw new ProxyError(503, 'config.token_unavailable', `Network cannot mint a token for ${label(service)}`);
++ const subject = String((req.user && req.user.subject_id) || '');
++ if (!SUBJECT_RE.test(subject)) throw new ProxyError(403, 'config.actor_required', 'the owner has no usr_ subject');
++ const headers = { accept: 'application/json', authorization: `Bearer ${token}`, 'x-ov-subject': subject };
++ if (fresh) headers['x-ov-fresh'] = '1';
++ if (body !== null) headers['content-type'] = 'application/json';
++ let upstream;
++ try {
++ upstream = await fetch(`${String(base).replace(/\/+$/, '')}${pathName}`, {
++ method,
++ headers,
++ redirect: 'error', // a service's answer is the answer: never follow it to another address
++ ...(body !== null ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(timeout),
++ });
++ } catch (err) {
++ // The message can carry the URL; it never carries a body.
++ log.warn(`[AiConfig] ${service} ${method} ${pathName}: ${err && err.name === 'TimeoutError' ? 'timed out' : 'did not answer'}`);
++ throw new ProxyError(502, 'config.service_unavailable', `${label(service)} did not answer`);
++ }
++ const type = upstream.headers.get('content-type') || '';
++ const text = await upstream.text();
++ let answer = null;
++ if (text) {
++ if (jsonType(type)) { try { answer = JSON.parse(text); } catch { answer = null; } }
++ if (answer === null) answer = { error: text.slice(0, 500) };
++ }
++ return { status: upstream.status, answer: secret ? redact(answer, secret) : answer };
++ }
++
++ function send(res, status, body) {
++ res.set('Cache-Control', 'no-store');
++ return res.status(status).json(body);
++ }
++ function fail(res, err) {
++ if (err instanceof ProxyError) return send(res, err.status, { ok: false, error: err.message, code: err.code, ...err.extra });
++ log.error(`[AiConfig] ${err && err.message ? err.message.split('\n')[0].slice(0, 200) : 'failed'}`);
++ return send(res, 500, { ok: false, error: 'The settings request failed', code: 'config.internal' });
++ }
++ const upstreamFailed = (res, service, out) => {
++ const answer = out.answer && typeof out.answer === 'object' ? out.answer : {};
++ return send(res, out.status, {
++ ok: false,
++ error: answer.detail || answer.error || `${label(service)} refused the change`,
++ code: answer.code || 'config.service_refused',
++ ...(answer.errors ? { errors: answer.errors } : {}),
++ });
++ };
++
++ /** The audit row: names, revision and action only — never a value, never a reason that could hold one. */
++ async function audit(req, { service, namespace, keys, action, revision = null }) {
++ const st = state(req);
++ if (!st.db || !req.user) return;
++ const list = [...new Set((keys || []).filter(Boolean))];
++ const details = { service, namespace, action, revision, keys: list, ...(list.length === 1 ? { key: list[0] } : {}) };
++ try {
++ await st.db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(req.user.id, 'ai_config_change', JSON.stringify(details));
++ } catch (err) {
++ log.warn(`[AiConfig] audit row failed: ${err.message}`);
++ }
++ }
++
++ /**
++ * One operator alert per change, for the owner and OPERATOR_ALERT_USERNAMES: the same notification type Host's
++ * alerts use, so it reaches the bell and web push and no block hides it. It names the service, namespace, key,
++ * action and actor; a value is never in it.
++ */
++ async function announce(req, { service, namespace, keys, action }) {
++ const st = state(req);
++ if (!st.notificationService || typeof st.notificationService.create !== 'function' || !st.db) return;
++ const who = req.user && req.user.username ? req.user.username : String((req.user && req.user.subject_id) || 'owner');
++ const list = [...new Set((keys || []).filter(Boolean))];
++ const message = `${label(service)} · ${namespace} · ${list.join(', ')} · ${action} by @${who}`.slice(0, 500);
++ let users = [];
++ try { users = await alerts.operatorUserIds(st.db); } catch { users = []; }
++ for (const uid of users) {
++ try {
++ await st.notificationService.create({
++ user_id: uid, type: 'OPERATOR_ALERT', category: 'admin', service: 'network', priority: 'high',
++ icon: '⚠️', silent: false, title: `AI settings changed: ${label(service)}`.slice(0, 120), message,
++ rich_content: { kind: 'ai_config_change', service, namespace, keys: list, action, actor: req.user && req.user.subject_id || null },
++ });
++ } catch (err) {
++ log.warn(`[AiConfig] operator alert failed: ${err.message}`);
++ }
++ }
++ }
++
++ const namespaceOf = (req, service) => {
++ const ns = String(req.params.namespace || '');
++ if (!NAMESPACE_RE.test(ns) || !ns.startsWith(`${service}.`)) {
++ throw new ProxyError(404, 'config.namespace_not_found', `no configuration namespace ${ns || '(none)'} for ${label(service)}`);
++ }
++ return ns;
++ };
++ const serviceOf = (req) => {
++ const service = String(req.params.service || '');
++ if (!SERVICES.includes(service)) throw new ProxyError(404, 'config.service_not_found', 'no such service');
++ return service;
++ };
++ const keyOf = (req) => {
++ const key = String(req.params.key || '');
++ if (!KEY_RE.test(key)) throw new ProxyError(400, 'config.bad_request', 'that is not a configuration key');
++ return key;
++ };
++
++ // ── The services (the owner's tab lists each one and its vault state) ──
++ r.get('/services', (req, res) => {
++ const st = state(req);
++ const out = SERVICES.map((service) => {
++ const entry = st.services && st.services[service] || {};
++ return { service, name: label(service), internal_url: entry.internalUrl || null, capability: `${service}.config.manage` };
++ });
++ send(res, 200, { ok: true, services: out });
++ });
++
++ // ── AI's provider registry (its own admin API, ai.provider.manage) ──
++ const providerPath = (suffix = '') => `/api/v1/providers${suffix}`;
++ async function providerCall(req, method, pathName, body = null, { fresh = false, secret = null } = {}) {
++ return await callService(req, 'ai', pathName, { method, body, cap: 'ai.provider.manage', fresh, secret });
++ }
++
++ r.get('/providers', async (req, res, next) => {
++ try {
++ const out = await providerCall(req, 'GET', providerPath());
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
++ const list = Array.isArray(out.answer && out.answer.providers) ? out.answer.providers.map(sanitizeProvider) : [];
++ send(res, 200, { ok: true, providers: list });
++ } catch (err) { fail(res, err); }
++ });
++
++ r.post('/providers', async (req, res) => {
++ try {
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ if (!body.key) throw new ProxyError(400, 'config.bad_request', 'a provider needs a key');
++ if (body.api_key !== undefined || body.secret !== undefined) throw new ProxyError(422, 'config.secret_write_only', 'send the key to its own write-only route, never here');
++ const out = await providerCall(req, 'POST', providerPath(), body);
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
++ await audit(req, { service: 'ai', namespace: 'ai.providers', keys: [body.key], action: 'provider.create', revision: null });
++ send(res, out.status, { ok: true, provider: sanitizeProvider(out.answer && out.answer.provider) });
++ } catch (err) { fail(res, err); }
++ });
++
++ r.patch('/providers/:key', async (req, res) => {
++ try {
++ const key = keyOf(req);
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ if (body.api_key !== undefined || body.secret !== undefined) throw new ProxyError(422, 'config.secret_write_only', 'send the key to its own write-only route, never here');
++ const out = await providerCall(req, 'PATCH', providerPath(`/${encodeURIComponent(key)}`), body);
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
++ await audit(req, { service: 'ai', namespace: 'ai.providers', keys: [key], action: 'provider.update', revision: null });
++ send(res, 200, { ok: true, provider: sanitizeProvider(out.answer && out.answer.provider) });
++ } catch (err) { fail(res, err); }
++ });
++
++ /** A provider key: AI's ai.provider_keys namespace (secret, bound to the provider's origin) and its secret_ref. */
++ async function providerSecret(req, res, action) {
++ const key = keyOf(req);
++ const st = state(req);
++ if (!isFresh(req, now())) {
++ return send(res, 403, {
++ ok: false, error: "Confirm it's you to change keys and money settings", code: 'config.fresh_required',
++ sign_in_url: signInUrl(st.config),
++ });
++ }
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ if (action === 'put' && (typeof body.value !== 'string' || !body.value)) {
++ throw new ProxyError(400, 'config.bad_request', 'the key is empty');
++ }
++ const secret = action === 'put' ? body.value : null;
++ const out = await callService(req, 'ai', `/internal/config/ai.provider_keys/secrets/${encodeURIComponent(key)}`, {
++ method: action === 'put' ? 'PUT' : 'DELETE',
++ body: action === 'put' ? { value: body.value, reason: String(body.reason || 'provider key from the admin panel').slice(0, 500) } : { reason: String(body.reason || 'provider key removed from the admin panel').slice(0, 500) },
++ fresh: true, secret,
++ });
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, 'ai', out);
++ // The provider now reads that namespace instead of env:NAME (or stops reading one).
++ const patch = await providerCall(req, 'PATCH', providerPath(`/${encodeURIComponent(key)}`), { secret_ref: action === 'put' ? `vault:${key}` : null });
++ if (patch.status < 200 || patch.status >= 300) return upstreamFailed(res, 'ai', patch);
++ await audit(req, { service: 'ai', namespace: 'ai.provider_keys', keys: [key], action: action === 'put' ? 'secret.put' : 'secret.delete', revision: out.answer && out.answer.revision });
++ await announce(req, { service: 'ai', namespace: 'ai.provider_keys', keys: [key], action: action === 'put' ? 'secret.put' : 'secret.delete' });
++ send(res, 200, { ok: true, marker: out.answer });
++ }
++
++ r.put('/providers/:key/key', async (req, res) => { try { await providerSecret(req, res, 'put'); } catch (err) { fail(res, err); } });
++ r.delete('/providers/:key/key', async (req, res) => { try { await providerSecret(req, res, 'delete'); } catch (err) { fail(res, err); } });
++
++ // ── A service's configuration namespaces ──
++ async function listNamespaces(req, res) {
++ try {
++ const service = serviceOf(req);
++ const out = await callService(req, service, '/internal/config');
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ const raw = out.answer && Array.isArray(out.answer.namespaces) ? out.answer.namespaces : [];
++ const namespaces = [];
++ const warnings = [];
++ let vault = null;
++ for (const item of raw) {
++ const { summary, extra } = normaliseSummary(item);
++ const checked = contracts.validate('common.config-namespace@1', summary);
++ if (!checked.valid || summary.service !== service) {
++ warnings.push({ service, namespace: summary && typeof summary.namespace === 'string' ? summary.namespace : null, reason: 'invalid-summary', detail: checked.valid ? `the summary is for ${summary.service}` : checked.errors.slice(0, 4).map((e) => `${e.path} ${e.message}`).join('; ') });
++ continue;
++ }
++ namespaces.push({ ...summary, ...(extra.pin_env ? { pin_env: extra.pin_env } : {}) });
++ if (vault === null && typeof summary.sealed === 'boolean') vault = { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null };
++ }
++ send(res, 200, { ok: true, service, namespaces, warnings, vault: vault || { sealed: null, kek: null } });
++ } catch (err) { fail(res, err); }
++ }
++ // The service called "services" would collide with GET /services (the list of services) if it were asked for as
++ // /services, so the list of its namespaces is at /service/services. Every other service works at both.
++ r.get('/service/:service', listNamespaces);
++ r.get('/:service', listNamespaces);
++
++ r.get('/:service/:namespace', async (req, res) => {
++ try {
++ const service = serviceOf(req);
++ const namespace = namespaceOf(req, service);
++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}`);
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ const { summary, extra } = normaliseSummary(out.answer);
++ const checked = contracts.validate('common.config-namespace@1', summary);
++ if (!checked.valid || summary.service !== service || summary.namespace !== namespace) {
++ return send(res, 200, {
++ ok: true, service, namespace: null,
++ warnings: [{ service, namespace, reason: 'invalid-summary', detail: checked.valid ? 'the summary names another namespace' : checked.errors.slice(0, 4).map((e) => `${e.path} ${e.message}`).join('; ') }],
++ vault: { sealed: null, kek: null },
++ });
++ }
++ send(res, 200, { ok: true, service, namespace: { ...summary, ...(extra.pin_env ? { pin_env: extra.pin_env } : {}) }, warnings: [], vault: { sealed: summary.sealed, kek: extra.kek !== undefined ? extra.kek : null } });
++ } catch (err) { fail(res, err); }
++ });
++
++ r.get('/:service/:namespace/history', async (req, res) => {
++ try {
++ const service = serviceOf(req);
++ const namespace = namespaceOf(req, service);
++ const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}/history${q}`);
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ send(res, out.status, out.answer);
++ } catch (err) { fail(res, err); }
++ });
++
++ r.post('/:service/:namespace/rollback', async (req, res) => {
++ try {
++ const service = serviceOf(req);
++ const namespace = namespaceOf(req, service);
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}/rollback`, { method: 'POST', body });
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ const revision = out.answer && Number.isInteger(out.answer.revision) ? out.answer.revision : null;
++ await audit(req, { service, namespace, keys: [], action: 'config.rollback', revision });
++ send(res, out.status, out.answer);
++ } catch (err) { fail(res, err); }
++ });
++
++ r.post('/:service/:namespace', async (req, res) => {
++ try {
++ const service = serviceOf(req);
++ const namespace = namespaceOf(req, service);
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const checked = contracts.validate('common.config-change@1', body);
++ if (!checked.valid) throw new ProxyError(400, 'config.bad_request', 'the change is not valid', { errors: checked.errors.slice(0, 8) });
++
++ // The schema and classification decide what is dangerous and what may never travel through POST.
++ const current = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}`);
++ if (current.status < 200 || current.status >= 300) return upstreamFailed(res, service, current);
++ const { summary } = normaliseSummary(current.answer);
++ if (!summary || summary.namespace !== namespace || !summary.schema) {
++ return send(res, 502, {
++ ok: false, error: `${label(service)} did not answer with a usable settings summary`, code: 'config.invalid_summary',
++ warnings: [{ service, namespace, reason: 'invalid-summary', detail: 'the service did not return this namespace\'s schema' }],
++ });
++ }
++ const props = summary.schema.properties || {};
++ const touched = body.merge === true ? [...new Set([...Object.keys(body.values || {}), ...((body.unset) || [])])] : Object.keys(props);
++ const classification = summary.classification || {};
++ const secretKeys = touched.filter((k) => classification[k] === 'secret');
++ if (secretKeys.length) {
++ throw new ProxyError(422, 'config.secret_write_only', `${secretKeys.join(', ')} ${secretKeys.length === 1 ? 'is a secret' : 'are secrets'}: use the write-only secret route`);
++ }
++ const danger = touched.filter((k) => dangerOf(summary, k));
++ if (danger.length && !isFresh(req, now())) {
++ return send(res, 403, {
++ ok: false, error: "Confirm it's you to change keys and money settings", code: 'config.fresh_required',
++ sign_in_url: signInUrl(state(req).config),
++ });
++ }
++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}`, {
++ method: 'POST', body, fresh: danger.length > 0,
++ });
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ const revision = out.answer && Number.isInteger(out.answer.revision) ? out.answer.revision : null;
++ await audit(req, { service, namespace, keys: touched, action: 'config.change', revision });
++ if (danger.length) await announce(req, { service, namespace, keys: danger, action: 'config.danger_change' });
++ send(res, out.status, out.answer);
++ } catch (err) { fail(res, err); }
++ });
++
++ r.put('/:service/:namespace/secrets/:key', async (req, res) => {
++ try {
++ const service = serviceOf(req);
++ const namespace = namespaceOf(req, service);
++ const key = keyOf(req);
++ if (!isFresh(req, now())) {
++ return send(res, 403, {
++ ok: false, error: "Confirm it's you to change keys and money settings", code: 'config.fresh_required',
++ sign_in_url: signInUrl(state(req).config),
++ });
++ }
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const checked = contracts.validate('common.config-secret-put@1', body);
++ if (!checked.valid) throw new ProxyError(400, 'config.bad_request', 'the secret is not valid', { errors: checked.errors.slice(0, 8) });
++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}/secrets/${encodeURIComponent(key)}`, {
++ method: 'PUT', body, fresh: true, secret: body.value,
++ });
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ await audit(req, { service, namespace, keys: [key], action: 'secret.put', revision: null });
++ await announce(req, { service, namespace, keys: [key], action: 'secret.put' });
++ send(res, out.status, out.answer);
++ } catch (err) { fail(res, err); }
++ });
++
++ r.delete('/:service/:namespace/secrets/:key', async (req, res) => {
++ try {
++ const service = serviceOf(req);
++ const namespace = namespaceOf(req, service);
++ const key = keyOf(req);
++ if (!isFresh(req, now())) {
++ return send(res, 403, {
++ ok: false, error: "Confirm it's you to change keys and money settings", code: 'config.fresh_required',
++ sign_in_url: signInUrl(state(req).config),
++ });
++ }
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await callService(req, service, `/internal/config/${encodeURIComponent(namespace)}/secrets/${encodeURIComponent(key)}`, {
++ method: 'DELETE', body: { reason: String(body.reason || 'removed from the admin panel').slice(0, 500) }, fresh: true,
++ });
++ if (out.status < 200 || out.status >= 300) return upstreamFailed(res, service, out);
++ await audit(req, { service, namespace, keys: [key], action: 'secret.delete', revision: null });
++ await announce(req, { service, namespace, keys: [key], action: 'secret.delete' });
++ send(res, out.status, out.answer);
++ } catch (err) { fail(res, err); }
++ });
++
++ return r;
++}
++
++module.exports = {
++ createAiConfigProxy, SERVICES, FRESH_S, WRITES_PER_HOUR, signInUrl, isFresh, dangerOf, normaliseSummary,
++ sanitizeProvider, redact,
++};
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex bd2cac66..87392a5f 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -313,6 +313,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
'email_from_openvibelive', 'email_from_openvibegames', 'email_from_openvibenetwork',
]);
const owner = isOwner(req.user);
++ const journal = (await siteConfig.forDb(db)).store;
for (const r of rows) {
if (EMAIL_MANAGED_KEYS.has(r.key)) continue;
// Provider secrets are environment-only (server/secrets.js): a database row is never used or
@@@ -328,11 +329,15 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
settings[r.key] = { value: r.value, type: r.type, source: src, env: secrets.envName(r.key), secret: secrets.isSecret(r.key) };
continue;
}
-- // Secrets (API keys / tokens / credentials) are owner-only — mask for admins.
++ // A non-provider secret-class value lives in the configuration journal (D11); the legacy row
++ // may be a blank slot after the boot migration. Secrets stay owner-only — mask for admins.
++ const stored = (r.type === 'secret' || isSensitiveSettingKey(r.key) || siteConfig.isSecret(r.key))
++ ? journal.get(r.key) : undefined;
++ const value = stored !== undefined ? stored : r.value;
if (!owner && (r.type === 'secret' || isSensitiveSettingKey(r.key))) {
-- settings[r.key] = { value: maskSecret(r.value), type: r.type, redacted: true };
++ settings[r.key] = { value: maskSecret(value), type: r.type, redacted: true };
} else {
-- settings[r.key] = { value: r.value, type: r.type };
++ settings[r.key] = { value, type: r.type };
}
}
res.json({ ok: true, settings });
@@@ -988,10 +993,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
router.get('/net-config', requireOwner, async (req, res) => {
try {
++ const journal = (await siteConfig.forDb(db)).store;
const config = {};
for (const key of NET_SETTING_KEYS) {
-- const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key);
-- config[key] = row ? row.value : '';
++ const value = journal.get(key);
++ config[key] = value == null ? '' : String(value);
}
// Mask tokens for display
for (const key of NET_SETTING_KEYS) {
@@@ -1013,7 +1019,10 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
// Skip if masked
if (value?.startsWith('••••')) return res.json({ ok: true, skipped: true });
-- await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(key, String(value), 'secret');
++ // D11: the token is a secret-class value; it lives in the sealed configuration journal, never in
++ // site_settings. The legacy row stays a blank slot.
++ await (await siteConfig.forDb(db)).change({ set: { [key]: String(value) }, types: { [key]: 'secret' } },
++ { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `net config ${key}`).slice(0, 300) });
await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'net_config_update', JSON.stringify({ key })
);
@@@ -1023,6 +1032,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
});
++ // ── The owner's platform settings (AI Exchange D11 S3) ────────────────────────────────────────────────
++ // /api/admin/ai/config: every service's openvibe-shared/config namespaces and AI's provider registry, as the
++ // owner (server/admin/ai-config-proxy.js). Owner only on every route; the proxy re-checks, and a write carries the
++ // owner in X-OV-Subject, with a fresh sign-in for keys and x-danger money settings.
++ router.use('/ai/config', requireOwner, require('./ai-config-proxy').createAiConfigProxy({ db, notificationService }));
++
return router;
};
ddiff --git a/server/admin/site-config.js b/server/admin/site-config.js
iindex 36ae6c61..f5b1f80b 100644
---- a/server/admin/site-config.js
++++ b/server/admin/site-config.js
@@@ -18,6 +18,7 @@
* push key pair (vapid_*), which a rollback must never swap.
*/
const config = require('openvibe-shared/config');
++const secrets = require('../secrets');
const NOT_CONFIG = /^(migr_|vapid_)/;
const SECRET = /(api[_-]?key|secret|token|password|private[_-]?key|access_key)/i;
@@@ -26,6 +27,7 @@ const SYSTEM = { type: 'service', id: 'network' };
const byDb = new WeakMap();
const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
++const isSecret = (key) => SECRET.test(key) || secrets.isSecret(key);
/** The journal for one database handle. */
async function forDb(db) {
@@@ -46,6 +48,7 @@ async function forDb(db) {
const classify = (key) => (secretRows.has(key) || SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
let pendingTypes = {}; // the types the change being applied names for new rows
++ const isSecretRow = (k) => SECRET.test(k) || secretRows.has(k) || secrets.isSecret(k);
async function writeRows(target) {
const now = await rowsNow();
const upd = db.prepare('UPDATE site_settings SET value = ? WHERE key = ?');
@@@ -54,10 +57,15 @@ async function forDb(db) {
await db.tx(async () => {
for (const [k, v] of Object.entries(target)) {
if (!isConfigKey(k)) continue;
++ // D11: a secret-class value never goes into site_settings; its row may stay as a blank, typed slot.
++ if (isSecretRow(k)) {
++ if (!(k in now)) await ins.run(k, '', pendingTypes[k] || types.get(k) || 'string');
++ continue;
++ }
if (!(k in now)) await ins.run(k, String(v), pendingTypes[k] || types.get(k) || 'string');
else if (now[k] !== String(v)) await upd.run(String(v), k);
}
-- for (const k of Object.keys(now)) if (!(k in target)) await del.run(k);
++ for (const k of Object.keys(now)) if (!(k in target) && !isSecretRow(k)) await del.run(k);
});
}
@@@ -72,12 +80,17 @@ async function forDb(db) {
log: { info: (m) => console.log(`[Config] ${m}`), warn: (m) => console.warn(`[Config] ${m}`), error: (m) => console.error(`[Config] ${m}`) },
});
const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
++ const nonSecret = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !isSecretRow(k)));
-- /** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */
++ /** Record non-secret rows written around the journal as a revision of their own. → the sync snapshot, or null */
async function sync() {
const now = await rowsNow();
-- if (canonical({ ...store.get() }) === canonical(now)) return null;
-- return store.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
++ const current = { ...store.get() };
++ const next = {};
++ for (const [k, v] of Object.entries(current)) if (isSecretRow(k)) next[k] = v; // secrets stay in the store
++ for (const [k, v] of Object.entries(now)) if (!isSecretRow(k)) next[k] = v;
++ if (canonical(nonSecret(current)) === canonical(nonSecret(next))) return null;
++ return store.apply(next, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
}
/** Change settings as one revision: set (key → value), types (key → row type for new rows), unset (keys). */
@@@ -89,7 +102,13 @@ async function forDb(db) {
const values = {};
for (const [k, v] of Object.entries(set)) values[k] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '');
pendingTypes = t;
-- try { return await store.apply(values, { merge: true, unset, actor, reason }); } finally { pendingTypes = {}; }
++ try {
++ const snap = await store.apply(values, { merge: true, unset, actor, reason });
++ // An explicit unset drops the store's secret; blank its legacy slot too (env-only provider secrets
++ // are never touched: their row is ignored and the environment is the source).
++ for (const k of unset) if (isSecretRow(k) && !secrets.isSecret(k)) await db.prepare('UPDATE site_settings SET value = ? WHERE key = ?').run('', k);
++ return snap;
++ } finally { pendingTypes = {}; }
}
async function rollback({ actor = SYSTEM, reason = null, to } = {}) {
@@@ -102,9 +121,63 @@ async function forDb(db) {
return out;
}
++/** The value of a secret-class key from a loaded journal, without touching the legacy row. */
++function readSecret(db, key) {
++ const out = byDb.get(db);
++ if (!out) return undefined;
++ if (out.store.classify(key, undefined) !== 'secret') return undefined;
++ return out.store.get(key);
++}
++
++/**
++ * D11: with a KEK, every secret-class row the sealed store holds is blanked (read-back and marker must agree).
++ * A provider secret managed by server/secrets.js is environment-only: never moved, never blanked. Without a KEK
++ * nothing is blanked; without a sealed copy, or with a locked envelope, the row is kept. Counts only.
++ */
++async function migrateLegacySecrets(db, out) {
++ const rows = await db.prepare('SELECT key, value, type FROM site_settings').all();
++ const candidates = rows.filter((r) => r.value != null && String(r.value) !== ''
++ && (r.type === 'secret' || SECRET.test(r.key)) && !secrets.isSecret(r.key));
++ if (!candidates.length) return { checked: 0, blanked: 0, differs: 0, kept: 0, sealed: !!out.store.sealed };
++ if (!out.store.sealed) {
++ console.log(`[Config] legacy site_settings: ${candidates.length} secret value(s) left (no KEK; the store is not sealed)`);
++ return { checked: candidates.length, blanked: 0, differs: 0, kept: candidates.length, sealed: false };
++ }
++ const summary = await out.store.summary();
++ const snap = summary.active;
++ const upd = db.prepare('UPDATE site_settings SET value = ? WHERE key = ?');
++ let blanked = 0, differs = 0;
++ for (const r of candidates) {
++ const stored = out.store.get(r.key);
++ const marker = summary.secrets[r.key] || (typeof out.store.secretMarker === 'function' ? await out.store.secretMarker(r.key) : null);
++ const sealedRow = snap && snap.values ? snap.values[r.key] : null;
++ const proven = stored !== undefined && stored !== null && String(stored) !== ''
++ && marker && sealedRow && sealedRow.redacted === true
++ && marker.fingerprint && marker.fingerprint === sealedRow.fingerprint;
++ // Never drop a value the store does not hold: the sealed copy must be exactly this legacy value.
++ if (!proven || String(stored) !== String(r.value)) { differs++; continue; }
++ await upd.run('', r.key);
++ blanked++;
++ }
++ console.log(`[Config] legacy site_settings: ${blanked} secret value(s) blanked (the store holds the same value); ${differs} kept (value differs or no sealed copy)${out.store.kek ? `; KEK ${out.store.kek}` : ''}`);
++ return { checked: candidates.length, blanked, differs, kept: differs, sealed: true };
++}
++
++let boot = null;
++/** Boot: load the journal, seal old plaintext rows, then blank the legacy copies the sealed store holds. */
++function init(db) {
++ if (boot) return boot;
++ boot = (async () => {
++ const out = await forDb(db);
++ if (out.store.sealed) await out.store.reseal();
++ return await migrateLegacySecrets(db, out);
++ })().catch((err) => { boot = null; throw err; });
++ return boot;
++}
++
/** A person as the journal records them. */
function actorOf(user) {
return user && user.subject_id ? { type: 'user', id: String(user.subject_id) } : SYSTEM;
}
--module.exports = { forDb, actorOf, isConfigKey, NOT_CONFIG };
++module.exports = { forDb, init, readSecret, migrateLegacySecrets, isSecret, actorOf, isConfigKey, NOT_CONFIG };
ddiff --git a/server/auth/qr-signin.js b/server/auth/qr-signin.js
nnew file mode 100644
iindex 00000000..255bd296
---- /dev/null
++++ b/server/auth/qr-signin.js
@@@ -0,0 +1,405 @@
++'use strict';
++/**
++ * Sign in on another device (owner, 2026-11-04; NETWORK_QR_SIGNIN, off by default).
++ *
++ * A signed-in source device starts a transfer (POST /auth/qr/start). The QR carries
++ * https://<network>/q#<id>.<secret>; the secret lives in the fragment, so no server log or
++ * Referer ever sees it. The target device reads the fragment, posts it to /auth/qr/scan, and gets a
++ * two-digit match code plus an httpOnly ov_qr cookie (path /auth/qr) that binds the transfer to
++ * that browser. The source device approves the match code from the session that created the transfer;
++ * the target's next /auth/qr/status poll creates an ordinary Network session (the same signToken +
++ * setSessionCookies a password sign-in uses), exactly once.
++ *
++ * Security notes:
++ * - secret, match code and ov_qr nonce are stored only as SHA-256;
++ * - every unknown id, bad secret or expired transfer answers the same 404;
++ * - approval needs the creating session (its jti), not merely the same person;
++ * - status completes only with the ov_qr cookie and an atomic approved -> used update;
++ * - nothing here logs a secret, a code, an email or an IP, and audit_log details carry none of them.
++ */
++
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const express = require('express');
++const rateLimit = require('express-rate-limit');
++const QRCode = require('qrcode');
++const { setSessionCookies } = require('./session');
++const accountMerge = require('../identity/account-merge');
++
++const COOKIE = 'ov_qr';
++const COOKIE_PATH = '/auth/qr';
++const COOKIE_MAX_AGE_MS = 300 * 1000;
++const TTL_MS = 120 * 1000;
++const MAX_LIVE = 3;
++const MAX_ATTEMPTS = 3;
++const ID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
++const SECRET_RE = /^[0-9a-f]{32}$/;
++const CODE_RE = /^[0-9]{2}$/;
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++
++/** Is the feature on? Config reads NETWORK_QR_SIGNIN (server/config.js); off means the router is not mounted. */
++function enabled(config = require('../config')) {
++ return !!(config && config.qrSignin && config.qrSignin.enabled);
++}
++
++/** A ULID: 48-bit millisecond timestamp + 80 random bits, Crockford base32. */
++function newUlid(ms = Date.now()) {
++ let time = '';
++ let t = ms;
++ for (let i = 0; i < 10; i++) { time = CROCKFORD[t % 32] + time; t = Math.floor(t / 32); }
++ let rand = '';
++ for (let i = 0; i < 16; i++) rand += CROCKFORD[crypto.randomInt(0, 32)];
++ return time + rand;
++}
++
++const sha256 = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
++const secretHash = (secret) => sha256(`qr-secret:${secret}`);
++const codeHash = (code) => sha256(`qr-code:${code}`);
++const nonceHash = (nonce) => sha256(`qr-nonce:${nonce}`);
++
++function sameHash(stored, presented) {
++ const a = Buffer.from(String(stored || ''), 'hex');
++ const b = Buffer.from(String(presented || ''), 'hex');
++ return a.length > 0 && a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/** "iPhone · Safari" from the User-Agent. Browser and OS only: never an IP, never the raw UA. */
++function deviceSummary(userAgent) {
++ const ua = String(userAgent || '');
++ let device = 'Device';
++ if (/iPhone/i.test(ua)) device = 'iPhone';
++ else if (/iPad/i.test(ua)) device = 'iPad';
++ else if (/Android/i.test(ua)) device = 'Android';
++ else if (/Macintosh|Mac OS X/i.test(ua)) device = 'Mac';
++ else if (/Windows/i.test(ua)) device = 'Windows';
++ else if (/Linux/i.test(ua)) device = 'Linux';
++ let browser = 'Browser';
++ if (/EdgA?\//i.test(ua)) browser = 'Edge';
++ else if (/SamsungBrowser/i.test(ua)) browser = 'Samsung Internet';
++ else if (/Firefox|FxiOS/i.test(ua)) browser = 'Firefox';
++ else if (/Chrome|CriOS/i.test(ua)) browser = 'Chrome';
++ else if (/Safari/i.test(ua)) browser = 'Safari';
++ return `${device} · ${browser}`.slice(0, 80);
++}
++
++/** The source session that created a transfer: its jti, or a stable hash for a token minted before jti. */
++function creatorOf(req) {
++ const jti = req.tokenClaims && req.tokenClaims.jti;
++ return jti ? String(jti) : `tok:${sha256(req.token || '')}`;
++}
++
++/** A same-origin browser request. No Origin (curl, server-to-server) is allowed; a foreign one is not. */
++function sameOrigin(req, config) {
++ const origin = req.headers && req.headers.origin;
++ if (!origin) return true;
++ let got;
++ try { got = new URL(origin); } catch { return false; }
++ if (got.protocol !== 'https:' && got.protocol !== 'http:') return false;
++ const allowed = new Set();
++ for (const raw of [config.networkUrl, config.baseUrl, config.loginUrl]) {
++ try { allowed.add(new URL(raw).origin); } catch { /* unset */ }
++ }
++ // The request's own host (local development, and a white-label install that names no URL). In
++ // production the configured https origin above is the rule; an http spelling from a proxy is not added.
++ if (req.get && req.get('host')) {
++ const candidate = `${req.protocol}://${req.get('host')}`;
++ if (config.nodeEnv !== 'production' || allowed.has(candidate)) allowed.add(candidate);
++ }
++ return allowed.has(got.origin);
++}
++
++async function audit(db, userId, action, details) {
++ try {
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(userId, action, JSON.stringify(details));
++ } catch { /* audit is best effort: it never fails a sign-in */ }
++}
++
++function publicUser(user) {
++ return { id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null };
++}
++
++function createQrSignin({ db, config, privateKey, signToken, requireAuth, notifications = null, now = () => Date.now() }) {
++ const router = express.Router();
++ router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++
++ // A browser POST must come from this Network origin, whatever first-party CORS would otherwise allow.
++ router.use((req, res, next) => {
++ if (req.method === 'POST' && !sameOrigin(req, config)) return res.status(403).json({ error: 'origin not allowed' });
++ next();
++ });
++
++ // 10 starts per person per hour. Signed-out requests never reach it (requireAuth first).
++ const startLimit = rateLimit({
++ windowMs: 60 * 60 * 1000,
++ max: 10,
++ standardHeaders: true,
++ legacyHeaders: false,
++ validate: false,
++ keyGenerator: (req) => `qr-start:${req.user ? req.user.id : 'none'}`,
++ handler: (_req, res) => res.status(429).json({ error: 'too_many_codes', error_description: 'You have asked for 10 codes this hour. Try again later.' }),
++ });
++
++ const notFound = (res) => res.status(404).json({ error: 'not_found' });
++ const liveSince = () => new Date(now()).toISOString();
++
++ async function transferById(id) {
++ if (!ID_RE.test(String(id || ''))) return null;
++ return await db.prepare(`SELECT t.*, u.username, u.display_name, u.avatar_url, u.is_banned, u.deleted_at, u.merged_into
++ FROM qr_signin_transfers t JOIN users u ON u.id = t.user_id WHERE t.id = ?`).get(String(id));
++ }
++
++ // ── Start (source device, signed in) ───────────────────────
++ router.post('/start', requireAuth, startLimit, async (req, res) => {
++ const id = newUlid(now());
++ const secret = crypto.randomBytes(16).toString('hex');
++ const expiresAt = new Date(now() + TTL_MS).toISOString();
++ // The target session inherits this, never a new "now": scanning a code is not a re-authentication.
++ const sourceAuthTime = Number.isFinite(Number(req.tokenClaims && req.tokenClaims.auth_time))
++ ? Math.trunc(Number(req.tokenClaims.auth_time)) : null;
++ const base = config.networkUrl || config.baseUrl || 'https://openvibe.network';
++ const url = new URL('/q', base);
++ url.hash = `${id}.${secret}`;
++
++ let qrSvg;
++ try {
++ qrSvg = await QRCode.toString(url.toString(), {
++ type: 'svg', errorCorrectionLevel: 'M', margin: 2, width: 232,
++ color: { dark: '#0d0d14', light: '#ffffff' },
++ });
++ } catch {
++ return res.status(500).json({ error: 'qr_unavailable' });
++ }
++
++ const created = await db.tx(async (t) => {
++ // One row lock per person: two tabs cannot both take the third live slot, and the hourly
++ // count in audit_log holds across processes (the in-memory limiter above is only a shortcut).
++ await t.prepare('SELECT id FROM users WHERE id = ? FOR UPDATE').get(req.user.id);
++ const starts = (await t.prepare("SELECT COUNT(*) AS n FROM qr_signin_starts WHERE user_id = ? AND created_at > datetime(ov_now(), '-1 hour')").get(req.user.id)).n;
++ if (Number(starts) >= 10) return 'rate';
++ const live = (await t.prepare("SELECT COUNT(*) AS n FROM qr_signin_transfers WHERE user_id = ? AND status IN ('waiting','scanned','approved') AND expires_at > ?").get(req.user.id, liveSince())).n;
++ if (Number(live) >= MAX_LIVE) return 'live';
++ await t.prepare('INSERT INTO qr_signin_starts (user_id) VALUES (?)').run(req.user.id);
++ await t.prepare(`INSERT INTO qr_signin_transfers (id, user_id, secret_hash, creator_jti, source_auth_time, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?)`).run(id, req.user.id, secretHash(secret), creatorOf(req), sourceAuthTime, expiresAt);
++ await t.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(req.user.id, 'qr_signin_started', JSON.stringify({ transfer_id: id, device: null, outcome: 'waiting' }));
++ return 'ok';
++ });
++ if (created === 'live') return res.status(429).json({ error: 'too_many_codes', error_description: 'Finish or cancel a code you already have first.' });
++ if (created !== 'ok') return res.status(429).json({ error: 'too_many_codes', error_description: 'You have asked for 10 codes this hour. Try again later.' });
++
++ res.status(201).json({ id, expires_at: expiresAt, url: url.toString(), qr_svg: qrSvg });
++ });
++
++ // ── Scan (target device, public but secret-gated) ──────────
++ router.post('/scan', async (req, res) => {
++ const id = String((req.body && req.body.id) || '').trim();
++ const secret = String((req.body && req.body.secret) || '').trim();
++ if (!ID_RE.test(id) || !SECRET_RE.test(secret)) return notFound(res);
++
++ const row = await transferById(id);
++ if (!row) return notFound(res);
++ if (row.expires_at <= liveSince()) return notFound(res);
++ if (!sameHash(row.secret_hash, secretHash(secret))) return notFound(res);
++ if (row.status !== 'waiting') return res.status(409).json({ error: 'already used on another device' });
++
++ const code = String(crypto.randomInt(10, 100));
++ const nonce = crypto.randomBytes(16).toString('hex');
++ const targetDevice = deviceSummary(req.headers['user-agent']);
++ const scannedAt = new Date(now()).toISOString();
++ const won = await db.prepare(`UPDATE qr_signin_transfers SET status = 'scanned', code_hash = ?, target_nonce_hash = ?,
++ device_summary = ?, scanned_at = ? WHERE id = ? AND status = 'waiting' AND expires_at > ?`)
++ .run(codeHash(code), nonceHash(nonce), targetDevice, scannedAt, id, liveSince());
++ if (!won.changes) {
++ const after = await transferById(id);
++ if (!after || after.expires_at <= liveSince()) return notFound(res);
++ return res.status(409).json({ error: 'already used on another device' });
++ }
++
++ res.cookie(COOKIE, nonce, {
++ httpOnly: true, secure: true, sameSite: 'lax', path: COOKIE_PATH, maxAge: COOKIE_MAX_AGE_MS,
++ });
++ await audit(db, row.user_id, 'qr_signin_scanned', { transfer_id: id, device: targetDevice, outcome: 'scanned' });
++ res.json({ code, user_hint: { display_name: row.display_name || row.username, avatar_url: row.avatar_url || null } });
++ });
++
++ // ── Target status (ov_qr cookie) ───────────────────────────
++ // Defined before /:id so "status" is not read as a transfer id.
++ router.get('/status', async (req, res) => {
++ const nonce = String((req.cookies && req.cookies[COOKIE]) || '');
++ if (!/^[0-9a-f]{32}$/.test(nonce)) return notFound(res);
++ const row = await db.prepare(`SELECT t.*, u.username, u.display_name, u.avatar_url, u.is_banned, u.deleted_at, u.merged_into
++ FROM qr_signin_transfers t JOIN users u ON u.id = t.user_id WHERE t.target_nonce_hash = ?`).get(nonceHash(nonce));
++ if (!row) return notFound(res);
++ if (row.expires_at <= liveSince()) {
++ res.clearCookie(COOKIE, { path: COOKIE_PATH, sameSite: 'lax', secure: true, httpOnly: true });
++ return notFound(res);
++ }
++
++ if (row.status === 'waiting') return res.json({ state: 'waiting' });
++ if (row.status === 'scanned') return res.json({ state: 'scanned' });
++ if (row.status === 'denied') {
++ res.clearCookie(COOKIE, { path: COOKIE_PATH, sameSite: 'lax', secure: true, httpOnly: true });
++ return res.json({ state: 'denied' });
++ }
++ if (row.status === 'used') {
++ res.clearCookie(COOKIE, { path: COOKIE_PATH, sameSite: 'lax', secure: true, httpOnly: true });
++ return res.json({ state: 'used', signed_in: false });
++ }
++
++ // status === 'approved': exactly one poll may create the session.
++ const result = await db.tx(async (t) => {
++ const user = await t.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
++ if (!user || user.is_banned || user.deleted_at) {
++ await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);
++ return { denied: true };
++ }
++ const survivor = await accountMerge.effectiveUser(t, user);
++ if (!survivor || survivor.is_banned || survivor.deleted_at) {
++ await t.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status = 'approved'").run(row.id);
++ return { denied: true };
++ }
++ const won = await t.prepare(`UPDATE qr_signin_transfers SET status = 'used', used_at = ?
++ WHERE id = ? AND status = 'approved' AND expires_at > ?`).run(new Date(now()).toISOString(), row.id, liveSince());
++ if (!won.changes) return { lost: true };
++
++ // A QR-created session is not a fresh sign-in: inherit the source session's auth_time (or none),
++ // so ADR-029's merge check and D11's key/money check still require a real re-authentication.
++ const authTime = row.source_auth_time == null ? null : Number(row.source_auth_time);
++ const token = signToken(survivor, privateKey, config, { authTime });
++ // The device summary only: no raw User-Agent and no IP in the target's session row.
++ await t.prepare(`INSERT INTO user_sessions (user_id, session_token, device_name, expires_at)
++ VALUES (?, ?, ?, ?)`).run(
++ survivor.id, crypto.randomUUID(), row.device_summary || 'Device',
++ new Date(now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
++ );
++ return { token, user: survivor };
++ });
++
++ if (result.denied) {
++ res.clearCookie(COOKIE, { path: COOKIE_PATH, sameSite: 'lax', secure: true, httpOnly: true });
++ return res.json({ state: 'denied' });
++ }
++ if (result.lost || !result.token) {
++ res.clearCookie(COOKIE, { path: COOKIE_PATH, sameSite: 'lax', secure: true, httpOnly: true });
++ return res.json({ state: 'used', signed_in: false });
++ }
++
++ setSessionCookies(res, result.token);
++ res.clearCookie(COOKIE, { path: COOKIE_PATH, sameSite: 'lax', secure: true, httpOnly: true });
++ await audit(db, result.user.id, 'qr_signin_used', { transfer_id: row.id, device: row.device_summary || 'Device', outcome: 'used' });
++ if (notifications) {
++ try {
++ await notifications.create({
++ user_id: result.user.id,
++ type: 'GENERIC',
++ category: 'system',
++ priority: 'high',
++ title: 'New sign-in',
++ message: `${row.device_summary || 'Device'} signed in by QR from your other device. If this was not you, sign out everywhere and change your password.`,
++ service: 'network',
++ url: 'https://openvibe.network/my',
++ });
++ } catch { /* the sign-in stands even if the notice cannot be stored */ }
++ }
++ res.json({ state: 'used', signed_in: true, user: publicUser(result.user) });
++ });
++
++ // ── Poll (source device, creating session) ─────────────────
++ router.get('/:id', requireAuth, async (req, res) => {
++ const row = await transferById(req.params.id);
++ if (!row || row.expires_at <= liveSince()) return notFound(res);
++ if (Number(row.user_id) !== Number(req.user.id)) return notFound(res);
++ if (row.creator_jti !== creatorOf(req)) return res.status(403).json({ error: 'not_the_creating_session' });
++ res.json({ id: row.id, state: row.status, device: row.device_summary || null, expires_at: row.expires_at });
++ });
++
++ // ── Approve (source device, creating session) ──────────────
++ router.post('/:id/approve', requireAuth, async (req, res) => {
++ const row = await transferById(req.params.id);
++ if (!row || row.expires_at <= liveSince()) return notFound(res);
++ if (Number(row.user_id) !== Number(req.user.id)) return notFound(res);
++ if (row.creator_jti !== creatorOf(req)) return res.status(403).json({ error: 'not_the_creating_session' });
++ if (row.status === 'approved') return res.json({ state: 'approved' });
++ if (row.status === 'used') return res.status(409).json({ error: 'already_used' });
++ if (row.status === 'denied') return res.status(403).json({ error: 'denied' });
++ if (row.status !== 'scanned') return res.status(409).json({ error: 'not_scanned' });
++
++ const code = String((req.body && req.body.code) || '').trim();
++ if (!CODE_RE.test(code)) return res.status(400).json({ error: 'code_required', attempts_left: MAX_ATTEMPTS - Number(row.attempts || 0) });
++
++ if (sameHash(row.code_hash, codeHash(code))) {
++ const won = await db.prepare("UPDATE qr_signin_transfers SET status = 'approved', approved_at = ? WHERE id = ? AND status = 'scanned'")
++ .run(new Date(now()).toISOString(), row.id);
++ if (!won.changes) {
++ const after = await transferById(row.id);
++ if (after && after.status === 'approved') return res.json({ state: 'approved' });
++ return res.status(409).json({ error: 'already_used' });
++ }
++ await audit(db, req.user.id, 'qr_signin_approved', { transfer_id: row.id, device: row.device_summary || null, outcome: 'approved' });
++ return res.json({ state: 'approved' });
++ }
++
++ // A wrong number counts, atomically: exactly three wrong attempts deny the transfer.
++ const result = await db.tx(async (t) => {
++ const current = await t.prepare('SELECT status, attempts FROM qr_signin_transfers WHERE id = ? FOR UPDATE').get(row.id);
++ if (!current || current.status !== 'scanned') return { status: current ? current.status : 'missing' };
++ const attempts = Number(current.attempts || 0) + 1;
++ const denied = attempts >= MAX_ATTEMPTS;
++ await t.prepare('UPDATE qr_signin_transfers SET attempts = ?, status = ? WHERE id = ?')
++ .run(attempts, denied ? 'denied' : 'scanned', row.id);
++ return { status: denied ? 'denied' : 'scanned', attempts, denied };
++ });
++ if (result.denied) {
++ await audit(db, req.user.id, 'qr_signin_denied', { transfer_id: row.id, device: row.device_summary || null, outcome: 'denied' });
++ return res.status(403).json({ error: 'denied', attempts_left: 0 });
++ }
++ if (result.status !== 'scanned') return res.status(409).json({ error: 'not_scanned' });
++ res.status(400).json({ error: 'code_mismatch', attempts_left: Math.max(0, MAX_ATTEMPTS - result.attempts) });
++ });
++
++ // ── Deny (source device, creating session) ─────────────────
++ router.post('/:id/deny', requireAuth, async (req, res) => {
++ const row = await transferById(req.params.id);
++ if (!row || row.expires_at <= liveSince()) return notFound(res);
++ if (Number(row.user_id) !== Number(req.user.id)) return notFound(res);
++ if (row.creator_jti !== creatorOf(req)) return res.status(403).json({ error: 'not_the_creating_session' });
++ if (row.status === 'used') return res.status(409).json({ error: 'already_used' });
++ if (row.status !== 'denied') {
++ await db.prepare("UPDATE qr_signin_transfers SET status = 'denied' WHERE id = ? AND status IN ('waiting','scanned','approved')").run(row.id);
++ await audit(db, req.user.id, 'qr_signin_denied', { transfer_id: row.id, device: row.device_summary || null, outcome: 'denied' });
++ }
++ res.json({ state: 'denied' });
++ });
++
++ return { router, sweep: (opts) => sweep(db, opts), enabled: enabled(config), page: pageHandler };
++}
++
++/** Delete transfers past their two minutes; the audit_log keeps what happened. */
++async function sweep(db, { now = Date.now() } = {}) {
++ const expired = new Date(now).toISOString();
++ const transfers = (await db.prepare('DELETE FROM qr_signin_transfers WHERE expires_at < ?').run(expired)).changes;
++ const starts = (await db.prepare("DELETE FROM qr_signin_starts WHERE created_at < datetime(ov_now(), '-2 hours')").run()).changes;
++ return transfers + starts;
++}
++
++/** The public /q page: static markup plus the shared look-boot script, never cached. */
++let targetPageHtml = null;
++function targetPage() {
++ if (!targetPageHtml) {
++ const lookBoot = require('../look-boot');
++ targetPageHtml = lookBoot.apply(fs.readFileSync(path.join(__dirname, '..', 'qr-signin', 'q.html'), 'utf8'));
++ }
++ return targetPageHtml;
++}
++function pageHandler(_req, res) {
++ res.set('Cache-Control', 'no-store');
++ res.type('html').send(targetPage());
++}
++
++module.exports = {
++ createQrSignin, sweep, enabled, pageHandler, deviceSummary, newUlid, sameOrigin, sameHash,
++ COOKIE, COOKIE_PATH, TTL_MS, MAX_LIVE, MAX_ATTEMPTS,
++};
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex a97e746d..41c51195 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -44,9 +44,11 @@ const DEFAULT_AUDIENCE = ['openvibe.live', 'openvibe.tools', 'openvibe.games', '
/**
* A person's session token. auth_time (OIDC) is when they actually signed in: now for a sign-in, register or
* password change; a renewal passes `renew` (the claims of the token it replaces) and keeps that token's
-- * auth_time, or none when it had none. An account merge needs a real sign-in within 10 minutes (ADR-029).
++ * auth_time, or none when it had none. A QR-created session passes `authTime` so it inherits the source's
++ * sign-in time (or null for none): it is not itself a fresh sign-in. An account merge and the owner's
++ * key/money checks need a real sign-in within 10 minutes (ADR-029, D11).
*/
--function signToken(user, privateKey, config, { renew = null } = {}) {
++function signToken(user, privateKey, config, { renew = null, authTime } = {}) {
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
// Audience always includes the contract service domains, plus the issuer
// host and any registry-configured service hosts (white-label installs).
@@@ -61,6 +63,14 @@ function signToken(user, privateKey, config, { renew = null } = {}) {
try { audienceSet.add(new URL(url).hostname); } catch { /* ignore */ }
}
const audience = [...audienceSet];
++ // jti names the session and survives sliding renewals: a QR sign-in is approved only by the
++ // exact session that started it (req.tokenClaims.jti), never by another session of the person.
++ const jti = renew && renew.jti ? renew.jti : crypto.randomBytes(16).toString('hex');
++ // `authTime === null` is a deliberate "no auth_time" (a QR source that had none); otherwise an
++ // explicit value wins, and the old renewal/new-sign-in rules stand.
++ const signInTime = authTime === null
++ ? undefined
++ : (authTime !== undefined ? (Number.isFinite(Number(authTime)) ? Number(authTime) : undefined) : (renew ? (Number.isFinite(renew.auth_time) ? renew.auth_time : undefined) : Math.floor(Date.now() / 1000)));
return jwt.sign(
{
@@@ -73,7 +83,7 @@ function signToken(user, privateKey, config, { renew = null } = {}) {
avatar_url: user.avatar_url,
profile_color: user.profile_color,
...staffClaims(user),
-- auth_time: renew ? (Number.isFinite(renew.auth_time) ? renew.auth_time : undefined) : Math.floor(Date.now() / 1000),
++ auth_time: signInTime,
},
privateKey,
{
@@@ -81,6 +91,7 @@ function signToken(user, privateKey, config, { renew = null } = {}) {
issuer: config.jwt.issuer,
audience,
expiresIn: config.jwt.accessTokenExpiry,
++ jwtid: jti,
}
);
}
@@@ -390,6 +401,7 @@ router.get('/me', requireAuth, async (req, res) => {
res.json({
user: sanitizeUser(req.user),
preferences: prefs || { theme_id: 'vibe' },
++ features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },
});
});
ddiff --git a/server/config.js b/server/config.js
iindex 76eeb8a3..75b9d91f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -26,6 +26,12 @@ module.exports = {
},
++ // Sign in on another device (owner, 2026-11-04; server/auth/qr-signin.js): a QR on the source device, a
++ // scan on the target and a two-digit match. Off unless NETWORK_QR_SIGNIN is explicitly on; off means the routes and /q are not mounted.
++ qrSignin: {
++ enabled: /^(1|true|on|yes)$/i.test(String(process.env.NETWORK_QR_SIGNIN || '')),
++ },
++
// Project secrets (ADR-056, server/developer/secrets.js): 32 random bytes, base64 or hex. Unset = the feature is off
// (503 secrets.unavailable); values are only ever stored encrypted under keys derived from it.
secrets: {
@@@ -51,6 +57,10 @@ module.exports = {
// First-party services (OAuth client ids) that may host a project member's agents (comma list).
// Unset = the code default (actor: server/developer/policy.js); set to an empty value = none.
agentHostServices: process.env.AGENT_HOST_SERVICES,
++ // Developer API keys and per-project budgets (plan G-P3, server/developer/api-keys.js).
++ // Off unless NETWORK_DEV_API_KEYS=1: the key lifecycle, the key exchange and the budget routes
++ // answer 503 api_keys.disabled while it is off.
++ apiKeys: process.env.NETWORK_DEV_API_KEYS === '1',
},
// OpenVibe.Events base URL for Network's own events (developer projects). Unset = no relay:
@@@ -134,5 +144,32 @@ module.exports = {
bot: {
internalUrl: process.env.OV_BOT_INTERNAL_URL || 'http://127.0.0.1:4630',
},
++ // The owner's settings proxy (server/admin/ai-config-proxy.js, D11 S3): each service's /internal/config,
++ // reached with Network's own token for openvibe.<service> and the owner in X-OV-Subject. Four are already
++ // above (billing); these are the rest. The URLs are operator config: never a request value.
++ ai: {
++ internalUrl: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700',
++ },
++ services: {
++ internalUrl: process.env.OV_SERVICES_INTERNAL_URL || 'http://127.0.0.1:4930',
++ },
++ watch: {
++ internalUrl: process.env.OV_WATCH_INTERNAL_URL || 'http://127.0.0.1:4730',
++ },
++ bench: {
++ internalUrl: process.env.OV_BENCH_INTERNAL_URL || 'http://127.0.0.1:5230',
++ },
++ report: {
++ internalUrl: process.env.OV_REPORT_INTERNAL_URL || 'http://127.0.0.1:5080',
++ },
++ host: {
++ internalUrl: process.env.OV_HOST_INTERNAL_URL || 'http://127.0.0.1:4910',
++ },
++ codes: {
++ internalUrl: process.env.OV_CODES_INTERNAL_URL || 'http://127.0.0.1:4900',
++ },
++ actor: {
++ internalUrl: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
++ },
},
};
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 1dcafa8d..06faa847 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -409,6 +409,11 @@ function attachHelpers(db) {
const fromEnv = secrets.fromEnv(key);
if (fromEnv !== null) return fromEnv;
if (secrets.isSecret(key)) return null;
++ // A non-provider secret-class value lives in the configuration journal (D11), not the legacy row.
++ try {
++ const stored = require('../admin/site-config').readSecret(db, key);
++ if (stored !== undefined) return stored;
++ } catch { /* the journal is not loaded (a test before boot): fall through to the row */ }
const row = await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
if (!row) return null;
if (row.type === 'boolean') return row.value === 'true';
ddiff --git a/server/developer/api-keys.js b/server/developer/api-keys.js
nnew file mode 100644
iindex 00000000..85eb9db9
---- /dev/null
++++ b/server/developer/api-keys.js
@@@ -0,0 +1,353 @@
++'use strict';
++/**
++ * Developer API keys and per-project budgets (plan G-P3, ADR-014; Network stays the identity authority).
++ *
++ * A developer API key is a project-bound credential: a person mints it in a project, sees the secret
++ * once, and uses it as a bearer credential against OpenVibe.AI. Only sha256(secret) is stored; the
++ * secret is returned exactly once, never stored, never logged and never written to the audit.
++ *
++ * people (project routes, Bearer user tokens; developer/routes.js)
++ * list viewer+ and staff: metadata only, never the secret
++ * create developer+ (admin+ for a live key): { name, app_id, scopes, environment }
++ * rotate the same key id with a new secret, shown once; the old secret fails at once
++ * revoke the key stops authenticating at once
++ *
++ * services (POST /internal/api-keys/exchange, internal/routes.js)
++ * exchange a service token (network.project.read) presents the key a caller sent and Network
++ * answers with the facts and a short-lived scoped token: the project's tenancy and the
++ * key's environment and scopes. A revoked or unknown key is refused at once.
++ *
++ * The key is bound to a project AND to one of that project's apps, so the token it exchanges for is
++ * an ordinary app token (sub app:app_<ULID>, project_id, ns, env). Revoking the app revokes its keys
++ * with it, and archiving the project does the same.
++ *
++ * Scopes are Network's own vocabulary (below), not capability ids: a key says what it may do, and
++ * the exchange turns the scopes into the token's cap claim (the capability id of the scope) for the
++ * audience the scope belongs to. A key may not exchange for an audience its scopes do not cover.
++ *
++ * The per-project monthly budget is an integer number of nano-USD (D1: 1e-9 USD, never a float),
++ * kept as a decimal string so it is exact. NULL means the project set no ceiling. The owning
++ * service (OpenVibe.AI) reads it with its service token (GET /internal/projects/:project_id/budget).
++ */
++const crypto = require('crypto');
++const { ids, serviceAuth, assertValid, capabilities } = require('openvibe-contracts');
++const store = require('./store');
++
++const { DevError } = store;
++const fail = (status, code, detail) => { throw new DevError(status, code, detail); };
++const nowIso = () => new Date().toISOString();
++
++const KEY_PREFIX = 'ovk_';
++const KEY_ID_RE = /^key_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ENVIRONMENTS = ['live', 'test'];
++/** The token environment a key's environment means (Network's sandbox/production). */
++const ENV_TO_TOKEN_ENV = { live: 'production', test: 'sandbox' };
++const TOKEN_TTL_S = 300;
++const MAX_KEYS_PER_PROJECT = 50;
++
++/**
++ * The scope vocabulary. A scope names what a key may do; `capability` is the id that goes into the
++ * token's cap claim and `audience` is the service that serves it.
++ *
++ * `ai.inference` is the OpenVibe.AI developer-inference scope (plan D8: the public capability
++ * ai.app.run, the public capability developer apps already hold: ADR-014; D8'). The capability id is used even
++ * when the installed contracts release is older and does not know it yet: the claim schema accepts
++ * it, and the receiver that knows the capability is the one that acts on it.
++ */
++const SCOPES = Object.freeze({
++ 'ai.inference': Object.freeze({
++ // `grants`, not `capability:`: openvibe-contracts-check reads `capability: '<id>'` as a capability this service
++ // enforces, and this one is AI's (the exchanged token carries it to openvibe.ai).
++ grants: 'ai.app.run', // the public capability apps already call AI with (ADR-014; AI Exchange D8')
++ audience: 'openvibe.ai',
++ description: 'Call OpenVibe.AI inference (POST /v1/chat/completions, POST /v1/messages, GET /v1/models)',
++ }),
++});
++
++const scopeIds = () => Object.keys(SCOPES).sort();
++
++/** The feature is off unless NETWORK_DEV_API_KEYS=1 (server/config.js). */
++function requireEnabled(config) {
++ const on = !!(config && config.developer && config.developer.apiKeys);
++ if (!on) fail(503, 'api_keys.disabled', 'developer API keys are off on this Network (NETWORK_DEV_API_KEYS)');
++}
++
++// ── The secret ───────────────────────────────────────────────
++
++/** A new key: 32 random bytes, base64url, behind the ovk_ prefix. Only its hash is stored. */
++function newKeySecret() {
++ const secret = KEY_PREFIX + crypto.randomBytes(32).toString('base64url');
++ return { secret, hash: crypto.createHash('sha256').update(secret).digest('hex'), hint: secret.slice(-4) };
++}
++
++const hashSecret = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++
++function cleanName(v) {
++ const s = String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, '').trim();
++ if (!s || s.length > 80) fail(422, 'api_key.invalid', 'name must be 1-80 characters');
++ return s;
++}
++
++function cleanEnvironment(v) {
++ const e = String(v || 'test');
++ if (!ENVIRONMENTS.includes(e)) fail(422, 'api_key.invalid', 'environment is live or test');
++ return e;
++}
++
++/** The scopes a key may hold: a non-empty subset of the vocabulary, in a stable order. */
++function cleanScopes(list) {
++ if (!Array.isArray(list) || !list.length) fail(422, 'api_key.invalid', 'scopes must be a non-empty array');
++ if (list.length > 10) fail(422, 'api_key.invalid', 'at most 10 scopes');
++ const wanted = [...new Set(list.map(String))].sort();
++ for (const id of wanted) {
++ if (!SCOPES[id]) fail(422, 'api_key.unknown_scope', `unknown scope ${id}; known scopes: ${scopeIds().join(', ')}`);
++ }
++ return wanted;
++}
++
++// ── Views ────────────────────────────────────────────────────
++
++function keyView(row) {
++ return {
++ id: row.id, name: row.name, project_id: row.project_id, app_id: row.app_id, hint: row.hint,
++ scopes: JSON.parse(row.scopes || '[]'), environment: row.environment, status: row.status,
++ created_at: row.created_at, rotated_at: row.rotated_at || null, revoked_at: row.revoked_at || null,
++ last_used_at: row.last_used_at || null,
++ };
++}
++
++async function loadKey(db, projectId, keyId) {
++ const row = KEY_ID_RE.test(String(keyId)) ? await db.prepare('SELECT * FROM dev_api_keys WHERE id = ? AND project_id = ?').get(String(keyId), projectId) : null;
++ if (!row) fail(404, 'api_key.not_found', 'no such key');
++ return row;
++}
++
++// ── People: the key lifecycle ────────────────────────────────
++
++/** Members (viewer+) and staff: the project's keys, metadata only. */
++async function list(db, actor, projectId, { config } = {}) {
++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
++ requireEnabled(config); // after access: a stranger gets the project's 404, never a hint the feature exists
++ const rows = await db.prepare('SELECT * FROM dev_api_keys WHERE project_id = ? ORDER BY id DESC').all(project.id);
++ return rows.map(keyView);
++}
++
++/**
++ * Mint a key. The secret is in the answer of this call and nowhere else: only sha256(secret) is
++ * stored. A live key needs an admin+ member and an app the project may run in production; a test key
++ * needs a developer+ member and a sandbox app.
++ */
++async function create(db, actor, projectId, body, { ctx, config }) {
++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
++ requireEnabled(config); // after access: a stranger gets the project's 404, never a hint the feature exists
++ const environment = cleanEnvironment(body.environment);
++ const manage = environment === 'live' ? 'admin' : 'developer';
++ await store.access(db, actor, projectId, { need: manage, staffOk: false, allowArchived: true });
++ const appId = String(body.app_id || '');
++ const app = await store.loadApp(db, project.id, appId);
++ if (app.revoked_at) fail(409, 'app.revoked', 'app is revoked');
++ const appEnvironment = environment === 'live' ? 'production' : 'sandbox';
++ if (app.environment !== appEnvironment) {
++ fail(422, 'api_key.invalid', `a ${environment} key belongs to a ${appEnvironment} app; this app is ${app.environment}`);
++ }
++ const scopes = cleanScopes(body.scopes);
++ const count = (await db.prepare('SELECT COUNT(*) AS n FROM dev_api_keys WHERE project_id = ?').get(project.id)).n;
++ if (count >= MAX_KEYS_PER_PROJECT) fail(409, 'api_key.limit', `at most ${MAX_KEYS_PER_PROJECT} keys per project`);
++ const id = `key_${ids.ulid()}`;
++ const secret = newKeySecret();
++ await db.tx(async () => {
++ await db.prepare(`INSERT INTO dev_api_keys (id, project_id, app_id, name, key_hash, hint, scopes, environment, status, created_at, created_by)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`)
++ .run(id, project.id, app.id, cleanName(body.name), secret.hash, secret.hint, JSON.stringify(scopes), environment, nowIso(), actor.label);
++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'api_key.created', target: `key:${id}`,
++ detail: { key_id: id, app_id: app.id, scopes, environment, hint: secret.hint }, ctx });
++ });
++ return { ...keyView(await db.prepare('SELECT * FROM dev_api_keys WHERE id = ?').get(id)), api_key: secret.secret, shown_once: true };
++}
++
++/**
++ * Rotate: the same key id with a new secret, shown once. The old secret fails from that moment
++ * (a key is a bearer credential in a request header, where an overlap would only widen the window).
++ */
++async function rotate(db, actor, projectId, keyId, { ctx, config }) {
++ const { project } = await store.access(db, actor, projectId, { need: 'viewer', staffOk: false, allowArchived: true });
++ requireEnabled(config); // after access: a stranger gets the project's 404, never a hint the feature exists
++ const row = await loadKey(db, project.id, keyId);
++ if (row.status !== 'active') fail(409, 'api_key.revoked', 'key is revoked');
++ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(row.app_id);
++ await store.access(db, actor, projectId, { need: app && app.environment === 'production' ? 'admin' : 'developer', staffOk: false, allowArchived: true });
++ const secret = newKeySecret();
++ await db.tx(async () => {
++ await db.prepare('UPDATE dev_api_keys SET key_hash = ?, hint = ?, rotated_at = ? WHERE id = ?').run(secret.hash, secret.hint, nowIso(), row.id);
++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'api_key.rotated', target: `key:${row.id}`,
++ detail: { key_id: row.id, app_id: row.app_id, scopes: JSON.parse(row.scopes || '[]'), environment: row.environment, hint: secret.hint }, ctx });
++ });
++ return { ...keyView(await db.prepare('SELECT * FROM dev_api_keys WHERE id = ?').get(row.id)), api_key: secret.secret, shown_once: true };
++}
++
++/** Revoke: the key stops authenticating at once. */
++async function revoke(db, actor, projectId, keyId, { ctx, config }) {
++ const { project } = await store.access(db, actor, projectId, { need: 'viewer', staffOk: true, allowArchived: true });
++ requireEnabled(config); // after access: a stranger gets the project's 404, never a hint the feature exists
++ const row = await loadKey(db, project.id, keyId);
++ if (row.status !== 'revoked') {
++ await db.tx(async () => {
++ await db.prepare('UPDATE dev_api_keys SET status = ?, revoked_at = ?, revoked_by = ? WHERE id = ?').run('revoked', nowIso(), actor.label, row.id);
++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'api_key.revoked', target: `key:${row.id}`,
++ detail: { key_id: row.id, app_id: row.app_id, scopes: JSON.parse(row.scopes || '[]'), environment: row.environment }, ctx });
++ });
++ }
++ return keyView(await db.prepare('SELECT * FROM dev_api_keys WHERE id = ?').get(row.id));
++}
++
++/** Revoke every key of an app (its app is revoked). Called inside the app's transaction. */
++async function revokeForApp(db, appId, { actor, reason, ctx }) {
++ const rows = await db.prepare("SELECT * FROM dev_api_keys WHERE app_id = ? AND status = 'active'").all(appId);
++ for (const row of rows) {
++ await db.prepare('UPDATE dev_api_keys SET status = ?, revoked_at = ?, revoked_by = ? WHERE id = ?').run('revoked', nowIso(), actor, row.id);
++ await store.audit(db, { projectId: row.project_id, actor, action: 'api_key.revoked', target: `key:${row.id}`,
++ detail: { key_id: row.id, app_id: appId, scopes: JSON.parse(row.scopes || '[]'), environment: row.environment, reason: reason || null }, ctx });
++ }
++ return rows.length;
++}
++
++// ── Services: the exchange ───────────────────────────────────
++
++/**
++ * The key a caller presented, if it is live. The lookup is by hash: an unknown, revoked or
++ * malformed key is the same 401, so a guess learns nothing.
++ */
++async function matchKey(db, presented) {
++ const raw = String(presented || '');
++ if (!raw.startsWith(KEY_PREFIX) || raw.length > 200) return null;
++ const want = Buffer.from(hashSecret(raw), 'hex');
++ const row = await db.prepare('SELECT * FROM dev_api_keys WHERE key_hash = ?').get(hashSecret(raw));
++ if (!row || row.status !== 'active') return null;
++ const stored = Buffer.from(row.key_hash, 'hex');
++ if (stored.length !== want.length || !crypto.timingSafeEqual(stored, want)) return null;
++ return row;
++}
++
++/** The audience a set of scopes covers. */
++const audiencesOf = (scopes) => [...new Set(scopes.map(s => (SCOPES[s] || {}).audience).filter(Boolean))];
++
++/**
++ * The scopes the app may use: the key's scopes, narrowed by the app's APPROVED grants. Fail closed: a
++ * scope whose capability the installed contracts release does not know, or that the app was not granted,
++ * is dropped (a key never stands in for a grant).
++ */
++async function scopesForApp(db, appId, scopes) {
++ const out = [];
++ for (const scope of scopes) {
++ const capability = (SCOPES[scope] || {}).grants;
++ if (!capability || !capabilities.get(capability)) continue;
++ const grant = await db.prepare("SELECT status FROM dev_grants WHERE app_id = ? AND capability = ?").get(appId, capability);
++ if (!grant || grant.status !== 'approved') continue;
++ out.push(scope);
++ }
++ return out;
++}
++
++/**
++ * Exchange a presented key for the facts and a short-lived scoped token.
++ *
++ * - the key must be live (a revoked key is refused at once);
++ * - the audience must be one the key's scopes cover (the scope is enforced here, by Network);
++ * - the token is an app token of the key's app: sub app:app_<ULID>, project_id, ns, env
++ * (live -> production, test -> sandbox), cap = the capability ids of the key's scopes.
++ *
++ * The token is signed with Network's key, so the receiver verifies it offline exactly as it does an
++ * app token today. Nothing here is ever logged: the key itself, its hash and the token stay out of
++ * the logs, the audit carries ids and scopes only.
++ */
++async function exchange(db, presented, { audience, privateKey, issuer, ctx }) {
++ const row = await matchKey(db, presented);
++ if (!row) fail(401, 'api_key.invalid', 'unknown or revoked key');
++ const scopes = JSON.parse(row.scopes || '[]');
++ const aud = String(audience || '').trim();
++ if (!aud) fail(400, 'api_key.invalid_audience', 'audience is required');
++ // The scope is enforced here, by Network: the key may only be used at an audience its scopes cover.
++ if (!audiencesOf(scopes).includes(aud)) fail(403, 'api_key.scope_denied', `this key may not be used at ${aud}`);
++ const app = await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(row.app_id);
++ if (!app || app.revoked_at) fail(401, 'api_key.invalid', 'the key\'s app is revoked');
++ const project = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(row.project_id);
++ if (!project || project.archived_at) fail(401, 'api_key.invalid', 'the key\'s project is archived');
++ // The key's scopes, narrowed by what the app holds (the narrowing bites once the installed
++ // contracts release knows the scope's capability).
++ const held = await scopesForApp(db, app.id, scopes);
++ if (!held.length) fail(403, 'api_key.scope_denied', 'this key holds no scope the app may use');
++ if (!audiencesOf(held).includes(aud)) fail(403, 'api_key.scope_denied', `this key may not be used at ${aud}`);
++ const cap = held.map(s => SCOPES[s].grants).filter(Boolean);
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap,
++ ns: [project.id, `app.${project.id}.*`], project_id: project.id,
++ env: ENV_TO_TOKEN_ENV[row.environment],
++ iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ assertValid('identity.service-token-claims@1', claims);
++ const token = serviceAuth.signServiceToken(claims, privateKey);
++ await db.prepare('UPDATE dev_api_keys SET last_used_at = ? WHERE id = ?').run(nowIso(), row.id);
++ await store.audit(db, { projectId: project.id, actor: 'system:network', action: 'api_key.exchanged', target: `key:${row.id}`,
++ detail: { key_id: row.id, app_id: app.id, audience: aud, scopes: held, environment: row.environment, jti: claims.jti, expires_at: new Date(claims.exp * 1000).toISOString() }, ctx });
++ return {
++ access_token: token, token_type: 'Bearer', expires_in: TOKEN_TTL_S,
++ project_id: project.id, app_id: app.id, subject: claims.sub, environment: row.environment,
++ token_env: claims.env, scopes: held, capability: cap, jti: claims.jti,
++ };
++}
++
++// ── The per-project monthly budget ───────────────────────────
++
++/** A budget is an integer number of nano-USD (D1), kept as a decimal string: never a float. */
++function cleanBudget(v) {
++ if (v === null || v === undefined || v === '') return null;
++ const s = String(v).trim();
++ if (!/^(0|[1-9][0-9]{0,24})$/.test(s)) fail(422, 'budget.invalid', 'monthly_budget_nano_usd is a whole number of nano-USD (a decimal string)');
++ return s;
++}
++
++/** The project's budget as the API answers it. */
++function budgetView(project) {
++ return {
++ project_id: project.id,
++ monthly_budget_nano_usd: project.monthly_budget_nano_usd || null,
++ currency: 'nano_usd',
++ updated_at: project.budget_updated_at || null,
++ };
++}
++
++/** Members (admin+) and staff: the project's monthly budget. */
++async function readBudget(db, actor, projectId, { config } = {}) {
++ const { project } = await store.access(db, actor, projectId, { need: 'admin', allowArchived: true });
++ requireEnabled(config); // after access: a stranger gets the project's 404, never a hint the feature exists
++ return budgetView(project);
++}
++
++/** Set or clear the project's monthly budget (admin+ members and staff). */
++async function writeBudget(db, actor, projectId, body, { ctx, config } = {}) {
++ const { project } = await store.access(db, actor, projectId, { need: 'admin', allowArchived: true });
++ requireEnabled(config); // after access: a stranger gets the project's 404, never a hint the feature exists
++ const value = cleanBudget(body ? body.monthly_budget_nano_usd : null);
++ await db.tx(async () => {
++ await db.prepare('UPDATE dev_projects SET monthly_budget_nano_usd = ?, budget_updated_at = ?, budget_updated_by = ? WHERE id = ?')
++ .run(value, nowIso(), actor.label, project.id);
++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'project.budget_set', target: project.id,
++ detail: { from: project.monthly_budget_nano_usd || null, to: value, currency: 'nano_usd' }, ctx });
++ });
++ return budgetView(await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id));
++}
++
++/** The budget as a service sees it (GET /internal/projects/:project_id/budget). */
++async function internalBudget(db, projectId) {
++ if (!store.PROJECT_ID_RE.test(String(projectId))) return null;
++ const project = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(String(projectId));
++ return project ? budgetView(project) : null;
++}
++
++module.exports = {
++ KEY_PREFIX, ENVIRONMENTS, ENV_TO_TOKEN_ENV, SCOPES, TOKEN_TTL_S, MAX_KEYS_PER_PROJECT,
++ requireEnabled, scopeIds, keyView, list, create, rotate, revoke, revokeForApp,
++ matchKey, exchange, audiencesOf,
++ cleanBudget, budgetView, readBudget, writeBudget, internalBudget,
++};
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex 101d3fa4..bed756a4 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -75,6 +75,7 @@ const policy = require('./policy');
const tokens = require('./tokens');
const usage = require('./usage');
const secrets = require('./secrets');
++const apiKeys = require('./api-keys');
/**
* The base of a user API: problems with request ids, JSON bodies, private no-store, and Bearer Network user tokens
@@@ -105,7 +106,7 @@ function userApi() {
/** handle(fn, status): fn(db, actor, req, { ctx, settings }) → JSON body, or 204 for undefined; errors as problems. */
function handler() {
-- const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config) });
++ const ctxOf = (req) => ({ ctx: req.ov, settings: policy.settings(req.app.locals.config), config: req.app.locals.config });
return (fn, status = 200) => async (req, res) => {
try {
const out = await fn(req.app.locals.db, req.actor, req, ctxOf(req));
@@@ -202,6 +203,17 @@ function router() {
return await store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
}));
++ // Developer API keys (plan G-P3): a project-bound credential, shown once, stored hashed. Off unless
++ // NETWORK_DEV_API_KEYS=1: a member then gets 503 api_keys.disabled; anyone who cannot see the project gets its 404.
++ r.get('/:project/api-keys', handle(async (db, a, req, o) => ({ keys: await apiKeys.list(db, a, req.params.project, o) })));
++ r.post('/:project/api-keys', handle(async (db, a, req, o) => await apiKeys.create(db, a, req.params.project, req.body || {}, o), 201));
++ r.post('/:project/api-keys/:key/rotate', handle(async (db, a, req, o) => await apiKeys.rotate(db, a, req.params.project, req.params.key, o)));
++ r.post('/:project/api-keys/:key/revoke', handle(async (db, a, req, o) => await apiKeys.revoke(db, a, req.params.project, req.params.key, o)));
++
++ // The project's own monthly budget, in nano-USD (D1). Read and written by admin+ members and staff.
++ r.get('/:project/budget', handle(async (db, a, req, o) => await apiKeys.readBudget(db, a, req.params.project, o)));
++ r.put('/:project/budget', handle(async (db, a, req, o) => await apiKeys.writeBudget(db, a, req.params.project, req.body || {}, o)));
++
// Project export (WS-N task 9): Codes asks with the person's token, once per audience and env.
r.post('/:project/export-tokens', handle(async (db, a, req, o) => await tokens.mintExportToken(db, a, req.params.project, req.body || {}, {
privateKey: req.app.locals.privateKey, issuer: req.app.locals.config.jwt.issuer, ctx: o.ctx,
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex 25e45b25..96f871b9 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -151,6 +151,8 @@ async function projectView(db, p, role, settings) {
// Held by sandbox apps without a staff decision (DEV_SANDBOX_ALLOWANCE); production apps use `allowance` only.
sandbox_allowance: (settings || policy.settings()).sandboxAllowance,
created_at: p.created_at, archived_at: p.archived_at || null,
++ // The project's own monthly ceiling, in nano-USD (D1). Null = no budget set.
++ monthly_budget_nano_usd: p.monthly_budget_nano_usd || null,
counts: {
members: (await db.prepare('SELECT COUNT(*) AS n FROM dev_project_members WHERE project_id = ?').get(p.id)).n,
apps: (await db.prepare('SELECT COUNT(*) AS n FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').get(p.id)).n,
@@@ -164,6 +166,18 @@ async function projectView(db, p, role, settings) {
* through dev_apps). No secrets, no client ids, no members other than the owner, no actor labels. Archived projects
* still return (archived_at set). -> null when the id is not a project.
*/
++/**
++ * The memberships of one person, as a receiving service sees them (network.project.member.read, GET
++ * /internal/projects?subject=usr_…). A service that acts for a person (Billing's Balance, D12) uses this to find
++ * the projects they administer without holding a person's token. -> null for a malformed subject.
++ */
++async function internalProjectMemberships(db, subject) {
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(subject))) return null;
++ const rows = await db.prepare(`SELECT m.project_id, m.role, m.added_at, p.name, p.archived_at FROM dev_project_members m
++ JOIN dev_projects p ON p.id = m.project_id WHERE m.subject_id = ? ORDER BY m.project_id`).all(String(subject));
++ return rows.map((r) => ({ project_id: r.project_id, name: r.name, role: r.role, archived: !!r.archived_at, added_at: r.added_at || null }));
++}
++
/**
* One person's membership of a project, as a receiving service sees it (network.project.member.read, GET
* /internal/projects/:project_id/members/:subject): the role, when they were added, and whether the project is
@@@ -503,6 +517,8 @@ async function revokeAppTx(db, actor, project, app, ctx, reason) {
await db.prepare('UPDATE dev_apps SET revoked_at = ?, revoked_by = ? WHERE id = ?').run(t, actor.label, app.id);
await db.prepare('UPDATE dev_credentials SET revoked_at = ?, revoked_by = ? WHERE app_id = ? AND revoked_at IS NULL').run(t, actor.label, app.id);
await db.prepare('UPDATE dev_auth_codes SET used = 1 WHERE app_id = ?').run(app.id);
++ // The app's developer API keys go with it (plan G-P3): a key is the app's credential.
++ await require('./api-keys').revokeForApp(db, app.id, { actor: actor.label, reason: reason || 'app_revoked', ctx });
await audit(db, { projectId: project.id, actor: actor.label, action: 'app.revoked', target: `app:${app.id}`, detail: { reason: reason || null }, ctx,
event: { type: 'network.app.revoked', subject: { type: 'app', id: app.id }, payload: { project_id: project.id, environment: app.environment, reason: reason || null } } });
// Its agents go with it (WS-Z2). Required here, not at the top: agents.js requires this module.
@@@ -725,9 +741,10 @@ async function deleteQuota(db, actor, projectId, capability, { ctx }) {
module.exports = {
ensureSchema, DevError, ROLES, APP_ID_RE, PROJECT_ID_RE, SECRET_PREFIX,
audit, listAudit, actorOf, access, memberRole, lockProject,
-- createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView, internalProjectView, internalMemberView,
++ createProject, listProjects, renameProject, archiveProject, setAllowance, setEnvironmentPolicy, setPlacement, projectView, internalProjectView,
++ internalProjectMemberships, internalMemberView,
listMembers, addMember, updateMember, removeMember,
-- listApps, getApp, createApp, updateApp, revokeApp,
++ listApps, getApp, createApp, updateApp, revokeApp, loadApp,
listCredentials, rotateCredential, revokeCredential, matchSecret, hashSecret,
listGrants, requestGrant, decideGrant,
listQuotas, setQuota, deleteQuota,
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
iindex fa7ee8b8..bf4c8de2 100644
---- a/server/identity/account-merge.js
++++ b/server/identity/account-merge.js
@@@ -44,6 +44,12 @@ class MergeError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
++/** A real sign-in (auth_time within 10 minutes), never a renewal or a QR session carrying an old source time. */
++function isFreshSignIn(claims, now = Date.now()) {
++ const authTime = Number(claims && claims.auth_time);
++ return Number.isFinite(authTime) && now / 1000 - authTime <= FRESH_SIGN_IN_S;
++}
++
async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
/** The survivor of a subject: itself, or what it was merged into (aliases are kept flat). */
@@@ -238,8 +244,7 @@ async function createIntent(db, intoUser, { now = Date.now() } = {}) {
/** The person's merge: the caller is the account being folded in (freshly signed in), the intent names the survivor. */
async function mergeWithIntent(db, caller, claims, intentId, { now = Date.now() } = {}) {
await ensureSchema(db);
-- const authTime = Number(claims && claims.auth_time);
-- if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {
++ if (!isFreshSignIn(claims, now)) {
throw new MergeError(401, 'merge.sign_in_again', 'sign in to this account again (within the last 10 minutes) to merge it');
}
const intent = await db.prepare('SELECT * FROM account_merge_intents WHERE id = ?').get(String(intentId || ''));
@@@ -301,4 +306,4 @@ function routers({ requireAuth, staffClaims }) {
return { me, admin };
}
--module.exports = { ensureSchema, survivorOf, effectiveUser, merge, mergeWithIntent, createIntent, reduceExpired, routers, MergeError, SPLIT_DAYS };
++module.exports = { ensureSchema, survivorOf, effectiveUser, merge, mergeWithIntent, createIntent, reduceExpired, routers, isFreshSignIn, MergeError, SPLIT_DAYS };
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 6afda5ba..1c70b3f4 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -306,6 +306,18 @@ const DEFAULT_GRANTS = [
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
['host', 'network.node.report', SELF_AUDIENCE, []],
['host', 'network.resource.report', SELF_AUDIENCE, []],
++ // D11 S3 (OWNER-SETTINGS.md): Network's admin proxy reads and changes every service's configuration as the owner,
++ // on /internal/config. The nine internal <svc>.config.manage capabilities (Contracts 0.288.0) are held by Network
++ // alone; the owner travels in X-OV-Subject, never in a grant. These are new rows, so the boot seed's ON CONFLICT
++ // DO NOTHING inserts them; nothing was revoked and no namespaces moved, so no RESTORED_GRANTS or
++ // CHANGED_DEFAULT_NAMESPACES entry is needed. The deploy applies them from ensureSchema() at boot.
++ ...['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']
++ .map((svc) => ['network', `${svc}.config.manage`, `openvibe.${svc}`, []]),
++ // The owner's AI tab lists and edits AI's provider registry through AI's own admin API (GET/POST/PATCH
++ // /api/v1/providers), as Network, naming the owner in X-OV-Subject.
++ ['network', 'ai.provider.manage', 'openvibe.ai', []],
++ // Host's demand planner reads AI's usage feed (Host #283): ai.usage.read on openvibe.ai.
++ ['host', 'ai.usage.read', 'openvibe.ai', []],
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
['bot', 'network.node.manage', SELF_AUDIENCE, []],
// Plan T15: an owner adds an operator by @username on the robot's panel; Bot resolves it to the subject here.
@@@ -441,6 +453,11 @@ const DEFAULT_GRANTS = [
// Plan T2 lane B step 3: Host places and runs per project, so it reads a project's tenancy, placement and quotas.
// Other services get this row only when they ship a caller of GET /internal/projects/:project_id.
['host', 'network.project.read', SELF_AUDIENCE, []],
++ // Plan G-P3: OpenVibe.AI is the service that spends for a developer project, so it reads the project's
++ // tenancy and its own monthly ceiling (GET /internal/projects/:project_id/budget) and exchanges the
++ // developer API keys a caller shows it (POST /internal/api-keys/exchange). Both are behind
++ // NETWORK_DEV_API_KEYS; the capability is first-party and never grantable to apps.
++ ['ai', 'network.project.read', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
@@@ -528,6 +545,9 @@ const DEFAULT_GRANTS = [
['community', 'events.event.read', 'openvibe.events', []],
['community', 'events.event.publish', 'openvibe.events', []], // community.* events (Community server/events.js, 2026-09-24)
['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],
++ // D12: a person's Balance lists the projects they administer and checks the role before a transfer. Billing acts
++ // for the signed-in person with its own service token, never with theirs.
++ ['billing', 'network.project.member.read', SELF_AUDIENCE, []],
['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],
['live', 'identity.subject.resolve', SELF_AUDIENCE, []],
// Live's avatar picker reports to Network, and Live reads the URL registry and
ddiff --git a/server/index.js b/server/index.js
iindex 8c94c6f3..f40fdb63 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -37,6 +37,7 @@ const { DiscordService } = require('./discord/discord-service');
const createDiscordRoutes = require('./discord/routes');
const createDeployRoutes = require('./deploy/routes');
const createCoinsRoutes = require('./coins/routes');
++const createWalletRoutes = require('./wallet/routes');
const { signToken } = require('./auth/routes');
// The boot runs asynchronously: opening PostgreSQL (migrations, seeds) is async (plan T2, ADR-035).
@@@ -292,6 +293,9 @@ app.use('/api/auth/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessful
// ── Database ─────────────────────────────────────────────────
const db = await initDb();
++// D11: load the configuration journal, reseal old plaintext under the KEK, then blank the legacy secret
++// copies it holds (provider secrets stay environment-only). Without a KEK nothing is blanked.
++await require('./admin/site-config').init(db);
await urlRegistry.initializeUrlRegistry(db);
await urlRegistry.seedBootstrapRegistry(db, process.env, config.bootstrapProfile);
await ensureAdminUser(db, config);
@@@ -418,6 +422,12 @@ const requireAuth = require('./auth/session').makeRequireAuth(authCtx, authRoute
// The extension's private-issuer Dock token, accepted only on the mounts that name a scope. Every
// other route keeps `requireAuth`, which refuses the different issuer.
const requireDock = (scope) => require('./auth/session').makeRequireAuth(authCtx, authRoutes.signToken, { dockScope: scope });
++// Sign in on your phone (owner, 2026-11-04): off unless NETWORK_QR_SIGNIN says on. Mounted at
++// /auth/qr (not /api/auth) because the phone's httpOnly ov_qr cookie has path /auth/qr.
++const qrSignin = require('./auth/qr-signin').createQrSignin({
++ db, config, privateKey, signToken: authRoutes.signToken, requireAuth, notifications: notificationService,
++});
++app.locals.qrSignin = qrSignin;
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
@@@ -633,6 +643,14 @@ app.get('/api/brand', (_req, res) => res.json(BRAND));
// Auth routes (SSO provider)
app.use('/api/auth', authRoutes);
++// Sign in on your phone (server/auth/qr-signin.js): mounted only when NETWORK_QR_SIGNIN is on. Its
++// per-address limit is the auth limiter's shape (30 failures / 15 min); successful polls do not spend it.
++if (qrSignin.enabled) {
++ app.use('/auth/qr/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessfulRequests: true }));
++ app.use('/auth/qr', qrSignin.router);
++ app.get(['/q', '/q/'], qrSignin.page);
++}
++
// Email verification (status / resend / consume)
app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
@@@ -650,6 +668,9 @@ app.use('/api/themes', require('./themes/routes'));
// OpenCoins wallet API (user-facing, Bearer JWT)
app.use('/api/coins', createCoinsRoutes(db, requireAuth));
++// The wallet's own API (MONEY.md D12): the person's Balance and Vibes through Billing, their activity, the projects
++// they administer, and the Vibes Pay charge the pay sheet shows. Bearer person tokens; see server/wallet/routes.js.
++app.use('/api/wallet', rateLimit({ windowMs: 60_000, max: 120 }), createWalletRoutes(db, requireAuth, { selfToken }));
// Versioned user modules: portable per-person preferences and summaries (server/identity/modules.js).
app.use('/api/modules', require('./identity/modules').userRouter(requireAuth));
@@@ -879,6 +900,22 @@ app.use(require('./seo/routes').createSeoRoutes({ release }));
const indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: config.indexnow.key });
if (indexnow.enabled) app.use(indexnow.keyFile);
++// The wallet (MONEY.md D12) and the Vibes Pay sheet: their own pages, in the account pages' style. A guest is sent
++// to sign in from the page, which then returns them to the same link.
++function sendWalletPage(res) {
++ res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
++ return lookBoot.send(res, 'wallet.html');
++}
++function sendPayPage(res) {
++ res.setHeader('Cache-Control', 'no-store');
++ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
++ return lookBoot.send(res, 'pay.html');
++}
++app.get('/wallet', (_req, res) => sendWalletPage(res));
++app.get('/wallet.html', (req, res) => redirectWithoutHtml(req, res, '/wallet'));
++app.get('/pay/:id', (_req, res) => sendPayPage(res));
++
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
app.get(require('./not-found').ACCOUNT_HUB_PATHS, (req, res) => {
@@@ -1060,6 +1097,16 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
};
timers.push(setTimeout(sweepConsent, 30 * 1000), setInterval(sweepConsent, 15 * 60 * 1000));
++ // Sign-in-on-your-phone transfers live 120 s. A sweep every minute deletes the expired rows
++ // (the audit_log keeps the outcome); checks also compare expires_at, so a late sweep never matters.
++ if (qrSignin.enabled) {
++ const sweepQr = async () => {
++ try { const n = await qrSignin.sweep(); if (n) console.log(`[QR sign-in] swept ${n} expired row(s)`); }
++ catch (e) { console.warn('[QR sign-in] sweep failed:', e.message); }
++ };
++ timers.push(setTimeout(sweepQr, 30 * 1000), setInterval(sweepQr, 60 * 1000));
++ }
++
// Clean expired sessions daily
timers.push(setInterval(async () => {
try {
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex ec41f679..cb4d9d55 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -15,6 +15,9 @@ const wallet = require('../coins/wallet');
function getDb(req) { return req.app.locals.db; }
function getConfig(req) { return req.app.locals.config; }
++// Set before a guard, so a refusal is not cached either.
++const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
++
// ── The gate ────────────────────────────────────────────────
// Every route below checks the one capability it performs on a service token (principals.guard); nothing
// else gets in.
@@@ -423,6 +426,21 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), async
}
});
++// ── The projects one person belongs to ───────────────────────
++// Owning services that act for a person (Billing's Balance) find the projects they administer here with their own
++// service token; the role is Network's, never the person token's claim. A malformed subject is refused before any read.
++router.get('/projects', principals.guard('network.project.member.read'), async (req, res) => {
++ const subject = String(req.query.subject || '');
++ try {
++ const projects = await require('../developer/store').internalProjectMemberships(getDb(req), subject);
++ if (!projects) return require('openvibe-contracts').http.sendProblem(res, 422, 'project.invalid_subject', { detail: 'subject is a person subject id' });
++ res.set('Cache-Control', 'no-store').json({ subject, projects });
++ } catch (err) {
++ console.error('[Internal] project memberships error:', err.message);
++ require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'project memberships failed' });
++ }
++});
++
// ── Read a developer project ─────────────────────────────────
// Owning services key tenancy by project_id and enforce the quotas Network records (docs/developer-projects.md, ADR-014).
// The capability is first-party and never grantable to apps; the view carries no secrets and no members but the owner.
@@@ -437,6 +455,39 @@ router.get('/projects/:project_id', principals.guard('network.project.read'), as
}
});
++// ── A project's monthly budget (plan G-P3) ───────────────────
++// The project's own ceiling, in nano-USD (D1), read by the service that spends for it (OpenVibe.AI)
++// with its service token. network.project.read is first-party and never grantable to apps.
++router.get('/projects/:project_id/budget', noStore, principals.guard('network.project.read'), async (req, res) => {
++ try {
++ const out = await require('../developer/api-keys').internalBudget(getDb(req), req.params.project_id);
++ if (!out) return require('openvibe-contracts').http.sendProblem(res, 404, 'project.not_found', { detail: 'no such project' });
++ res.json(out);
++ } catch (err) {
++ console.error('[Internal] project budget read error:', err.message);
++ require('openvibe-contracts').http.sendProblem(res, 500, 'internal.error', { detail: 'project budget read failed' });
++ }
++});
++
++// ── Exchange a developer API key (plan G-P3) ─────────────────
++// A developer shows their key to a service (OpenVibe.AI); the service sends it here with its own
++// service token and Network, the identity authority, answers with the key's facts and a short-lived
++// scoped token. A revoked or unknown key is refused at once; the key itself is never logged.
++router.post('/api-keys/exchange', noStore, principals.guard('network.project.read'), express.json({ limit: '4kb' }), async (req, res) => {
++ const { http } = require('openvibe-contracts');
++ try {
++ const out = await require('../developer/api-keys').exchange(getDb(req), (req.body || {}).api_key, {
++ audience: (req.body || {}).audience, privateKey: req.app.locals.privateKey,
++ issuer: getConfig(req).jwt.issuer, ctx: req.ov,
++ });
++ res.json(out);
++ } catch (err) {
++ if (err && err.status && err.code) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });
++ console.error('[Internal] api key exchange error:', err && err.message ? err.message.slice(0, 200) : err);
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'api key exchange failed' });
++ }
++});
++
// ── One person's membership of a project ─────────────────────
// Project delegation (ADR-003 amendment 2026-10-12): before doing a project's work for a person a first-party service
// names (X-OV-Subject + X-OV-Project), the receiver checks here that they are an admin or owner of the unarchived
@@@ -453,8 +504,6 @@ router.get('/projects/:project_id/members/:subject', principals.guard('network.p
});
// ── Read an agent ────────────────────────────────────────────
--// Set before the guard so its refusals are not cached either.
--const noStore = (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); };
// The owning service enforces an agent's delegated grants and budgets (plan T2 WS-Z2 slice 6,
// docs/t2-projects-and-grants.md section 4): only the grants at the caller's own audience, never another's.
router.get('/agents/:agent', noStore, principals.guard('network.project.read'), async (req, res) => {
ddiff --git a/server/qr-signin/q.html b/server/qr-signin/q.html
nnew file mode 100644
iindex 00000000..7304089e
---- /dev/null
++++ b/server/qr-signin/q.html
@@@ -0,0 +1,191 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Sign in on another device — OpenVibe</title>
++<meta name="robots" content="noindex, nofollow">
++<meta name="referrer" content="no-referrer">
++<meta name="theme-color" content="#0d0d14">
++<meta name="color-scheme" content="dark light">
++<!--ov:look-boot-->
++<style>
++@font-face{font-family:'OV Brand';src:url('/shared/ov-brand.woff2') format('woff2');font-weight:700 900;font-style:normal;font-display:swap}
++*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
++:root{
++ color-scheme:dark light;
++ --bg:var(--bg-primary,#0d0d14);--card:var(--bg-card,#1a1a26);--line:var(--border,#2a2a3a);
++ --text:var(--text-primary,#e8e6e3);--muted:var(--text-secondary,#a9b3c9);
++ --accent:#8b5cf6;--on-accent:#fff;
++}
++@media (prefers-color-scheme:light){
++ :root:not([data-theme]){--bg:#f5f6fa;--card:#fff;--line:#e3e5ec;--text:#181a22;--muted:#5c6170;--accent:#2563eb;--on-accent:#fff}
++}
++html,body{min-height:100%}
++body{
++ min-height:100vh;background:var(--bg);color:var(--text);
++ font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
++ display:flex;flex-direction:column;align-items:center;justify-content:center;
++ padding:24px 16px;gap:18px;overflow-x:hidden;
++}
++.brand{display:flex;align-items:center;gap:8px}
++.brand-name{font-family:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:20px;font-weight:800;letter-spacing:-.2px;line-height:1}
++.card{width:100%;max-width:390px;background:var(--card);border:1px solid var(--line);border-radius:20px;padding:26px 22px;text-align:center}
++.eyebrow{font-size:14px;font-weight:600;color:var(--muted);margin-bottom:14px}
++.muted{color:var(--muted);font-size:14px;line-height:1.55}
++.who{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:22px;font-size:15px;line-height:1.35;overflow-wrap:anywhere}
++.who-avatar{width:40px;height:40px;border-radius:50%;flex:none;display:grid;place-items:center;overflow:hidden;background:var(--accent);color:var(--on-accent);font-size:18px;font-weight:800}
++.who-avatar img{width:100%;height:100%;display:block;object-fit:cover;border-radius:50%}
++.who b{font-weight:700}
++.label{margin:12px 0 0;font-size:16px;font-weight:500;line-height:1.45;color:var(--muted)}
++.code{font-size:64px;line-height:1.05;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:.06em;margin:2px 0 0;color:var(--text)}
++.status{margin-top:14px;font-size:14px;color:var(--muted);line-height:1.5;min-height:21px}
++.big{font-size:22px;font-weight:800;letter-spacing:-.2px;margin:6px 0 8px;line-height:1.25}
++.check{width:52px;height:52px;border-radius:50%;display:grid;place-items:center;margin:0 auto 14px;background:var(--accent);color:var(--on-accent);font-size:26px;font-weight:800}
++.foot{max-width:390px;text-align:center;font-size:12px;line-height:1.5;color:var(--muted);padding:0 8px}
++[hidden]{display:none!important}
++@media (max-width:359px){
++ body{padding:18px 12px}
++ .card{padding:22px 16px;border-radius:16px}
++ .code{font-size:56px}
++}
++</style>
++<script src="/shared/ov-mark.js" defer></script>
++</head>
++<body>
++<header class="brand"><span class="ov-mark" data-size="32" data-wide aria-hidden="true"></span><span class="brand-name">OpenVibe</span></header>
++
++<main class="card">
++ <section id="state-scan">
++ <p class="eyebrow">Sign in on another device</p>
++ <p class="muted" id="scan-status" aria-live="polite">Reading the code…</p>
++ </section>
++
++ <section id="state-code" hidden>
++ <div class="who"><span class="who-avatar" id="who-avatar-box"><img id="who-avatar" alt=""><span id="who-initial" hidden>?</span></span><span>Sign in to OpenVibe as <b id="who-name">you</b></span></div>
++ <div class="code" id="code" aria-live="polite">--</div>
++ <p class="label">Type this number on the device showing the code</p>
++ <p class="status" id="wait-status" aria-live="polite">Waiting for your other device</p>
++ </section>
++
++ <section id="state-done" hidden>
++ <div class="check" aria-hidden="true">✓</div>
++ <p class="big">You're signed in</p>
++ <p class="muted">Taking you to OpenVibe…</p>
++ </section>
++
++ <section id="state-stopped" hidden>
++ <p class="big" id="stopped-title">This code expired</p>
++ <p class="muted" id="stopped-text">Ask your other device for a new code.</p>
++ </section>
++</main>
++
++<p class="foot">Only the device that scanned this code can finish this sign-in.</p>
++
++<script>
++(function () {
++ 'use strict';
++ var $ = function (id) { return document.getElementById(id); };
++ var STATES = ['state-scan', 'state-code', 'state-done', 'state-stopped'];
++ function show(id) { STATES.forEach(function (s) { $(s).hidden = s !== id; }); }
++ function stop(title, text) { $('stopped-title').textContent = title; $('stopped-text').textContent = text; show('state-stopped'); }
++
++ // The secret is in the fragment: read it, then take it out of the address bar/history. It was never
++ // sent in a URL, so no server log or Referer has it.
++ var fragment = String(location.hash || '').replace(/^#/, '');
++ if (fragment) {
++ try { history.replaceState(null, '', '/q'); } catch (e) { /* keep going */ }
++ }
++ var dot = fragment.indexOf('.');
++ var id = dot > 0 ? fragment.slice(0, dot) : '';
++ var secret = dot > 0 ? fragment.slice(dot + 1) : '';
++ if (!id || !secret) {
++ stop('This link is missing its code.', 'Ask your other device for a new code.');
++ return;
++ }
++
++ var timer = null;
++ function remember(user) {
++ try {
++ var m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ var token = m ? decodeURIComponent(m[1]) : null;
++ if (!token) return;
++ var accounts = [];
++ try { accounts = JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); } catch (e) { accounts = []; }
++ accounts = accounts.filter(function (a) { return a && !a.is_anon && String(a.id) !== String(user.id); });
++ accounts.push({
++ id: user.id, username: user.username,
++ display_name: user.display_name || user.username,
++ avatar_url: user.avatar_url || null, email: user.email || null,
++ token: token, is_anon: false,
++ });
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_active_account', String(user.id));
++ localStorage.setItem('ov_token', token);
++ } catch (e) { /* the session cookie is the sign-in; the account list only makes switching easy */ }
++ }
++
++ function done(user) {
++ if (timer) { clearTimeout(timer); timer = null; }
++ if (user) remember(user);
++ show('state-done');
++ setTimeout(function () { location.href = 'https://openvibe.network/'; }, 900);
++ }
++
++ function poll() {
++ fetch('/auth/qr/status', { credentials: 'same-origin', cache: 'no-store' })
++ .then(function (r) {
++ if (r.status === 404) { stop('This code expired.', 'Ask your other device for a new code.'); return null; }
++ if (!r.ok) throw new Error('status ' + r.status);
++ return r.json();
++ })
++ .then(function (d) {
++ if (!d) return;
++ if (d.state === 'denied') { stop('This sign-in was denied.', 'Ask your other device for a new code.'); return; }
++ if (d.state === 'used') {
++ if (d.signed_in) done(d.user);
++ else stop('This code was already used.', 'Ask your other device for a new code.');
++ return;
++ }
++ timer = setTimeout(poll, 2000);
++ })
++ .catch(function () { timer = setTimeout(poll, 3000); });
++ }
++
++ fetch('/auth/qr/scan', {
++ method: 'POST',
++ credentials: 'same-origin',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ id: id, secret: secret }),
++ }).then(function (r) {
++ if (r.status === 409) { stop('This code was already used on another device.', 'Ask your other device for a new code.'); return null; }
++ if (r.status === 404) { stop('This code expired.', 'Ask your other device for a new code.'); return null; }
++ if (r.status === 429) { stop('Too many tries.', 'Ask your other device for a new code.'); return null; }
++ if (!r.ok) throw new Error('scan ' + r.status);
++ return r.json();
++ }).then(function (d) {
++ if (!d) return;
++ var hint = d.user_hint || {};
++ var displayName = hint.display_name || 'you';
++ $('who-name').textContent = displayName;
++ $('code').textContent = String(d.code || '');
++ var avatar = $('who-avatar'), initial = $('who-initial');
++ if (hint.avatar_url) {
++ avatar.src = hint.avatar_url;
++ avatar.alt = '';
++ avatar.hidden = false;
++ initial.hidden = true;
++ } else {
++ avatar.hidden = true;
++ initial.hidden = false;
++ initial.textContent = String(displayName).trim().charAt(0).toUpperCase() || '?';
++ }
++ show('state-code');
++ poll();
++ }).catch(function () {
++ stop('This code could not be read.', 'Ask your other device for a new code.');
++ });
++})();
++</script>
++</body>
++</html>
ddiff --git a/server/wallet/routes.js b/server/wallet/routes.js
nnew file mode 100644
iindex 00000000..2c28bad4
---- /dev/null
++++ b/server/wallet/routes.js
@@@ -0,0 +1,509 @@
++'use strict';
++
++/**
++ * openvibe.network/wallet — the one place a person sees and moves everything they hold (MONEY.md, D12).
++ *
++ * The page never talks to Billing itself: every site's CSP allows openvibe.network and not
++ * billing.openvibe.network, and the person's token must not be exposed to a third origin. So Network forwards the
++ * person's own Authorization header to Billing's person routes, exactly as /api/coins/me does for the navbar:
++ *
++ * GET /api/wallet Balance + Vibes + OpenCoins + projects + activity, one answer
++ * GET /api/wallet/rates Billing's public pack prices (no person data), cached a minute
++ * POST /api/wallet/transfers personal Balance → a project's Balance
++ * POST /api/wallet/vibes/from-balance personal Balance → Vibes, at the pack value rate
++ * POST /api/wallet/topups Add money: start a provider checkout
++ * GET /api/wallet/charge/:id the Vibes Pay sheet's charge (app, item, amount, the person's Vibes)
++ * POST /api/wallet/charge/:id/pay pay it (once, atomic in Billing)
++ * POST /api/wallet/charge/:id/cancel cancel it
++ *
++ * Activity is one list over both currencies. Billing's person API has no history route, so the journal is read from
++ * Billing's service API (billing.balance.read) with Network's own service token, scoped by the person's subject id:
++ * only that person's rows, never another's. Money stays integer end to end: nano-USD as decimal strings and Vibes as
++ * whole numbers, formatted by the browser.
++ */
++
++const express = require('express');
++const wallet = require('../coins/wallet');
++const config = require('../config');
++
++const TIMEOUT_MS = 3500;
++const RATES_TTL_MS = 60_000;
++const CHARGE_RE = /^vch_[0-9A-HJKMNP-TV-Z]{26}$/;
++// A retried write must carry the same key: the ledger moves money once for it (Billing's rule).
++const KEY_RE = /^[A-Za-z0-9._:-]{8,128}$/;
++// The transactions read is a service-token capability (OpenVibe.Contracts billing.balance.read).
++const BALANCE_READ = ['billing.balance.read'];
++const SERVICE_AUDIENCE = 'openvibe.billing';
++
++/** Billing's base URL, no trailing slash. */
++function billingBase(opts) {
++ return String(opts.billingUrl || (config.services && config.services.billing && config.services.billing.internalUrl) || '').replace(/\/+$/, '');
++}
++
++function cacheHeaders(res) {
++ res.set('Cache-Control', 'private, no-store');
++ return res;
++}
++
++function text(v, max = 400) {
++ return String(v == null ? '' : v).slice(0, max);
++}
++
++async function readJson(r) {
++ try { return await r.json(); } catch { return null; }
++}
++
++/** One request to Billing. The person's own bearer token unless `token` says otherwise. */
++async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS } = {}) {
++ const headers = { accept: 'application/json' };
++ if (authorization) headers.authorization = authorization;
++ if (body !== undefined) headers['content-type'] = 'application/json';
++ const r = await fetch(url, {
++ method, headers,
++ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ return { status: r.status, body: await readJson(r) };
++}
++
++function personToken(req) {
++ const h = String(req.headers.authorization || '');
++ return /^Bearer\s+/i.test(h) ? h : '';
++}
++
++/** The API is same-origin and reads a person's own money, so a write must carry the page's bearer token: a cookie
++ * session alone could be driven by a cross-site form. */
++function bearerOnly(req, res, next) {
++ if (!personToken(req)) return res.status(401).json({ error: 'token.missing', detail: 'sign in to move money' });
++ return next();
++}
++
++function problem(res, status, code, detail) {
++ return res.status(status).json({ error: code, detail });
++}
++
++/** The charge inside Billing's answer (a wrapper { charge } or the charge itself). */
++function chargeIn(body) {
++ return body && body.charge && typeof body.charge === 'object' ? body.charge : (body && typeof body === 'object' ? body : null);
++}
++
++/**
++ * A pay sheet needs the app's name; Billing names it from Network's registry, but if its answer carries only the
++ * app id, Network fills the name from the project it owns. An answer with a name is never overwritten.
++ */
++async function enrichCharge(db, body) {
++ const c = chargeIn(body);
++ if (!c) return body;
++ const app = c.app;
++ const appId = typeof app === 'string' ? app : (app && typeof app === 'object' ? app.id : c.app_id);
++ if (!/^app_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(appId || ''))) return body;
++ const row = await db.prepare('SELECT name FROM dev_apps WHERE id = ?').get(String(appId));
++ if (!row || !row.name) return body;
++ if (!c.app || typeof c.app !== 'object') c.app = { id: String(appId) };
++ if (!c.app.name) c.app.name = row.name;
++ if (!c.app_name) c.app_name = row.name;
++ return body;
++}
++
++/** Forward Billing's answer (status, JSON, code) without inventing a second error language. */
++function relay(res, out, detail) {
++ cacheHeaders(res);
++ if (out && out.body && typeof out.body === 'object') return res.status(out.status).json(out.body);
++ return problem(res, 502, 'billing.unavailable', detail);
++}
++
++function needsSignIn(res, out) {
++ return out && (out.status === 401 || out.status === 403);
++}
++
++// ── Activity: one row per currency movement on the person's own accounts ─────────────────────────
++
++function titleCase(s) {
++ return String(s || 'Activity').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase());
++}
++
++/** The label and the second line of what a journal transaction was, from its type and metadata. */
++function describe(t) {
++ const m = (t && t.metadata) || {};
++ if (t.type === 'credit' && m.credit === 'settle') return { label: 'AI usage', detail: m.model ? String(m.model) : 'Metered usage' };
++ if (t.type === 'credit' && m.credit === 'topup') return { label: 'Added money', detail: 'Balance top-up' };
++ if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {
++ const got = Number(m.vibes || 0);
++ return { label: got ? `Got ${got.toLocaleString('en-US')} Vibes with your Balance` : 'Got Vibes with your Balance', detail: '' };
++ }
++ if (t.type === 'purchase') {
++ const bought = Number(m.vibes || m.bits || m.amount_bits || 0);
++ return { label: bought ? `Bought ${bought.toLocaleString('en-US')} Vibes` : 'Bought Vibes', detail: 'With a card' };
++ }
++ if (t.type === 'balance_transfer') return { label: 'Moved to a project', detail: m.to_project_id ? String(m.to_project_id) : 'Project Balance' };
++ if (t.type === 'donation') return { label: 'Tip', detail: m.message ? text(m.message, 80) : 'Live tip' };
++ if (t.type === 'subscription' || t.type === 'subscription_share') return { label: 'Subscription', detail: 'Recurring' };
++ if (t.type === 'refund') return { label: 'Refund', detail: 'Returned' };
++ if (t.type === 'vibes_charge' || m.vibes_charge) return { label: m.description ? text(m.description, 80) : 'App payment', detail: 'Vibes Pay' };
++ if (t.type === 'cashout_paid' || t.type === 'cashout_request') return { label: 'Cash out', detail: 'Payable' };
++ return { label: titleCase(t.type), detail: '' };
++}
++
++/** The person's two spendable parts of one journal transaction, in display order: Balance, then Vibes. */
++function partsOf(subject, t) {
++ const entries = t && Array.isArray(t.entries) ? t.entries : [];
++ let vibes = 0;
++ let nano = 0n;
++ let sawVibes = false;
++ let sawNano = false;
++ for (const e of entries) {
++ const a = e && e.account;
++ if (!a || !a.owner) continue;
++ if (a.currency === 'vibes-bits' && a.kind === 'user_credit' && String(a.owner) === subject) {
++ vibes += Number(e.amount) || 0;
++ sawVibes = true;
++ } else if (a.currency === 'nano-usd' && a.kind === 'usd_credit'
++ && (String(a.owner) === subject || String(a.owner) === `user:${subject}`)) {
++ nano += BigInt(String(e.amount == null ? 0 : e.amount));
++ sawNano = true;
++ }
++ }
++ const parts = [];
++ if (sawNano && nano !== 0n) parts.push({ kind: 'balance', amount: nano.toString() });
++ if (sawVibes && vibes !== 0) parts.push({ kind: 'vibes', amount: vibes });
++ return parts;
++}
++
++/**
++ * One row per transaction: a purchase of Vibes with Balance moves both currencies at once, and the page shows it as a
++ * single "Got N Vibes with your Balance" row with both amounts, not as two movements. Vibes count only the spendable
++ * user_credit, never creator payable; Balance counts only usd_credit, never sandbox.
++ */
++function mapActivity(subject, transactions) {
++ if (!subject || !Array.isArray(transactions)) return [];
++ const rows = [];
++ for (const t of transactions) {
++ if (!t || !t.id) continue;
++ const parts = partsOf(subject, t);
++ if (!parts.length) continue;
++ const d = describe(t);
++ rows.push({
++ id: String(t.id), at: t.created_at, label: d.label, detail: d.detail,
++ kinds: parts.map((p) => p.kind), parts,
++ });
++ }
++ // Newest first; the same timestamp keeps journal order (Billing pages newest first already).
++ rows.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
++ return rows.slice(0, 100);
++}
++
++/** A Billing /api/me/activity row, already in the person's shape, as this page's parts. */
++function personPart(kind, amount) {
++ const k = personKind(kind);
++ if (!k || amount == null) return null;
++ if (k === 'vibes' && Number.isSafeInteger(Number(amount)) && Number(amount) !== 0) return { kind: 'vibes', amount: Number(amount) };
++ if (k === 'balance' && /^-?[0-9]{1,19}$/.test(String(amount)) && BigInt(String(amount)) !== 0n) return { kind: 'balance', amount: String(amount) };
++ return null;
++}
++
++function partsOfPersonRow(row) {
++ if (!row || typeof row !== 'object') return null;
++ if (Array.isArray(row.parts)) {
++ const parts = row.parts.map((p) => personPart(p && (p.kind || p.currency), p && (p.amount_nano_usd != null ? p.amount_nano_usd : p.amount))).filter(Boolean);
++ return parts.length ? parts : null;
++ }
++ // A route may already answer one object of { balance, vibes } amounts.
++ const both = row.amounts && typeof row.amounts === 'object' ? row.amounts : (row.amount && typeof row.amount === 'object' ? row.amount : null);
++ if (both) {
++ const parts = [personPart('balance', both.balance != null ? both.balance : both.nano_usd), personPart('vibes', both.vibes != null ? both.vibes : both.bits)].filter(Boolean);
++ return parts.length ? parts : null;
++ }
++ // One row may carry its two amounts as named fields.
++ if (row.balance_nano_usd != null || row.nano_usd != null || row.vibes != null || row.amount_vibes != null) {
++ const parts = [
++ personPart('balance', row.balance_nano_usd != null ? row.balance_nano_usd : row.nano_usd),
++ personPart('vibes', row.vibes != null && typeof row.vibes !== 'object' ? row.vibes : row.amount_vibes),
++ ].filter(Boolean);
++ if (parts.length) return parts;
++ }
++ const kind = personKind(row.kind || row.currency);
++ const amount = row.amount_nano_usd != null ? row.amount_nano_usd
++ : row.nano_usd != null ? row.nano_usd
++ : row.amount_bits != null ? row.amount_bits
++ : row.amount;
++ const one = personPart(kind, amount);
++ return one ? [one] : null;
++}
++
++function personKind(v) {
++ const s = String(v || '').toLowerCase();
++ if (s === 'vibes' || s === 'vibes-bits' || s === 'user_credit') return 'vibes';
++ if (s === 'balance' || s === 'usd' || s === 'nano-usd' || s === 'usd-cents' || s === 'usd_credit') return 'balance';
++ return null;
++}
++
++/**
++ * Billing's GET /api/me/activity (the person's own token) in either plausible shape: the journal transactions it
++ * already has (entries per account), or one row per movement. Null means "not that shape" so the caller can use the
++ * marked fallback below. Grouping is by transaction id, so a from-Balance purchase stays one row.
++ */
++function mapPersonActivity(subject, body) {
++ const list = body && (body.activity || body.transactions || body.items || body.entries);
++ if (!Array.isArray(list)) return null;
++ if (list.length && list.every((t) => t && Array.isArray(t.entries))) return mapActivity(subject, list);
++ const grouped = new Map();
++ for (const item of list) {
++ if (!item || typeof item !== 'object') continue;
++ const parts = partsOfPersonRow(item);
++ if (!parts) continue;
++ const id = String(item.transaction_id || item.txn_id || item.id || `${item.at || item.created_at || ''}:${parts[0].kind}`);
++ const at = item.at || item.created_at || item.date || null;
++ const label = text(item.label || item.description || item.title || 'Activity', 120);
++ const detail = text(item.detail || item.subtitle || '', 120);
++ const row = grouped.get(id) || { id, at, label, detail, parts: [] };
++ for (const p of parts) if (!row.parts.some((x) => x.kind === p.kind)) row.parts.push(p);
++ grouped.set(id, row);
++ }
++ const rows = [...grouped.values()].filter((r) => r.parts.length);
++ for (const row of rows) {
++ row.parts.sort((a, b) => (a.kind === 'balance' ? -1 : 1));
++ row.kinds = row.parts.map((p) => p.kind);
++ }
++ rows.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')));
++ return rows.slice(0, 100);
++}
++
++// ── Routes ──────────────────────────────────────────────────────────────────────────────────────
++
++module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
++ const r = express.Router();
++ const billingUrl = billingBase(opts);
++ const selfToken = typeof opts.selfToken === 'function' ? opts.selfToken : null;
++ let ratesCache = { at: 0, body: null };
++
++ const json = express.json({ limit: '16kb' });
++
++ /** Billing's public rates (packs, bits per USD): no person data, safe to cache for a minute. */
++ async function rates() {
++ if (ratesCache.body && Date.now() - ratesCache.at < RATES_TTL_MS) return ratesCache.body;
++ if (!billingUrl) return null;
++ try {
++ const out = await ask(`${billingUrl}/api/v1/rates`, { timeoutMs: 2000 });
++ if (out.status !== 200 || !out.body) return ratesCache.body;
++ const b = out.body;
++ ratesCache = {
++ at: Date.now(),
++ body: {
++ bits_per_usd: Number(b.bits_per_usd) || 100,
++ min_purchase_bits: Number(b.min_purchase_bits) || 100,
++ packages: Array.isArray(b.packages) ? b.packages.filter((p) => p && Number.isInteger(Number(p.bits))).slice(0, 12)
++ .map((p) => ({ bits: Number(p.bits), price_cents: Number(p.price_cents) || 0 })) : [],
++ providers: b.providers && typeof b.providers === 'object' ? b.providers : {},
++ },
++ };
++ return ratesCache.body;
++ } catch {
++ return ratesCache.body;
++ }
++ }
++
++ /**
++ * The person's activity: Billing's GET /api/me/activity with their OWN token first (the same CORS as
++ * /api/me/balance). While that route is not in Billing yet (it lands in the d12-billing-balance PR), a clearly
++ * marked TEMPORARY FALLBACK reads the journal with Network's service token. The fallback is not seeded as a
++ * default grant: without an operator's explicit billing.balance.read grant it reads nothing.
++ */
++ async function activityOf(req) {
++ const subject = req.user && req.user.subject_id;
++ if (!subject) return { rows: [], source: 'none' };
++ const auth = personToken(req);
++ if (auth) {
++ try {
++ const out = await ask(`${billingUrl}/api/me/activity?limit=80`, { authorization: auth, timeoutMs: 2500 });
++ if (out.status === 200) {
++ const rows = mapPersonActivity(subject, out.body);
++ if (rows) return { rows, source: 'person' };
++ }
++ } catch { /* the fallback below covers a Network error */ }
++ }
++ // TEMPORARY FALLBACK — Billing has no person-token activity route yet. Remove it when /api/me/activity ships;
++ // it is a service read (billing.balance.read) scoped to this signed-in person's subject, and Network does not
++ // seed that capability by default.
++ if (!selfToken) return { rows: [], source: 'none' };
++ const token = selfToken(SERVICE_AUDIENCE, BALANCE_READ);
++ if (!token) return { rows: [], source: 'none' };
++ try {
++ const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}` });
++ if (out.status !== 200 || !out.body) return { rows: [], source: 'none' };
++ return { rows: mapActivity(subject, out.body.transactions), source: 'journal' };
++ } catch {
++ return { rows: [], source: 'none' };
++ }
++ }
++
++ // ── GET /api/wallet ──────────────────────────────────────
++ // One answer for the page: two person reads forwarded with the person's token, the journal read with Network's
++ // service token, the pack prices, and OpenCoins from Network's own ledger. A service that is down or not open
++ // yet nulls its part and says so; the rest still draws.
++ r.get('/', requireAuth, async (req, res) => {
++ if (!billingUrl) return problem(res, 503, 'billing.unavailable', 'Billing is not configured');
++ cacheHeaders(res);
++ const auth = personToken(req);
++ const [balance, vibes, activityAnswer, priceList] = await Promise.all([
++ auth ? ask(`${billingUrl}/api/me/balance`, { authorization: auth }).catch(() => null) : null,
++ auth ? ask(`${billingUrl}/api/me/vibes`, { authorization: auth }).catch(() => null) : null,
++ activityOf(req),
++ rates(),
++ ]);
++
++ const out = {
++ opencoins: Number(await wallet.getBalance(db, req.user.id)) || 0,
++ balance: null,
++ vibes: null,
++ projects: [],
++ activity: activityAnswer.rows,
++ activity_source: activityAnswer.source,
++ rates: priceList,
++ state: { balance: 'unavailable', vibes: 'unavailable' },
++ };
++
++ if (balance && balance.status === 200 && balance.body && balance.body.personal) {
++ out.balance = balance.body;
++ out.state.balance = 'open';
++ const projects = Array.isArray(balance.body.projects) ? balance.body.projects : [];
++ if (projects.length) {
++ const ids = projects.map((p) => p && p.owner && p.owner.id).filter((id) => /^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')));
++ const names = new Map();
++ if (ids.length) {
++ try {
++ const rows = await db.prepare(`SELECT id, name FROM dev_projects WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
++ for (const row of rows) names.set(row.id, row.name);
++ } catch { /* names are a nicety: the balance still shows */ }
++ }
++ out.projects = projects
++ .filter((p) => p && p.owner && /^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(p.owner.id || '')))
++ .map((p) => ({
++ id: p.owner.id,
++ name: names.get(p.owner.id) || 'Project',
++ url: `https://openvibe.services/projects/${encodeURIComponent(p.owner.id)}`,
++ balance_nano_usd: String(p.balance_nano_usd || '0'),
++ held_nano_usd: String(p.held_nano_usd || '0'),
++ available_nano_usd: String(p.available_nano_usd || '0'),
++ }));
++ }
++ } else if (balance && balance.status === 404) {
++ out.state.balance = 'closed';
++ }
++
++ if (vibes && vibes.status === 200 && vibes.body && Number.isFinite(Number(vibes.body.credit))) {
++ out.vibes = { credit: Number(vibes.body.credit), payable: Number(vibes.body.payable) || 0, bits_per_usd: Number(vibes.body.bits_per_usd) || (priceList ? priceList.bits_per_usd : 100) };
++ out.state.vibes = 'open';
++ } else if (vibes && vibes.status === 409) {
++ out.state.vibes = 'not_authoritative';
++ }
++
++ return res.json(out);
++ });
++
++ // ── GET /api/wallet/rates ────────────────────────────────
++ r.get('/rates', async (_req, res) => {
++ const body = await rates();
++ if (!body) return problem(res, 503, 'billing.rates_unavailable', 'prices are unavailable right now');
++ res.set('Cache-Control', 'private, max-age=60');
++ return res.json(body);
++ });
++
++ // ── POST /api/wallet/transfers ───────────────────────────
++ r.post('/transfers', bearerOnly, json, async (req, res) => {
++ const b = req.body || {};
++ const amount = String(b.amount_nano_usd == null ? '' : b.amount_nano_usd);
++ if (!/^[1-9][0-9]{0,18}$/.test(amount)) return problem(res, 422, 'wallet.invalid_amount', 'amount_nano_usd is a positive integer of nano-USD');
++ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(b.to_project_id || ''))) return problem(res, 422, 'wallet.invalid_project', 'to_project_id is a project id');
++ const key = String(b.idempotency_key || '').trim();
++ if (!KEY_RE.test(key)) return problem(res, 422, 'wallet.invalid_key', 'idempotency_key is 8-128 letters, digits, dot, colon, underscore or dash');
++ try {
++ const out = await ask(`${billingUrl}/api/me/balance/transfers`, {
++ authorization: personToken(req), method: 'POST',
++ body: { to_project_id: String(b.to_project_id), amount_nano_usd: amount, idempotency_key: key },
++ });
++ return relay(res, out, 'the transfer is unavailable');
++ } catch { return problem(res, 502, 'billing.unavailable', 'the transfer is unavailable'); }
++ });
++
++ // ── POST /api/wallet/vibes/from-balance ──────────────────
++ r.post('/vibes/from-balance', bearerOnly, json, async (req, res) => {
++ const b = req.body || {};
++ const vibes = Number(b.vibes);
++ if (!Number.isSafeInteger(vibes) || vibes <= 0) return problem(res, 422, 'wallet.invalid_amount', 'vibes is a positive whole number');
++ const key = String(b.idempotency_key || '').trim();
++ if (!KEY_RE.test(key)) return problem(res, 422, 'wallet.invalid_key', 'idempotency_key is 8-128 letters, digits, dot, colon, underscore or dash');
++ try {
++ const out = await ask(`${billingUrl}/api/me/vibes/from-balance`, {
++ authorization: personToken(req), method: 'POST',
++ body: { vibes, idempotency_key: key },
++ });
++ return relay(res, out, 'the Vibes purchase is unavailable');
++ } catch { return problem(res, 502, 'billing.unavailable', 'the Vibes purchase is unavailable'); }
++ });
++
++ // ── POST /api/wallet/topups ──────────────────────────────
++ r.post('/topups', bearerOnly, json, async (req, res) => {
++ const b = req.body || {};
++ const amount = Number(b.amount_cents);
++ if (!Number.isSafeInteger(amount) || amount <= 0) return problem(res, 422, 'wallet.invalid_amount', 'amount_cents is a positive whole number');
++ if (!/^[a-z0-9-]{2,32}$/.test(String(b.provider || ''))) return problem(res, 422, 'wallet.invalid_provider', 'choose a payment provider');
++ try {
++ const out = await ask(`${billingUrl}/api/me/credits/topups`, {
++ authorization: personToken(req), method: 'POST',
++ body: { provider: String(b.provider), amount_cents: amount },
++ });
++ return relay(res, out, 'the top-up is unavailable');
++ } catch { return problem(res, 502, 'billing.unavailable', 'the top-up is unavailable'); }
++ });
++
++ // ── Vibes Pay: the pay sheet's charge ────────────────────
++ function chargeId(req, res) {
++ const id = String(req.params.id || '');
++ if (!CHARGE_RE.test(id)) { problem(res, 404, 'wallet.charge_not_found', 'no such payment link'); return null; }
++ return id;
++ }
++
++ r.get('/charge/:id', requireAuth, async (req, res) => {
++ const id = chargeId(req, res);
++ if (!id) return;
++ try {
++ const out = await ask(`${billingUrl}/api/me/vibes/charges/${encodeURIComponent(id)}`, { authorization: personToken(req) });
++ if (needsSignIn(res, out)) return relay(res, out, 'sign in again to see this payment');
++ if (out.status === 200 && out.body) await enrichCharge(db, out.body).catch(() => { /* the name is a nicety */ });
++ return relay(res, out, 'this payment link is unavailable');
++ } catch { return problem(res, 502, 'billing.unavailable', 'this payment link is unavailable'); }
++ });
++
++ r.post('/charge/:id/pay', bearerOnly, json, async (req, res) => {
++ const id = chargeId(req, res);
++ if (!id) return;
++ const b = req.body || {};
++ const key = String(b.idempotency_key || '').trim();
++ if (!KEY_RE.test(key)) return problem(res, 422, 'wallet.invalid_key', 'idempotency_key is 8-128 letters, digits, dot, colon, underscore or dash');
++ try {
++ const out = await ask(`${billingUrl}/api/me/vibes/charges/${encodeURIComponent(id)}/pay`, {
++ authorization: personToken(req), method: 'POST',
++ body: { idempotency_key: key },
++ });
++ return relay(res, out, 'the payment could not be completed');
++ } catch { return problem(res, 502, 'billing.unavailable', 'the payment could not be completed'); }
++ });
++
++ r.post('/charge/:id/cancel', bearerOnly, json, async (req, res) => {
++ const id = chargeId(req, res);
++ if (!id) return;
++ try {
++ const out = await ask(`${billingUrl}/api/me/vibes/charges/${encodeURIComponent(id)}/cancel`, {
++ authorization: personToken(req), method: 'POST', body: {},
++ });
++ return relay(res, out, 'the payment could not be cancelled');
++ } catch { return problem(res, 502, 'billing.unavailable', 'the payment could not be cancelled'); }
++ });
++
++ return r;
++};
++
++module.exports.mapActivity = mapActivity;
++module.exports.describe = describe;
++module.exports.enrichCharge = enrichCharge;
++module.exports.mapPersonActivity = mapPersonActivity;
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 5290b74a..a62105a9 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/ai-config-proxy.test.js b/test/ai-config-proxy.test.js
nnew file mode 100644
iindex 00000000..8d2891a8
---- /dev/null
++++ b/test/ai-config-proxy.test.js
@@@ -0,0 +1,251 @@
++'use strict';
++// The owner's platform settings proxy (server/admin/ai-config-proxy.js, AI Exchange D11 S3) against fake services:
++// every route maps to the service's /internal/config with Network's own token, the owner's usr_ id in X-OV-Subject
++// and (when fresh) X-OV-Fresh; a secret write or an x-danger change needs a sign-in within 10 minutes; an invalid
++// summary is dropped with a warning; every secret and danger change is audited and raises an operator alert; and no
++// value ever reaches a Network table or log line.
++// node test/ai-config-proxy.test.js
++const assert = require('assert');
++const http = require('http');
++const jwt = require('jsonwebtoken');
++const { buildWorld } = require('./security-world');
++const { checkSecretsStored } = require('./security-suite');
++const principals = require('../server/identity/principals');
++
++const out = (...a) => process.stdout.write(a.join(' ') + '\n');
++const NOW = new Date().toISOString();
++const FP = 'a'.repeat(64);
++
++/** A fake service: records every request and answers from `routes` ({ 'METHOD /path': body | fn }). */
++function fake(name) {
++ const seen = [];
++ const holder = { name, seen, routes: {} };
++ holder.srv = http.createServer((req, res) => {
++ let text = '';
++ req.on('data', (d) => { text += d; });
++ req.on('end', () => {
++ let body = null;
++ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
++ const rec = { method: req.method, url: req.url, headers: req.headers, body, text };
++ seen.push(rec);
++ const key = `${req.method} ${req.url.split('?')[0]}`;
++ const hit = holder.routes[key];
++ const answer = typeof hit === 'function' ? hit(rec) : hit;
++ if (answer === undefined) { res.writeHead(404, { 'content-type': 'application/problem+json' }); return res.end(JSON.stringify({ code: 'config.route_not_found', error: 'no such route' })); }
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify(answer));
++ });
++ });
++ return new Promise((resolve) => holder.srv.listen(0, '127.0.0.1', () => { holder.url = `http://127.0.0.1:${holder.srv.address().port}`; resolve(holder); }));
++}
++
++function summary(service, namespace, extra = {}) {
++ return {
++ service, namespace, revision: 7,
++ values: { paid_requests: false, daily_budget_nano_usd: 2500000000, model_catalog: ['deepseek-chat'], routing: 'auto' },
++ classification: { paid_requests: 'internal', daily_budget_nano_usd: 'internal', model_catalog: 'internal', routing: 'internal' },
++ sealed: true, pins: [], schema: {
++ title: 'AI Exchange', description: 'What the exchange routes, spends and models.',
++ type: 'object',
++ properties: {
++ paid_requests: { type: 'boolean', title: 'Paid AI requests', description: 'Paid AI requests will spend Credits', 'x-danger': true },
++ daily_budget_nano_usd: { type: 'integer', title: 'Daily budget', 'x-unit': 'nano_usd' },
++ model_catalog: { type: 'array', title: 'Model catalog' },
++ routing: { type: 'string', title: 'Routing', enum: ['auto', 'pinned'] },
++ },
++ },
++ active: null, last_known_good: null,
++ ...extra,
++ };
++}
++
++(async () => {
++ const SECRET = 'proxy-sentinel-value-3f9a1c-do-not-store';
++ const OWNER = 'rootowner';
++ const ai = await fake('ai');
++ const billing = await fake('billing');
++ let w = null;
++ try {
++ w = await buildWorld({
++ label: 'ai-config',
++ env: { OV_AI_INTERNAL_URL: ai.url, OV_BILLING_INTERNAL_URL: billing.url, AI_CONFIG_WRITES_PER_HOUR: '60' },
++ });
++ const owner = w.users.owner;
++ ai.seen.length = 0; billing.seen.length = 0;
++ const configCalls = () => [...ai.seen, ...billing.seen].filter((x) => x.url.startsWith('/internal/config') || x.url.startsWith('/api/v1/providers'));
++ const configWrites = () => configCalls().filter((x) => !['GET', 'HEAD'].includes(x.method));
++ const fresh = jwt.sign({ sub: owner.id, id: owner.id, username: owner.username, subject_id: owner.subject, role: owner.role, auth_time: Math.floor(Date.now() / 1000) },
++ w.keys.privateKey, { algorithm: 'RS256', issuer: w.issuer, expiresIn: '1h' });
++ const stale = w.users.owner.token; // no auth_time: the account-merge tests' shape
++ const call = (who, method, p, body) => w.call(who, method, p, body);
++ const ownerCall = (method, p, body) => call({ authorization: `Bearer ${fresh}`, cookie: `ov_token=${fresh}` }, method, p, body);
++
++ // ── The answer the fakes give, per namespace ───────────────────
++ const exchange = summary('ai', 'ai.exchange', { kek: 'k1' });
++ const broken = summary('ai', 'ai.broken', { values: { api_key: { redacted: true, fingerprint: FP, hint: 'a1b2', value: SECRET } } });
++ const credits = summary('billing', 'billing.credits', { sealed: false });
++ const creditsBroken = summary('billing', 'billing.broken', { values: { api_key: { redacted: true, fingerprint: FP, value: 'a-plain-value' } } });
++ Object.assign(ai.routes, {
++ 'GET /internal/config': { namespaces: [exchange, broken] },
++ 'GET /internal/config/ai.exchange': exchange,
++ 'GET /internal/config/ai.exchange/history': { namespace: 'ai.exchange', snapshots: [{ revision: 7, reason: 'seed' }], next_before: null },
++ 'POST /internal/config/ai.exchange': { service: 'ai', namespace: 'ai.exchange', revision: 8, state: 'active' },
++ 'POST /internal/config/ai.exchange/rollback': { service: 'ai', namespace: 'ai.exchange', revision: 9, state: 'active' },
++ 'PUT /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },
++ 'DELETE /internal/config/ai.exchange/secrets/provider_key': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },
++ 'PUT /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' },
++ 'DELETE /internal/config/ai.provider_keys/secrets/deepseek': { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: null },
++ 'GET /api/v1/providers': { providers: [{ key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: 'https://api.deepseek.com', secret_ref: 'vault:deepseek', billing_profile: 'metered', metadata: { provider_access: 'OWN_DEPLOYMENT' }, credentials: 'configured' }] },
++ 'POST /api/v1/providers': { provider: { key: 'new', display_name: 'New', kind: 'openai', status: 'active', base_url: 'https://new.example', secret_ref: null, billing_profile: 'metered', credentials: 'missing' } },
++ 'PATCH /api/v1/providers/deepseek': { provider: { key: 'deepseek', display_name: 'DeepSeek', kind: 'openai', status: 'active', base_url: 'https://api.deepseek.com', secret_ref: 'vault:deepseek', billing_profile: 'metered', credentials: 'configured' } },
++ });
++ Object.assign(billing.routes, {
++ 'GET /internal/config': { namespaces: [credits, creditsBroken] },
++ 'GET /internal/config/billing.credits': credits,
++ });
++
++ // ── Only the owner: a user and a plain admin are refused, and nothing reaches a service ──
++ let r = await call('alice', 'GET', '/api/admin/ai/config/services');
++ assert.strictEqual(r.status, 403, 'a user never gets the owner settings');
++ r = await call('staff', 'GET', '/api/admin/ai/config/services');
++ assert.strictEqual(r.status, 403, 'a plain admin never gets the owner settings');
++ assert.strictEqual(configCalls().length, 0, 'a refused request never reaches a service');
++
++ // ── GET /services: the nine services and their operator-set internal URLs ──
++ r = await ownerCall('GET', '/api/admin/ai/config/services');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.services.length, 9);
++ assert.strictEqual(r.body.services.find((s) => s.service === 'ai').internal_url, ai.url);
++ assert.strictEqual(r.body.services.find((s) => s.service === 'billing').internal_url, billing.url);
++
++ // ── GET /:service: an invalid summary is dropped, with a warning row; the vault keeps the KEK ──
++ r = await ownerCall('GET', '/api/admin/ai/config/service/ai');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.namespaces.map((n) => n.namespace), ['ai.exchange'], 'the unambiguous /service/:service alias lists a service');
++ r = await ownerCall('GET', '/api/admin/ai/config/ai');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.namespaces.map((n) => n.namespace), ['ai.exchange'], 'the invalid summary is dropped');
++ assert.strictEqual(r.body.warnings.length, 1);
++ assert.strictEqual(r.body.warnings[0].namespace, 'ai.broken');
++ assert.match(r.body.warnings[0].detail, /api_key|redacted|additional/i);
++ assert.deepStrictEqual(r.body.vault, { sealed: true, kek: 'k1' });
++ assert.ok(!r.text.includes(SECRET), 'the dropped summary never reaches the browser');
++ const listCall = ai.seen.find((x) => x.method === 'GET' && x.url === '/internal/config');
++ assert.match(listCall.headers.authorization, /^Bearer /, 'the service gets Network\'s own token');
++ assert.strictEqual(listCall.headers['x-ov-subject'], owner.subject, 'the owner travels in X-OV-Subject');
++ assert.strictEqual(billing.seen.filter((x) => x.url.startsWith('/internal/config')).length, 0, 'AI is the only service asked so far');
++
++ // ── GET one namespace, and its history (query kept) ──
++ r = await ownerCall('GET', '/api/admin/ai/config/ai/ai.exchange');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.namespace.revision, 7);
++ assert.ok(Array.isArray(r.body.namespace.pins));
++ r = await ownerCall('GET', '/api/admin/ai/config/ai/ai.exchange/history?limit=5');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(ai.seen.some((x) => x.url === '/internal/config/ai.exchange/history?limit=5'), 'the history query reaches AI');
++
++ // ── A stale sign-in: secret and danger writes are refused before any service sees them ──
++ const before = configWrites().length;
++ r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'PUT', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { value: SECRET, reason: 'test' });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.body.code, 'config.fresh_required');
++ assert.match(r.body.sign_in_url, /\/login\?return=.*%2Fadmin%23ai/, 'the sign-in returns to /admin#ai');
++ r = await call({ authorization: `Bearer ${stale}`, cookie: `ov_token=${stale}` }, 'POST', '/api/admin/ai/config/ai/ai.exchange', { values: { paid_requests: true }, reason: 'test' });
++ assert.strictEqual(r.status, 403, r.text);
++ assert.strictEqual(r.body.code, 'config.fresh_required');
++ assert.strictEqual(configWrites().length, before, 'a stale sign-in never sends a write to AI');
++
++ // ── A fresh danger change: X-OV-Fresh to the service, an audit row and an operator alert ──
++ r = await ownerCall('POST', '/api/admin/ai/config/ai/ai.exchange', { values: { paid_requests: true }, reason: 'turn paid requests on', merge: true });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.revision, 8);
++ const dangerCall = ai.seen.filter((x) => x.method === 'POST' && x.url === '/internal/config/ai.exchange').at(-1);
++ assert.strictEqual(dangerCall.headers['x-ov-fresh'], '1', 'a fresh danger change tells the service it is fresh');
++ assert.deepStrictEqual(dangerCall.body, { values: { paid_requests: true }, reason: 'turn paid requests on', merge: true }, 'the change is streamed through unchanged');
++ const audit = await w.db.prepare("SELECT action, details FROM audit_log WHERE user_id = ? AND action = 'ai_config_change' ORDER BY id DESC LIMIT 1").get(owner.id);
++ const details = JSON.parse(audit.details);
++ assert.deepStrictEqual({ service: details.service, namespace: details.namespace, key: details.key, action: details.action, revision: details.revision },
++ { service: 'ai', namespace: 'ai.exchange', key: 'paid_requests', action: 'config.change', revision: 8 });
++ assert.strictEqual((await w.db.prepare("SELECT COUNT(*) AS n FROM notifications WHERE user_id = ? AND type = 'OPERATOR_ALERT'").get(owner.id)).n, 1, 'the danger change raises one operator alert');
++ const note = await w.db.prepare("SELECT title, message FROM notifications WHERE user_id = ? AND type = 'OPERATOR_ALERT' ORDER BY id DESC LIMIT 1").get(owner.id);
++ assert.match(note.message, /ai\.exchange/);
++ assert.match(note.message, /paid_requests/);
++ assert.match(note.message, new RegExp(`@${OWNER}`));
++
++ // ── A fresh secret write: forwarded once, audited, alerted, and never stored or logged ──
++ r = await ownerCall('PUT', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { value: SECRET, reason: 'rotate' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body, { redacted: true, fingerprint: FP, hint: 'a1b2', set_at: NOW, bound_to: 'https://api.deepseek.com' });
++ const secretCall = ai.seen.filter((x) => x.method === 'PUT' && x.url === '/internal/config/ai.exchange/secrets/provider_key').at(-1);
++ assert.strictEqual(secretCall.method, 'PUT');
++ assert.strictEqual(secretCall.headers['x-ov-fresh'], '1');
++ assert.strictEqual(secretCall.body.value, SECRET, 'the service gets the value once');
++ assert.strictEqual((await w.db.prepare("SELECT COUNT(*) AS n FROM notifications WHERE user_id = ? AND type = 'OPERATOR_ALERT'").get(owner.id)).n, 2, 'the secret write raises another alert');
++ const secretAudit = JSON.parse((await w.db.prepare("SELECT details FROM audit_log WHERE user_id = ? AND action = 'ai_config_change' ORDER BY id DESC LIMIT 1").get(owner.id)).details);
++ assert.deepStrictEqual({ service: secretAudit.service, namespace: secretAudit.namespace, key: secretAudit.key, action: secretAudit.action }, { service: 'ai', namespace: 'ai.exchange', key: 'provider_key', action: 'secret.put' });
++ assert.ok(!JSON.stringify(secretAudit).includes(SECRET));
++
++ // ── Secret delete, rollback, and the provider registry (GET/POST/PATCH and its write-only key) ──
++ r = await ownerCall('DELETE', '/api/admin/ai/config/ai/ai.exchange/secrets/provider_key', { reason: 'gone' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await ownerCall('POST', '/api/admin/ai/config/ai/ai.exchange/rollback', { to: 6, reason: 'back' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await ownerCall('GET', '/api/admin/ai/config/providers');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.providers[0].provider_access, 'OWN_DEPLOYMENT');
++ assert.strictEqual(r.body.providers[0].key, 'deepseek');
++ assert.ok(ai.seen.filter((x) => x.method === 'GET' && x.url === '/api/v1/providers').every((x) => x.headers['x-ov-subject'] === owner.subject));
++ r = await ownerCall('POST', '/api/admin/ai/config/providers', { key: 'new', display_name: 'New', kind: 'openai', base_url: 'https://new.example', billing_profile: 'metered', provider_access: 'UNVERIFIED' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await ownerCall('PATCH', '/api/admin/ai/config/providers/deepseek', { display_name: 'DeepSeek 2' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await ownerCall('PUT', '/api/admin/ai/config/providers/deepseek/key', { value: SECRET, reason: 'vault it' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(ai.seen.some((x) => x.method === 'PUT' && x.url === '/internal/config/ai.provider_keys/secrets/deepseek' && x.body.value === SECRET));
++ assert.ok(ai.seen.some((x) => x.method === 'PATCH' && x.url === '/api/v1/providers/deepseek' && x.body.secret_ref === 'vault:deepseek'));
++ r = await ownerCall('DELETE', '/api/admin/ai/config/providers/deepseek/key', { reason: 'remove' });
++ assert.strictEqual(r.status, 200, r.text);
++
++ // ── An invalid service answer is dropped, with a warning row ──
++ r = await ownerCall('GET', '/api/admin/ai/config/billing');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.namespaces.map((n) => n.namespace), ['billing.credits']);
++ assert.strictEqual(r.body.warnings.length, 1);
++ assert.strictEqual(r.body.warnings[0].namespace, 'billing.broken');
++ assert.deepStrictEqual(r.body.vault, { sealed: false, kek: null }, 'the vault says billing is not sealed');
++
++ // ── The grants: nine config.manage capabilities for Network, ai.provider.manage, and Host's usage read ──
++ const has = (client, cap, aud) => principals.DEFAULT_GRANTS.some((g) => g[0] === client && g[1] === cap && g[2] === aud);
++ for (const svc of ['ai', 'billing', 'services', 'watch', 'bench', 'report', 'host', 'codes', 'actor']) {
++ assert.ok(has('network', `${svc}.config.manage`, `openvibe.${svc}`), `Network holds ${svc}.config.manage`);
++ }
++ assert.ok(has('network', 'ai.provider.manage', 'openvibe.ai'), 'Network holds ai.provider.manage');
++ assert.ok(has('host', 'ai.usage.read', 'openvibe.ai'), 'Host holds ai.usage.read');
++ // The boot seed only inserts a grant when its client exists. In production the network principal does; here we
++ // make it and run the same ensureSchema() the deploy runs at boot, then check the rows.
++ await w.db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('network', 'x', 'OpenVibe.Network (service)', '[]', 1) ON CONFLICT DO NOTHING").run();
++ await principals.ensureSchema(w.db);
++ const seeded = (await w.db.prepare("SELECT capability, audience FROM principal_grants WHERE client_id = 'network' AND capability LIKE '%config.manage' AND revoked_at IS NULL ORDER BY capability").all()).map((x) => `${x.capability}|${x.audience}`).sort();
++ assert.deepStrictEqual(seeded, ['actor.config.manage|openvibe.actor', 'ai.config.manage|openvibe.ai', 'bench.config.manage|openvibe.bench', 'billing.config.manage|openvibe.billing', 'codes.config.manage|openvibe.codes', 'host.config.manage|openvibe.host', 'report.config.manage|openvibe.report', 'services.config.manage|openvibe.services', 'watch.config.manage|openvibe.watch']);
++ assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'network' AND capability = 'ai.provider.manage' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'the provider grant is seeded');
++ assert.ok(await w.db.prepare("SELECT 1 FROM principal_grants WHERE client_id = 'host' AND capability = 'ai.usage.read' AND audience = 'openvibe.ai' AND revoked_at IS NULL").get(), 'Host\'s usage grant is seeded');
++
++ // ── No secret in a Network table or log line (the audit and alerts above ran meanwhile) ──
++ await checkSecretsStored(w, () => ({ 'proxy secret': SECRET, 'proxy secret hint': FP }));
++
++ // ── 60 writes an hour: keep writing until the limit answers 429 ──
++ let limited = null;
++ for (let i = 0; i < 70 && !limited; i++) {
++ const rr = await ownerCall('POST', '/api/admin/ai/config/ai/ai.exchange', { values: { routing: 'auto' }, reason: 'limit probe', merge: true });
++ if (rr.status === 429) limited = rr;
++ }
++ assert.ok(limited, 'the owner is rate-limited after the allowance');
++ assert.strictEqual(limited.body.code, 'config.rate_limited');
++
++ out('ai config proxy: all checks passed');
++ } finally {
++ if (w) await w.stop();
++ await new Promise((r) => ai.srv.close(r));
++ await new Promise((r) => billing.srv.close(r));
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/developer-api-keys.test.js b/test/developer-api-keys.test.js
nnew file mode 100644
iindex 00000000..e0346b53
---- /dev/null
++++ b/test/developer-api-keys.test.js
@@@ -0,0 +1,339 @@
++'use strict';
++// Developer API keys and per-project budgets (plan G-P3, server/developer/api-keys.js):
++// - a key is shown once, and only sha256(secret) is stored;
++// - a revoked key is refused at once (the exchange says so immediately);
++// - the scope is enforced: a key may only be exchanged for an audience its scopes cover, and the
++// token carries the capability of the scope (ai.inference -> ai.app.run, D8);
++// - the per-project monthly budget is an integer number of nano-USD (D1), read and written;
++// - the key never reaches a log, an audit row or an error message (every log line is captured here);
++// - everything is off unless NETWORK_DEV_API_KEYS=1.
++// node test/developer-api-keys.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { serviceAuth } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const apiKeys = require('../server/developer/api-keys');
++
++(async () => {
++const logged = [];
++const orig = { log: console.log, warn: console.warn, error: console.error };
++for (const k of ['log', 'warn', 'error']) console[k] = (...a) => { logged.push(a.map(String).join(' ')); };
++const out = (...a) => orig.log(...a);
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-apikeys-'));
++const db = getDb();
++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (12, 'viewer', 'x', 'user'), (13, 'stranger', 'x', 'user'),
++ (14, 'staff', 'x', 'admin')`).run();
++await db.prepare("UPDATE oauth_clients SET client_secret = 'ai-secret' WHERE client_id = 'ai'").run();
++const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.locals.db = db;
++app.locals.config = {
++ baseUrl: ISSUER, loginUrl: ISSUER,
++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
++ // The feature is on here; the flag-off case is proved below with a second app.
++ developer: { sandboxAudiences: 'openvibe.ai', sandboxAllowance: '', credentialOverlapS: 60, apiKeys: true },
++};
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/internal', require('../server/internal/routes'));
++app.use('/api/v1/projects', require('../server/developer/routes').router());
++const server = http.createServer(app);
++
++const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++const T = { owner: userToken(10), dev: userToken(11), viewer: userToken(12), stranger: userToken(13), staff: userToken(14) };
++const secretsSeen = [];
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const api = async (who, method, p, body) => {
++ const h = { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) };
++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, type: r.headers.get('content-type') || '', text, body: text ? JSON.parse(text) : null };
++ };
++ const serviceToken = async (id, secret) => (await (await fetch(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }),
++ })).json()).access_token;
++ const exchange = async (apiKey, audience, token) => {
++ const r = await fetch(`${base}/internal/api-keys/exchange`, {
++ method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
++ body: JSON.stringify({ api_key: apiKey, audience }),
++ });
++ const text = await r.text();
++ return { status: r.status, type: r.headers.get('content-type') || '', text, body: text ? JSON.parse(text) : null };
++ };
++ const readBudget = async (id, token) => {
++ const r = await fetch(`${base}/internal/projects/${encodeURIComponent(id)}/budget`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
++ const text = await r.text();
++ return { status: r.status, cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
++ };
++ try {
++ // ── A project, a sandbox app and a live app ──
++ let r = await api('owner', 'POST', '', { name: 'Keys' });
++ assert.strictEqual(r.status, 201, r.text);
++ const P = r.body.id;
++ r = await api('owner', 'POST', `/${P}/members`, { username: 'dev', role: 'developer' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('owner', 'POST', `/${P}/members`, { username: 'viewer', role: 'viewer' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Test app', environment: 'sandbox', type: 'confidential' });
++ assert.strictEqual(r.status, 201, r.text);
++ const TEST_APP = r.body.id;
++ // Production needs a staff-set environment policy.
++ r = await api('staff', 'PUT', `/${P}/environment-policy`, { environment_policy: 'sandbox+production' });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Live app', environment: 'production', type: 'confidential' });
++ assert.strictEqual(r.status, 201, r.text);
++ const LIVE_APP = r.body.id;
++ // The apps hold the D8 capability (ai.app.run, public and active in contracts 0.284.0):
++ // staff set the project's allowance, then the grant is requested and approved.
++ r = await api('staff', 'PUT', `/${P}/allowance`, { capabilities: ['ai.app.run'] });
++ assert.strictEqual(r.status, 200, r.text);
++ for (const appId of [TEST_APP, LIVE_APP]) {
++ r = await api('owner', 'POST', `/${P}/apps/${appId}/grants`, { capability: 'ai.app.run' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('owner', 'POST', `/${P}/apps/${appId}/grants/ai.app.run/approve`);
++ assert.strictEqual(r.status, 200, r.text);
++ }
++
++ // ── The key is shown once, and only its hash is stored ──
++ r = await api('owner', 'POST', `/${P}/api-keys`, { name: 'CI key', app_id: TEST_APP, scopes: ['ai.inference'], environment: 'test' });
++ assert.strictEqual(r.status, 201, r.text);
++ const KEY = r.body;
++ assert.match(KEY.id, /^key_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.match(KEY.api_key, /^ovk_[A-Za-z0-9_-]{40,}$/, 'the secret is an ovk_ key');
++ assert.strictEqual(KEY.shown_once, true);
++ assert.strictEqual(KEY.environment, 'test');
++ assert.deepStrictEqual(KEY.scopes, ['ai.inference']);
++ assert.strictEqual(KEY.hint, KEY.api_key.slice(-4), 'the hint is the last four characters');
++ secretsSeen.push(KEY.api_key);
++ const row = await db.prepare('SELECT * FROM dev_api_keys WHERE id = ?').get(KEY.id);
++ assert.ok(row, 'the key has a row');
++ assert.strictEqual(row.key_hash, crypto.createHash('sha256').update(KEY.api_key).digest('hex'), 'only sha256(secret) is stored');
++ assert.ok(!JSON.stringify(row).includes(KEY.api_key), 'the secret is nowhere in the row');
++ assert.ok(!KEY.api_key.includes(row.key_hash) && !row.key_hash.includes(KEY.api_key), 'the secret is not the hash');
++ assert.ok(row.key_hash.length === 64, 'a sha256 hex digest');
++
++ // It is never shown again: the list carries metadata only.
++ r = await api('owner', 'GET', `/${P}/api-keys`);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.keys.length, 1);
++ assert.ok(!r.text.includes(KEY.api_key), 'the secret is not in the list');
++ assert.strictEqual(r.body.keys[0].hint, KEY.hint);
++ assert.strictEqual(r.body.keys[0].status, 'active');
++
++ // ── The scope is enforced and the token carries the capability (D8: ai.app.run) ──
++ const aiToken = await serviceToken('ai', 'ai-secret');
++ assert.ok(aiToken, 'AI holds a service token for openvibe.network');
++ let x = await exchange(KEY.api_key, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 200, x.text);
++ assert.strictEqual(x.body.project_id, P);
++ assert.strictEqual(x.body.app_id, TEST_APP);
++ assert.strictEqual(x.body.subject, `app:${TEST_APP}`);
++ assert.strictEqual(x.body.environment, 'test');
++ assert.strictEqual(x.body.token_env, 'sandbox', 'a test key is a sandbox token');
++ assert.deepStrictEqual(x.body.scopes, ['ai.inference']);
++ assert.deepStrictEqual(x.body.capability, ['ai.app.run'], 'the scope maps to the D8 capability');
++ assert.strictEqual(x.body.expires_in, 300);
++ const claims = serviceAuth.verifyServiceToken(x.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.ai', acceptSandbox: true });
++ assert.ok(claims.ok, 'the exchanged token verifies like an app token');
++ assert.strictEqual(claims.claims.sub, `app:${TEST_APP}`);
++ assert.deepStrictEqual(claims.claims.cap, ['ai.app.run']);
++ assert.strictEqual(claims.claims.project_id, P);
++ assert.strictEqual(claims.claims.env, 'sandbox');
++
++ // A scope the key does not hold: the audience is refused, by Network.
++ x = await exchange(KEY.api_key, 'openvibe.media', aiToken);
++ assert.strictEqual(x.status, 403, x.text);
++ assert.strictEqual(x.body.code, 'api_key.scope_denied');
++
++ // The exchange needs a service token: no token, no key.
++ x = await exchange(KEY.api_key, 'openvibe.ai', null);
++ assert.strictEqual(x.status, 401, x.text);
++ assert.strictEqual(x.body.code, 'token.missing');
++
++ // ── The budget: an integer number of nano-USD (D1), read and written ──
++ r = await api('owner', 'GET', `/${P}/budget`);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.monthly_budget_nano_usd, null, 'no budget yet');
++ assert.strictEqual(r.body.currency, 'nano_usd');
++ r = await api('owner', 'PUT', `/${P}/budget`, { monthly_budget_nano_usd: '5000000000' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.monthly_budget_nano_usd, '5000000000', 'the budget is a nano-USD string');
++ r = await api('owner', 'GET', `/${P}/budget`);
++ assert.strictEqual(r.body.monthly_budget_nano_usd, '5000000000');
++ // A float or a fraction is refused: money is an integer number of nano-USD.
++ r = await api('owner', 'PUT', `/${P}/budget`, { monthly_budget_nano_usd: '1.5' });
++ assert.strictEqual(r.status, 422, r.text);
++ assert.strictEqual(r.body.code, 'budget.invalid');
++ r = await api('owner', 'PUT', `/${P}/budget`, { monthly_budget_nano_usd: '-1' });
++ assert.strictEqual(r.status, 422);
++ // A viewer does not read or write the budget; a developer neither.
++ r = await api('viewer', 'GET', `/${P}/budget`);
++ assert.strictEqual(r.status, 403, r.text);
++ r = await api('dev', 'PUT', `/${P}/budget`, { monthly_budget_nano_usd: '1' });
++ assert.strictEqual(r.status, 403);
++ // AI reads it with its service token.
++ let b = await readBudget(P, aiToken);
++ assert.strictEqual(b.status, 200, b.text);
++ assert.strictEqual(b.body.monthly_budget_nano_usd, '5000000000');
++ assert.strictEqual(b.body.currency, 'nano_usd');
++ assert.strictEqual(b.cache, 'no-store', 'the budget is never cached');
++ b = await readBudget(P, null);
++ assert.strictEqual(b.status, 401);
++ b = await readBudget('prj_00000000000000000000000000', aiToken);
++ assert.strictEqual(b.status, 404);
++
++ // ── Rotate: the same key id, a new secret, shown once; the old one stops working ──
++ r = await api('owner', 'POST', `/${P}/api-keys/${KEY.id}/rotate`, {});
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.id, KEY.id, 'the key keeps its id');
++ assert.strictEqual(r.body.shown_once, true);
++ const ROTATED = r.body.api_key;
++ secretsSeen.push(ROTATED);
++ assert.notStrictEqual(ROTATED, KEY.api_key);
++ x = await exchange(ROTATED, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 200, x.text);
++ x = await exchange(KEY.api_key, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 401, 'the old secret fails at once');
++ assert.strictEqual(x.body.code, 'api_key.invalid');
++
++ // ── Revoke: refused at once ──
++ r = await api('owner', 'POST', `/${P}/api-keys/${KEY.id}/revoke`, {});
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.status, 'revoked');
++ x = await exchange(ROTATED, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 401, 'a revoked key is refused at once');
++ assert.strictEqual(x.body.code, 'api_key.invalid');
++ r = await api('owner', 'GET', `/${P}/api-keys`);
++ assert.strictEqual(r.body.keys[0].status, 'revoked');
++
++ // ── The audit: ids and scopes, never a secret ──
++ const audit = await db.prepare('SELECT * FROM dev_audit WHERE project_id = ? ORDER BY id').all(P);
++ const actions = audit.map(a => a.action);
++ assert.ok(actions.includes('api_key.created'), 'the creation is audited');
++ assert.ok(actions.includes('api_key.rotated'), 'the rotation is audited');
++ assert.ok(actions.includes('api_key.revoked'), 'the revocation is audited');
++ assert.ok(actions.includes('api_key.exchanged'), 'the exchange is audited');
++ assert.ok(actions.includes('project.budget_set'), 'the budget is audited');
++ for (const a of audit) {
++ assert.ok(!a.detail.includes('ovk_'), 'no audit row carries a key');
++ for (const s of secretsSeen) assert.ok(!a.detail.includes(s), 'no audit row carries the secret');
++ }
++
++ // ── The key is never logged ──
++ for (const s of secretsSeen) {
++ assert.ok(!logged.join('\n').includes(s), 'the key is not in any log line');
++ assert.ok(!logged.join('\n').includes('ovk_'), 'no log line carries an ovk_ key');
++ }
++ // And not in an error message either.
++ x = await exchange('ovk_' + 'A'.repeat(43), 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 401);
++ assert.ok(!x.text.includes('ovk_'), 'the error answer carries no key');
++
++ // ── Roles: a developer mints a test key, an admin a live one ──
++ r = await api('dev', 'POST', `/${P}/api-keys`, { name: 'Dev key', app_id: TEST_APP, scopes: ['ai.inference'], environment: 'test' });
++ assert.strictEqual(r.status, 201, r.text);
++ const DEV_KEY = r.body.api_key;
++ secretsSeen.push(DEV_KEY);
++ r = await api('dev', 'POST', `/${P}/api-keys`, { name: 'Live key', app_id: LIVE_APP, scopes: ['ai.inference'], environment: 'live' });
++ assert.strictEqual(r.status, 403, 'a developer may not mint a live key');
++ r = await api('owner', 'POST', `/${P}/api-keys`, { name: 'Live key', app_id: LIVE_APP, scopes: ['ai.inference'], environment: 'live' });
++ assert.strictEqual(r.status, 201, r.text);
++ const LIVE_KEY = r.body.api_key;
++ secretsSeen.push(LIVE_KEY);
++ x = await exchange(LIVE_KEY, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 200, x.text);
++ assert.strictEqual(x.body.token_env, 'production', 'a live key is a production token');
++ const liveClaims = serviceAuth.verifyServiceToken(x.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.ai' });
++ assert.ok(liveClaims.ok, 'a production token needs no sandbox opt-in');
++ assert.strictEqual(liveClaims.claims.env, 'production');
++ // A test key belongs to a sandbox app and a live key to a production one.
++ r = await api('owner', 'POST', `/${P}/api-keys`, { name: 'Mismatch', app_id: LIVE_APP, scopes: ['ai.inference'], environment: 'test' });
++ assert.strictEqual(r.status, 422, r.text);
++ // A stranger sees nothing.
++ r = await api('stranger', 'GET', `/${P}/api-keys`);
++ assert.strictEqual(r.status, 404, r.text);
++ // An unknown scope is refused.
++ r = await api('owner', 'POST', `/${P}/api-keys`, { name: 'Bad scope', app_id: TEST_APP, scopes: ['ai.everything'], environment: 'test' });
++ assert.strictEqual(r.status, 422, r.text);
++ assert.strictEqual(r.body.code, 'api_key.unknown_scope');
++
++ // ── Revoking the app revokes its keys ──
++ r = await api('owner', 'DELETE', `/${P}/apps/${TEST_APP}`);
++ assert.strictEqual(r.status, 200, r.text);
++ x = await exchange(DEV_KEY, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 401, 'a revoked app takes its keys with it');
++
++ // ── The scope is narrowed by the app's grants ──
++ // The installed release knows ai.app.run, so the app must hold it as an approved grant.
++ r = await api('owner', 'POST', `/${P}/api-keys`, { name: 'Narrowed', app_id: LIVE_APP, scopes: ['ai.inference'], environment: 'live' });
++ assert.strictEqual(r.status, 201, r.text);
++ const NARROWED = r.body.api_key;
++ secretsSeen.push(NARROWED);
++ x = await exchange(NARROWED, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 200, x.text);
++ assert.deepStrictEqual(x.body.capability, ['ai.app.run']);
++ // Revoking the grant takes the scope with it: the key may not be used any more.
++ r = await api('owner', 'DELETE', `/${P}/apps/${LIVE_APP}/grants/ai.app.run`);
++ assert.strictEqual(r.status, 200, r.text);
++ x = await exchange(NARROWED, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 403, 'a key whose app lost the grant may not be used');
++ assert.strictEqual(x.body.code, 'api_key.scope_denied');
++ // With the grant approved again, the same key works.
++ r = await api('owner', 'POST', `/${P}/apps/${LIVE_APP}/grants`, { capability: 'ai.app.run' });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await api('owner', 'POST', `/${P}/apps/${LIVE_APP}/grants/ai.app.run/approve`);
++ assert.strictEqual(r.status, 200, r.text);
++ x = await exchange(NARROWED, 'openvibe.ai', aiToken);
++ assert.strictEqual(x.status, 200, x.text);
++ assert.deepStrictEqual(x.body.capability, ['ai.app.run']);
++
++ // ── The flag: off by default ──
++ // A second app with the feature off answers 503 on every new route.
++ const offApp = express();
++ offApp.use(express.json());
++ offApp.locals.db = db;
++ offApp.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { apiKeys: false } };
++ offApp.locals.privateKey = keys.privateKey;
++ offApp.locals.publicKey = keys.publicKey;
++ offApp.use('/api/v1/projects', require('../server/developer/routes').router());
++ const offServer = http.createServer(offApp);
++ await new Promise(r => offServer.listen(0, '127.0.0.1', r));
++ const offBase = `http://127.0.0.1:${offServer.address().port}`;
++ let off = await fetch(`${offBase}/api/v1/projects/${P}/api-keys`, { headers: { authorization: `Bearer ${T.owner}` } });
++ assert.strictEqual(off.status, 503, 'the key routes are off unless NETWORK_DEV_API_KEYS=1');
++ const offBody = await off.json();
++ assert.strictEqual(offBody.code, 'api_keys.disabled');
++ off = await fetch(`${offBase}/api/v1/projects/${P}/budget`, { headers: { authorization: `Bearer ${T.owner}` } });
++ assert.strictEqual(off.status, 503, 'the budget routes are off too');
++ await new Promise(r => offServer.close(r));
++ assert.throws(() => apiKeys.requireEnabled({ developer: { apiKeys: false } }), (e) => e.code === 'api_keys.disabled');
++ assert.throws(() => apiKeys.requireEnabled({}), (e) => e.code === 'api_keys.disabled');
++ } catch (err) {
++ out('FAIL', err && err.stack ? err.stack : err);
++ out(logged.slice(-40).join('\n'));
++ process.exitCode = 1;
++ } finally {
++ server.close();
++ for (const k of ['log', 'warn', 'error']) console[k] = orig[k];
++ }
++})();
++})();
ddiff --git a/test/internal-projects.test.js b/test/internal-projects.test.js
iindex e03f8984..840c85ae 100644
---- a/test/internal-projects.test.js
++++ b/test/internal-projects.test.js
@@@ -1,6 +1,6 @@
'use strict';
// The service-facing project read (plan T2 lane B step 3, docs/developer-projects.md "Service-side project reads"):
--// GET /internal/projects/:project_id takes network.project.read on a service token (Host only by default), returns
++// GET /internal/projects/:project_id takes network.project.read on a service token (Host and AI by default), returns
// the project's tenancy, placement, allowance, quotas, apps and their grants, and never a secret, a client id, an
// actor label or a member other than the owner. Archived projects still read; a missing or malformed id is a 404.
// node test/internal-projects.test.js
@@@ -21,6 +21,7 @@ console.log = log;
await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'watch-secret' WHERE client_id = 'watch'").run();
++await db.prepare("UPDATE oauth_clients SET client_secret = 'billing-secret' WHERE client_id = 'billing'").run();
await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
(10, 'owner', 'x', 'user'), (11, 'dev', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
@@@ -60,9 +61,11 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
return { status: r.status, type: r.headers.get('content-type') || '', cache: r.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
};
try {
-- // 0. The grant: Host only by default, and never grantable to apps.
-- assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(g => g[1] === 'network.project.read').map(g => [g[0], g[2]]), [['host', principals.SELF_AUDIENCE]],
-- 'only Host holds network.project.read by default');
++ // 0. The grant: Host (tenancy) and AI (the service that spends for a project, plan G-P3) hold it by default,
++ // and it is never grantable to apps.
++ assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(g => g[1] === 'network.project.read').map(g => [g[0], g[2]]).sort(),
++ [['ai', principals.SELF_AUDIENCE], ['host', principals.SELF_AUDIENCE]],
++ 'Host and AI hold network.project.read by default');
assert.strictEqual(policy.grantability('network.project.read').grantable, false, 'network.project.read is never grantable to apps');
// A project made through the members' API: allowance, a quota, a second member, two apps with grants, one revoked.
@@@ -147,10 +150,23 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
// its console and control; never grantable;
// members only, and a non-member reads exactly like an unknown project.
assert.deepStrictEqual(principals.DEFAULT_GRANTS.filter(g => g[1] === 'network.project.member.read').map(g => [g[0], g[2]]).sort(),
-- [['actor', principals.SELF_AUDIENCE], ['media', principals.SELF_AUDIENCE], ['run', principals.SELF_AUDIENCE], ['watch', principals.SELF_AUDIENCE], ['zone', principals.SELF_AUDIENCE]],
-- 'Run, Watch, Actor, Media and Zone hold network.project.member.read');
++ [['actor', principals.SELF_AUDIENCE], ['billing', principals.SELF_AUDIENCE], ['media', principals.SELF_AUDIENCE], ['run', principals.SELF_AUDIENCE], ['watch', principals.SELF_AUDIENCE], ['zone', principals.SELF_AUDIENCE]],
++ 'Run, Watch, Actor, Media, Zone and Billing hold network.project.member.read');
assert.strictEqual(policy.grantability('network.project.member.read').grantable, false, 'never grantable to apps');
assert.ok(principals.DEFAULT_GRANTS.some(g => g[0] === 'watch' && g[1] === 'run.job.submit' && g[2] === 'openvibe.run'), 'Watch may submit Run jobs');
++ const memberList = async (subject, headers = {}) => {
++ const x = await fetch(`${base}/internal/projects?subject=${encodeURIComponent(subject)}`, { headers });
++ const text = await x.text();
++ return { status: x.status, cache: x.headers.get('cache-control'), text, body: text ? JSON.parse(text) : null };
++ };
++ const bill = { authorization: `Bearer ${await token('billing', 'billing-secret')}` };
++ r = await memberList(owner, bill);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.cache, 'no-store');
++ assert.deepStrictEqual(r.body.projects.map(p => [p.project_id, p.name, p.role, p.archived]), [[P, 'Tenancy', 'owner', false]]);
++ assert.strictEqual((await memberList(owner)).status, 401, 'no token');
++ assert.strictEqual((await memberList(owner, host)).status, 403, 'Host lacks network.project.member.read');
++ assert.strictEqual((await memberList('nope', bill)).status, 422, 'a malformed subject is refused');
const memberRead = async (project, subject, headers = {}) => {
const x = await fetch(`${base}/internal/projects/${encodeURIComponent(project)}/members/${encodeURIComponent(subject)}`, { headers });
const text = await x.text();
ddiff --git a/test/legacy-secrets-kek.test.js b/test/legacy-secrets-kek.test.js
nnew file mode 100644
iindex 00000000..40f56cd3
---- /dev/null
++++ b/test/legacy-secrets-kek.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * D11: a secret-class value copied into site_settings is blanked at boot once the sealed config journal holds it.
++ * A provider secret managed by server/secrets.js is environment-only and is never moved or blanked. Readers get
++ * the journal's value, and no summary or log carries the plaintext.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/legacy-secrets-kek.test.js
++ */
++const assert = require('assert');
++process.env.NODE_ENV = 'test';
++process.env.OV_CONFIG_KEKS = `k1:${Buffer.alloc(32, 9).toString('base64')}`;
++process.env.OV_CONFIG_KEK_CURRENT = 'k1';
++for (const n of ['NET_IPINFO_TOKEN', 'NET_GLOBALPING_TOKEN', 'DISCORD_BOT_TOKEN']) delete process.env[n];
++
++const SECRET = 'sentinel-not-a-secret-ipinfo';
++const PROVIDER = 'sentinel-not-a-secret-provider-token';
++const logs = [];
++const real = { log: console.log, warn: console.warn, error: console.error };
++console.log = (...a) => logs.push(a.map(String).join(' '));
++console.warn = (...a) => logs.push(a.map(String).join(' '));
++console.error = (...a) => logs.push(a.map(String).join(' '));
++
++(async () => {
++ const db = await require('../server/db/database').getDb();
++ const set = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type');
++ await set.run('net.ipinfo_token', SECRET, 'secret');
++ await set.run('discord_bot_token', PROVIDER, 'secret');
++ await set.run('platform_name', 'OpenVibe', 'string');
++
++ const siteConfig = require('../server/admin/site-config');
++ const report = await siteConfig.init(db);
++ const journal = await siteConfig.forDb(db);
++ assert.strictEqual(journal.store.sealed, true);
++ assert.strictEqual(report.blanked, 1, 'the non-provider secret row was blanked');
++ assert.strictEqual(report.differs, 0, 'an equal value is not counted as differing');
++
++ const raw = async (k) => (await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(k));
++ assert.strictEqual((await raw('net.ipinfo_token')).value, '', 'no secret-class value remains in site_settings');
++ assert.strictEqual((await raw('net.ipinfo_token')).type, 'secret', 'the typed slot stays for the settings page');
++ assert.strictEqual((await raw('discord_bot_token')).value, PROVIDER, 'a provider secret is never moved or blanked');
++ assert.strictEqual((await raw('platform_name')).value, 'OpenVibe', 'a non-secret row is untouched');
++
++ assert.strictEqual(await db.getSetting('net.ipinfo_token'), SECRET, 'the reader gets the journal value');
++ assert.strictEqual(await db.getSetting('discord_bot_token'), null, 'a provider secret is still environment-only');
++
++ const summary = JSON.stringify(await journal.store.summary());
++ assert.ok(!summary.includes(SECRET), 'the summary carries no value');
++ const stored = await db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'network.site_settings' AND state = 'active'").get();
++ assert.ok(!stored.values_json.includes(SECRET), 'values_json holds no plaintext');
++ assert.ok(stored.values_json.includes('\"v1:'), 'the secret is stored as an envelope');
++ assert.ok(!logs.join('\n').includes(SECRET) && !logs.join('\n').includes(PROVIDER), 'no log line carries a value');
++
++ await journal.change({ set: { 'net.globalping_token': 'sentinel-rotated' }, types: { 'net.globalping_token': 'secret' } }, { reason: 'rotate' });
++ assert.strictEqual((await raw('net.globalping_token')).value, '', 'a new token is not written to the legacy row');
++ assert.strictEqual(await db.getSetting('net.globalping_token'), 'sentinel-rotated', 'the rotated token is read from the journal');
++
++ // A legacy row newer than the journal is kept, not blanked: the journal does not hold this value.
++ await journal.change({ set: { 'net.globalping_token': 'store-side-token' }, types: { 'net.globalping_token': 'secret' } }, { reason: 'store side' });
++ await set.run('net.globalping_token', 'legacy-side-token', 'secret');
++ // A secret row the journal does not hold at all is kept too.
++ await set.run('ses_secret_access_key', 'legacy-only-secret', 'string');
++ const kept = await siteConfig.migrateLegacySecrets(db, journal);
++ assert.strictEqual(kept.blanked, 0, 'a differing value is never blanked');
++ assert.strictEqual(kept.differs, 2, 'one unequal value and one the journal does not hold');
++ assert.strictEqual((await raw('net.globalping_token')).value, 'legacy-side-token', 'the newer legacy value is kept');
++ assert.strictEqual((await raw('ses_secret_access_key')).value, 'legacy-only-secret', 'the only copy is kept');
++ assert.strictEqual(await db.getSetting('net.globalping_token'), 'store-side-token', 'the reader keeps the journal value');
++ assert.ok(!logs.join('\n').includes('store-side-token') && !logs.join('\n').includes('legacy-side-token') && !logs.join('\n').includes('legacy-only-secret'), 'no log line carries either side');
++
++ real.log('legacy secrets (KEK): all checks passed');
++ process.exit(0);
++})().catch((err) => { real.error(err); process.exit(1); });
ddiff --git a/test/legacy-secrets-nokek.test.js b/test/legacy-secrets-nokek.test.js
nnew file mode 100644
iindex 00000000..85621095
---- /dev/null
++++ b/test/legacy-secrets-nokek.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++/**
++ * D11: without a KEK the config journal is not sealed and NO legacy secret value is blanked. Readers still get
++ * the value from the journal (as before), and a provider secret stays environment-only.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/legacy-secrets-nokek.test.js
++ */
++const assert = require('assert');
++process.env.NODE_ENV = 'test';
++delete process.env.OV_CONFIG_KEKS;
++delete process.env.OV_CONFIG_KEK_CURRENT;
++delete process.env.NET_IPINFO_TOKEN;
++
++const SECRET = 'sentinel-not-a-secret-no-kek';
++const real = { log: console.log, error: console.error };
++console.log = () => {}; console.warn = () => {}; console.error = () => {};
++
++(async () => {
++ const db = await require('../server/db/database').getDb();
++ await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run('net.ipinfo_token', SECRET, 'secret');
++
++ const siteConfig = require('../server/admin/site-config');
++ const report = await siteConfig.init(db);
++ const journal = await siteConfig.forDb(db);
++ assert.strictEqual(journal.store.sealed, false, 'the journal is not sealed without a KEK');
++ assert.strictEqual(report.blanked, 0, 'nothing is blanked without a sealed journal');
++
++ const row = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get('net.ipinfo_token');
++ assert.strictEqual(row.value, SECRET, 'the only copy is still the legacy row');
++ assert.strictEqual(await db.getSetting('net.ipinfo_token'), SECRET, 'the reader still gets its value');
++
++ real.log('legacy secrets (no KEK): all checks passed');
++ process.exit(0);
++})().catch((err) => { real.error(err); process.exit(1); });
ddiff --git a/test/look-boot.test.js b/test/look-boot.test.js
iindex 55e528ef..95db1f36 100644
---- a/test/look-boot.test.js
++++ b/test/look-boot.test.js
@@@ -15,6 +15,8 @@ const { MARK } = require('../server/look-boot');
const PAGES = [
['/', 'the home page'],
['/my', 'the account hub'],
++ ['/wallet', 'the wallet'],
++ ['/pay/vch_01JAB2C3D4E5F6G7H8J9K0ABCD', 'the Vibes Pay sheet'],
['/admin', 'the admin panel'],
['/verify-email', 'the email verification page'],
['/sso/fanout', 'the sign-out fanout'],
@@@ -33,10 +35,11 @@ const PAGES = [
// A linked sheet or an inline <style>: /verify-email has only its own (its one link was Font Awesome's, L54 K4).
const firstSheet = html.search(/<link[^>]+rel="stylesheet"|<style\b/);
assert.ok(at > 0 && firstSheet > 0 && at < firstSheet, `${path}: it paints before any stylesheet`);
-- // The one-time fallback for a browser from before look-boot: its theme only, colours only.
++ // The boot reads the painted look; openvibe-shared 4.109.2 dropped the old theme-only fallback (#552), so
++ // there is no ov_theme fallback left to check, only that no unsafe value is painted.
const legacy = html.slice(at, firstSheet);
-- assert.match(legacy, /if \(localStorage\.getItem\('ov_paint'\)\) return;/, `${path}: the fallback waits for a painted look`);
-- assert.match(legacy, /!\/url\|expression\/i\.test/, `${path}: the fallback takes colours only`);
++ assert.match(legacy, /localStorage\.getItem\('ov_paint'\)/, `${path}: the boot reads the painted look`);
++ assert.match(legacy, /ok\.test\(v\)/, `${path}: the boot paints only checked values`);
}
// No public page keeps the old bootstrap (it painted the theme only, so the season stayed on until the loader ran).
const pub = path.join(__dirname, '..', 'public');
ddiff --git a/test/qr-signin.test.js b/test/qr-signin.test.js
nnew file mode 100644
iindex 00000000..f4232887
---- /dev/null
++++ b/test/qr-signin.test.js
@@@ -0,0 +1,341 @@
++'use strict';
++// Sign in on another device (owner, 2026-11-04; NETWORK_QR_SIGNIN): the full flow, the one-session rule under a
++// race, every refusal (409 second scan, 404 unknown/expired/guessed, 403 another session, three wrong codes),
++// the ov_qr binding, the audit's contents, the new-sign-in notice, the rate limits and the flag-off 404.
++// node test/qr-signin.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { bootServer } = require('./helpers/boot-server');
++const qr = require('../server/auth/qr-signin');
++const { signToken } = require('../server/auth/routes');
++const subjects = require('../server/identity/subjects');
++const accountMerge = require('../server/identity/account-merge');
++const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1';
++
++// The same-origin rule the router applies to every POST (CORS's first-party list is wider than one origin).
++const conf = { networkUrl: 'https://openvibe.network', baseUrl: 'https://openvibe.network', loginUrl: 'https://openvibe.network' };
++assert.strictEqual(qr.sameOrigin({ headers: {} }, conf), true, 'no Origin: a non-browser request');
++assert.strictEqual(qr.sameOrigin({ headers: { origin: 'https://openvibe.network' } }, conf), true);
++assert.strictEqual(qr.sameOrigin({ headers: { origin: 'https://openvibe.live' } }, conf), false, 'another first-party site is not this origin');
++assert.strictEqual(qr.sameOrigin({ headers: { origin: 'https://openvibe.network.evil.example' } }, conf), false);
++assert.strictEqual(qr.sameOrigin({ headers: { origin: 'http://openvibe.network' } }, conf), false, 'scheme matters');
++assert.strictEqual(qr.deviceSummary(IPHONE_UA), 'iPhone · Safari');
++assert.strictEqual(qr.deviceSummary('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36'), 'Android · Chrome');
++assert.match(qr.newUlid(), /^[0-9A-HJKMNP-TV-Z]{26}$/);
++
++function setCookies(res) {
++ if (typeof res.headers.getSetCookie === 'function') return res.headers.getSetCookie();
++ const one = res.headers.get('set-cookie');
++ return one ? [one] : [];
++}
++function cookieOf(cookies, name) {
++ for (const raw of cookies) {
++ const m = String(raw).match(new RegExp(`^${name}=([^;]*)`));
++ if (m) return decodeURIComponent(m[1]);
++ }
++ return null;
++}
++
++(async () => {
++ const srv = await bootServer({ env: { NETWORK_QR_SIGNIN: '1' } });
++ const db = srv.app.locals.db;
++ const base = srv.base;
++
++ const api = async (method, path, { token, cookie, body, headers = {} } = {}) => {
++ const h = { origin: 'https://openvibe.network', 'x-forwarded-for': '203.0.113.9', ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ if (cookie) h.cookie = cookie;
++ if (body !== undefined) h['content-type'] = 'application/json';
++ const res = await fetch(base + path, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });
++ const text = await res.text();
++ let json = null;
++ try { json = JSON.parse(text); } catch { /* non-JSON */ }
++ return { status: res.status, text, json, setCookies: setCookies(res) };
++ };
++
++ const suffix = Math.random().toString(36).slice(2, 9);
++ const username = `qr${suffix}`;
++ const config = srv.app.locals.config;
++ const privateKey = srv.app.locals.privateKey;
++ const mkUser = async (name) => {
++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, display_name, subject_id) VALUES (?, ?, ?, ?, ?) RETURNING id')
++ .run(name, `${name}@example.com`, 'x', name, subjects.newUserSubjectId())).lastInsertRowid;
++ const row = await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++ return { user: row, token: signToken(row, privateKey, config) };
++ };
++ // A session that signed in an hour ago: the source for the auth_time inheritance and fresh-check tests.
++ const signSourceAt = (u, authTime) => {
++ const claims = { sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role: u.role,
++ jti: `src_${crypto.randomBytes(8).toString('hex')}` };
++ if (authTime != null) claims.auth_time = authTime;
++ return jwt.sign(claims, privateKey, { algorithm: privateKey.includes('BEGIN') ? 'RS256' : 'HS256', issuer: config.jwt.issuer, expiresIn: '2h' });
++ };
++ const register = await mkUser(username);
++ const user = register.user;
++ const source = register.token;
++ // Another session of the same person: a different jti, so it must not approve the first one's transfer.
++ const secondSession = signToken(user, privateKey, config);
++ const refreshed = await api('POST', '/api/auth/refresh', { token: source, body: {} });
++ assert.strictEqual(refreshed.status, 200, refreshed.text);
++ assert.strictEqual(jwt.decode(refreshed.json.token).jti, jwt.decode(source).jti, 'a sliding renewal keeps the session jti');
++ const liveUser = await mkUser(`ql${suffix}`);
++ const limitUser = await mkUser(`qh${suffix}`);
++
++ const start = async (token = source) => {
++ const r = await api('POST', '/auth/qr/start', { token, body: {} });
++ if (r.status !== 201) return r;
++ const split = new URL(r.json.url).hash.slice(1).split('.');
++ assert.strictEqual(split[0], r.json.id, 'the URL names the transfer');
++ assert.match(split[1], /^[0-9a-f]{32}$/, 'a 128-bit secret in the fragment');
++ return { ...r, id: r.json.id, secret: split[1] };
++ };
++ const scan = async (id, secret) => api('POST', '/auth/qr/scan', {
++ body: { id, secret }, headers: { 'user-agent': IPHONE_UA },
++ });
++ const targetCookie = (scanResponse) => cookieOf(scanResponse.setCookies, 'ov_qr');
++ const status = (cookie) => api('GET', '/auth/qr/status', { cookie: cookie ? `ov_qr=${cookie}` : undefined });
++ const countSessions = async () => Number((await db.prepare('SELECT COUNT(*) AS n FROM user_sessions WHERE user_id = ?').get(user.id)).n);
++
++ // ── Happy path: scan, poll, approve, target completes once ──
++ let started = await start();
++ assert.strictEqual(started.status, 201, started.text);
++ assert.match(started.json.qr_svg, /^<svg\b/, 'Network renders the SVG itself');
++ assert.ok(started.json.url.endsWith(`/q#${started.id}.${started.secret}`), 'the secret is only in the fragment');
++ assert.ok(Date.parse(started.json.expires_at) - Date.now() <= 120000, '120 s');
++ assert.ok(Date.parse(started.json.expires_at) - Date.now() > 110000);
++
++ let scanned = await scan(started.id, started.secret);
++ assert.strictEqual(scanned.status, 200, scanned.text);
++ assert.match(String(scanned.json.code), /^[1-9][0-9]$/, 'two digits, 10-99');
++ assert.strictEqual(scanned.json.user_hint.display_name, user.display_name || user.username);
++ const ovQr = targetCookie(scanned);
++ assert.ok(ovQr && /^[0-9a-f]{32}$/.test(ovQr), 'a 128-bit ov_qr nonce');
++ const qrSet = scanned.setCookies.find((c) => c.startsWith('ov_qr='));
++ assert.match(qrSet, /HttpOnly/i);
++ assert.match(qrSet, /Secure/i);
++ assert.match(qrSet, /SameSite=Lax/i);
++ assert.match(qrSet, /Path=\/auth\/qr/i);
++ assert.match(qrSet, /Max-Age=300/i);
++
++ let polled = await api('GET', `/auth/qr/${started.id}`, { token: source });
++ assert.strictEqual(polled.status, 200, polled.text);
++ assert.strictEqual(polled.json.state, 'scanned');
++ assert.strictEqual(polled.json.device, 'iPhone · Safari');
++ assert.ok(!('code' in polled.json), 'the source device never receives the code');
++
++ let approved = await api('POST', `/auth/qr/${started.id}/approve`, { token: source, body: { code: scanned.json.code } });
++ assert.strictEqual(approved.status, 200, approved.text);
++ assert.strictEqual(approved.json.state, 'approved');
++
++ const before = await countSessions();
++ const completed = await status(ovQr);
++ assert.strictEqual(completed.status, 200, completed.text);
++ assert.strictEqual(completed.json.state, 'used');
++ assert.strictEqual(completed.json.signed_in, true);
++ assert.strictEqual(completed.json.user.id, user.id);
++ assert.strictEqual(await countSessions(), before + 1, 'exactly one device session');
++ const sessionToken = cookieOf(completed.setCookies, 'ov_token');
++ assert.ok(sessionToken, 'the ordinary session cookies are set');
++ assert.ok(completed.setCookies.some((c) => c.startsWith('ov_qr=') && /Max-Age=0/i), 'ov_qr is cleared');
++ const me = await api('GET', '/api/auth/me', { token: sessionToken });
++ assert.strictEqual(me.status, 200, me.text);
++ assert.strictEqual(me.json.user.id, user.id);
++ const device = await db.prepare("SELECT device_name, ip FROM user_sessions WHERE user_id = ? ORDER BY id DESC LIMIT 1").get(user.id);
++ assert.strictEqual(device.device_name, 'iPhone · Safari');
++ assert.strictEqual(device.ip, null, 'the target session records no IP');
++
++ const again = await status(ovQr);
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json.state, 'used');
++ assert.strictEqual(again.json.signed_in, false);
++ assert.strictEqual(await countSessions(), before + 1, 'still exactly one session');
++
++ // ── A phone-sized signed-in session can show the code and approve it ──
++ const phoneSource = signToken(user, privateKey, config);
++ const phoneStart = await api('POST', '/auth/qr/start', { token: phoneSource, body: {}, headers: { 'user-agent': IPHONE_UA, 'sec-ch-ua-mobile': '?1' } });
++ assert.strictEqual(phoneStart.status, 201, phoneStart.text);
++ const phoneFrag = new URL(phoneStart.json.url).hash.slice(1).split('.');
++ const targetScan = await scan(phoneFrag[0], phoneFrag[1]);
++ assert.strictEqual(targetScan.status, 200, targetScan.text);
++ const phoneApprove = await api('POST', `/auth/qr/${phoneFrag[0]}/approve`, { token: phoneSource, body: { code: targetScan.json.code }, headers: { 'user-agent': IPHONE_UA, 'sec-ch-ua-mobile': '?1' } });
++ assert.strictEqual(phoneApprove.status, 200, phoneApprove.text);
++ assert.strictEqual((await status(targetCookie(targetScan))).json.signed_in, true, 'a phone-sized source creates its own session');
++
++ // ── Security: a QR session inherits the source's auth_time and is not a fresh sign-in ──
++ const authUser = await mkUser(`qg${suffix}`);
++ const mergeSurvivor = await mkUser(`qm${suffix}`);
++ const staleAuthTime = Math.floor(Date.now() / 1000) - 60 * 60;
++ const staleSource = signSourceAt(authUser.user, staleAuthTime);
++ const staleStart = await start(staleSource);
++ assert.strictEqual(staleStart.status, 201, staleStart.text);
++ const staleScanned = await scan(staleStart.id, staleStart.secret);
++ assert.strictEqual(staleScanned.status, 200, staleScanned.text);
++ assert.strictEqual((await api('POST', `/auth/qr/${staleStart.id}/approve`, { token: staleSource, body: { code: staleScanned.json.code } })).status, 200);
++ const staleTarget = await status(targetCookie(staleScanned));
++ assert.strictEqual(staleTarget.json.signed_in, true, staleTarget.text);
++ const staleToken = cookieOf(staleTarget.setCookies, 'ov_token');
++ const staleClaims = jwt.decode(staleToken);
++ assert.strictEqual(staleClaims.auth_time, staleAuthTime, 'the QR-created session inherits the source auth_time, not now');
++ assert.strictEqual(accountMerge.isFreshSignIn(staleClaims), false, 'the merge fresh check reports the QR session as stale');
++
++ const noAuthSource = signSourceAt(authUser.user, null);
++ const noAuthStart = await start(noAuthSource);
++ assert.strictEqual(noAuthStart.status, 201, noAuthStart.text);
++ const noAuthScanned = await scan(noAuthStart.id, noAuthStart.secret);
++ await api('POST', `/auth/qr/${noAuthStart.id}/approve`, { token: noAuthSource, body: { code: noAuthScanned.json.code } });
++ const noAuthTarget = await status(targetCookie(noAuthScanned));
++ assert.strictEqual(jwt.decode(cookieOf(noAuthTarget.setCookies, 'ov_token')).auth_time, undefined, 'a source with no auth_time gives a target with none');
++
++ const survivorToken = signToken(mergeSurvivor.user, privateKey, config);
++ const intent = await api('POST', '/api/v1/account/merge/intents', { token: survivorToken, body: {} });
++ assert.strictEqual(intent.status, 201, intent.text);
++ const refused = await api('POST', '/api/v1/account/merge', { token: staleToken, body: { intent: intent.json.intent } });
++ assert.strictEqual(refused.status, 401, refused.text);
++ assert.strictEqual(refused.json.error, 'merge.sign_in_again', 'a QR session with a stale source cannot merge accounts');
++
++ // ── A second scan is refused with 409 ──
++ let secondStart = await start();
++ let secondScan = await scan(secondStart.id, secondStart.secret);
++ assert.strictEqual(secondScan.status, 200, secondScan.text);
++ const againScan = await scan(secondStart.id, secondStart.secret);
++ assert.strictEqual(againScan.status, 409);
++ assert.strictEqual(againScan.json.error, 'already used on another device');
++ await api('POST', `/auth/qr/${secondStart.id}/deny`, { token: source, body: {} });
++
++ // ── A wrong code three times denies the transfer ──
++ let wrongStart = await start();
++ let wrongScan = await scan(wrongStart.id, wrongStart.secret);
++ const wrong = wrongScan.json.code === '99' ? '98' : '99';
++ const tries = [];
++ for (let i = 0; i < 3; i++) tries.push(await api('POST', `/auth/qr/${wrongStart.id}/approve`, { token: source, body: { code: wrong } }));
++ assert.deepStrictEqual(tries.map((r) => r.status), [400, 400, 403]);
++ assert.strictEqual(tries[0].json.error, 'code_mismatch');
++ assert.strictEqual(tries[0].json.attempts_left, 2);
++ assert.strictEqual(tries[2].json.error, 'denied');
++ const deniedPoll = await api('GET', `/auth/qr/${wrongStart.id}`, { token: source });
++ assert.strictEqual(deniedPoll.json.state, 'denied');
++ assert.strictEqual((await status(targetCookie(wrongScan))).json.state, 'denied');
++
++ // ── Guessed id or secret and an expired transfer are the same 404 ──
++ const guessId = await api('POST', '/auth/qr/scan', { body: { id: '01J00000000000000000000000', secret: 'a'.repeat(32) } });
++ assert.strictEqual(guessId.status, 404);
++ const guessSecret = await scan(secondStart.id, 'b'.repeat(32));
++ assert.strictEqual(guessSecret.status, 404);
++ assert.deepStrictEqual(guessId.json, guessSecret.json);
++
++ let expired = await start();
++ await db.prepare('UPDATE qr_signin_transfers SET expires_at = ? WHERE id = ?').run(new Date(Date.now() - 1000).toISOString(), expired.id);
++ assert.strictEqual((await scan(expired.id, expired.secret)).status, 404, 'an expired transfer is 404, like a guess');
++ assert.strictEqual((await api('GET', `/auth/qr/${expired.id}`, { token: source })).status, 404);
++ const expiryStart = await start();
++ const expiryScanned = await scan(expiryStart.id, expiryStart.secret);
++ assert.strictEqual(expiryScanned.status, 200);
++ await db.prepare('UPDATE qr_signin_transfers SET expires_at = ? WHERE id = ?').run(new Date(Date.now() - 1000).toISOString(), expiryStart.id);
++ assert.strictEqual((await status(targetCookie(expiryScanned))).status, 404, 'the phone learns an expired transfer as 404 too');
++ await qr.sweep(db, { now: Date.now() });
++ assert.ok(!(await db.prepare('SELECT 1 AS ok FROM qr_signin_transfers WHERE id = ?').get(expired.id)), 'the jobs sweep deletes expired transfers');
++
++ // ── The phone cannot finish without its ov_qr cookie ──
++ let noCookieStart = await start();
++ let noCookieScan = await scan(noCookieStart.id, noCookieStart.secret);
++ await api('POST', `/auth/qr/${noCookieStart.id}/approve`, { token: source, body: { code: noCookieScan.json.code } });
++ assert.strictEqual((await status(null)).status, 404);
++ assert.strictEqual((await status('c'.repeat(32))).status, 404);
++ await api('POST', `/auth/qr/${noCookieStart.id}/deny`, { token: source, body: {} });
++
++ // ── Approval needs the creating session, not just the person ──
++ let sessionStart = await start();
++ let sessionScan = await scan(sessionStart.id, sessionStart.secret);
++ const other = await api('POST', `/auth/qr/${sessionStart.id}/approve`, { token: secondSession, body: { code: sessionScan.json.code } });
++ assert.strictEqual(other.status, 403, 'another session of the same user cannot approve');
++ assert.strictEqual((await api('GET', `/auth/qr/${sessionStart.id}`, { token: secondSession })).status, 403);
++ assert.strictEqual((await api('POST', `/auth/qr/${sessionStart.id}/approve`, { token: source, body: { code: sessionScan.json.code } })).status, 200);
++ assert.strictEqual((await status(targetCookie(sessionScan))).json.signed_in, true);
++
++ // ── A renewed source token is still the creating session (jti survives renewal) ──
++ let renewStart = await start();
++ let renewScan = await scan(renewStart.id, renewStart.secret);
++ assert.strictEqual((await api('POST', `/auth/qr/${renewStart.id}/approve`, { token: refreshed.json.token, body: { code: renewScan.json.code } })).status, 200);
++ assert.strictEqual((await status(targetCookie(renewScan))).json.signed_in, true);
++
++ // ── Racing polls create exactly one session ──
++ let raceStart = await start();
++ let raceScan = await scan(raceStart.id, raceStart.secret);
++ await api('POST', `/auth/qr/${raceStart.id}/approve`, { token: source, body: { code: raceScan.json.code } });
++ const raceBefore = await countSessions();
++ const raceCookie = targetCookie(raceScan);
++ const raced = await Promise.all([status(raceCookie), status(raceCookie)]);
++ assert.strictEqual(raced.filter((r) => r.json && r.json.signed_in === true).length, 1, 'one poll signs in');
++ assert.strictEqual(raced.filter((r) => cookieOf(r.setCookies, 'ov_token')).length, 1, 'one response sets the session');
++ assert.strictEqual(await countSessions(), raceBefore + 1, 'exactly one session from the race');
++
++ // ── The notice names the device and the QR sign-in ──
++ const notice = await db.prepare("SELECT title, message FROM notifications WHERE user_id = ? AND service = 'network' ORDER BY created_at DESC, id DESC LIMIT 5").all(user.id);
++ const signInNotice = notice.find((n) => n.title === 'New sign-in');
++ assert.ok(signInNotice, 'the person gets the new-sign-in notice');
++ assert.ok(signInNotice.message.includes('iPhone · Safari') && signInNotice.message.includes('by QR from your other device'));
++
++ // ── The audit has the user, transfer and outcome; never the secret, code or IP ──
++ const audits = await db.prepare("SELECT action, details, ip FROM audit_log WHERE user_id = ? AND action LIKE 'qr_signin%' ORDER BY id").all(user.id);
++ const actions = new Set(audits.map((a) => a.action));
++ for (const action of ['qr_signin_started', 'qr_signin_scanned', 'qr_signin_approved', 'qr_signin_used', 'qr_signin_denied']) assert.ok(actions.has(action), action);
++ for (const row of audits) {
++ assert.strictEqual(row.ip, null, 'the audit stores no IP');
++ const details = JSON.parse(row.details);
++ assert.deepStrictEqual(Object.keys(details).sort(), ['device', 'outcome', 'transfer_id'], 'only the summary fields');
++ assert.notStrictEqual(details.transfer_id, undefined);
++ assert.notStrictEqual(details.transfer_id, started.secret);
++ assert.notStrictEqual(String(details.transfer_id), String(scanned.json.code));
++ assert.ok(!JSON.stringify(details).includes('203.0.113.9'), 'no IP');
++ }
++
++ // ── At most 3 live transfers ──
++ const live = [];
++ for (let i = 0; i < 3; i++) live.push(await start(liveUser.token));
++ assert.ok(live.every((r) => r.status === 201), live.map((r) => r.status).join(','));
++ const fourth = await start(liveUser.token);
++ assert.strictEqual(fourth.status, 429);
++ assert.strictEqual(fourth.json.error, 'too_many_codes');
++ for (const r of live) await api('POST', `/auth/qr/${r.id}/deny`, { token: liveUser.token, body: {} });
++
++ // ── 10 starts per person per hour ──
++ for (let i = 0; i < 10; i++) {
++ const r = await start(limitUser.token);
++ assert.strictEqual(r.status, 201, `start ${i + 1} of the hourly budget`);
++ await api('POST', `/auth/qr/${r.id}/deny`, { token: limitUser.token, body: {} });
++ }
++ assert.strictEqual((await start(limitUser.token)).status, 429, 'the 11th start in an hour is refused');
++
++ // ── A foreign Origin cannot start a transfer ──
++ const foreign = await api('POST', '/auth/qr/start', { token: source, body: {}, headers: { origin: 'https://phish.example' } });
++ assert.strictEqual(foreign.status, 403);
++
++ // ── The phone page is served only with the flag on ──
++ const page = await api('GET', '/q');
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Type this number on the device showing the code'));
++
++ // ── Repeated guesses spend the per-address auth limiter's budget and are refused ──
++ const guesses = [];
++ for (let i = 0; i < 40; i++) {
++ guesses.push((await api('POST', '/auth/qr/scan', { body: { id: '01J00000000000000000000000', secret: 'd'.repeat(32) } })).status);
++ }
++ assert.ok(guesses.includes(429), 'repeated 404s from one address hit the auth limiter');
++
++ await srv.stop();
++
++ // ── Off by default: the same routes and /q are 404 ──
++ const off = await bootServer({ child: true });
++ try {
++ const r = await fetch(`${off.base}/auth/qr/start`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
++ assert.strictEqual(r.status, 404, 'the start route is not mounted when the flag is off');
++ const q = await fetch(`${off.base}/q`);
++ assert.strictEqual(q.status, 404, '/q is not served when the flag is off');
++ } finally {
++ await off.stop();
++ }
++
++ console.log('qr sign-in: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-private-personal.test.js b/test/security-private-personal.test.js
iindex 372d938e..634a5997 100644
---- a/test/security-private-personal.test.js
++++ b/test/security-private-personal.test.js
@@@ -14,6 +14,8 @@ withWorld('private-personal', async (w) => {
await privateMine(w, 'alice', '/api/notifications', 'alice-private-notification');
await privateMine(w, 'alice', '/api/auth/sessions', 'alice-laptop');
await privateMine(w, 'alice', '/api/history', 'alice-private-history');
++ const wallet = await w.call(null, 'GET', '/api/wallet');
++ assert.strictEqual(wallet.status, 401, "the wallet API is the person's own, never a guest's");
// The linked-account positive control was deleted with the retired internal key.
const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
assert.strictEqual(pub.status, 200);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex c38c96a5..a10c0db2 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -121,9 +121,11 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
++ 'server/wallet/routes.js fetch': [1, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
++ 'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy: the nine services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
ddiff --git a/test/security-suite.js b/test/security-suite.js
iindex b003a91d..77e32063 100644
---- a/test/security-suite.js
++++ b/test/security-suite.js
@@@ -56,7 +56,7 @@ function privateValues(w, name) {
function privateArea(path) {
if (path.startsWith('/api/v1/projects')) return 'projects';
-- if (path.match(/^\/api\/(auth|notifications|history|modules|coins|push|v1\/me|v1\/follows|v1\/status)(\/|\?|$)/)) return 'personal';
++ if (path.match(/^\/api\/(auth|notifications|history|modules|coins|wallet|push|v1\/me|v1\/follows|v1\/status)(\/|\?|$)/)) return 'personal';
return 'platform';
}
ddiff --git a/test/unknown-paths.test.js b/test/unknown-paths.test.js
iindex b7f336ae..3a650141 100644
---- a/test/unknown-paths.test.js
++++ b/test/unknown-paths.test.js
@@@ -16,8 +16,10 @@ const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
const real = [
'/', '/login', '/forgot-password', '/reset-password', '/admin', '/admin/settings', '/admin/analytics/overview',
'/verify-email', '/status', '/sso/fanout', '/terms', '/privacy', '/dmca', '/llms.txt', '/sites',
++ '/wallet', '/pay/vch_01JAB2C3D4E5F6G7H8J9K0ABCD',
'/robots.txt', '/sitemap.xml', '/manifest.webmanifest', '/assets/logo.svg', '/js/login.js', '/shared/navbar.js', '/shared/v1/navbar.js',
-- '/css/login.css', '/css/my.css', '/css/admin.css',
++ '/css/login.css', '/css/my.css', '/css/admin.css', '/css/wallet.css',
++ '/js/wallet/money.js', '/js/wallet/common.js', '/js/wallet/sheets.js', '/js/wallet/page.js', '/js/wallet/pay.js',
'/openvibe-sw.js', '/release.json', '/.well-known/openvibe', '/.well-known/web-identity', '/api/health', '/api/brand',
'/api/v1/registry/services', '/api/v1/registry/categories', '/api/v1/registry/featured', '/api/.well-known/jwks',
...ACCOUNT_HUB_PATHS.filter(p => p !== '/my.html'),
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex e63d8070..4ee96931 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
ddiff --git a/test/wallet.test.js b/test/wallet.test.js
nnew file mode 100644
iindex 00000000..eff6266a
---- /dev/null
++++ b/test/wallet.test.js
@@@ -0,0 +1,321 @@
++'use strict';
++// The wallet (MONEY.md D12): the page's money words, the activity mapping, and Network's proxies to Billing.
++//
++// - money: nano-USD and whole Vibes are integer end to end; the browser's formatter never starts from a float.
++// - activity: one list over both currencies, only the person's own accounts, never creator payable or sandbox.
++// - proxies: the person's own token goes to Billing's /api/me routes; the journal read uses Network's service token;
++// a charge id is checked before it is put in a URL; a write needs the bearer token (no cookie-only CSRF).
++// - the pages: a static pay sheet with no form, three wallet cards, both themes, no spinner, no sideways width.
++// node --import ./test/helpers/pg-preload.mjs test/wallet.test.js
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const createWalletRoutes = require('../server/wallet/routes');
++const principals = require('../server/identity/principals');
++const money = require('../public/js/wallet/money');
++
++const ROOT = path.join(__dirname, '..');
++const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0ABCD';
++const VCH = 'vch_01JAB2C3D4E5F6G7H8J9K0ABCD';
++const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
++
++(async () => {
++ // ── Money words ─────────────────────────────────────────
++ assert.strictEqual(money.money('12400000000'), '$12.40');
++ assert.strictEqual(money.money('0'), '$0.00');
++ assert.strictEqual(money.money('4200000'), '$0.0042', 'below a cent, to the nano');
++ assert.strictEqual(money.money('-4200000'), '−$0.0042');
++ assert.deepStrictEqual(money.signedMoney('-4200000'), { sign: '−', text: '$0.0042' });
++ assert.deepStrictEqual(money.signedMoney('2500000000'), { sign: '+', text: '$2.50' });
++ assert.strictEqual(money.dollarsToNano('12.40'), '12400000000');
++ assert.strictEqual(money.dollarsToNano('$1,000'), '1000000000000');
++ assert.strictEqual(money.dollarsToNano('0.000000001'), '1', 'a nano is the smallest unit');
++ assert.strictEqual(money.dollarsToNano('12.1234567890'), null, 'a tenth decimal is not money');
++ assert.strictEqual(money.dollarsToNano('abc'), null);
++ assert.strictEqual(money.dollarsToNano(''), null);
++ assert.strictEqual(money.priceNano(1000, 100), '10000000000', '1,000 Vibes at 100 per USD is $10');
++ assert.strictEqual(money.priceNano(1, 100), '10000000', 'rounded up to one cent, never down');
++ assert.strictEqual(money.priceNano(0, 100), null);
++ assert.strictEqual(money.whole('1,250'), 1250);
++ assert.strictEqual(money.whole('1.5'), null);
++ assert.strictEqual(money.vibes(1250), '1,250');
++
++ // ── Activity mapping: one person, two currencies, no one else's money ──
++ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0ABCD';
++ const activity = createWalletRoutes.mapActivity(subject, [
++ {
++ id: 'txn_buy', type: 'purchase', created_at: '2026-11-04T10:00:00.000Z', metadata: { balance: 'vibes_from_balance', vibes: 500 },
++ entries: [
++ { account: { kind: 'usd_credit', owner: `user:${subject}`, currency: 'nano-usd' }, amount: '-5000000000' },
++ { account: { kind: 'user_credit', owner: subject, currency: 'vibes-bits' }, amount: 500 },
++ { account: { kind: 'platform_revenue', owner: null, currency: 'nano-usd' }, amount: '5000000000' },
++ ],
++ },
++ {
++ id: 'txn_ai', type: 'credit', created_at: '2026-11-03T09:00:00.000Z', metadata: { credit: 'settle', model: 'openvibe/auto' },
++ entries: [
++ { account: { kind: 'usd_credit', owner: `user:${subject}`, currency: 'nano-usd' }, amount: '-123000000' },
++ { account: { kind: 'platform_revenue', owner: null, currency: 'nano-usd' }, amount: '123000000' },
++ ],
++ },
++ {
++ id: 'txn_payable', type: 'donation', created_at: '2026-11-02T09:00:00.000Z', metadata: {},
++ entries: [
++ { account: { kind: 'creator_payable', owner: subject, currency: 'vibes-bits' }, amount: 40 },
++ { account: { kind: 'user_credit', owner: 'usr_OTHER', currency: 'vibes-bits' }, amount: -40 },
++ ],
++ },
++ {
++ id: 'txn_sandbox', type: 'credit', created_at: '2026-11-01T09:00:00.000Z', metadata: { credit: 'sandbox_grant' },
++ entries: [{ account: { kind: 'usd_credit_sandbox', owner: `user:${subject}`, currency: 'nano-usd' }, amount: '999' }],
++ },
++ ]);
++ assert.deepStrictEqual(activity.map((r) => [r.kinds, r.parts, r.label]), [
++ [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'Got 500 Vibes with your Balance'],
++ [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'AI usage'],
++ ], 'one row per transaction, both currencies together; creator payable and sandbox are not the spendable wallet');
++ assert.strictEqual(createWalletRoutes.mapActivity(subject, null).length, 0);
++ assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
++ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },
++ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },
++ ] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]]], 'the person-shaped route is grouped too');
++ assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
++ { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'Got 10 Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },
++ ] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-100000000' }, { kind: 'vibes', amount: 10 }]]], 'an { amounts } shape is read too');
++ assert.ok(!principals.DEFAULT_GRANTS.some(([client, cap]) => client === 'network' && cap === 'billing.balance.read'), 'Network does not seed a broad Billing read');
++
++ // ── The proxies, against a stand-in Billing ─────────────
++ const db = getDb();
++ await db.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('walletuser', '[email protected]', 'x', 'user') ON CONFLICT DO NOTHING").run();
++ const user = await db.prepare("SELECT * FROM users WHERE username = 'walletuser'").get();
++ await db.prepare('INSERT INTO wallets (user_id, balance) VALUES (?, 340) ON CONFLICT (user_id) DO UPDATE SET balance = 340').run(user.id);
++ await db.prepare(`INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by)
++ VALUES (?, ?, 'Wallet Project', ?::timestamptz, 'test') ON CONFLICT (id) DO NOTHING`).run(PRJ, subject, new Date().toISOString());
++ const APP = 'app_01JAB2C3D4E5F6G7H8J9K0ABCD';
++ await db.prepare(`INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, redirect_uris, created_at, created_by)
++ VALUES (?, ?, 'Sticker Cats', 'sandbox', 'wallet-test-client', 'public', '[]', ?, 'test') ON CONFLICT (id) DO NOTHING`).run(APP, PRJ, new Date().toISOString());
++
++ const asked = [];
++ let balanceMode = 'ok';
++ let vibesMode = 'ok';
++ let chargeMode = 'ok';
++ let activityMode = 'missing';
++ const billing = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://billing.test');
++ const auth = req.headers.authorization || '';
++ let body = '';
++ req.on('data', (d) => { body += d; });
++ req.on('end', () => {
++ asked.push({ url: url.pathname + url.search, auth, method: req.method, body });
++ res.setHeader('content-type', 'application/json');
++ if (url.pathname === '/api/v1/rates') return res.end(JSON.stringify({ bits_per_usd: 100, min_purchase_bits: 100, packages: [{ bits: 1000, price_cents: 1000 }], providers: { stripe: true, paypal: true } }));
++ if (url.pathname === '/api/me/activity') {
++ if (activityMode === 'missing') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.not_found' })); }
++ return res.end(JSON.stringify({ activity: [
++ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },
++ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },
++ ] }));
++ }
++ if (url.pathname === '/api/v1/transactions') return res.end(JSON.stringify({ transactions: [{ id: 'txn_buy', type: 'purchase', created_at: '2026-11-04T10:00:00.000Z', metadata: { balance: 'vibes_from_balance', vibes: 500 }, entries: [
++ { account: { kind: 'usd_credit', owner: `user:${subject}`, currency: 'nano-usd' }, amount: '-5000000000' },
++ { account: { kind: 'user_credit', owner: subject, currency: 'vibes-bits' }, amount: 500 },
++ ] }], next_cursor: null }));
++ if (url.pathname === '/api/me/balance') {
++ if (balanceMode === 'closed') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.not_enabled' })); }
++ return res.end(JSON.stringify({
++ personal: { owner: { type: 'user', id: subject }, balance_nano_usd: '12400000000', held_nano_usd: '400000000', available_nano_usd: '12000000000' },
++ projects: [{ owner: { type: 'project', id: PRJ }, balance_nano_usd: '5000000000', held_nano_usd: '100000000', available_nano_usd: '4900000000' }],
++ }));
++ }
++ if (url.pathname === '/api/me/vibes') {
++ if (vibesMode === 'not_authoritative') { res.statusCode = 409; return res.end(JSON.stringify({ code: 'billing.not_authoritative' })); }
++ return res.end(JSON.stringify({ subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit: 1250, payable: 0, bits_per_usd: 100 }));
++ }
++ if (url.pathname === `/api/me/vibes/charges/${VCH}`) {
++ if (chargeMode === 'missing') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.charge_not_found' })); }
++ return res.end(JSON.stringify({ charge: { id: VCH, app: { id: 'app_01JAB2C3D4E5F6G7H8J9K0ABCD', name: 'Sticker Cats' }, amount: 50, description: 'Sticker pack: Neon cats', status: 'pending', return_url: 'https://cats.example/thanks', reference: 'order_1' }, vibes: { credit: 1250 } }));
++ }
++ if (url.pathname === `/api/me/vibes/charges/${VCH}/pay`) return res.end(JSON.stringify({ charge: { id: VCH, app: { name: 'Sticker Cats' }, amount: 50, description: 'Sticker pack: Neon cats', status: 'paid', return_url: 'https://cats.example/thanks' }, vibes: { credit: 1200 } }));
++ if (url.pathname === `/api/me/vibes/charges/${VCH}/cancel`) return res.end(JSON.stringify({ charge: { id: VCH, status: 'cancelled' } }));
++ if (url.pathname === '/api/me/balance/transfers') { res.statusCode = 201; return res.end(JSON.stringify({ transfer_id: 'btr_1', amount_nano_usd: '5000000000' })); }
++ if (url.pathname === '/api/me/vibes/from-balance') { res.statusCode = 201; return res.end(JSON.stringify({ vibes: 1000, price_nano_usd: '10000000000', vibes_credit: 2250 })); }
++ if (url.pathname === '/api/me/credits/topups') { res.statusCode = 201; return res.end(JSON.stringify({ intent: { id: 'pi_1' }, checkout_url: 'https://checkout.stripe.example/abc' })); }
++ res.statusCode = 404;
++ res.end(JSON.stringify({ error: 'billing.not_found' }));
++ });
++ });
++ await new Promise((r) => billing.listen(0, '127.0.0.1', r));
++
++ let authUser = { id: Number(user.id), subject_id: subject };
++ const requireAuth = (req, _res, next) => { req.user = authUser; next(); };
++ const app = express();
++ app.use(express.json());
++ app.use('/api/wallet', createWalletRoutes(db, requireAuth, {
++ billingUrl: `http://127.0.0.1:${billing.address().port}`,
++ selfToken: () => 'network-service-token',
++ }));
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = (method, p, { tok = 'person-token', body } = {}) => fetch(base + p, {
++ method, headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++
++ try {
++ const w = await call('GET', '/api/wallet');
++ assert.strictEqual(w.status, 200, JSON.stringify(w.body));
++ assert.strictEqual(w.body.opencoins, 340, "Network's own OpenCoins");
++ assert.strictEqual(w.body.balance.personal.balance_nano_usd, '12400000000');
++ assert.strictEqual(w.body.vibes.credit, 1250);
++ assert.deepStrictEqual(w.body.projects.map((p) => [p.id, p.name, p.url, p.held_nano_usd, p.available_nano_usd]), [[PRJ, 'Wallet Project', `https://openvibe.services/projects/${PRJ}`, '100000000', '4900000000']]);
++ assert.strictEqual(w.body.state.balance, 'open');
++ assert.strictEqual(w.body.state.vibes, 'open');
++ assert.strictEqual(w.body.rates.packages[0].bits, 1000);
++ assert.strictEqual(w.body.activity.length, 1);
++ assert.deepStrictEqual(w.body.activity[0].kinds, ['balance', 'vibes']);
++ assert.deepStrictEqual(w.body.activity[0].parts, [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]);
++ assert.strictEqual(w.body.activity[0].label, 'Got 500 Vibes with your Balance');
++ assert.strictEqual(w.body.activity_source, 'journal', 'the marked fallback until Billing has /api/me/activity');
++ const personActivity = asked.filter((a) => a.url.startsWith('/api/me/activity'));
++ assert.strictEqual(personActivity.length, 1);
++ assert.strictEqual(personActivity[0].auth, 'Bearer person-token', 'activity is asked with the person\'s own token first');
++ const personCalls = asked.filter((a) => a.url.startsWith('/api/me/') && !a.url.startsWith('/api/me/activity'));
++ assert.ok(personCalls.length >= 2 && personCalls.every((a) => a.auth === 'Bearer person-token'), "Billing's person reads carry the person's own token");
++ const journal = asked.filter((a) => a.url.startsWith('/api/v1/transactions'));
++ assert.strictEqual(journal.length, 1);
++ assert.strictEqual(journal[0].auth, 'Bearer network-service-token', 'the fallback journal read carries the service token');
++ assert.match(journal[0].url, new RegExp(`subject=${encodeURIComponent(subject)}`));
++
++ // Billing ships /api/me/activity: the person's own token answers the rows; the journal is not read.
++ activityMode = 'person';
++ const beforePerson = asked.filter((a) => a.url.startsWith('/api/v1/transactions')).length;
++ const own = await call('GET', '/api/wallet');
++ assert.strictEqual(own.status, 200);
++ assert.strictEqual(own.body.activity_source, 'person');
++ assert.strictEqual(own.body.activity.length, 1);
++ assert.deepStrictEqual(own.body.activity[0].parts, [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]);
++ assert.strictEqual(asked.filter((a) => a.url.startsWith('/api/v1/transactions')).length, beforePerson, 'the journal fallback is not read when the person route answers');
++ assert.strictEqual(asked.filter((a) => a.url.startsWith('/api/me/activity')).pop().auth, 'Bearer person-token');
++ activityMode = 'missing';
++
++ // A not-open Balance, a Vibes ledger Billing has not taken over yet: the page still draws.
++ balanceMode = 'closed'; vibesMode = 'not_authoritative';
++ const closed = await call('GET', '/api/wallet');
++ assert.strictEqual(closed.status, 200);
++ assert.strictEqual(closed.body.balance, null);
++ assert.strictEqual(closed.body.state.balance, 'closed');
++ assert.strictEqual(closed.body.vibes, null);
++ assert.strictEqual(closed.body.state.vibes, 'not_authoritative');
++ balanceMode = 'ok'; vibesMode = 'ok';
++
++ // Writes: validated first, then forwarded with the person's token.
++ let bad = await call('POST', '/api/wallet/transfers', { body: { to_project_id: PRJ, amount_nano_usd: '1.5', idempotency_key: KEY(1) } });
++ assert.strictEqual(bad.status, 422, 'nano-USD is an integer string');
++ bad = await call('POST', '/api/wallet/transfers', { body: { to_project_id: 'evil', amount_nano_usd: '1', idempotency_key: KEY(1) } });
++ assert.strictEqual(bad.status, 422);
++ bad = await call('POST', '/api/wallet/transfers', { body: { to_project_id: PRJ, amount_nano_usd: '1' } });
++ assert.strictEqual(bad.status, 422, 'a money write without its idempotency key is refused');
++ assert.ok(!asked.some((a) => a.url === '/api/me/balance/transfers'), 'a bad body never reaches Billing');
++ const moved = await call('POST', '/api/wallet/transfers', { body: { to_project_id: PRJ, amount_nano_usd: '5000000000', idempotency_key: KEY(1) } });
++ assert.strictEqual(moved.status, 201);
++ const sent = asked.find((a) => a.url === '/api/me/balance/transfers');
++ assert.ok(sent && sent.auth === 'Bearer person-token');
++ assert.deepStrictEqual(JSON.parse(sent.body), { to_project_id: PRJ, amount_nano_usd: '5000000000', idempotency_key: KEY(1) });
++
++ const vibes = await call('POST', '/api/wallet/vibes/from-balance', { body: { vibes: 1000, idempotency_key: KEY(2) } });
++ assert.strictEqual(vibes.status, 201);
++ assert.deepStrictEqual(JSON.parse(asked.find((a) => a.url === '/api/me/vibes/from-balance').body), { vibes: 1000, idempotency_key: KEY(2) });
++
++ const topup = await call('POST', '/api/wallet/topups', { body: { provider: 'stripe', amount_cents: 2000 } });
++ assert.strictEqual(topup.status, 201);
++ assert.strictEqual(topup.body.checkout_url, 'https://checkout.stripe.example/abc');
++
++ // A write without a bearer token is refused before Billing (a cookie session alone is not enough).
++ const noBearer = await call('POST', '/api/wallet/vibes/from-balance', { tok: null, body: { vibes: 1000, idempotency_key: KEY(3) } });
++ assert.strictEqual(noBearer.status, 401);
++
++ // The charge carries only an app id: Network fills the name from its own app registry, and never overwrites one.
++ const enriched = await createWalletRoutes.enrichCharge(db, { charge: { id: VCH, app: APP, amount: 50 } });
++ assert.strictEqual(enriched.charge.app.name, 'Sticker Cats');
++ const typed = await createWalletRoutes.enrichCharge(db, { charge: { id: VCH, app: { id: APP, name: 'The app’s own name' } } });
++ assert.strictEqual(typed.charge.app.name, 'The app’s own name');
++
++ // The pay sheet's charge: a vch_ id only, forwarded with the person's token.
++ bad = await call('GET', '/api/wallet/charge/javascript:alert(1)');
++ assert.strictEqual(bad.status, 404);
++ const charge = await call('GET', `/api/wallet/charge/${VCH}`);
++ assert.strictEqual(charge.status, 200);
++ assert.strictEqual(charge.body.charge.app.name, 'Sticker Cats');
++ assert.strictEqual(charge.body.vibes.credit, 1250);
++ const paid = await call('POST', `/api/wallet/charge/${VCH}/pay`, { body: { idempotency_key: KEY(4) } });
++ assert.strictEqual(paid.status, 200);
++ assert.strictEqual(paid.body.charge.status, 'paid');
++ assert.deepStrictEqual(JSON.parse(asked.find((a) => a.url === `/api/me/vibes/charges/${VCH}/pay`).body), { idempotency_key: KEY(4) });
++ const cancelled = await call('POST', `/api/wallet/charge/${VCH}/cancel`, { body: {} });
++ assert.strictEqual(cancelled.status, 200);
++ chargeMode = 'missing';
++ const missing = await call('GET', `/api/wallet/charge/${VCH}`);
++ assert.strictEqual(missing.status, 404);
++ chargeMode = 'ok';
++
++ // Rates are cached: a second page does not ask Billing again for prices.
++ const before = asked.filter((a) => a.url === '/api/v1/rates').length;
++ await call('GET', '/api/wallet/rates');
++ assert.strictEqual(asked.filter((a) => a.url === '/api/v1/rates').length, before, 'the pack prices are cached for a minute');
++
++ // ── The pages ─────────────────────────────────────────
++ const walletHtml = read('public', 'wallet.html');
++ const payHtml = read('public', 'pay.html');
++ const css = read('public', 'css', 'wallet.css');
++ const payJs = read('public', 'js', 'wallet', 'pay.js');
++ const pageJs = read('public', 'js', 'wallet', 'page.js');
++ const sheets = read('public', 'js', 'wallet', 'sheets.js');
++ const routesJs = read('server', 'wallet', 'routes.js');
++ for (const page of [walletHtml, payHtml]) {
++ assert.ok(page.includes('<!--ov:look-boot-->'), 'both pages paint the kept look before their stylesheet');
++ assert.ok(page.includes('/shared/kit.css') && page.includes('/css/wallet.css'), 'the shared kit and the wallet styles');
++ assert.ok(page.indexOf('/shared/kit.css') > page.indexOf('<!--ov:look-boot-->'), 'look-boot comes before any stylesheet');
++ assert.match(page, /<meta name="robots" content="noindex, nofollow">/);
++ }
++ assert.match(walletHtml, /id="w-cards"/);
++ assert.match(walletHtml, />Balance</);
++ assert.match(walletHtml, />Vibes</);
++ assert.match(walletHtml, />OpenCoins</);
++ assert.match(walletHtml, />Add money</);
++ assert.match(walletHtml, />Move to a project</);
++ assert.match(walletHtml, />Get Vibes</);
++ assert.match(walletHtml, /Earned by watching and chatting/);
++ assert.match(walletHtml, /Where each works/);
++ assert.ok(!/Credits/i.test(walletHtml + payHtml), 'the public word is Balance, never Credits');
++ assert.ok(!/\bEarn(ing)? Vibes\b/i.test(walletHtml + payHtml + sheets + payJs), 'Vibes are bought and spent, never earned');
++ assert.ok(!/<input\b|<form\b|card number/i.test(payHtml), 'the pay sheet itself never holds a card form');
++ assert.match(payJs, /postMessage\(\{ type: 'ov-vibes-pay', status, charge_id: c\.id \}, u\.origin\)/, 'the opener is posted to the return_url origin only');
++ assert.match(payJs, /W\.httpsUrl\(c\.returnUrl\)/, 'the return URL is https and never arbitrary');
++ assert.match(css, /max-width: 420px/, 'the pay card is 420 px at most');
++ assert.match(css, /\.w-cards \{ display: grid; grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/, 'three cards in a row');
++ assert.match(css, /@media \(max-width: 899px\) \{ \.w-cards \{ grid-template-columns: minmax\(0, 1fr\); \}/, 'they stack on a phone');
++ assert.match(css, /min-height: 44px/, 'tap targets reach 44 px');
++ assert.ok(!/w-project-id/.test(pageJs), 'project rows do not print the raw project id');
++ assert.match(pageJs, /w-project-ext/, 'the project name links out with a small arrow');
++ assert.match(pageJs, /Available \$\{M\.money\(p\.available_nano_usd\)\}/, 'Available appears under the name only with held money');
++ assert.match(pageJs, /w-act-amt--stack/, 'one transaction with both currencies stacks its two amounts');
++ assert.match(pageJs, /li\.dataset\.kinds/, 'the filters read every currency on the row');
++ assert.match(css, /\.w-act-amt--stack/, 'the stacked amounts are laid out');
++ assert.match(routesJs, /\/api\/me\/activity/, 'Billing\'s person-token activity route is tried first');
++ assert.match(routesJs, /TEMPORARY FALLBACK/, 'the service-token journal read is marked as the fallback');
++ assert.ok(!/spinner|@keyframes[^}]*rotate/i.test(css + walletHtml + payHtml + sheets + payJs), 'nothing spins');
++ assert.ok(!/parseFloat\s*\(/.test(read('public', 'js', 'wallet', 'money.js') + read('server', 'wallet', 'routes.js')), 'money never becomes a float');
++ assert.ok(!/\.credits|credits\./.test(read('server', 'wallet', 'routes.js')), 'the API speaks Balance');
++
++ console.log('wallet: money words, activity, proxies, pay sheet and both pages ok');
++ } finally {
++ server.close();
++ billing.close();
++ }
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });