openvibers/openvibe.space Commits
Emit daily gb-month storage readings to Billing in local store mode (#43)
Changes
@@@ -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:@@@ -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.@@@ -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@@@ -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",nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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)++);nnew file mode 100644@@@ -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 };@@@ -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'),@@@ -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, fconst 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') {@@@ -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');nnew file mode 100644@@@ -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; });@@@ -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;
@@ -78,6 +78,10 @@ SPACE_STORAGE_DIR=./data/objects
@@ -78,6 +78,10 @@ SPACE_STORAGE_DIR=./data/objects
# SPACE_ACTOR_LIMITS=on
# SPACE_ACTOR_LIMITS=on
# EVENTS_URL=
# EVENTS_URL=
# EVENTS_RELAY_INTERVAL_MS=2000
# 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=
# INDEXNOW_KEY=
# VALKEY_URL=
# VALKEY_URL=
# VALKEY_PREFIX=ov:space:
# VALKEY_PREFIX=ov:space:
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
## Unreleased
## 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.
- 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.
- 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.
- 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.
@@ -54,7 +54,7 @@ The transactional outbox emits `space.deploy.created`, `space.deploy.activated`,
@@ -54,7 +54,7 @@ The transactional outbox emits `space.deploy.created`, `space.deploy.activated`,
## Data and configuration
## 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:
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).
@@ -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_FORM_SECRET` | random per process | Dashboard CSRF form signing |
| `SPACE_WORKER` | on | Domain rechecks, object sweep, outbox pruning |
| `SPACE_WORKER` | on | Domain rechecks, object sweep, outbox pruning |
| `EVENTS_URL` | unset | Outbox relay destination; unset keeps rows queued |
| `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 |
| `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.
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
@@ -83,7 +84,7 @@ npm test
## Security and dependencies
## 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
## License
@@ -23,7 +23,8 @@
@@ -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",
"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",
"tenant serving at <name>.openvibe.website",
"permanent forum redirects to OpenVibe.Community",
"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": [
"knownGaps": [
"Public project creation is off until abuse handling and a designated DMCA agent are in place",
"Public project creation is off until abuse handling and a designated DMCA agent are in place",
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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)
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -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 };
@@ -90,6 +90,15 @@ function load(env = process.env) {
@@ -90,6 +90,15 @@ function load(env = process.env) {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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: {
community: {
internalUrl: trim(env.SPACE_COMMUNITY_URL || ''),
internalUrl: trim(env.SPACE_COMMUNITY_URL || ''),
publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
publicUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
@@ -22,6 +22,7 @@ const { createApi } = require('./http/api');
@@ -22,6 +22,7 @@ const { createApi } = require('./http/api');
const resourceIndex = require('./registry/resource-index');
const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createDashboard } = require('./http/dashboard');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
+const { createSpaceBilling } = require('./billing');
const { createStore } = require('./db');
const { createStore } = require('./db');
function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, fetchImpl, indexnow: injectedIndexNow, log = console, storageDir, valkey, forumRedirect } = {}) {
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
@@ -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 tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
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 });
ctx.actorLimits = require('./http/actor-limits').createHostActorLimits({ registry: app.locals.metrics, valkey: ctx.valkey });
app.locals.ctx = ctx;
app.locals.ctx = ctx;
if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
if (app.locals.metrics && typeof app.locals.metrics.gauge === 'function') {
@@ -18,6 +18,7 @@ const { createApp } = require('./app');
@@ -18,6 +18,7 @@ const { createApp } = require('./app');
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
app.locals.hosting.worker.start();
app.locals.hosting.worker.start();
app.locals.hosting.outbox.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) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
@@ -27,7 +28,7 @@ const { createApp } = require('./app');
@@ -27,7 +28,7 @@ const { createApp } = require('./app');
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
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: [
close: [
async () => {
async () => {
console.log('[Space] stopped');
console.log('[Space] stopped');
new file mode 100644
new file mode 100644
@@ -0,0 +1,123 @@
@@ -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; });
@@ -18,6 +18,7 @@ const { load } = require('../server/hosting-config');
@@ -18,6 +18,7 @@ const { load } = require('../server/hosting-config');
assert.strictEqual(config.sitesDomain, 'openvibe.website');
assert.strictEqual(config.sitesDomain, 'openvibe.website');
const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });
const app = await createApp({ db, auth, hostingConfig: config, storageDir: dir });
const h = app.locals.hosting;
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 };
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');
await assert.rejects(h.projects.create(user, { name: 'Blocked' }), (e) => e.status === 403 && e.code === 'space.not_launched');
user.staff = true;
user.staff = true;