openvibers/openvibe.billing Commits

A person's own Vibes for every OpenVibe site: GET /api/me/vibes (their Network token, CORS for OpenVibe's own origins, 409 until Billing is the ledger) (#70)

OP OpenVibers committed 27 days ago · f38baa94a0 · 8 files, +171 −4

Changes

ddiff --git a/README.md b/README.md
iindex 191d73e..e2e5347 100644
---- a/README.md
++++ b/README.md
@@@ -183,6 +183,12 @@ POST needs an `Idempotency-Key` (8–200 of `A-Za-z0-9._:-`); a replay returns t
are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "type": "user", "id": "usr_…" }`
(path parameters take the bare id); amounts are integer bits or cents.
++One public route takes a **person's** token instead: `GET https://billing.openvibe.network/api/me/vibes` with the
++person's Network user JWT (`Authorization: Bearer`) answers their own `{ credit, payable, currency, bits_per_usd }`
++for the shared navbar on every OpenVibe site (`server/api/me.js`). It is CORS-open to the exact https origins the
++Contracts service manifests name (never a wildcard), never cached, limited to 30 a minute per person, and answers
++`409 billing.not_authoritative` while OpenVibe.Live is still the ledger.
++
| Method & path | Capability | Does |
|---|---|---|
| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) |
ddiff --git a/deploy/nginx/billing.openvibe.network.conf b/deploy/nginx/billing.openvibe.network.conf
iindex 13b869c..4ceec8b 100644
---- a/deploy/nginx/billing.openvibe.network.conf
++++ b/deploy/nginx/billing.openvibe.network.conf
@@@ -58,6 +58,12 @@ server {
}
location = /api/health { proxy_pass http://127.0.0.1:4600; }
++ # A signed-in person's own Vibes, read by the shared navbar on every OpenVibe site (server/api/me.js: a user token,
++ # CORS for OpenVibe's own origins). The only other public /api path.
++ location = /api/me/vibes {
++ limit_req zone=ovbilling_console burst=20 nodelay;
++ proxy_pass http://127.0.0.1:4600;
++ }
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# answers 404 to any request a proxy relayed.
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
iindex c5af678..2f6530c 100644
---- a/server/api/actor-limits.js
++++ b/server/api/actor-limits.js
@@@ -53,7 +53,7 @@ function personOf(v) {
}
/**
-- * createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, refund }.
++ * createActorLimits({ config, db, now, registry, log }) → { budget(name, personFrom), history, selfRead, payerOf }.
* personFrom(req) names the person a request is counted against (or null: its principal).
*/
function createActorLimits({ config, db, now = () => Date.now(), registry = null, log = console, valkey = null }) {
@@@ -97,13 +97,20 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
return historyLimit(req, res, next);
}
++ /** A person reading their own Vibes (GET /api/me/vibes: the navbar on every site, once per menu): 30 a minute, 600 an hour. */
++ const selfLimit = limiter('billing.vibes.self', { minute: 30, hour: 600 });
++ function selfRead(req, res, next) {
++ req.limitPerson = personOf(req.person && req.person.subject);
++ return selfLimit(req, res, next);
++ }
++
/** The payer of the transfer a refund gives back (one primary-key read), else null. */
async function payerOf(req) {
const row = await db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));
return row ? personOf(row.from_subject) : null;
}
-- return { budget, history, payerOf };
++ return { budget, history, selfRead, payerOf };
}
module.exports = { createActorLimits, personOf, BUDGETS };
ddiff --git a/server/api/me.js b/server/api/me.js
nnew file mode 100644
iindex 0000000..8f5c7c7
---- /dev/null
++++ b/server/api/me.js
@@@ -0,0 +1,80 @@
++'use strict';
++
++/**
++ * The signed-in person's own Vibes, for every OpenVibe site (owner 2026-10-08: "make vibes cross site too like opencoins";
++ * plan T5 step 2). The shared navbar shows them next to OpenCoins.
++ *
++ * GET /api/me/vibes Authorization: Bearer <the person's Network user JWT>
++ * → 200 { subject, currency: 'vibes-bits', credit, payable, bits_per_usd }
++ * credit: Vibes to spend; payable: Vibes received as a creator, to cash out
++ * → 409 billing.not_authoritative while OpenVibe.Live is still the ledger (BILLING_AUTHORITY unset): Billing's
++ * numbers are a shadow then, so the chip stays hidden rather than show them
++ * → 401 without a person's token (service, app, module and typed tokens are refused)
++ *
++ * CORS: OpenVibe's own sites only (exact https origins from the Contracts service manifests' domains, as Network does;
++ * never a wildcard, so a tenant subdomain of a hosting product is never allowed), Authorization allowed, no cookies.
++ * The answer is private and never cached.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++const { balance } = require('../ledger');
++const { A } = require('../ops/common');
++
++const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
++let origins = null;
++function firstPartyOrigins() {
++ if (origins) return origins;
++ const out = new Set();
++ for (const m of (contracts.services && contracts.services.manifests) || []) {
++ for (const d of m.domains || []) {
++ const host = String(d || '').trim().toLowerCase();
++ if (HOST_RE.test(host)) { out.add(`https://${host}`); out.add(`https://www.${host}`); }
++ }
++ }
++ origins = out;
++ return out;
++}
++
++function meRouter({ ctx, config, keys, limits }) {
++ const r = express.Router();
++ const problem = (res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail });
++
++ r.use((req, res, next) => {
++ const origin = req.get('origin');
++ res.set({ 'Cache-Control': 'private, no-store', Vary: 'Origin' });
++ if (origin && firstPartyOrigins().has(origin)) {
++ res.set({ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'Authorization', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' });
++ }
++ if (req.method === 'OPTIONS') return res.status(204).end();
++ return next();
++ });
++
++ async function person(req, res, next) {
++ const header = String(req.get('authorization') || '');
++ if (!header.startsWith('Bearer ')) return problem(res, 401, 'token.missing', 'sign in to see your Vibes');
++ try {
++ const claims = await verifyUserToken(header.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, now: ctx.now() });
++ const subject = claims.subject_id;
++ if (typeof subject !== 'string' || !contracts.ids.isSubjectId('user', subject)) return problem(res, 401, 'token.invalid', 'this token names no person');
++ req.person = { subject };
++ return next();
++ } catch (e) {
++ if (e && e.code === 'token.unavailable') return problem(res, 503, 'auth.unavailable', 'the Network key is not loaded yet');
++ return problem(res, 401, (e && e.code) || 'token.invalid', 'sign in again to see your Vibes');
++ }
++ }
++
++ r.get('/vibes', person, limits.selfRead, async (req, res) => {
++ if (config.authority !== 'billing') return problem(res, 409, 'billing.not_authoritative', 'OpenVibe.Live keeps Vibes balances until the switch to OpenVibe.Billing');
++ const s = req.person.subject;
++ res.json({
++ subject: { type: 'user', id: s }, currency: 'vibes-bits',
++ credit: await balance(ctx.db, A.credit(s)), payable: await balance(ctx.db, A.payable(s)), bits_per_usd: ctx.rates.bitsPerUsd,
++ });
++ });
++
++ return r;
++}
++
++module.exports = { meRouter, firstPartyOrigins };
ddiff --git a/server/app.js b/server/app.js
iindex afbff69..375e6c2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -7,6 +7,7 @@
* GET /api/health, /api/ready liveness / readiness
* GET /metrics Prometheus text (direct loopback callers only; server/metrics.js)
* /api/v1/* operations API (service tokens, see api/v1.js)
++ * /api/me/vibes a signed-in person's own Vibes (user token; CORS for OpenVibe's own sites, api/me.js)
* /webhooks/<provider> provider receipts (see api/webhooks.js)
* /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)
*
@@@ -102,6 +103,8 @@ async function createApp(opts = {}) {
// Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
++ // A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).
++ app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits }));
// The billing policy is public and indexable; the staff console and the API are not.
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex a817819..ef2b5ee 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -96,7 +96,9 @@ async function startNetwork() {
});
const url = await listen(server);
issuer = url;
-- return { url, publicPem, signService, addUser, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
++ // A person's Network session token, as the browser holds it (the shared navbar sends it).
++ const signUser = (u) => userToken({ role: 'user', username: 'someone', nowMs: Date.now(), ...u });
++ return { url, publicPem, signService, signUser, addUser, authorize, revoked, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
}
async function startStripe() {
ddiff --git a/test/me-vibes.test.js b/test/me-vibes.test.js
nnew file mode 100644
iindex 0000000..1970526
---- /dev/null
++++ b/test/me-vibes.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// GET /api/me/vibes: a signed-in person's own Vibes for the shared navbar on every OpenVibe site (owner: "make vibes
++// cross site too like opencoins"). Only once Billing is the ledger; a person's token only; CORS for OpenVibe's own sites.
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++(async () => {
++ const live = await boot();
++ const ann = live.user(101);
++ const annToken = live.network.signUser({ subject_id: ann.id, username: 'ann' });
++ const get = (t, token, headers = {}) => fetch(`${t.base}/api/me/vibes`, { headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers } });
++
++ await check('Live still the ledger: the balance is a shadow, so 409 and the chip stays hidden', async () => {
++ const r = await get(live, annToken);
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual((await r.json()).code, 'billing.not_authoritative');
++ });
++ await live.close();
++
++ const t = await boot({ env: { BILLING_AUTHORITY: 'billing' } });
++ const bob = t.user(102);
++ const bobToken = t.network.signUser({ subject_id: bob.id, username: 'bob' });
++ await fund(t, bob.id, 500);
++
++ await check('the person reads their own Vibes with their token; nothing is cached', async () => {
++ const r = await get(t, bobToken);
++ assert.strictEqual(r.status, 200);
++ const j = await r.json();
++ assert.deepStrictEqual(j.subject, { type: 'user', id: bob.id });
++ assert.strictEqual(j.credit, 500);
++ assert.strictEqual(j.payable, 0);
++ assert.strictEqual(j.currency, 'vibes-bits');
++ assert.match(r.headers.get('cache-control'), /private, no-store/);
++ });
++
++ await check('no token, a service token or a forged one: 401', async () => {
++ assert.strictEqual((await get(t, null)).status, 401);
++ assert.strictEqual((await get(t, t.network.signService({ cap: ['billing.*'] }))).status, 401, 'a service token is not a person');
++ assert.strictEqual((await get(t, `${bobToken.slice(0, -4)}AAAA`)).status, 401);
++ });
++
++ await check('CORS: an OpenVibe site gets its origin back, any other site none; preflight allows Authorization', async () => {
++ const own = await get(t, bobToken, { Origin: 'https://openvibe.tips' });
++ assert.strictEqual(own.headers.get('access-control-allow-origin'), 'https://openvibe.tips');
++ assert.match(own.headers.get('vary') || '', /Origin/);
++ const foreign = await get(t, bobToken, { Origin: 'https://evil.example' });
++ assert.strictEqual(foreign.headers.get('access-control-allow-origin'), null);
++ const tenant = await get(t, bobToken, { Origin: 'https://someone.openvibe.website' });
++ assert.strictEqual(tenant.headers.get('access-control-allow-origin'), null, 'a hosting tenant is not OpenVibe');
++ const pre = await fetch(`${t.base}/api/me/vibes`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.live', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'authorization' } });
++ assert.strictEqual(pre.status, 204);
++ assert.match(pre.headers.get('access-control-allow-headers'), /Authorization/);
++ assert.strictEqual(pre.headers.get('access-control-allow-credentials'), null, 'no cookies');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/nginx-vhost.test.js b/test/nginx-vhost.test.js
iindex 6626948..5cea188 100644
---- a/test/nginx-vhost.test.js
++++ b/test/nginx-vhost.test.js
@@@ -19,4 +19,9 @@ assert.match(block, /add_header X-Content-Type-Options nosniff always;/);
assert.match(block, /add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;/);
assert.match(block, /proxy_pass http:\/\/127\.0\.0\.1:4600;/);
assert.ok(tls.indexOf(m[0]) < tls.indexOf('location / {'), 'before the console catch-all');
--console.log('nginx vhost: the policy is indexable, the rest noindex');
++// The public /api: health and a person's own Vibes (the navbar), both before the catch-all 404; the service API stays loopback.
++const me = tls.match(/location = \/api\/me\/vibes \{([^}]*)\}/);
++assert.ok(me && /proxy_pass http:\/\/127\.0\.0\.1:4600;/.test(me[1]) && /limit_req /.test(me[1]), 'a rate-limited location for /api/me/vibes');
++assert.ok(tls.indexOf(me[0]) < tls.indexOf('location ^~ /api/ { return 404; }'), 'before the /api 404');
++assert.match(tls, /location \^~ \/api\/v1 \{ deny all; \}/);
++console.log('nginx vhost: the policy is indexable, the rest noindex; /api/me/vibes public, /api/v1 loopback only');