openvibers/openvibe.tips Commits
Build the Billing client on openvibe-sdk/commerce v0.28.0
Changes
@@@ -8,8 +8,8 @@"authoritative": false,"deployed": true,"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects); TIPS_CHAT_ADAPTER=chat delivers through OpenVibe.Chat's ingress once tips is granted chat.message.send, chat.event.publish and identity.subject.resolve.",-- "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.25.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",-- "contracts": "openvibe-contracts v0.79.0",++ "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.28.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",++ "contracts": "openvibe-contracts v0.92.0","notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.","updated": "2026-10-04","features": [@@@ -24,6 +24,7 @@"/api/v1 by capability","per-actor limits (openvibe-sdk/limits)","PostgreSQL + Valkey data tier (ADR-035): serializable money paths, leased background work, shared limits and overlay fan-out",-- "SDK outbox and inbox on PostgreSQL"++ "SDK outbox and inbox on PostgreSQL",++ "Billing client on openvibe-sdk/commerce (shared with VIP): same capabilities, idempotency keys and retry rule"]}@@@ -14,8 +14,8 @@"express": "^4.21.2","iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0"},@@@ -1117,9 +1117,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.79.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",++ "version": "0.92.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",++ "integrity": "sha512-2Wm1CdsNqIBou+XYAtIiIcQ6GyXWWh5miG81iFYrPvWGLVF1BUjEN4XxQGK2S2bC6TCKz7XQtp5IJx3Mg0vPIw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1133,9 +1133,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.26.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",++ "version": "0.28.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",++ "integrity": "sha512-jjLisFDWBN+xDVsixa5BJRr3355kRijmvOh1/9rz4s1yVb3wZWCbpTLNcOvXm57ksnRoqK57393W38G0Ey3PFw==","license": "MIT","engines": {"node": ">=20"@@@ -26,8 +26,8 @@"express": "^4.21.2","iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0"},@@@ -1,8 +1,9 @@'use strict';/**-- * OpenVibe.Billing client (the only place money moves, ADR-012). Tips holds a client-credentials-- * token for audience openvibe.billing with:++ * OpenVibe.Billing client (the only place money moves, ADR-012): a thin wrapper over++ * openvibe-sdk/commerce, the client Tips shares with VIP. Tips holds one client-credentials token++ * for audience openvibe.billing with:** billing.intent.create POST /api/v1/intents checkout: the supporter buys the credit they give* billing.transfer.create POST /api/v1/transfers a tip from the supporter's credit to the creator@@@ -11,75 +12,28 @@* Every POST carries an Idempotency-Key derived from the interaction id, so a retry after a lost* response (or a crash) returns Billing's original transaction instead of moving money twice.*-- * Errors: a Billing problem+json becomes a BillingCallError with .status/.code (4xx: Billing-- * refused, e.g. billing.insufficient_funds, billing.frozen); a network failure has no status and is-- * retryable.++ * Errors: a Billing problem+json becomes a BillingCallError (commerce's CommerceError) with++ * .status/.code (4xx: Billing refused, e.g. billing.insufficient_funds, billing.frozen); a network++ * failure has no status and is retryable.*/--const { serviceAuth } = require('openvibe-contracts');++const { createCommerceClient, CommerceError: BillingCallError } = require('openvibe-sdk/commerce');--class BillingCallError extends Error {-- constructor(message, { status = null, code = null, body = null } = {}) {-- super(message);-- this.name = 'BillingCallError';-- this.status = status;-- this.code = code;-- this.body = body;-- }-- get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }--}++// One token for every call, as before: every logical capability maps to the same scope, so++// commerce creates (or takes) a single token client.++const SCOPE = 'billing.intent.create billing.transfer.create';++const CAPS = { intent: SCOPE, transfer: SCOPE, rates: SCOPE };function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {-- const base = config.billing.url;-- let tokens = tokenClient || null;-- const tokenClientFor = () => {-- if (!tokens) {-- tokens = serviceAuth.createTokenClient({-- tokenUrl: `${config.network.internalUrl}/oauth/token`,-- clientId: config.oauth.clientId,-- clientSecret: config.oauth.clientSecret,-- audience: config.billing.audience,-- scope: 'billing.intent.create billing.transfer.create',-- fetchImpl,-- });-- }-- return tokens;-- };---- async function call(method, path, { body, key, traceparent, retried = false } = {}) {-- let auth;-- try { auth = await tokenClientFor().authHeaders(); } catch (e) { throw new BillingCallError(`token: ${e.message}`); }-- const headers = { Accept: 'application/json', ...auth };-- if (body !== undefined) headers['Content-Type'] = 'application/json';-- if (key) headers['Idempotency-Key'] = key;-- if (traceparent) headers.traceparent = traceparent;-- let res;-- try {-- res = await fetchImpl(`${base}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10000) });-- } catch (e) {-- throw new BillingCallError(`Billing unreachable: ${e.message}`);-- }-- if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return await call(method, path, { body, key, traceparent, retried: true }); }-- const data = await res.json().catch(() => null);-- if (!res.ok) {-- const code = (data && (data.code || data.error)) || `http_${res.status}`;-- throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });-- }-- return data;-- }--++ const commerce = createCommerceClient(config, { caps: CAPS, fetchImpl, tokenClients: tokenClient ? { [SCOPE]: tokenClient } : null });return {/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */-- createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => await call('POST', '/api/v1/intents', {-- body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },-- key, traceparent,-- }),++ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) =>++ await commerce.createIntent({ provider, kind: 'purchase', subject, bits, successUrl, cancelUrl, key, traceparent }),/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */-- createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await call('POST', '/api/v1/transfers', {-- body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },-- key, traceparent,-- }),-- refundTransfer: async ({ txnId, amount, reason, key }) => await call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),-- rates: async () => await call('GET', '/api/v1/rates'),++ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) =>++ await commerce.createTransfer({ from, to, amount, kind, target, message, key, traceparent }),++ refundTransfer: async ({ txnId, amount, reason, key }) => await commerce.refundTransfer({ txnId, amount, reason, key }),++ rates: async () => await commerce.rates(),};}nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++// server/billing-client.js is a thin wrapper over openvibe-sdk/commerce: the same exported names,++// one token with the same scope for every call, the same request bodies, the Idempotency-Key sent++// unchanged on the one retry after a 401, and BillingCallError is commerce's error.++const assert = require('assert');++const { check, done } = require('./helpers/app');++const { CommerceError } = require('openvibe-sdk/commerce');++const { createBillingClient, BillingCallError } = require('../server/billing-client');++++const config = { billing: { url: 'http://billing.test', audience: 'openvibe.billing' }, network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'tips', clientSecret: 's' } };++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++++function fakeBilling(answer) {++ const seen = [];++ let tokens = 0;++ const fetchImpl = async (url, init) => {++ if (url.endsWith('/oauth/token')) {++ seen.push({ token: new URLSearchParams(init.body).get('scope') });++ return json(200, { access_token: `t${++tokens}`, token_type: 'Bearer', expires_in: 300 });++ }++ const call = { method: init.method, path: url.slice(config.billing.url.length), key: init.headers['Idempotency-Key'], auth: init.headers.Authorization, body: init.body ? JSON.parse(init.body) : undefined };++ seen.push(call);++ return answer(call, seen);++ };++ return { seen, fetchImpl };++}++++(async () => {++ await check('the exports are unchanged and BillingCallError is commerce\'s CommerceError', async () => {++ assert.strictEqual(typeof createBillingClient, 'function');++ assert.strictEqual(BillingCallError, CommerceError);++ const e = new BillingCallError('x', { status: 409, code: 'billing.frozen' });++ assert.ok(e.retryable);++ assert.ok(!new BillingCallError('x', { status: 402, code: 'billing.insufficient_funds' }).retryable);++ assert.ok(new BillingCallError('x').retryable);++ });++++ await check('one token with the combined scope; the same bodies and keys; a 401 is retried once with the same key', async () => {++ const { seen, fetchImpl } = fakeBilling((call, all) => (all.filter((c) => c.method).length === 1 ? json(401, {}) : json(200, { ok: true })));++ const billing = createBillingClient(config, { fetchImpl });++ await billing.createIntent({ provider: 'stripe', subject: 'u1', bits: 100, successUrl: 'https://s', cancelUrl: 'https://c', key: 'tips:intent:i1' });++ await billing.createTransfer({ from: 'u1', to: 'u2', amount: 5, kind: 'tip', target: { type: 'tips.interaction', id: 'i1' }, key: 'tips:transfer:i1' });++ await billing.refundTransfer({ txnId: 'txn/1', amount: 5, reason: 'not played', key: 'tips:refund:i1' });++ await billing.rates();++ const tokens = seen.filter((c) => c.token);++ assert.deepStrictEqual(tokens.map((c) => c.token), ['billing.intent.create billing.transfer.create', 'billing.intent.create billing.transfer.create']);++ const calls = seen.filter((c) => c.method);++ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path} ${c.key || ''}`), [++ 'POST /api/v1/intents tips:intent:i1', 'POST /api/v1/intents tips:intent:i1',++ 'POST /api/v1/transfers tips:transfer:i1', 'POST /api/v1/transfers/txn%2F1/refund tips:refund:i1', 'GET /api/v1/rates ',++ ]);++ assert.deepStrictEqual([calls[0].auth, calls[1].auth], ['Bearer t1', 'Bearer t2']);++ assert.deepStrictEqual(calls[1].body, { provider: 'stripe', kind: 'purchase', subject: { type: 'user', id: 'u1' }, bits: 100, success_url: 'https://s', cancel_url: 'https://c' });++ assert.deepStrictEqual(calls[2].body, { from: { type: 'user', id: 'u1' }, to: { type: 'user', id: 'u2' }, amount: 5, kind: 'tip', target: { type: 'tips.interaction', id: 'i1' } });++ assert.deepStrictEqual(calls[3].body, { amount: 5, reason: 'not played' });++ });++++ await check('a passed token client is used verbatim; a problem+json becomes a BillingCallError', async () => {++ const { seen, fetchImpl } = fakeBilling(() => json(402, { type: 'about:blank', code: 'billing.insufficient_funds', detail: 'not enough credit' }));++ const tokenClient = { authHeaders: async () => ({ Authorization: 'Bearer injected' }), invalidate() {} };++ const billing = createBillingClient(config, { fetchImpl, tokenClient });++ const e = await billing.createTransfer({ from: 'u1', to: 'u2', amount: 5, kind: 'tip', key: 'tips:transfer:i2' }).catch((err) => err);++ assert.ok(e instanceof BillingCallError);++ assert.strictEqual(e.status, 402);++ assert.strictEqual(e.code, 'billing.insufficient_funds');++ assert.ok(!e.retryable);++ assert.ok(!seen.some((c) => c.token));++ assert.strictEqual(seen[0].auth, 'Bearer injected');++ });++++ done();++})();
@@ -8,8 +8,8 @@
@@ -8,8 +8,8 @@
"authoritative": false,
"authoritative": false,
"deployed": true,
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects); TIPS_CHAT_ADAPTER=chat delivers through OpenVibe.Chat's ingress once tips is granted chat.message.send, chat.event.publish and identity.subject.resolve.",
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects); TIPS_CHAT_ADAPTER=chat delivers through OpenVibe.Chat's ingress once tips is granted chat.message.send, chat.event.publish and identity.subject.resolve.",
- "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.25.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
- "contracts": "openvibe-contracts v0.79.0",
+ "database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.28.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
+ "contracts": "openvibe-contracts v0.92.0",
"notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"updated": "2026-10-04",
"updated": "2026-10-04",
"features": [
"features": [
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
"/api/v1 by capability",
"/api/v1 by capability",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
"PostgreSQL + Valkey data tier (ADR-035): serializable money paths, leased background work, shared limits and overlay fan-out",
"PostgreSQL + Valkey data tier (ADR-035): serializable money paths, leased background work, shared limits and overlay fan-out",
- "SDK outbox and inbox on PostgreSQL"
+ "SDK outbox and inbox on PostgreSQL",
+ "Billing client on openvibe-sdk/commerce (shared with VIP): same capabilities, idempotency keys and retry rule"
]
]
}
}
@@ -14,8 +14,8 @@
@@ -14,8 +14,8 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1117,9 +1117,9 @@
@@ -1117,9 +1117,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.79.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
- "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
+ "version": "0.92.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
+ "integrity": "sha512-2Wm1CdsNqIBou+XYAtIiIcQ6GyXWWh5miG81iFYrPvWGLVF1BUjEN4XxQGK2S2bC6TCKz7XQtp5IJx3Mg0vPIw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1133,9 +1133,9 @@
@@ -1133,9 +1133,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.26.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
+ "version": "0.28.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
+ "integrity": "sha512-jjLisFDWBN+xDVsixa5BJRr3355kRijmvOh1/9rz4s1yVb3wZWCbpTLNcOvXm57ksnRoqK57393W38G0Ey3PFw==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -26,8 +26,8 @@
@@ -26,8 +26,8 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.92.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Billing client (the only place money moves, ADR-012). Tips holds a client-credentials
- * token for audience openvibe.billing with:
+ * OpenVibe.Billing client (the only place money moves, ADR-012): a thin wrapper over
+ * openvibe-sdk/commerce, the client Tips shares with VIP. Tips holds one client-credentials token
+ * for audience openvibe.billing with:
*
*
* billing.intent.create POST /api/v1/intents checkout: the supporter buys the credit they give
* billing.intent.create POST /api/v1/intents checkout: the supporter buys the credit they give
* billing.transfer.create POST /api/v1/transfers a tip from the supporter's credit to the creator
* billing.transfer.create POST /api/v1/transfers a tip from the supporter's credit to the creator
@@ -11,75 +12,28 @@
@@ -11,75 +12,28 @@
* Every POST carries an Idempotency-Key derived from the interaction id, so a retry after a lost
* Every POST carries an Idempotency-Key derived from the interaction id, so a retry after a lost
* response (or a crash) returns Billing's original transaction instead of moving money twice.
* response (or a crash) returns Billing's original transaction instead of moving money twice.
*
*
- * Errors: a Billing problem+json becomes a BillingCallError with .status/.code (4xx: Billing
- * refused, e.g. billing.insufficient_funds, billing.frozen); a network failure has no status and is
- * retryable.
+ * Errors: a Billing problem+json becomes a BillingCallError (commerce's CommerceError) with
+ * .status/.code (4xx: Billing refused, e.g. billing.insufficient_funds, billing.frozen); a network
+ * failure has no status and is retryable.
*/
*/
-const { serviceAuth } = require('openvibe-contracts');
+const { createCommerceClient, CommerceError: BillingCallError } = require('openvibe-sdk/commerce');
-class BillingCallError extends Error {
- constructor(message, { status = null, code = null, body = null } = {}) {
- super(message);
- this.name = 'BillingCallError';
- this.status = status;
- this.code = code;
- this.body = body;
- }
- get retryable() { return this.status == null || this.status >= 500 || this.status === 429 || this.status === 401 || this.code === 'billing.frozen'; }
-}
+// One token for every call, as before: every logical capability maps to the same scope, so
+// commerce creates (or takes) a single token client.
+const SCOPE = 'billing.intent.create billing.transfer.create';
+const CAPS = { intent: SCOPE, transfer: SCOPE, rates: SCOPE };
function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {
function createBillingClient(config, { fetchImpl = globalThis.fetch, tokenClient } = {}) {
- const base = config.billing.url;
- let tokens = tokenClient || null;
- const tokenClientFor = () => {
- if (!tokens) {
- tokens = serviceAuth.createTokenClient({
- tokenUrl: `${config.network.internalUrl}/oauth/token`,
- clientId: config.oauth.clientId,
- clientSecret: config.oauth.clientSecret,
- audience: config.billing.audience,
- scope: 'billing.intent.create billing.transfer.create',
- fetchImpl,
- });
- }
- return tokens;
- };
-
- async function call(method, path, { body, key, traceparent, retried = false } = {}) {
- let auth;
- try { auth = await tokenClientFor().authHeaders(); } catch (e) { throw new BillingCallError(`token: ${e.message}`); }
- const headers = { Accept: 'application/json', ...auth };
- if (body !== undefined) headers['Content-Type'] = 'application/json';
- if (key) headers['Idempotency-Key'] = key;
- if (traceparent) headers.traceparent = traceparent;
- let res;
- try {
- res = await fetchImpl(`${base}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(10000) });
- } catch (e) {
- throw new BillingCallError(`Billing unreachable: ${e.message}`);
- }
- if (res.status === 401 && !retried) { tokenClientFor().invalidate(); return await call(method, path, { body, key, traceparent, retried: true }); }
- const data = await res.json().catch(() => null);
- if (!res.ok) {
- const code = (data && (data.code || data.error)) || `http_${res.status}`;
- throw new BillingCallError(`Billing ${res.status} ${code}: ${(data && data.detail) || ''}`.trim(), { status: res.status, code, body: data });
- }
- return data;
- }
-
+ const commerce = createCommerceClient(config, { caps: CAPS, fetchImpl, tokenClients: tokenClient ? { [SCOPE]: tokenClient } : null });
return {
return {
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
/** { intent, checkout_url } — kind purchase: the supporter buys `bits` of credit. */
- createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) => await call('POST', '/api/v1/intents', {
- body: { provider, kind: 'purchase', subject: { type: 'user', id: subject }, bits, success_url: successUrl, cancel_url: cancelUrl },
- key, traceparent,
- }),
+ createIntent: async ({ provider, subject, bits, successUrl, cancelUrl, key, traceparent }) =>
+ await commerce.createIntent({ provider, kind: 'purchase', subject, bits, successUrl, cancelUrl, key, traceparent }),
/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
/** { transaction } — credit of `from` → payable of `to`, tagged with the interaction. */
- createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) => await call('POST', '/api/v1/transfers', {
- body: { from: { type: 'user', id: from }, to: { type: 'user', id: to }, amount, kind, target, message: message || undefined },
- key, traceparent,
- }),
- refundTransfer: async ({ txnId, amount, reason, key }) => await call('POST', `/api/v1/transfers/${encodeURIComponent(txnId)}/refund`, { body: { amount, reason }, key }),
- rates: async () => await call('GET', '/api/v1/rates'),
+ createTransfer: async ({ from, to, amount, kind, target, message, key, traceparent }) =>
+ await commerce.createTransfer({ from, to, amount, kind, target, message, key, traceparent }),
+ refundTransfer: async ({ txnId, amount, reason, key }) => await commerce.refundTransfer({ txnId, amount, reason, key }),
+ rates: async () => await commerce.rates(),
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+// server/billing-client.js is a thin wrapper over openvibe-sdk/commerce: the same exported names,
+// one token with the same scope for every call, the same request bodies, the Idempotency-Key sent
+// unchanged on the one retry after a 401, and BillingCallError is commerce's error.
+const assert = require('assert');
+const { check, done } = require('./helpers/app');
+const { CommerceError } = require('openvibe-sdk/commerce');
+const { createBillingClient, BillingCallError } = require('../server/billing-client');
+
+const config = { billing: { url: 'http://billing.test', audience: 'openvibe.billing' }, network: { internalUrl: 'http://network.test' }, oauth: { clientId: 'tips', clientSecret: 's' } };
+const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+function fakeBilling(answer) {
+ const seen = [];
+ let tokens = 0;
+ const fetchImpl = async (url, init) => {
+ if (url.endsWith('/oauth/token')) {
+ seen.push({ token: new URLSearchParams(init.body).get('scope') });
+ return json(200, { access_token: `t${++tokens}`, token_type: 'Bearer', expires_in: 300 });
+ }
+ const call = { method: init.method, path: url.slice(config.billing.url.length), key: init.headers['Idempotency-Key'], auth: init.headers.Authorization, body: init.body ? JSON.parse(init.body) : undefined };
+ seen.push(call);
+ return answer(call, seen);
+ };
+ return { seen, fetchImpl };
+}
+
+(async () => {
+ await check('the exports are unchanged and BillingCallError is commerce\'s CommerceError', async () => {
+ assert.strictEqual(typeof createBillingClient, 'function');
+ assert.strictEqual(BillingCallError, CommerceError);
+ const e = new BillingCallError('x', { status: 409, code: 'billing.frozen' });
+ assert.ok(e.retryable);
+ assert.ok(!new BillingCallError('x', { status: 402, code: 'billing.insufficient_funds' }).retryable);
+ assert.ok(new BillingCallError('x').retryable);
+ });
+
+ await check('one token with the combined scope; the same bodies and keys; a 401 is retried once with the same key', async () => {
+ const { seen, fetchImpl } = fakeBilling((call, all) => (all.filter((c) => c.method).length === 1 ? json(401, {}) : json(200, { ok: true })));
+ const billing = createBillingClient(config, { fetchImpl });
+ await billing.createIntent({ provider: 'stripe', subject: 'u1', bits: 100, successUrl: 'https://s', cancelUrl: 'https://c', key: 'tips:intent:i1' });
+ await billing.createTransfer({ from: 'u1', to: 'u2', amount: 5, kind: 'tip', target: { type: 'tips.interaction', id: 'i1' }, key: 'tips:transfer:i1' });
+ await billing.refundTransfer({ txnId: 'txn/1', amount: 5, reason: 'not played', key: 'tips:refund:i1' });
+ await billing.rates();
+ const tokens = seen.filter((c) => c.token);
+ assert.deepStrictEqual(tokens.map((c) => c.token), ['billing.intent.create billing.transfer.create', 'billing.intent.create billing.transfer.create']);
+ const calls = seen.filter((c) => c.method);
+ assert.deepStrictEqual(calls.map((c) => `${c.method} ${c.path} ${c.key || ''}`), [
+ 'POST /api/v1/intents tips:intent:i1', 'POST /api/v1/intents tips:intent:i1',
+ 'POST /api/v1/transfers tips:transfer:i1', 'POST /api/v1/transfers/txn%2F1/refund tips:refund:i1', 'GET /api/v1/rates ',
+ ]);
+ assert.deepStrictEqual([calls[0].auth, calls[1].auth], ['Bearer t1', 'Bearer t2']);
+ assert.deepStrictEqual(calls[1].body, { provider: 'stripe', kind: 'purchase', subject: { type: 'user', id: 'u1' }, bits: 100, success_url: 'https://s', cancel_url: 'https://c' });
+ assert.deepStrictEqual(calls[2].body, { from: { type: 'user', id: 'u1' }, to: { type: 'user', id: 'u2' }, amount: 5, kind: 'tip', target: { type: 'tips.interaction', id: 'i1' } });
+ assert.deepStrictEqual(calls[3].body, { amount: 5, reason: 'not played' });
+ });
+
+ await check('a passed token client is used verbatim; a problem+json becomes a BillingCallError', async () => {
+ const { seen, fetchImpl } = fakeBilling(() => json(402, { type: 'about:blank', code: 'billing.insufficient_funds', detail: 'not enough credit' }));
+ const tokenClient = { authHeaders: async () => ({ Authorization: 'Bearer injected' }), invalidate() {} };
+ const billing = createBillingClient(config, { fetchImpl, tokenClient });
+ const e = await billing.createTransfer({ from: 'u1', to: 'u2', amount: 5, kind: 'tip', key: 'tips:transfer:i2' }).catch((err) => err);
+ assert.ok(e instanceof BillingCallError);
+ assert.strictEqual(e.status, 402);
+ assert.strictEqual(e.code, 'billing.insufficient_funds');
+ assert.ok(!e.retryable);
+ assert.ok(!seen.some((c) => c.token));
+ assert.strictEqual(seen[0].auth, 'Bearer injected');
+ });
+
+ done();
+})();