openvibers/openvibe.network Commits
Document that usage rollups are counts, never money, and guard it
Changes
@@@ -139,6 +139,8 @@ A quota says how much of one capability a project may use: for example, `media.oThe services that own developer capabilities count each project's use per environment and UTC hour, and send one rollup per closed hour through OpenVibe.Events (roadmap WS-N task 4): `tools.usage.recorded` from each Tools satellite (jobs, per `tools.job.create` or `tools.tool.run` and job type or tool) and `events.usage.recorded` from Events (`events.app.publish` in `events`, `events.app.subscribe` in `deliveries`). The payload is `common.usage-recorded@1` (openvibe-contracts 0.63.0): project, environment, capability, dimension, unit, window, quantity, errors, errors by code and up to ten sampled failures (time, code, status, trace id, job or event id). A rollup never names who did the work.++Rollups are counts for quotas and dashboards, never money. Network never ingests the `platform.usage-sample` readings and stores no subject with a count. Money lives only in Billing.++The Events consumer (`POST /internal/events`, [server/developer/usage.js](../server/developer/usage.js)) records each one inside its inbox transaction:- `dev_usage_windows`: one row per rollup key (service, project, environment, capability, dimension, unit, window start). A later rollup for the same key replaces it (the higher `revision` wins), so a re-sent hour is never counted twice. Kept 35 days.@@@ -14,6 +14,9 @@* twice. The failures a rollup samples (time, code, status, trace id, job or event id) are kept for* the dashboard's recent errors. Nothing here names who did the work.*++ * Rollups are counts for quotas and dashboards, never money. Network never ingests the++ * platform.usage-sample readings and stores no subject with a count; money lives only in Billing.++ ** dev_usage_windows one row per rollup key (WINDOW_DAYS, 35 days)* dev_usage_daily per project, env, service, capability, dimension, unit and UTC day (DAILY_DAYS, 400 days)* dev_usage_errors sampled failures (ERROR_DAYS, 30 days; the newest ERRORS_PER_PROJECT per project)@@@ -245,6 +245,8 @@ const DEFAULT_GRANTS = [...['live', 'chat', 'community'].flatMap(c => ['tools.tool.run', 'tools.job.read'].map(cap => [c, cap, 'openvibe.tools', []])),// Tools indexes its own tool pages in Search through the owner API (owner tools only).['tools', 'search.document.write', 'openvibe.search', []],++ // Plan T5 step 7: Tools posts one usage reading per ended tool job to Billing (POST /api/v1/usage).++ ['tools', 'billing.usage.record', 'openvibe.billing', []],];// Grants withdrawn by decision; applied at every boot so an old default can't come back.@@@ -162,6 +162,8 @@ const server = http.createServer(app);await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');++ const tb = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.billing' });++ assert.strictEqual(tb.body.scope, 'billing.usage.record', 'Tools posts usage readings to Billing and holds no money capability');// Coupons subscribes to its Events sources with its own token (OpenVibe.Coupons scripts/subscribe.js).await db.prepare("UPDATE oauth_clients SET client_secret = 'coupons-secret' WHERE client_id = 'coupons'").run();const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });@@@ -83,6 +83,9 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';// ── The consumer subscribes to both rollups, and records them ──assert.ok(TOPICS.includes('tools.usage.recorded') && TOPICS.includes('events.usage.recorded'));++ // Rollups are counts, never money: the consumer takes no billing topic and no raw usage sample.++ // billing.staff.action is the one exception: a staff audit record (server/admin/moderation-audit.js), no amounts.++ assert.deepStrictEqual(TOPICS.filter((t) => (t.startsWith('billing.') && t !== 'billing.staff.action') || t.includes('usage-sample')), []);assert.deepStrictEqual(topicsFrom(['--topic', 'tools.usage.recorded', '--topic', 'events.usage.recorded']), ['tools.usage.recorded', 'events.usage.recorded']);const rollup = (svc, payload, over = {}) => ({event_id: ids.newId('event'), event_type: `${svc}.usage.recorded`, version: 1, source: svc, actor: { type: 'service', id: svc },
@@ -139,6 +139,8 @@ A quota says how much of one capability a project may use: for example, `media.o
@@ -139,6 +139,8 @@ A quota says how much of one capability a project may use: for example, `media.o
The services that own developer capabilities count each project's use per environment and UTC hour, and send one rollup per closed hour through OpenVibe.Events (roadmap WS-N task 4): `tools.usage.recorded` from each Tools satellite (jobs, per `tools.job.create` or `tools.tool.run` and job type or tool) and `events.usage.recorded` from Events (`events.app.publish` in `events`, `events.app.subscribe` in `deliveries`). The payload is `common.usage-recorded@1` (openvibe-contracts 0.63.0): project, environment, capability, dimension, unit, window, quantity, errors, errors by code and up to ten sampled failures (time, code, status, trace id, job or event id). A rollup never names who did the work.
The services that own developer capabilities count each project's use per environment and UTC hour, and send one rollup per closed hour through OpenVibe.Events (roadmap WS-N task 4): `tools.usage.recorded` from each Tools satellite (jobs, per `tools.job.create` or `tools.tool.run` and job type or tool) and `events.usage.recorded` from Events (`events.app.publish` in `events`, `events.app.subscribe` in `deliveries`). The payload is `common.usage-recorded@1` (openvibe-contracts 0.63.0): project, environment, capability, dimension, unit, window, quantity, errors, errors by code and up to ten sampled failures (time, code, status, trace id, job or event id). A rollup never names who did the work.
+Rollups are counts for quotas and dashboards, never money. Network never ingests the `platform.usage-sample` readings and stores no subject with a count. Money lives only in Billing.
+
The Events consumer (`POST /internal/events`, [server/developer/usage.js](../server/developer/usage.js)) records each one inside its inbox transaction:
The Events consumer (`POST /internal/events`, [server/developer/usage.js](../server/developer/usage.js)) records each one inside its inbox transaction:
- `dev_usage_windows`: one row per rollup key (service, project, environment, capability, dimension, unit, window start). A later rollup for the same key replaces it (the higher `revision` wins), so a re-sent hour is never counted twice. Kept 35 days.
- `dev_usage_windows`: one row per rollup key (service, project, environment, capability, dimension, unit, window start). A later rollup for the same key replaces it (the higher `revision` wins), so a re-sent hour is never counted twice. Kept 35 days.
@@ -14,6 +14,9 @@
@@ -14,6 +14,9 @@
* twice. The failures a rollup samples (time, code, status, trace id, job or event id) are kept for
* twice. The failures a rollup samples (time, code, status, trace id, job or event id) are kept for
* the dashboard's recent errors. Nothing here names who did the work.
* the dashboard's recent errors. Nothing here names who did the work.
*
*
+ * Rollups are counts for quotas and dashboards, never money. Network never ingests the
+ * platform.usage-sample readings and stores no subject with a count; money lives only in Billing.
+ *
* dev_usage_windows one row per rollup key (WINDOW_DAYS, 35 days)
* dev_usage_windows one row per rollup key (WINDOW_DAYS, 35 days)
* dev_usage_daily per project, env, service, capability, dimension, unit and UTC day (DAILY_DAYS, 400 days)
* dev_usage_daily per project, env, service, capability, dimension, unit and UTC day (DAILY_DAYS, 400 days)
* dev_usage_errors sampled failures (ERROR_DAYS, 30 days; the newest ERRORS_PER_PROJECT per project)
* dev_usage_errors sampled failures (ERROR_DAYS, 30 days; the newest ERRORS_PER_PROJECT per project)
@@ -245,6 +245,8 @@ const DEFAULT_GRANTS = [
@@ -245,6 +245,8 @@ const DEFAULT_GRANTS = [
...['live', 'chat', 'community'].flatMap(c => ['tools.tool.run', 'tools.job.read'].map(cap => [c, cap, 'openvibe.tools', []])),
...['live', 'chat', 'community'].flatMap(c => ['tools.tool.run', 'tools.job.read'].map(cap => [c, cap, 'openvibe.tools', []])),
// Tools indexes its own tool pages in Search through the owner API (owner tools only).
// Tools indexes its own tool pages in Search through the owner API (owner tools only).
['tools', 'search.document.write', 'openvibe.search', []],
['tools', 'search.document.write', 'openvibe.search', []],
+ // Plan T5 step 7: Tools posts one usage reading per ended tool job to Billing (POST /api/v1/usage).
+ ['tools', 'billing.usage.record', 'openvibe.billing', []],
];
];
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
@@ -162,6 +162,8 @@ const server = http.createServer(app);
@@ -162,6 +162,8 @@ const server = http.createServer(app);
await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'tools-secret' WHERE client_id = 'tools'").run();
const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });
const ts = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.search' });
assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');
assert.strictEqual(ts.body.scope, 'search.document.write', 'Tools indexes its tools in Search');
+ const tb = await token({ client_id: 'tools', client_secret: 'tools-secret', audience: 'openvibe.billing' });
+ assert.strictEqual(tb.body.scope, 'billing.usage.record', 'Tools posts usage readings to Billing and holds no money capability');
// Coupons subscribes to its Events sources with its own token (OpenVibe.Coupons scripts/subscribe.js).
// Coupons subscribes to its Events sources with its own token (OpenVibe.Coupons scripts/subscribe.js).
await db.prepare("UPDATE oauth_clients SET client_secret = 'coupons-secret' WHERE client_id = 'coupons'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'coupons-secret' WHERE client_id = 'coupons'").run();
const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
@@ -83,6 +83,9 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
@@ -83,6 +83,9 @@ const JOB = 'job_01JAB2C3D4E5F6G7H8J9K0MNPR';
// ── The consumer subscribes to both rollups, and records them ──
// ── The consumer subscribes to both rollups, and records them ──
assert.ok(TOPICS.includes('tools.usage.recorded') && TOPICS.includes('events.usage.recorded'));
assert.ok(TOPICS.includes('tools.usage.recorded') && TOPICS.includes('events.usage.recorded'));
+ // Rollups are counts, never money: the consumer takes no billing topic and no raw usage sample.
+ // billing.staff.action is the one exception: a staff audit record (server/admin/moderation-audit.js), no amounts.
+ assert.deepStrictEqual(TOPICS.filter((t) => (t.startsWith('billing.') && t !== 'billing.staff.action') || t.includes('usage-sample')), []);
assert.deepStrictEqual(topicsFrom(['--topic', 'tools.usage.recorded', '--topic', 'events.usage.recorded']), ['tools.usage.recorded', 'events.usage.recorded']);
assert.deepStrictEqual(topicsFrom(['--topic', 'tools.usage.recorded', '--topic', 'events.usage.recorded']), ['tools.usage.recorded', 'events.usage.recorded']);
const rollup = (svc, payload, over = {}) => ({
const rollup = (svc, payload, over = {}) => ({
event_id: ids.newId('event'), event_type: `${svc}.usage.recorded`, version: 1, source: svc, actor: { type: 'service', id: svc },
event_id: ids.newId('event'), event_type: `${svc}.usage.recorded`, version: 1, source: svc, actor: { type: 'service', id: svc },