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)
Changes
@@@ -183,6 +183,12 @@ POST needs an `Idempotency-Key` (8–200 of `A-Za-z0-9._:-`); a replay returns tare 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) |@@@ -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.@@@ -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 = nullreturn 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 };nnew file mode 100644@@@ -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 };@@@ -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'));@@@ -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() {nnew file mode 100644@@@ -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();++})();@@@ -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');
@@ -183,6 +183,12 @@ POST needs an `Idempotency-Key` (8–200 of `A-Za-z0-9._:-`); a replay returns t
@@ -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_…" }`
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.
(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 |
| Method & path | Capability | Does |
|---|---|---|
|---|---|---|
| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) |
| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) |
@@ -58,6 +58,12 @@ server {
@@ -58,6 +58,12 @@ server {
}
}
location = /api/health { proxy_pass http://127.0.0.1:4600; }
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
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# answers 404 to any request a proxy relayed.
# answers 404 to any request a proxy relayed.
@@ -53,7 +53,7 @@ function personOf(v) {
@@ -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).
* 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 }) {
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
@@ -97,13 +97,20 @@ function createActorLimits({ config, db, now = () => Date.now(), registry = null
return historyLimit(req, res, next);
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. */
/** The payer of the transfer a refund gives back (one primary-key read), else null. */
async function payerOf(req) {
async function payerOf(req) {
const row = await db.prepare('SELECT from_subject FROM transactions WHERE id = ?').get(String(req.params.id));
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 row ? personOf(row.from_subject) : null;
}
}
- return { budget, history, payerOf };
+ return { budget, history, selfRead, payerOf };
}
}
module.exports = { createActorLimits, personOf, BUDGETS };
module.exports = { createActorLimits, personOf, BUDGETS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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 };
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* GET /api/health, /api/ready liveness / readiness
* GET /api/health, /api/ready liveness / readiness
* GET /metrics Prometheus text (direct loopback callers only; server/metrics.js)
* GET /metrics Prometheus text (direct loopback callers only; server/metrics.js)
* /api/v1/* operations API (service tokens, see api/v1.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)
* /webhooks/<provider> provider receipts (see api/webhooks.js)
* /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)
* /, /auth/*, /cashouts, … staff console (Network SSO, server-rendered; see console/index.js)
*
*
@@ -102,6 +103,8 @@ async function createApp(opts = {}) {
@@ -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.
// 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 });
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 }));
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.
// 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'));
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
@@ -96,7 +96,9 @@ async function startNetwork() {
@@ -96,7 +96,9 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
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() {
async function startStripe() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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();
+})();
@@ -19,4 +19,9 @@ assert.match(block, /add_header X-Content-Type-Options nosniff always;/);
@@ -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, /add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;/);
assert.match(block, /proxy_pass http:\/\/127\.0\.0\.1:4600;/);
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');
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');