openvibers/openvibe.space Commits

Emit daily gb-month storage readings to Billing in local store mode (#43)

OP OpenVibers committed 30 days ago · 75c2944c64 · 12 files, +414 −5

* Emit daily gb-month storage readings to Billing in local store mode

Asked: T5 step 14, Space slice (govern universal, storage usage). Moved here from Host: job 50-host-t5-step-14-host-slice-231140 found Host keeps no deploy bytes since e9b9120 (Host #84 moved the static hosting engine to OpenVibe.Space; Host migration 0008 dropped host_blobs/host_deploys). Read its reason…
Harness job space-t5-step-14-space-slice-000352 (plan T5).

* Add cutover runbook for the billing readings outbox migration

Requested by finish-f1. Handed to the local harness by the cloud job pr-OpenVibe.Space-43; done by Claude sonnet.

* Space: storage readings name the OpenVibe.Network project (network_project_id); a project without one is not billed

Changes

ddiff --git a/.env.example b/.env.example
iindex 3a5153a..128343a 100644
---- a/.env.example
++++ b/.env.example
@@@ -78,6 +78,10 @@ SPACE_STORAGE_DIR=./data/objects
# SPACE_ACTOR_LIMITS=on
# EVENTS_URL=
# EVENTS_RELAY_INTERVAL_MS=2000
++# SPACE_BILLING_INTERVAL_MS=0 # >0: daily gb-month storage readings to Billing (local store only)
++# OV_BILLING_URL= # Billing base URL; unset keeps readings queued
++# OV_BILLING_AUDIENCE=openvibe.billing
++# SPACE_BILLING_TIMEOUT_MS=10000
# INDEXNOW_KEY=
# VALKEY_URL=
# VALKEY_PREFIX=ov:space:
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5822d42..9e8d028 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,7 @@
## Unreleased
++- Usage readings (plan T5 step 14, Space slice): `server/billing.js` turns the bytes the local object store keeps (`space_blobs`, each blob once per project) into one `platform.usage-sample@1` `gb-month` reading per production project and owner per closed UTC day, queued in `billing_readings` (migration `0006_billing_readings.sql`) and relayed to OpenVibe.Billing's `billing.usage.record`. Off unless `SPACE_BILLING_INTERVAL_MS` > 0; with `SPACE_OBJECT_STORE=media` Space emits none, because Media bills the stored bytes.
- The forum moved back to OpenVibe.Community (owner decision, 2026-10-08), so Space keeps none of it. Removed: `server/forum/*`, `server/relay/*`, `server/vip/*`, `server/media/*`, `server/identity/*`, `server/{chat-rooms,votes,limits,actor-limits,events,events-consumer}.js`, `server/http/v1.js`, `server/render/{board,forum,markdown,jsonld,highlight}.js`, `public/js/space.js`, the forum suites and their fixtures, `docs/discord-relay.md` and the roadmap JSON.
- Every forum URL answers a permanent redirect to the same path and query on https://openvibe.community: `/s` and `/s/*` by 301 for GET and HEAD and 308 otherwise (the method and body are preserved), and `/api/v1/spaces*`, `/api/v1/posts/*`, `/api/v1/space-groups*` and `/api/v1/relay*` by 308.
- Space's home page (`/`) is now a short, honest page: Space will host code and dynamic pages, and "spaces" — apps people build on their OpenVibe account, the network's services and the SDK — with a clear link to the forum at https://openvibe.community/s. It claims no feature Space does not have.
ddiff --git a/README.md b/README.md
iindex 839c002..de80750 100644
---- a/README.md
++++ b/README.md
@@@ -54,7 +54,7 @@ The transactional outbox emits `space.deploy.created`, `space.deploy.activated`,
## Data and configuration
--`migrations/0002_initial.sql` through `0005_git_sources.sql` create the hosting tables, indexes and immutable-artifact triggers from Host Stage B with `space_` names. They run through `openvibe-sdk/db` at boot; tables are never created by a request. `0001_initial.sql` remains the historical forum schema, which this release does not drop. This is a code move, with no data copy.
++`migrations/0002_initial.sql` through `0005_git_sources.sql` create the hosting tables, `0006_billing_readings.sql` the usage-reading outbox,, indexes and immutable-artifact triggers from Host Stage B with `space_` names. They run through `openvibe-sdk/db` at boot; tables are never created by a request. `0001_initial.sql` remains the historical forum schema, which this release does not drop. This is a code move, with no data copy.
Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600). Important values:
@@@ -67,6 +67,7 @@ Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600).
| `SPACE_FORM_SECRET` | random per process | Dashboard CSRF form signing |
| `SPACE_WORKER` | on | Domain rechecks, object sweep, outbox pruning |
| `EVENTS_URL` | unset | Outbox relay destination; unset keeps rows queued |
++| `SPACE_BILLING_INTERVAL_MS` | `0` (off) | Daily `gb-month` storage readings to OpenVibe.Billing (`OV_BILLING_URL`) in local mode, per OpenVibe.Network project (`network_project_id`; a project that names none gets no reading); Media mode emits none (Media bills the bytes) |
| `DATABASE_URL`, `DATABASE_DIRECT_URL` | unset | PostgreSQL serving and owner migration connections |
Other `SPACE_*` quota, upload, domain and DNS settings are listed in `.env.example` and `server/hosting-config.js`. Space keeps its existing OAuth and forum redirect settings.
@@@ -83,7 +84,7 @@ npm test
## Security and dependencies
--Static uploads are validated before storage; tenant content runs on a separate registrable domain and previews use a sandbox CSP. APIs ignore cookies. OpenVibe.Network supplies identity; `svc:space` needs `events.event.publish` and, when `SPACE_OBJECT_STORE=media`, the `media.object.upload/read/list/delete` grants for the `space` namespace. OpenVibe.Media may hold tenant objects; OpenVibe.Events receives the outbox when configured. Space's own database is `ov_space` through `openvibe-sdk/db`. See [SECURITY.md](SECURITY.md).
++Static uploads are validated before storage; tenant content runs on a separate registrable domain and previews use a sandbox CSP. APIs ignore cookies. OpenVibe.Network supplies identity; `svc:space` needs `events.event.publish`, `billing.usage.record` (audience `openvibe.billing`) once `SPACE_BILLING_INTERVAL_MS` is on, and, when `SPACE_OBJECT_STORE=media`, the `media.object.upload/read/list/delete` grants for the `space` namespace. OpenVibe.Media may hold tenant objects; OpenVibe.Events receives the outbox when configured. Space's own database is `ov_space` through `openvibe-sdk/db`. See [SECURITY.md](SECURITY.md).
## License
ddiff --git a/STATUS.json b/STATUS.json
iindex 0469abc..0aaa58f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -23,7 +23,8 @@
"staff-only static hosting engine: projects, sites, immutable deploys, previews, rollback, Git ingest, custom domains, takedowns, Media-backed objects and resource index",
"tenant serving at <name>.openvibe.website",
"permanent forum redirects to OpenVibe.Community",
-- "Network OAuth sign-in, discovery, health and readiness"
++ "Network OAuth sign-in, discovery, health and readiness",
++ "usage readings (storage, local mode): daily gb-month readings to OpenVibe.Billing, off unless SPACE_BILLING_INTERVAL_MS > 0"
],
"knownGaps": [
"Public project creation is off until abuse handling and a designated DMCA agent are in place",
ddiff --git a/docs/cutover-pr-openvibe-space-43.md b/docs/cutover-pr-openvibe-space-43.md
nnew file mode 100644
iindex 0000000..f364baa
---- /dev/null
++++ b/docs/cutover-pr-openvibe-space-43.md
@@@ -0,0 +1,42 @@
++# Cutover runbook: PR #43, billing readings outbox (`migrations/0006_billing_readings.sql`)
++
++What changes in data: two new tables, `billing_readings` (outbox of `platform.usage-sample@1` readings, with a trigger that refuses editing a stored reading) and `billing_periods` (closed UTC days already aggregated), plus two indexes and one trigger function. Nothing existing is altered, dropped or copied. The migration is phase `expand` (additive only).
++
++The new code is off by default: `SPACE_BILLING_INTERVAL_MS` is unset or `0`, so `billing.start()` starts no timer, aggregates nothing and posts nothing. The migration alone changes no behaviour.
++
++## Order
++
++1. **Backup** (before the deploy, on the production host, owner connection):
++ `pg_dump --format=custom --file=ov_space-pre-0006-$(date -u +%Y%m%dT%H%M%SZ).dump "$DATABASE_DIRECT_URL"`
++ Keep the dump until step 5 has passed. A table-level dump is enough, because the migration only adds tables, but the full dump is cheap and also covers rollback of the code.
++2. **Deploy** this PR's merged `main` through the normal pipeline (`ov deploy OpenVibe.Space "<why>"`). Space runs `migrations/0006_billing_readings.sql` through `openvibe-sdk/db` at boot; tables are never created by a request. The migration runs once; a second boot applies nothing.
++3. **Verify the schema** (see Verification). Billing stays off at this point.
++4. **Enable (separate, later step, not part of this cutover):** grant `svc:space` the `billing.usage.record` capability (audience `openvibe.billing`), then set `SPACE_BILLING_INTERVAL_MS` to a positive value and restart. Local store mode only; Media mode emits no readings.
++5. **Close:** once step 3 is green, the dump from step 1 may be discarded after the owner's retention window.
++
++## Verification
++
++Run on production after step 2 (read-only `\d`/count queries):
++
++- `SELECT to_regclass('billing_readings'), to_regclass('billing_periods');` returns both names, not NULL.
++- `SELECT count(*) FROM billing_readings;` and `... FROM billing_periods;` return `0` while billing is off.
++- `SELECT tgname FROM pg_trigger WHERE tgrelid = 'billing_readings'::regclass AND NOT tgisinternal;` lists `billing_readings_frozen`.
++- The service answers its health check and `ov access run openvibe-ovh health` is green; the journal of the Space unit shows no migration error.
++- When billing is later enabled (step 4): `SELECT count(*) FILTER (WHERE sent_at IS NULL AND rejected_at IS NULL) FROM billing_readings;` drains to 0 shortly after each daily run, and `billing_periods` gains one `gb-month` row per closed UTC day.
++
++Rehearsal on a scratch PostgreSQL (run by the harness, `ov rehearse OpenVibe.Space 43`): main's migrations, the repository's fixtures, this PR's migrations, a second migration run that must apply nothing, then the commands below. They check that both tables and the trigger exist, that a repeated `event_id` inserts nothing, that delivery columns can move, that editing the reading is refused, and that its test row is removed afterwards.
++
++```rehearse
++# both tables and the trigger exist; a repeated key inserts nothing; delivery columns move; the reading cannot be edited
++psql "$REHEARSAL_DATABASE_URL" -v ON_ERROR_STOP=1 -c 'DO $do$ DECLARE n bigint; refused boolean := false; BEGIN IF to_regclass($b$billing_readings$b$) IS NULL OR to_regclass($b$billing_periods$b$) IS NULL THEN RAISE EXCEPTION $b$rehearsal: billing tables missing$b$; END IF; INSERT INTO billing_readings (event_id, envelope, created_at) VALUES ($b$rh-1$b$, $b${"a":1}$b$, 1); INSERT INTO billing_readings (event_id, envelope, created_at) VALUES ($b$rh-1$b$, $b${"a":1}$b$, 1) ON CONFLICT (event_id) DO NOTHING; GET DIAGNOSTICS n = ROW_COUNT; IF n <> 0 THEN RAISE EXCEPTION $b$rehearsal: a repeated idempotency key inserted a row$b$; END IF; UPDATE billing_readings SET attempts = 1, sent_at = 2, seq = 3 WHERE event_id = $b$rh-1$b$; BEGIN UPDATE billing_readings SET envelope = $b${"a":2}$b$ WHERE event_id = $b$rh-1$b$; EXCEPTION WHEN raise_exception THEN refused := true; END; IF NOT refused THEN RAISE EXCEPTION $b$rehearsal: editing a stored reading was allowed$b$; END IF; DELETE FROM billing_readings WHERE event_id = $b$rh-1$b$; END $do$;'
++```
++
++## Way back
++
++The migration is additive and the code is off by default, so the way back is cheap and has no data loss for existing features:
++
++1. **Code only (preferred):** if the new code misbehaves, set `SPACE_BILLING_INTERVAL_MS=0` (or remove it) and restart. The timer stops; the new tables stay, unused and empty or holding unsent readings. Or roll the release back with the pipeline's guarded rollback; the old code ignores the extra tables.
++2. **Tables too (only if the owner wants them gone):** nothing else references them, so they drop cleanly. Run as the owner role:
++ `DROP TABLE IF EXISTS billing_periods; DROP TABLE IF EXISTS billing_readings; DROP FUNCTION IF EXISTS billing_readings_frozen();`
++ then delete the `0006_billing_readings.sql` row from the migrations bookkeeping table of `openvibe-sdk/db`, or the next boot of a build that still contains the file will recreate the tables. Unsent readings in `billing_readings` are lost with the table; readings already posted stay recorded at Billing, and the idempotency key makes a repost harmless.
++3. **Full restore (not expected):** `pg_restore --clean --if-exists --dbname="$DATABASE_DIRECT_URL" ov_space-pre-0006-<stamp>.dump` from the step 1 backup. This rewinds any Space writes made since the backup, so use it only if the database itself is damaged.
ddiff --git a/migrations/0006_billing_readings.sql b/migrations/0006_billing_readings.sql
nnew file mode 100644
iindex 0000000..078c3cb
---- /dev/null
++++ b/migrations/0006_billing_readings.sql
@@@ -0,0 +1,51 @@
++-- phase: expand
++-- Plan T5 step 14 (govern universal), Space slice: the outbox of platform.usage-sample@1 readings Space posts to
++-- OpenVibe.Billing (POST /api/v1/usage, capability billing.usage.record; server/billing.js) plus the aggregation
++-- state that decides which closed UTC days were already aggregated. The same two tables as OpenVibe.Media's
++-- migrations/0004_billing_readings.sql.
++--
++-- billing_readings is the openvibe-sdk/usage createUsageReporter outbox (outboxSchema('billing_readings')): one row per
++-- reading, `event_id` is the reading's idempotency_key (unique: re-queueing a day inserts nothing, ON CONFLICT DO
++-- NOTHING) and `envelope` is the reading JSON. The reporter only ever moves the sending columns (attempts,
++-- next_attempt_at, sent_at, seq, rejected_at, last_error); the trigger below refuses any change to the reading itself,
++-- so a retry always posts the same body.
++--
++-- billing_periods: one row per (metric, closed UTC day start) already aggregated, so a re-run of a day inserts nothing
++-- even when it produced no reading. Additive only: two new tables and their indexes/trigger; nothing existing changes.
++CREATE TABLE billing_readings (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text,
++ -- `event_id` IS the reading's idempotency_key; this generated column names it so a reader can say so.
++ idempotency_key text GENERATED ALWAYS AS (event_id) STORED
++);
++CREATE INDEX IF NOT EXISTS billing_readings_due ON billing_readings (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS billing_readings_sent ON billing_readings (sent_at) WHERE sent_at IS NOT NULL;
++
++-- A reading is never edited: only its delivery columns move. event_id/envelope/created_at are the reading.
++CREATE FUNCTION billing_readings_frozen() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF NEW.event_id IS DISTINCT FROM OLD.event_id OR NEW.envelope IS DISTINCT FROM OLD.envelope
++ OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
++ RAISE EXCEPTION 'billing_readings: reading % is never edited', OLD.event_id;
++ END IF;
++ RETURN NEW;
++END $$;
++CREATE TRIGGER billing_readings_frozen BEFORE UPDATE ON billing_readings FOR EACH ROW EXECUTE FUNCTION billing_readings_frozen();
++
++-- The closed periods already aggregated, per metric (gb-month: the UTC day).
++CREATE TABLE billing_periods (
++ metric text COLLATE "C" NOT NULL,
++ period_start bigint NOT NULL,
++ readings bigint NOT NULL DEFAULT 0,
++ aggregated_at bigint NOT NULL,
++ PRIMARY KEY (metric, period_start)
++);
ddiff --git a/server/billing.js b/server/billing.js
nnew file mode 100644
iindex 0000000..b8ce2f7
---- /dev/null
++++ b/server/billing.js
@@@ -0,0 +1,173 @@
++'use strict';
++/**
++ * Space's storage usage readings → OpenVibe.Billing (plan T5 step 14, Space slice). The shape of OpenVibe.Media's
++ * server/billing.js.
++ *
++ * Space emits one platform.usage-sample@1 metric, gb-month (Media's storage metric):
++ *
++ * gb-month the deploy bytes the local object store keeps for one closed UTC day, per (project, subject,
++ * tier): every space_blobs row, i.e. each content-addressed blob once per project (the same bytes
++ * in two projects are stored, and billed, twice), billed to the space_projects prj_ id and its
++ * owner_subject. resource 'gb-month', provider the tier ('local'), unit 'GB', operation 'store'.
++ * Each daily reading carries that day's share of a GB-month — bytes / GB / (days in that UTC
++ * month) — so a month of daily readings sums to one GB-month.
++ *
++ * Media mode (SPACE_OBJECT_STORE=media, server/hosting-config.js) emits no reading: the bytes live in OpenVibe.Media,
++ * and Media bills every present copy there itself (its server/billing.js), so Space's disk is only a read cache and
++ * a reading here would bill the same bytes twice. A day aggregated in Media mode is not marked, and readings queued
++ * before a switch to Media are still relayed.
++ *
++ * Sandbox and deleted projects are never recorded. The reporter is the SDK's own (openvibe-sdk/usage
++ * createUsageReporter): it queues each reading idempotency-keyed in the outbox inside the transaction that marks the
++ * day, and relays it to Billing's billing.usage.record (POST /api/v1/usage) with Space's Network service token
++ * (clientId OV_OAUTH_CLIENT_ID, default 'space', audience openvibe.billing). A relay that cannot reach Billing stays
++ * pending with backoff; only Billing refusing the reading itself is marked rejected. A reading is never edited
++ * (migrations/0006_billing_readings.sql).
++ *
++ * Off by default: SPACE_BILLING_INTERVAL_MS=0 starts no timer and aggregates nothing. With an interval but no
++ * OV_BILLING_URL (or OV_OAUTH_CLIENT_SECRET) readings are aggregated and stay queued.
++ */
++const { createUsageReporter } = require('openvibe-sdk/usage');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const DAY_MS = 24 * 60 * 60 * 1000;
++const GRACE_MS = 5 * 60 * 1000; // a day is aggregated only once this much of the next one passed
++const GB = 1000 * 1000 * 1000; // a GB in bytes: the unit gb-month is metered in
++const TABLE = 'billing_readings';
++const STORAGE = 'gb-month';
++const TIER = 'local'; // the only tier Space itself keeps bytes on
++
++const dayStart = (ms) => Math.floor(ms / DAY_MS) * DAY_MS;
++const iso = (ms) => new Date(ms).toISOString();
++/** Days in the UTC month containing `ms`: the divisor that turns a day's bytes into its share of a GB-month. */
++function daysInMonth(ms) { const d = new Date(ms); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate(); }
++
++/**
++ * @param {object} o
++ * @param {object} o.db Space's database (openvibe-sdk/db)
++ * @param {object} o.config the hosting config (server/hosting-config.js): billing, objectStore, oauth, networkInternalUrl
++ * @param {() => number} [o.now]
++ * @param {Function} [o.fetchImpl]
++ * @param {object} [o.log]
++ * @param {object} [o.reporter] a ready createUsageReporter (tests); default one over `db`/`config`
++ */
++function createSpaceBilling({ db, config, now = () => Date.now(), fetchImpl = globalThis.fetch, log = console, reporter: injected } = {}) {
++ const billing = config.billing || {};
++ const intervalMs = Number(billing.intervalMs) || 0;
++ const mediaMode = config.objectStore && config.objectStore.mode === 'media';
++ let tokenClient = null;
++ if (billing.url && config.oauth && config.oauth.clientSecret) {
++ tokenClient = createServiceTokenClient({
++ network: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: billing.audience, fetch: fetchImpl, timeoutMs: billing.timeoutMs, now,
++ });
++ }
++ const reporter = injected || createUsageReporter({
++ db, service: 'space', source: 'space.billing', table: TABLE,
++ billingUrl: billing.url, tokenClient, audience: billing.audience,
++ fetchImpl, timeoutMs: billing.timeoutMs || 5000, now, log,
++ });
++ let lastError = null;
++
++ const periodDone = (metric, periodStart) =>
++ db.prepare('SELECT readings FROM billing_periods WHERE metric = ? AND period_start = ?').get(metric, periodStart);
++
++ /**
++ * Aggregate the closed UTC day [periodStart, +1d) into one gb-month reading per (project, subject), in the
++ * transaction that marks the day. space_blobs keys a blob by (project, sha256), so each content-addressed blob
++ * counts once per project however many deploys name it. A re-run of the day inserts nothing. Media mode: nothing.
++ */
++ async function aggregateStorageDay(periodStart, { now: at = now() } = {}) {
++ if (periodStart !== dayStart(periodStart)) throw new RangeError(`billing: ${periodStart} is not the start of a day`);
++ if (periodStart + DAY_MS + GRACE_MS > at) throw new RangeError(`billing: the day ${iso(periodStart)} has not closed`);
++ if (mediaMode) return { period: periodStart, readings: 0, created: 0, skipped: 'media' };
++ // Billing bills OpenVibe.Network projects: a reading names the project's network_project_id. A Space project
++ // that names none (staff hosting before Network projects) has nobody Billing could bill, so it gets no reading.
++ const rows = await db.prepare(`SELECT p.network_project_id AS project_id, p.owner_subject, SUM(b.size) AS bytes
++ FROM space_blobs b JOIN space_projects p ON p.id = b.project_id
++ WHERE p.status = 'active' AND p.environment = 'production' AND p.network_project_id IS NOT NULL
++ GROUP BY p.network_project_id, p.owner_subject`).all();
++ const days = daysInMonth(periodStart);
++ return await db.tx(async (t) => {
++ if (await periodDone(STORAGE, periodStart)) return { period: periodStart, readings: 0, created: 0, skipped: true };
++ const day = iso(periodStart);
++ let created = 0, count = 0;
++ for (const row of rows) {
++ const bytes = Number(row.bytes || 0);
++ if (!(bytes > 0)) continue;
++ const project = String(row.project_id), subject = row.owner_subject || undefined;
++ const key = reporter.key('store', project, subject || '-', TIER, day);
++ const reading = reporter.sample({ idempotency_key: key, project, subject,
++ resource: STORAGE, provider: TIER, operation: 'store', quantity: (bytes / GB) / days, unit: 'GB', at: day });
++ if (await reporter.record(t, reading)) created++;
++ count++;
++ }
++ await t.exec('INSERT INTO billing_periods (metric, period_start, readings, aggregated_at) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
++ [STORAGE, periodStart, count, at]);
++ return { period: periodStart, readings: count, created };
++ });
++ }
++
++ /**
++ * Aggregate the newest closed UTC day. space_blobs holds only the current state, so a past day can never be
++ * reconstructed: a day missed while the service was down is simply not billed.
++ */
++ async function aggregateClosed({ now: at = now() } = {}) {
++ const r = await aggregateStorageDay(dayStart(at - GRACE_MS) - DAY_MS, { now: at });
++ return { created: r.created };
++ }
++
++ /** One pass: aggregate the newest closed day, then relay what is due. Never overlaps itself; never throws. */
++ let running = null;
++ function tick() {
++ if (!running) running = (async () => {
++ try {
++ const a = await aggregateClosed();
++ const s = await reporter.flush();
++ return { ...a, ...s };
++ } catch (err) {
++ lastError = err.message;
++ log.error(`[Space] billing tick failed: ${err.message}`);
++ return null;
++ }
++ })().finally(() => { running = null; });
++ return running;
++ }
++
++ let timer = null;
++ return {
++ enabled: intervalMs > 0,
++ reporting: !!tokenClient,
++ mediaMode,
++ reporter,
++ aggregateStorageDay, aggregateClosed, tick,
++ send: () => reporter.flush(),
++ start() {
++ if (timer || !(intervalMs > 0)) return false;
++ if (mediaMode) log.warn('[Space] SPACE_OBJECT_STORE=media: Media bills the stored bytes, Space emits no storage readings');
++ else if (!tokenClient) log.warn('[Space] OV_BILLING_URL or OV_OAUTH_CLIENT_SECRET is not set: billing readings are stored and stay queued');
++ timer = setInterval(tick, intervalMs);
++ if (timer.unref) timer.unref();
++ return true;
++ },
++ async stop() {
++ if (timer) clearInterval(timer);
++ timer = null;
++ if (running) await running;
++ await reporter.stop();
++ },
++ get running() { return !!timer; },
++ async status() {
++ return {
++ enabled: intervalMs > 0,
++ reporting: !!tokenClient,
++ media_mode: mediaMode,
++ pending: await reporter.pending(),
++ rejected: await reporter.rejected(),
++ last_error: lastError,
++ };
++ },
++ };
++}
++
++module.exports = { createSpaceBilling, DAY_MS, GRACE_MS, GB, TABLE, STORAGE, dayStart };
ddiff --git a/server/hosting-config.js b/server/hosting-config.js
iindex 097ca80..2da7708 100644
---- a/server/hosting-config.js
++++ b/server/hosting-config.js
@@@ -90,6 +90,15 @@ function load(env = process.env) {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
},
++ // OpenVibe.Billing (plan T5 step 14): daily gb-month storage readings for the bytes the local object store
++ // keeps (server/billing.js). SPACE_BILLING_INTERVAL_MS=0 (the default) starts nothing; Media mode emits no
++ // reading (Media bills the bytes). Relayed with Space's service token (OV_OAUTH_CLIENT_ID/SECRET).
++ billing: {
++ intervalMs: Math.max(0, int(env.SPACE_BILLING_INTERVAL_MS, 0)),
++ url: trim(env.OV_BILLING_URL || env.OV_BILLING_INTERNAL_URL || ''),
++ audience: env.OV_BILLING_AUDIENCE || 'openvibe.billing',
++ timeoutMs: int(env.SPACE_BILLING_TIMEOUT_MS, 10000),
++ },
community: {
internalUrl: trim(env.SPACE_COMMUNITY_URL || ''),
publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
ddiff --git a/server/hosting.js b/server/hosting.js
iindex e47fa6a..ce8774d 100644
---- a/server/hosting.js
++++ b/server/hosting.js
@@@ -22,6 +22,7 @@ const { createApi } = require('./http/api');
const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createWorker } = require('./worker');
++const { createSpaceBilling } = require('./billing');
const { createStore } = require('./db');
function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fetchImpl, indexnow: injectedIndexNow, log = console, storageDir, valkey, forumRedirect } = {}) {
@@@ -58,7 +59,8 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
-- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log, valkey: valkey || null };
++ const billing = createSpaceBilling({ db, config, now: store.now, ...(fetchImpl ? { fetchImpl } : {}), log });
++ const ctx = { config, store, blobs, outbox, billing, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log, valkey: valkey || null };
ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });
app.locals.ctx = ctx;
if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
ddiff --git a/server/index.js b/server/index.js
iindex 44bbb0a..ba067e0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -18,6 +18,7 @@ const { createApp } = require('./app');
server.keepAliveTimeout = 65_000;
app.locals.hosting.worker.start();
app.locals.hosting.outbox.start();
++ app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
@@@ -27,7 +28,7 @@ const { createApp } = require('./app');
gracefulStop({
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
-- stop: [async () => { app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); }],
++ stop: [async () => { app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
close: [
async () => {
console.log('[Space] stopped');
ddiff --git a/test/billing.test.js b/test/billing.test.js
nnew file mode 100644
iindex 0000000..79031b9
---- /dev/null
++++ b/test/billing.test.js
@@@ -0,0 +1,123 @@
++'use strict';
++// Usage readings → Billing (plan T5 step 14, Space slice; server/billing.js, migrations/0006_billing_readings.sql).
++// One closed UTC day aggregates into one gb-month reading per production (project, owner): that day's share of a
++// GB-month (bytes / GB / days in the UTC month), each space_blobs row counted once. Sandbox, deleted and empty
++// projects are never recorded; re-running a day inserts nothing; Media mode emits nothing; and at
++// SPACE_BILLING_INTERVAL_MS=0 (the default) nothing runs.
++const assert = require('assert');
++const { testDb } = require('./helpers/db');
++const { load } = require('../server/hosting-config');
++const { createSpaceBilling, DAY_MS, GB, dayStart } = require('../server/billing');
++
++const PRJ_A = 'prj_01JAB2C3D4E5F6G7H8J9K0AAAA';
++const PRJ_B = 'prj_01JAB2C3D4E5F6G7H8J9K0BBBB';
++const PRJ_SBX = 'prj_01JAB2C3D4E5F6G7H8J9K0SBX1';
++const PRJ_DEL = 'prj_01JAB2C3D4E5F6G7H8J9K0DEL1';
++const PRJ_EMPTY = 'prj_01JAB2C3D4E5F6G7H8J9K0EMP1';
++const USR_A = 'usr_01JAB2C3D4E5F6G7H8J9K0AAA1';
++const USR_B = 'usr_01JAB2C3D4E5F6G7H8J9K0BBB2';
++const FIXED = Date.UTC(2026, 9, 4, 12, 0, 0); // a fixed clock: nothing here depends on wall time
++const iso = (ms) => new Date(ms).toISOString();
++const sha = (n) => String(n).padStart(64, '0');
++const silent = { log() {}, warn() {}, error() {} };
++
++(async () => {
++ const db = await testDb();
++ try {
++ const json = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
++ const count = async () => Number((await db.prepare('SELECT COUNT(*) AS n FROM billing_readings').get()).n);
++ // Space projects bill to the OpenVibe.Network project they name (network_project_id).
++ const NET = (id) => `prj_01NETWRK${id.slice(-18)}`;
++ const addProject = (id, owner, environment = 'production', status = 'active', networkProject = NET(id)) => db.prepare(
++ `INSERT INTO space_projects (id, network_project_id, owner_subject, name, environment, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 0, 0)`)
++ .run(id, networkProject, owner, id, environment, status);
++ const addBlob = (project, n, size) => db.prepare('INSERT INTO space_blobs (project_id, sha256, size, created_at) VALUES (?, ?, ?, 0)').run(project, sha(n), size);
++
++ // 1. Off by default: no interval, no timer, nothing aggregated.
++ const off = load({ NODE_ENV: 'test' });
++ assert.strictEqual(off.billing.intervalMs, 0);
++ assert.strictEqual(off.objectStore.mode, 'local');
++ const idle = createSpaceBilling({ db, config: off, now: () => FIXED, log: silent });
++ assert.strictEqual(idle.enabled, false);
++ assert.strictEqual(idle.start(), false);
++ assert.strictEqual(idle.running, false);
++ assert.strictEqual(await count(), 0);
++ await idle.stop();
++
++ // 2. One closed UTC day → one reading per production (project, owner); each blob once; others out.
++ await addProject(PRJ_A, USR_A);
++ await addProject(PRJ_B, USR_B);
++ await addProject(PRJ_SBX, USR_A, 'sandbox');
++ await addProject(PRJ_DEL, USR_A, 'production', 'deleted');
++ await addProject(PRJ_EMPTY, USR_B);
++ const PRJ_LOCAL = 'prj_01K0000000000000000000LOCL';
++ await addProject(PRJ_LOCAL, USR_B, 'production', 'active', null);
++ await addBlob(PRJ_A, 1, 3 * GB);
++ await addBlob(PRJ_A, 2, 1 * GB);
++ await addBlob(PRJ_B, 1, GB / 2); // the same sha in another project is that project's copy
++ await addBlob(PRJ_SBX, 3, 5 * GB);
++ await addBlob(PRJ_DEL, 4, 7 * GB);
++ await addBlob(PRJ_EMPTY, 5, 0);
++ await addBlob(PRJ_LOCAL, 6, 2 * GB); // no Network project: nobody to bill, no reading
++
++ const config = load({ NODE_ENV: 'test', SPACE_BILLING_INTERVAL_MS: '60000' });
++ assert.strictEqual(config.billing.intervalMs, 60000);
++ assert.strictEqual(config.billing.audience, 'openvibe.billing');
++ const billing = createSpaceBilling({ db, config, now: () => FIXED, log: silent });
++ assert.strictEqual(billing.enabled, true);
++ assert.strictEqual(billing.reporting, false, 'no OV_BILLING_URL: readings stay queued');
++
++ const day = dayStart(FIXED) - 2 * DAY_MS; // 2026-10-02, October has 31 days
++ await assert.rejects(billing.aggregateStorageDay(dayStart(FIXED), { now: FIXED }), RangeError, 'today has not closed');
++ await assert.rejects(billing.aggregateStorageDay(day + 1, { now: FIXED }), RangeError, 'not a day start');
++ const first = await billing.aggregateStorageDay(day, { now: FIXED });
++ assert.deepStrictEqual([first.readings, first.created], [2, 2]);
++
++ const keyA = `space:store:${NET(PRJ_A)}:${USR_A}:local:${iso(day)}`;
++ const keyB = `space:store:${NET(PRJ_B)}:${USR_B}:local:${iso(day)}`;
++ const rows = await db.prepare('SELECT event_id, envelope, sent_at FROM billing_readings ORDER BY event_id').all();
++ assert.deepStrictEqual(rows.map((r) => r.event_id), [keyA, keyB].sort());
++ const a = json(rows.find((r) => r.event_id === keyA).envelope);
++ assert.strictEqual(a.project, NET(PRJ_A), 'the reading names the Network project');
++ assert.strictEqual(a.subject, USR_A);
++ assert.strictEqual(a.resource, 'gb-month');
++ assert.strictEqual(a.provider, 'local');
++ assert.strictEqual(a.operation, 'store');
++ assert.strictEqual(a.unit, 'GB');
++ assert.strictEqual(a.at, iso(day));
++ assert.ok(Math.abs(a.quantity - 4 / 31) < 1e-12, `4 GB for one October day: ${a.quantity}`);
++ const b = json(rows.find((r) => r.event_id === keyB).envelope);
++ assert.ok(Math.abs(b.quantity - 0.5 / 31) < 1e-12);
++ const period = await db.prepare('SELECT readings FROM billing_periods WHERE metric = ? AND period_start = ?').get('gb-month', day);
++ assert.strictEqual(Number(period.readings), 2);
++
++ // 3. A closed day is aggregated once: a re-run (even after the store changed) inserts nothing.
++ await addBlob(PRJ_A, 6, 9 * GB);
++ const again = await billing.aggregateStorageDay(day, { now: FIXED });
++ assert.deepStrictEqual([again.created, again.skipped], [0, true]);
++ assert.strictEqual(await count(), 2);
++
++ // 4. Media mode: Media bills the bytes, Space emits nothing and leaves the day unmarked.
++ const media = createSpaceBilling({ db, config: load({ NODE_ENV: 'test', SPACE_BILLING_INTERVAL_MS: '60000', SPACE_OBJECT_STORE: 'media' }), now: () => FIXED, log: silent });
++ assert.strictEqual(media.mediaMode, true);
++ const mday = day - DAY_MS;
++ const none = await media.aggregateStorageDay(mday, { now: FIXED });
++ assert.deepStrictEqual([none.readings, none.created, none.skipped], [0, 0, 'media']);
++ assert.strictEqual(await count(), 2);
++ assert.strictEqual(await db.prepare('SELECT 1 FROM billing_periods WHERE period_start = ?').get(mday), undefined);
++ assert.strictEqual((await media.tick()).created, 0, 'the Media-mode tick aggregates nothing');
++ await media.stop();
++
++ // 5. The tick aggregates the newest closed day (2026-10-03) and, with no Billing URL, keeps it queued.
++ const ticked = await billing.tick();
++ assert.ok(ticked, 'the tick never throws');
++ assert.strictEqual(ticked.created, 2);
++ assert.strictEqual(Number((await db.prepare('SELECT COUNT(*) AS n FROM billing_readings WHERE sent_at IS NULL').get()).n), 4);
++ assert.ok(await db.prepare('SELECT 1 FROM billing_readings WHERE event_id = ?').get(`space:store:${NET(PRJ_A)}:${USR_A}:local:${iso(day + DAY_MS)}`));
++ assert.strictEqual((await billing.status()).last_error, null);
++ await billing.stop();
++ console.log('billing: passed');
++ } finally {
++ await db.close();
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/hosting-core.test.js b/test/hosting-core.test.js
iindex 1dde7b3..95a2ef5 100644
---- a/test/hosting-core.test.js
++++ b/test/hosting-core.test.js
@@@ -18,6 +18,7 @@ const { load } = require('../server/hosting-config');
assert.strictEqual(config.sitesDomain, 'openvibe.website');
const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });
const h = app.locals.hosting;
++ assert.strictEqual(h.billing.enabled, false, 'storage usage readings are off unless SPACE_BILLING_INTERVAL_MS > 0');
const user = { kind: 'user', subject: 'usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', staff: false };
await assert.rejects(h.projects.create(user, { name: 'Blocked' }), (e) => e.status === 403 && e.code === 'space.not_launched');
user.staff = true;