/** * T12 — billing-metering hooks (EXPLORE §6): meter PAIRED HOSTS + CONCURRENT VIEWERS (not * bandwidth). The sample's `nodeId` is the caller's authenticated mTLS identity (INV3-analog) and * its `accountId` is re-derived SERVER-SIDE from the host registry (INV1) — a node-supplied account * is never trusted. Samples are append-only immutable rows (INV8); zero terminal payload (INV10). */ import { z } from 'zod' import type { MeteringSampleRow } from '../model/records.js' import type { MeteringStore } from '../store/ports.js' import type { HostRegistry } from '../registry/hosts.js' import type { NodeIdentity } from '../node-auth/identity.js' export interface MeteringSample { readonly hostId: string readonly concurrentViewers: number readonly sampledAt: string // NO client/node-supplied accountId or nodeId — both are derived server-side. } const MeteringSampleSchema = z .object({ hostId: z.string().uuid(), concurrentViewers: z.number().int().nonnegative(), sampledAt: z.string().datetime({ offset: true }), }) .strict() export class MeteringError extends Error {} export interface UsageRollup { readonly pairedHostPeak: number readonly viewerPeak: number readonly viewerHours: number } export interface MeteringCollector { ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise pairedHostCount(accountId: string): Promise rollupUsage(accountId: string, from: string, to: string): Promise } export interface MeteringDeps { readonly metering: MeteringStore readonly hosts: HostRegistry } export function createMeteringCollector(deps: MeteringDeps): MeteringCollector { return { async ingestSample(caller, sample) { const parsed = MeteringSampleSchema.safeParse(sample) if (!parsed.success) throw new MeteringError(`invalid metering sample: ${parsed.error.issues[0]?.message}`) const host = await deps.hosts.getHost(parsed.data.hostId) // Attribution derived from ownership — a hostId the caller can't substantiate is rejected (INV1). if (host === null) throw new MeteringError('unknown hostId — cannot attribute usage') const row: MeteringSampleRow = { hostId: parsed.data.hostId, accountId: host.accountId, // server-derived, never node-asserted nodeId: caller.nodeId, // authenticated identity, never a body field concurrentViewers: parsed.data.concurrentViewers, sampledAt: parsed.data.sampledAt, } await deps.metering.append(row) // append-only (INV8) }, async pairedHostCount(accountId) { const hosts = await deps.hosts.listHosts(accountId) return hosts.filter((h) => h.status !== 'revoked').length }, async rollupUsage(accountId, from, to) { const samples = [...(await deps.metering.query(accountId, from, to))].sort( (a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt), ) const viewerPeak = samples.reduce((m, s) => Math.max(m, s.concurrentViewers), 0) const distinctHosts = new Set(samples.map((s) => s.hostId)) // Step-function integral of concurrent viewers over the window → viewer-hours. const toMs = Date.parse(to) let viewerHours = 0 for (let i = 0; i < samples.length; i++) { const cur = samples[i] as MeteringSampleRow const nextMs = i + 1 < samples.length ? Date.parse((samples[i + 1] as MeteringSampleRow).sampledAt) : toMs const spanHours = Math.max(0, nextMs - Date.parse(cur.sampledAt)) / 3_600_000 viewerHours += cur.concurrentViewers * spanHours } return { pairedHostPeak: distinctHosts.size, viewerPeak, viewerHours } }, } }