openvibers/openvibe.ai Commits
Free-tier abuse controls: trust allowances, ASN and device signals, proof of work, anomaly throttling (#324)
Changes
@@@ -165,6 +165,34 @@ AI_MODEL=#AI_FREE_PROVIDERS=deepseek,local,community#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1.5++# ── Abuse prevention for the free tier (plan E5; server/abuse/, docs/abuse-prevention.md) ──++# The full daily allowance (verified tier); a new account gets a quarter, over 7 days half, kudos add up to++# one more full. Zero turns the free allowance off. The salt keys the device cookie and every hash; set it.++#AI_FREE_DAILY_TOKENS=20000++#AI_ABUSE=1++#AI_ABUSE_SALT=++# Network trust signals (account age, passkey, links, 30-day activity) through the identity route.++#AI_ABUSE_NETWORK=1++# Only these service-token ids may forward x-ov-client-ip / x-ov-device-id (the Exchange).++#AI_FIRST_PARTY_SERVICES=exchange++# Set for openvibe.exchange so the site and api.openvibe.exchange share the signed device cookie.++#AI_DEVICE_COOKIE_DOMAIN=.openvibe.exchange++# The local monthly iptoasn.com table (node scripts/update-ip2asn.js); never a paid API.++#AI_IP2ASN_V4=/srv/openvibe/ai/data/ip2asn/ip2asn-v4.tsv++#AI_IP2ASN_V6=/srv/openvibe/ai/data/ip2asn/ip2asn-v6.tsv++#ASNs an operator knows are hosting ones, on top of the organisation-name match.++#AI_HOSTING_ASNS=++#AI_ABUSE_RATE_PER_MIN=60++#AI_ABUSE_HOSTING_RATE_PER_MIN=20++# Free tokens per minute against the week's per-minute baseline, per ASN, device and input bucket.++#AI_ABUSE_VELOCITY_FACTOR=10++#AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN=10000++# In-house hashcash for the anonymous Try it (no account); required in production, off in tests.++#AI_POW_REQUIRED=1++#AI_POW_BITS=18++#AI_POW_TTL_MS=300000++#AI_POW_CHALLENGES_PER_MIN=10++# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete# A local model server speaking the OpenAI API (llama.cpp llama-server, Ollama /v1): provider `local`, route@@@ -613,6 +613,18 @@ not capped this way. The check is admission, not pricing: it never changes a priThe Exchange catalog marks each offer `free_tier` by the same two rules (distinct from `free`, which meansprepaid capacity with no per-token price).++Abuse prevention for the free tier (plan E5; `server/abuse/`, `docs/abuse-prevention.md`, privacy note++`docs/privacy.md`): the daily free allowance is `AI_FREE_DAILY_TOKENS` (default 20,000) scaled by trust — ¼ for a++new or unvouched account, ½ after 7 days, full when Network says a passkey, a linked account or 30 days with++activity verifies it, plus up to one extra full for a volunteer's kudos. A **hosting/data-centre ASN** is always++the new tier and a lower rate; the address is looked up in a local monthly iptoasn.com table and only the ASN and++an HMAC of its `/24`–`/48` are kept, for 24 h. A signed `HttpOnly` device cookie counts accounts per device, and++more than three accounts on one device share one allowance at the lowest tier. The anonymous Try it needs one++in-house hashcash proof of work (`POST /api/v1/exchange/tryit/challenge`). Every finished free run feeds a++per-minute velocity check per ASN, device and input bucket against the last week's baseline; above it the run is++`429 ai.abuse_throttled` and an `abuse.throttled` row goes to `/console/abuse` and the++`ai_abuse_throttles` gauge (OpenVibe.Host alerts on it). No raw IP, device id or prompt is stored.++Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two@@@ -816,6 +828,8 @@ mix vectors from different providers (re-embed when the provider changes).| Hourly DeepSeek balance gauge, no key in logs, off under a drill | `test/provider-balance.test.js` || Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` || Free allowance: free up to the card's allowance then fully priced, reset per day/month (never for `none`), per subject, never past the cap under concurrent claims, absent at 0 | `test/free-allowance.test.js` |++| Abuse prevention (plan E5): allowance by trust tier, kudos bonus, hosting override, `/24`–`/48` hashes, ASN table, signed device cookie, >3 accounts share one allowance, rate limit, per-minute velocity against the week's baseline, single-use proof of work, operator events and prune | `test/abuse-trust.test.js`, `test/abuse.test.js`, `test/abuse-pow.test.js` |++| The run path refuses a spent daily allowance (402) and the anonymous Try it without/with a used proof of work (403) | `test/abuse-runs.test.js` || Tier budgets: enforced per subject and per project and reset per window, two concurrent runs never both take the last unit, the free allowance's numbers unchanged on the Valkey store, one reading per accounted run from govern's `onUsage`, Valkey unreachable (paid 503, free admitted), off by default | `test/govern-tiers.test.js` || The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` || Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` |nnew file mode 100644@@@ -0,0 +1,95 @@++# Abuse prevention for the free tier (plan E5)++++OpenVibe.AI's free tier is the owner's DeepSeek key, local models and community volunteers. The allowance is++small, so mass sign-ups must not farm it. This is what stands in the way, and exactly what it stores.++++Code: `server/abuse/`. Migration: `0034_abuse.sql`. Tests: `test/abuse-trust.test.js`, `test/abuse-pow.test.js`,++`test/abuse.test.js`, `test/abuse-runs.test.js`; Network's signals: `test/identity-trust.test.js`.++++## 1. The allowance grows with trust++++`curl GET /internal/identity/resolve?subject_id=…&trust=1` on Network (server token,++`identity.subject.resolve`) adds a `trust` object to the usual projection: account age, whether a Locker passkey++exists, **how many** accounts are linked, whether the account is 30 days old and active, and++`verified`/`verified_by`. No email, no linked site ids, no last-seen timestamp leaves Network.++++| Tier | Condition | Allowance |++|---|---|---|++| new | under 24 h, unverified, or Network cannot vouch for it | ¼ of `AI_FREE_DAILY_TOKENS` |++| established | over 7 days, unverified | ½ |++| verified | a passkey, a linked account, or 30 days with activity | full |++| contributor | verified, with community kudos | full + up to one extra full (kudos tokens) |++++A hosting/data-centre ASN is always the new tier, whatever the account says. The counter is per UTC day, per++account, or per device when the device is shared (below). At the limit the run is refused **before** a provider is++touched: `402 ai.allowance_spent` with the reset time and how many tokens were used. This is admission, separate++from the provider `free_allowance` in `server/free-allowance.js` (which is pricing only).++++`AI_FREE_DAILY_TOKENS` (default 20 000) is the full tier. Zero turns the free allowance off.++++## 2. Network signals: ASN, never the address++++A client address reaches AI from the loopback proxy, or in `x-ov-client-ip` from a first-party service token++(`AI_FIRST_PARTY_SERVICES`, default `exchange`); any other caller's header is ignored. AI maps it with a local++monthly table — `scripts/update-ip2asn.js` downloads iptoasn.com's free TSV files to `data/ip2asn/`, never a paid++API — and keeps only:++++* the ASN number and whether its organisation is a hosting/data-centre one, and++* an HMAC-SHA256 of the address's `/24` (IPv4) or `/48` (IPv6), with `AI_ABUSE_SALT`.++++That row expires after **24 hours**. `AI_HOSTING_ASNS` adds ASNs the organisation-name match misses. A hosting++request gets the new tier and `AI_ABUSE_HOSTING_RATE_PER_MIN` (default 20/min) instead of++`AI_ABUSE_RATE_PER_MIN` (default 60/min).++++## 3. Devices++++The API sets `ovai_device` — a signed, `HttpOnly`, `SameSite=Lax`, `Secure` cookie, `Domain` from++`AI_DEVICE_COOKIE_DOMAIN` (set it to `.openvibe.exchange` so the site and `api.openvibe.exchange` share it). The++Exchange may instead forward the cookie's signed value in `x-ov-device-id` from its own server; the signature is++what matters, so a raw id is refused. A first-party service forwards the client address in `x-ov-client-ip` only++when it holds a first-party token.++++Accounts are counted per device. At **more than three** accounts, they share one allowance: the counter key is the++device, and its cap is the lowest tier among the accounts on it. A device row is pruned 180 days after it was last++seen.++++## 4. Proof of work for the anonymous Try it++++No account, no Balance: `POST /api/v1/exchange/tryit/challenge` returns a random challenge, `AI_POW_BITS` (default++18, about a second on a phone; clamped to 8–28) and a five-minute expiry. The client sends++`x-ov-pow: <challenge>:<nonce>` where `sha256("<challenge>:<nonce>")` has that many leading zero bits. AI stores++only `sha256(challenge)` and accepts one solution once. `AI_POW_REQUIRED` is on in production and off in tests.++++## 5. Anomaly throttling++++Every finished free run adds its tokens (input + output) to three per-minute buckets: its ASN, its device, and an++HMAC of the run's input hash (a repeated scripted request). Before a run, the current minute is compared with the++last week's per-minute baseline for that bucket. Above `AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN` (default 10 000) or++10× the baseline, the run is refused with `429 ai.abuse_throttled` and a plain retry-in-a-minute message, and an++`abuse.throttled` row goes to the operator audit log. The rate limit above records `abuse.rate_limited` the same++way.++++Operators see both on `/console/abuse`, and the gauge `ai_abuse_throttles{reason}` (events in the last 15 minutes)++is on `/metrics`. OpenVibe.Host's `deploy/prometheus/openvibe-rules.yml` raises `OpenVibeAiAbuseThrottling`++(severity ticket) above 20 in 15 minutes.++++## Privacy: what is kept, for how long++++| Data | Where | Kept |++|---|---|---|++| Raw client IP | request memory only | not written; gone when the request ends |++| ASN + HMAC of `/24` or `/48` | `abuse_network_seen` | 24 hours after last seen |++| Raw device id | the person's browser cookie | their browser controls it; signed with `AI_ABUSE_SALT` |++| HMAC of the device id | `abuse_devices` | 180 days after last seen |++| Free tokens per account/device | `abuse_daily_usage` | 30 days |++| Free tokens per minute per ASN/device/input bucket | `abuse_velocity` | 8 days (seven-day baseline plus a day) |++| Proof-of-work challenge hash | `abuse_pow` | one day after expiry, then pruned |++| Network trust facts | AI process memory | 5 minutes (a miss: 1 minute) |++| Throttle/rate events | `audit_log` (`target_type='abuse'`) | with the audit log; ASN, tier and counts only, never an address, device id or prompt |++++The salt is `AI_ABUSE_SALT`; without it a per-boot random key is used, so hashes cannot be linked across boots and++device cookies are re-issued after a restart. Set it in production. Nothing in these tables can be turned back into++an address, a device or a prompt without the salt, and the salt never leaves the service.++++The IP-to-ASN files are public data (`iptoasn.com`), not a person's data. The table is replaced monthly by the++job, and the service loads it lazily.nnew file mode 100644@@@ -0,0 +1,34 @@++# Privacy notes (service data)++++This is the engineering note for what OpenVibe.AI keeps. The public legal text is served by++`openvibe-shared/legal` (`/privacy`); the facts here must match the code, and a change to one is a change to both.++++## A run++++A run keeps the prompt (unless the workflow is passthrough or `history: false`), the answer, the model and why it++was chosen, tokens, cost, payer and the receipt. Retention follows `AI_RUN_RETENTION_DAYS`; the receipt goes with++the run. A person's own provider key is encrypted and never logged. See `README.md` ("Runs", "Presets, receipts,++BYOK and the privacy label").++++## Abuse prevention (plan E5)++++The free tier's controls are described in full in [`abuse-prevention.md`](abuse-prevention.md#privacy-what-is-kept-for-how-long):++++- **No raw client IP is stored.** AI uses the address only in memory to look up an ASN in a local table, then++ keeps the ASN and an HMAC-SHA256 of the `/24` (IPv4) or `/48` (IPv6) for 24 hours.++- **No raw device id is stored.** The browser holds a signed `HttpOnly` cookie; AI stores only an HMAC of it, for++ 180 days after the device was last seen.++- **The anonymous Try it's proof of work** is stored as `sha256(challenge)`, accepted once, and pruned a day after++ it expires.++- **Counters**: free tokens per account/device for 30 days; free tokens per minute per ASN, device and input++ bucket for 8 days. No prompt is stored by the abuse code; the input bucket is an HMAC of the run's existing++ input hash.++- **Network's trust signals** (account age, a passkey, a link count, 30-day activity) are read through the++ existing identity route and cached in memory for five minutes; AI never sees an email or which sites are linked.++- **Operator events** (`audit_log`, `target_type='abuse'`) carry the reason, ASN, tier and counts, never an++ address, a device id or a prompt, and are visible only in the operator console (`/console/abuse`,++ `/console/audit`).++++`AI_ABUSE_SALT` keys every one of those hashes and signs the device cookie. Without it, a per-boot key is used:++the rows cannot be linked across boots and device cookies are re-issued, which is the safe default. Set it in++production.nnew file mode 100644@@@ -0,0 +1,60 @@++-- phase: expand++-- Plan E5 (abuse prevention): allowances by trust, device and network signals, proof of work and anomaly++-- throttling (server/abuse/). Nothing here is a raw client address or a raw device id: the rows hold only a++-- keyed hash of a /24 or /48 prefix, the ASN that prefix belonged to, and a keyed hash of the device id.++-- Every row has an expiry or is a counter that is pruned by the module's own housekeeping.++--++-- abuse_daily_usage free tokens a person (or the accounts sharing one device) spent on a UTC day++-- abuse_devices accounts seen on one device, and the allowance tier each one gave the device++-- abuse_network_seen an ASN and a keyed /24 or /48 hash, for 24 h, so a repeated request is not looked up twice++-- abuse_velocity free tokens per minute per ASN, device and input bucket (the anomaly baseline)++-- abuse_pow an issued hashcash challenge (its hash only), single use++--++-- Expand only: an older release never reads these tables and nothing here changes an existing one.++CREATE TABLE IF NOT EXISTS abuse_daily_usage (++ subject text COLLATE "C" NOT NULL, -- user:<subject> | device:<keyed device hash> | anon:<keyed hash>++ day text COLLATE "C" NOT NULL, -- UTC date, YYYY-MM-DD++ tokens bigint NOT NULL DEFAULT 0, -- free tokens claimed, input + output++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (subject, day)++);++++CREATE TABLE IF NOT EXISTS abuse_devices (++ device_hash text COLLATE "C" NOT NULL, -- keyed hash of the device id, never the id++ subject text COLLATE "C" NOT NULL, -- usr_…++ tier_tokens bigint, -- the allowance this account gave the device (null until it used free capacity)++ first_seen text COLLATE "C" NOT NULL,++ last_seen text COLLATE "C" NOT NULL,++ PRIMARY KEY (device_hash, subject)++);++CREATE INDEX IF NOT EXISTS idx_abuse_devices_last_seen ON abuse_devices (last_seen);++++CREATE TABLE IF NOT EXISTS abuse_network_seen (++ prefix_hash text COLLATE "C" PRIMARY KEY, -- keyed hash of the IPv4 /24 or IPv6 /48, never the address++ asn bigint,++ hosting bigint NOT NULL DEFAULT 0,++ first_seen text COLLATE "C" NOT NULL,++ last_seen text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL -- the row is kept for 24 h, then pruned++);++CREATE INDEX IF NOT EXISTS idx_abuse_network_expires ON abuse_network_seen (expires_at);++++CREATE TABLE IF NOT EXISTS abuse_velocity (++ kind text COLLATE "C" NOT NULL, -- asn | device | input++ key_hash text COLLATE "C" NOT NULL, -- keyed hash of the ASN, the device id or the run's input hash++ minute bigint NOT NULL, -- epoch ms truncated to the minute++ tokens bigint NOT NULL DEFAULT 0,++ updated_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (kind, key_hash, minute)++);++CREATE INDEX IF NOT EXISTS idx_abuse_velocity_minute ON abuse_velocity (minute);++++CREATE TABLE IF NOT EXISTS abuse_pow (++ challenge_hash text COLLATE "C" PRIMARY KEY, -- sha256 of the issued challenge++ device_hash text COLLATE "C",++ prefix_hash text COLLATE "C",++ issued_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ used_at text COLLATE "C" -- single use: the first accepted solution sets it++);++CREATE INDEX IF NOT EXISTS idx_abuse_pow_expires ON abuse_pow (expires_at);nnew file mode 100644@@@ -0,0 +1,64 @@++#!/usr/bin/env node++'use strict';++/**++ * Plan E5: download iptoasn.com's free monthly IP-to-ASN table into data/ip2asn/ (never a paid API).++ *++ * node scripts/update-ip2asn.js # data/ip2asn/ip2asn-v4.tsv and -v6.tsv++ * node scripts/update-ip2asn.js --dir DIR # somewhere else (AI_IP2ASN_V4 / AI_IP2ASN_V6 point there)++ *++ * The files are the source data (range_start, range_end, ASN, country, org), tab separated and gzipped. The++ * service reads them lazily (server/abuse/asn.js); a download replaces the old file only after the new one has++ * been checked, so a failed run never leaves a half-written table. Run it monthly (iptoasn publishes monthly):++ *++ * 17 4 1 * * cd /srv/openvibe/ai && node scripts/update-ip2asn.js >> data/ip2asn/update.log 2>&1++ */++const fs = require('fs');++const path = require('path');++const zlib = require('zlib');++++const SOURCES = [++ { name: 'ip2asn-v4.tsv', url: 'https://iptoasn.com/data/ip2asn-v4.tsv.gz', family: 4 },++ { name: 'ip2asn-v6.tsv', url: 'https://iptoasn.com/data/ip2asn-v6.tsv.gz', family: 6 },++];++++function arg(name) {++ const at = process.argv.indexOf(name);++ return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : null;++}++++async function download(source) {++ const res = await fetch(source.url, { headers: { 'user-agent': 'openvibe-ai/ip2asn-update (+https://openvibe.services)' }, signal: AbortSignal.timeout(120_000) });++ if (!res.ok) throw new Error(`${source.url} answered ${res.status}`);++ const gz = Buffer.from(await res.arrayBuffer());++ if (gz.length < 1024 || gz[0] !== 0x1f || gz[1] !== 0x8b) throw new Error(`${source.url} did not answer a gzip file`);++ const text = zlib.gunzipSync(gz).toString('utf8');++ const lines = text.split(/\r?\n/).filter((l) => l && !l.startsWith('#'));++ if (lines.length < 1000) throw new Error(`${source.url} returned only ${lines.length} rows`);++ const first = lines[0].split('\t');++ if (first.length < 5) throw new Error(`${source.url} rows do not have five columns`);++ // A quick sanity check on every 1000th row: at least one row must parse as an address range, so an HTML++ // error page or an empty snapshot never replaces a working table.++ const bad = lines.slice(0, 2000).filter((line) => line.split('\t').length < 5).length;++ if (bad) throw new Error(`${source.url}: ${bad} malformed rows in the first 2000`);++ return { text, rows: lines.length };++}++++async function main() {++ const dir = path.resolve(arg('--dir') || process.env.AI_IP2ASN_DIR || path.join(__dirname, '..', 'data', 'ip2asn'));++ fs.mkdirSync(dir, { recursive: true });++ for (const source of SOURCES) {++ process.stdout.write(`[ip2asn] ${source.url}\n`);++ const got = await download(source);++ const target = path.join(dir, source.name);++ const tmp = `${target}.${process.pid}.tmp`;++ fs.writeFileSync(tmp, got.text);++ fs.renameSync(tmp, target);++ process.stdout.write(`[ip2asn] ${target}: ${got.rows} rows, ${(got.text.length / 1024 / 1024).toFixed(1)} MB\n`);++ }++ process.stdout.write(`[ip2asn] done at ${new Date().toISOString()}\n`);++}++++main().catch((err) => {++ process.stderr.write(`[ip2asn] failed: ${err && err.stack ? err.stack : err}\n`);++ process.exit(1);++});nnew file mode 100644@@@ -0,0 +1,218 @@++'use strict';++/**++ * Plan E5: a local IP-to-ASN table, with no API call (scripts/update-ip2asn.js downloads iptoasn.com's free++ * monthly TSV files into data/ip2asn/). The module answers two questions about a client address:++ *++ * lookup(ip) → { asn, country, org, hosting } which ASN the address is in, and whether it is a hosting one++ * prefixOf(ip) → { family, prefix } the IPv4 /24 or IPv6 /48 the address belongs to++ *++ * Nothing here keeps the address: the caller hashes the prefix with its own salt and stores the ASN and that++ * hash only (server/abuse/index.js, migrations/0034_abuse.sql).++ *++ * A hosting ASN is one whose organisation name says so (hosting, cloud, data centre, a known provider), or one++ * an operator names in AI_HOSTING_ASNS. The list is deliberately broad: a false positive costs a smaller free++ * allowance, a false negative lets a data-centre farm through.++ */++const fs = require('fs');++const net = require('net');++++const HOSTING_ORG = /(hosting|hosted|cloud|data[ -]?cent(er|re)|datacenter|colocat|colo\b|dedicated|vps|virtual server|serverhub|leaseweb|m247|datacamp|cdn77|equinix|fastly|cloudflare|amazon|aws|google|microsoft|azure|digitalocean|hetzner|ovh|linode|akamai|vultr|choopa|contabo|oracle|alibaba|aliyun|tencent|scaleway|upcloud|ionos|1&1|rack[ -]?space|kamatera|aiven|linode|hostkey|psychz|quadranet|hivelocity|servarica|incero|frantech|buyvm)/i;++++/** An IPv4 address as an unsigned 32-bit number, or null. */++function ipv4ToInt(value) {++ const s = String(value || '').trim();++ const parts = s.split('.');++ if (parts.length !== 4) return null;++ let out = 0;++ for (const part of parts) {++ if (!/^\d{1,3}$/.test(part)) return null;++ const n = Number(part);++ if (n > 255) return null;++ out = (out * 256) + n;++ }++ return out >>> 0;++}++++/** An IPv6 address as a BigInt, or null. An embedded IPv4 tail (::ffff:192.0.2.1) is accepted. */++function ipv6ToBigInt(value) {++ let s = String(value || '').trim().toLowerCase();++ if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);++ s = s.split('%')[0]; // zone id, never part of the address++ if (!s || !s.includes(':')) return null;++ if (s.includes('.')) {++ const at = s.lastIndexOf(':');++ const v4 = ipv4ToInt(s.slice(at + 1));++ if (v4 == null) return null;++ s = `${s.slice(0, at + 1)}${((v4 >>> 16) & 0xffff).toString(16)}:${(v4 & 0xffff).toString(16)}`;++ }++ const halves = s.split('::');++ if (halves.length > 2) return null;++ const head = halves[0] ? halves[0].split(':') : [];++ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];++ if (halves.length === 1 && (head.length !== 8 || tail.length)) return null;++ if (halves.length === 2 && head.length + tail.length > 8) return null;++ const groups = [];++ for (let i = 0; i < 8; i++) {++ if (i < head.length) groups.push(head[i]);++ else if (i >= 8 - tail.length) groups.push(tail[i - (8 - tail.length)]);++ else groups.push('0');++ }++ let out = 0n;++ for (const g of groups) {++ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;++ out = (out << 16n) | BigInt(parseInt(g, 16));++ }++ return out;++}++++/** { family: 'v4' | 'v6', value } for an address, or null. A v4-mapped IPv6 address is treated as v4. */++function parseIp(value) {++ const s = String(value || '').trim();++ if (net.isIP(s) === 4) return { family: 'v4', value: ipv4ToInt(s) };++ if (net.isIP(s) !== 6) return null;++ const big = ipv6ToBigInt(s);++ if (big == null) return null;++ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(s);++ if (mapped) {++ const v4 = ipv4ToInt(mapped[1]);++ if (v4 != null) return { family: 'v4', value: v4 };++ }++ return { family: 'v6', value: big };++}++++/** The IPv4 /24 or IPv6 /48 an address belongs to, as text (never the address). Null when unparseable. */++function prefixOf(value) {++ const p = parseIp(value);++ if (!p) return null;++ if (p.family === 'v4') {++ const base = p.value & 0xffffff00;++ return { family: 'v4', prefix: `${[(base >>> 24) & 255, (base >>> 16) & 255, (base >>> 8) & 255, 0].join('.')}/24` };++ }++ const base = (p.value >> 80n) << 80n;++ return { family: 'v6', prefix: `${ipv6Text(base)}/48` };++}++++/** BigInt IPv6 → compressed-enough text (the :: rule, lower case). */++function ipv6Text(value) {++ const groups = [];++ for (let i = 7; i >= 0; i--) groups.push(Number((value >> BigInt(i * 16)) & 0xffffn));++ let bestAt = -1;++ let bestLen = 0;++ for (let i = 0; i < 8;) {++ if (groups[i] !== 0) { i++; continue; }++ let j = i;++ while (j < 8 && groups[j] === 0) j++;++ if (j - i > bestLen) { bestAt = i; bestLen = j - i; }++ i = j;++ }++ const hex = groups.map((g) => g.toString(16));++ if (bestLen < 2) return hex.join(':');++ return `${hex.slice(0, bestAt).join(':')}::${hex.slice(bestAt + bestLen).join(':')}`;++}++++function hostingOf({ asn, org } = {}, explicit) {++ if (asn != null && explicit && explicit.has(Number(asn))) return true;++ return Boolean(org && HOSTING_ORG.test(String(org)));++}++++/** The parsed rows of one iptoasn file. Missing/unreadable files are an empty table, never a crash. */++function readRows(file, family, log) {++ if (!file) return [];++ let text;++ try { text = fs.readFileSync(file, 'utf8'); } catch (err) {++ if (err && err.code !== 'ENOENT') log.warn(`[abuse] cannot read ${file}: ${err.message}`);++ return [];++ }++ const out = [];++ let bad = 0;++ for (const line of text.split(/\r?\n/)) {++ if (!line || line.startsWith('#')) continue;++ const cols = line.split('\t');++ if (cols.length < 5) { bad++; continue; }++ const start = family === 'v4' ? ipv4ToInt(cols[0]) : ipv6ToBigInt(cols[0]);++ const end = family === 'v4' ? ipv4ToInt(cols[1]) : ipv6ToBigInt(cols[1]);++ const asn = Number(cols[2]);++ if (start == null || end == null || !Number.isFinite(asn)) { bad++; continue; }++ // The org is the last column; an org with a tab in it stays one org.++ out.push({ start, end, asn, country: cols[3] || '', org: cols.slice(4).join(' ').trim() });++ }++ if (bad) log.warn(`[abuse] ${file}: ${bad} malformed rows ignored`);++ out.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));++ return out;++}++++function makeTable(rows, family) {++ if (!rows.length) return null;++ if (family === 'v4') {++ const starts = new Uint32Array(rows.length);++ const ends = new Uint32Array(rows.length);++ const asns = new Uint32Array(rows.length);++ const orgs = new Array(rows.length);++ const countries = new Array(rows.length);++ for (let i = 0; i < rows.length; i++) {++ starts[i] = rows[i].start; ends[i] = rows[i].end; asns[i] = rows[i].asn; orgs[i] = rows[i].org; countries[i] = rows[i].country;++ }++ return { family, starts, ends, asns, orgs, countries, n: rows.length };++ }++ return { family, rows, n: rows.length };++}++++function findV4(table, value) {++ let lo = 0;++ let hi = table.starts.length - 1;++ while (lo <= hi) {++ const mid = (lo + hi) >>> 1;++ if (value < table.starts[mid]) hi = mid - 1;++ else if (value > table.ends[mid]) lo = mid + 1;++ else return { asn: table.asns[mid], country: table.countries[mid], org: table.orgs[mid] };++ }++ return null;++}++++function findV6(table, value) {++ const rows = table.rows;++ let lo = 0;++ let hi = rows.length - 1;++ while (lo <= hi) {++ const mid = (lo + hi) >>> 1;++ if (value < rows[mid].start) hi = mid - 1;++ else if (value > rows[mid].end) lo = mid + 1;++ else return { asn: rows[mid].asn, country: rows[mid].country, org: rows[mid].org };++ }++ return null;++}++++/**++ * createAsnTable({ v4File, v6File, hostingAsns, log }): lazy, process-wide and read-only. `lookup(ip)` never++ * keeps the address and answers `{ asn: null, country: null, org: null, hosting: false }` when the table has++ * no row (a missing dataset, a private address, a new range).++ */++function createAsnTable({ v4File = '', v6File = '', hostingAsns = [], log = console } = {}) {++ const explicit = new Set((hostingAsns || []).map(Number).filter((n) => Number.isFinite(n)));++ let loaded = false;++ let v4 = null;++ let v6 = null;++ function load() {++ if (loaded) return;++ loaded = true;++ v4 = makeTable(readRows(v4File, 'v4', log), 'v4');++ v6 = makeTable(readRows(v6File, 'v6', log), 'v6');++ if (!v4 && !v6) log.warn(`[abuse] no IP-to-ASN table loaded (${v4File || '-'}, ${v6File || '-'}): ASN signals are off until scripts/update-ip2asn.js runs`);++ }++ function lookup(ip) {++ load();++ const p = parseIp(ip);++ if (!p) return { asn: null, country: null, org: null, hosting: false };++ const row = p.family === 'v4' ? (v4 && findV4(v4, p.value)) : (v6 && findV6(v6, p.value));++ if (!row) return { asn: null, country: null, org: null, hosting: false };++ return { ...row, hosting: hostingOf(row, explicit) };++ }++ return {++ lookup,++ prefixOf,++ hostingOf: (row) => hostingOf(row, explicit),++ status: () => { load(); return { v4_ranges: v4 ? v4.n : 0, v6_ranges: v6 ? v6.n : 0, hosting_asns: explicit.size }; },++ };++}++++module.exports = { createAsnTable, parseIp, prefixOf, ipv4ToInt, ipv6ToBigInt, ipv6Text, hostingOf, HOSTING_ORG };nnew file mode 100644@@@ -0,0 +1,435 @@++'use strict';++/**++ * Plan E5: abuse prevention for the free tier (OpenVibe.AI's server; the Exchange is the service token `exchange`).++ *++ * The pieces, each with its own seam in this module:++ *++ * trust server/abuse/trust.js Network's signals decide the daily free allowance: a quarter for a++ * new account, half after 7 days, the full one when verified, plus kudos++ * network server/abuse/asn.js a client address becomes an ASN and a salted /24 or /48 hash; a hosting++ * ASN gets the new tier and a lower rate. The address is never stored++ * device this module a signed, HttpOnly device cookie (or a first-party service forwarding++ * it); more than three accounts on one device share one allowance++ * proof server/abuse/pow.js an in-house hashcash challenge for the anonymous Try it, single use++ * anomaly this module free tokens per minute against the last week's baseline, per ASN,++ * device and input bucket; above it a 429 and an operator event++ *++ * What is stored (migrations/0034_abuse.sql, docs/abuse-prevention.md): keyed hashes of the device id and the++ * /24–/48 prefix, the ASN, token counters and the challenge hash. No raw address, no raw device id, no prompt.++ * The prefix row lives 24 h; velocity rows are kept for the 7-day baseline; daily counters are per UTC day.++ */++const crypto = require('crypto');++const net = require('net');++const express = require('express');++const { AiError, iso, parseJson, sendError } = require('../util');++const trust = require('./trust');++const asnLib = require('./asn');++const { createNetworkSignals } = require('./network');++const { createPow } = require('./pow');++++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const DAY_MS = 86_400_000;++const MIN_MS = 60_000;++const RING_CAP = 2000;++const COOKIE_MAX_AGE_S = 365 * 24 * 3600;++++function isLoopback(address) {++ const a = String(address || '');++ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';++}++++/** The first address of an `x-forwarded-for`-shaped value (still never stored). */++function firstAddress(value) {++ const v = String(value || '').split(',')[0].trim();++ return net.isIP(v) ? v : null;++}++++function cookiesOf(header) {++ const out = {};++ for (const part of String(header || '').split(';')) {++ const at = part.indexOf('=');++ if (at > 0) out[part.slice(0, at).trim()] = part.slice(at + 1).trim();++ }++ return out;++}++++function createAbuse({ db, config, env = process.env, clock = { now: () => Date.now() }, log = console, registry = null, community = null, fetchImpl = globalThis.fetch, tokens = null } = {}) {++ const cfg = (config && config.abuse) || {};++ const enabled = cfg.enabled !== false;++ // A per-boot salt when the owner has not set one: hashes and device signatures then cannot be linked across++ // restarts (and a cookie issued by the previous boot is replaced), which is the safe default. Production should++ // set AI_ABUSE_SALT so signed device cookies survive a deploy.++ const salt = String(cfg.salt || '').trim() || crypto.randomBytes(32).toString('hex');++ if (enabled && !String(cfg.salt || '').trim() && config && config.isProduction) {++ log.warn('[abuse] AI_ABUSE_SALT is not set: hashes cannot be linked across restarts and device cookies are re-issued on deploy');++ }++ const keyed = (kind, value) => crypto.createHmac('sha256', salt).update(`${kind}:${value}`).digest('hex');++ const now = () => clock.now();++ const baseTokens = Math.max(0, Math.floor(Number(cfg.dailyTokens) || 0));++ const firstParty = new Set((cfg.firstPartyServices || ['exchange']).map((s) => String(s).trim().toLowerCase()).filter(Boolean));++ const asn = asnLib.createAsnTable({++ v4File: cfg.ip2asnV4 || '', v6File: cfg.ip2asnV6 || '', hostingAsns: cfg.hostingAsns || [], log,++ });++ const signals = createNetworkSignals({ config, env, fetchImpl, clock, log, tokens });++ const pow = createPow({ db, clock, bits: cfg.powBits, ttlMs: cfg.powTtlMs, log });++ const cookieName = String(cfg.deviceCookieName || 'ovai_device').trim() || 'ovai_device';++ const cookieDomain = String(cfg.deviceCookieDomain || '').trim();++ const cookieSecure = cfg.deviceCookieSecure !== false;++ const cookieSameSite = cfg.deviceCookieSameSite || 'Lax';++++ // ── Signed device ids ─────────────────────────────────++ function signDevice(id) {++ const body = Buffer.from(id, 'utf8').toString('base64url');++ return `d1.${body}.${keyed('device-sign', body).slice(0, 43)}`;++ }++ function verifyDevice(token) {++ const raw = String(token || '').trim();++ const m = /^d1\.([A-Za-z0-9_-]{8,64})\.([A-Za-z0-9_-]{16,64})$/.exec(raw);++ if (!m) return null;++ const want = keyed('device-sign', m[1]).slice(0, 43);++ const have = m[2];++ if (have.length !== want.length) return null;++ try {++ if (!crypto.timingSafeEqual(Buffer.from(have), Buffer.from(want))) return null;++ } catch { return null; }++ let id;++ try { id = Buffer.from(m[1], 'base64url').toString('utf8'); } catch { return null; }++ return /^[A-Za-z0-9_-]{16,64}$/.test(id) ? id : null;++ }++ function newDevice() {++ const id = crypto.randomBytes(18).toString('base64url');++ return { id, token: signDevice(id) };++ }++ function cookieHeader(token) {++ const parts = [`${cookieName}=${token}`, 'Path=/', 'HttpOnly', `SameSite=${cookieSameSite}`, `Max-Age=${COOKIE_MAX_AGE_S}`];++ if (cookieDomain) parts.push(`Domain=${cookieDomain}`);++ if (cookieSecure) parts.push('Secure');++ return parts.join('; ');++ }++++ // ── Request metadata: no address is kept here, only what a trust decision needs ──++ const rate = new Map(); // key -> { minute, count }++ const throttleRing = []; // { at, kind }++++ function middleware() {++ return (req, res, next) => {++ try {++ const cookies = cookiesOf(req.headers.cookie);++ const forwarded = String(req.headers['x-ov-device-id'] || '').trim();++ let id = verifyDevice(forwarded) || verifyDevice(cookies[cookieName]);++ if (!id) {++ const made = newDevice();++ id = made.id;++ // A Domain cookie is only valid from a host inside that domain: ai.openvibe.services must not++ // try to set .openvibe.exchange. Elsewhere the request still gets a device for this call.++ const host = String(req.hostname || '').toLowerCase();++ const base = cookieDomain.replace(/^\./, '');++ if (!base || host === base || host.endsWith(`.${base}`)) {++ const prev = res.getHeader('Set-Cookie');++ res.setHeader('Set-Cookie', prev ? [].concat(prev, cookieHeader(made.token)) : [cookieHeader(made.token)]);++ }++ }++ req.abuse = {++ deviceHash: keyed('device', id),++ explicitIp: firstAddress(req.headers['x-ov-client-ip']),++ reqIp: req.ip || null,++ loopback: isLoopback(req.socket && req.socket.remoteAddress),++ pow: String(req.headers['x-ov-pow'] || '').trim() || null,++ at: now(),++ };++ } catch (err) {++ log.warn(`[abuse] request metadata: ${err && err.message}`);++ req.abuse = { at: now() };++ }++ return next();++ };++ }++++ /** The address a trust decision may use: a first-party service's header, or the loopback proxy's own answer. */++ function clientIp(meta, principal) {++ if (!meta) return null;++ const service = principal && principal.subject && principal.subject.type === 'service' ? principal.subject.id : null;++ if (meta.explicitIp && service && firstParty.has(String(service).toLowerCase())) return meta.explicitIp;++ if (meta.loopback) return firstAddress(meta.reqIp);++ return null;++ }++++ async function touchNetwork(prefixHash, info, at) {++ const ts = iso(at);++ await db.prepare(`INSERT INTO abuse_network_seen (prefix_hash, asn, hosting, first_seen, last_seen, expires_at)++ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (prefix_hash) DO UPDATE SET asn = excluded.asn, hosting = excluded.hosting,++ last_seen = excluded.last_seen, expires_at = excluded.expires_at`)++ .run(prefixHash, info.asn == null ? null : Number(info.asn), info.hosting ? 1 : 0, ts, ts, iso(at + DAY_MS));++ if (Math.random() < 0.01) await db.prepare('DELETE FROM abuse_network_seen WHERE expires_at < ?').run(ts).catch(() => {});++ }++++ async function networkOf(meta, principal) {++ const ip = clientIp(meta, principal);++ if (!ip) return { asn: null, hosting: false, prefixHash: null, asnHash: null };++ const look = asn.lookup(ip);++ const prefix = asnLib.prefixOf(ip);++ const prefixHash = prefix ? keyed('prefix', prefix.prefix) : null;++ if (prefixHash) await touchNetwork(prefixHash, look, now());++ return {++ asn: look.asn == null ? null : Number(look.asn), hosting: Boolean(look.hosting), prefixHash,++ asnHash: look.asn == null ? (prefixHash ? keyed('asn', `unknown:${prefixHash}`) : null) : keyed('asn', String(look.asn)),++ };++ }++++ async function touchDevice(deviceHash, subject, allowance, at) {++ if (!deviceHash || !subject) return;++ const ts = iso(at);++ await db.prepare(`INSERT INTO abuse_devices (device_hash, subject, tier_tokens, first_seen, last_seen)++ VALUES (?, ?, ?, ?, ?) ON CONFLICT (device_hash, subject) DO UPDATE SET last_seen = excluded.last_seen,++ tier_tokens = COALESCE(excluded.tier_tokens, abuse_devices.tier_tokens)`)++ .run(deviceHash, subject, allowance == null ? null : Math.max(0, Math.floor(Number(allowance) || 0)), ts, ts);++ if (Math.random() < 0.01) await db.prepare('DELETE FROM abuse_devices WHERE last_seen < ?').run(iso(at - 180 * DAY_MS)).catch(() => {});++ }++++ async function accountsOn(deviceHash) {++ const row = await db.prepare('SELECT COUNT(*) AS n, MIN(tier_tokens) AS cap FROM abuse_devices WHERE device_hash = ?').get(deviceHash);++ return { accounts: Number(row && row.n) || 0, cap: row && row.cap != null ? Number(row.cap) : null };++ }++++ function countRate(key, limit, at) {++ if (!key) return true;++ const minute = Math.floor(at / MIN_MS);++ const hit = rate.get(key);++ if (!hit || hit.minute !== minute) {++ rate.set(key, { minute, count: 1 });++ if (rate.size > 20_000) for (const [k, v] of rate) if (v.minute < minute - 2) rate.delete(k);++ return true;++ }++ hit.count++;++ return hit.count <= limit;++ }++++ function noteThrottle(kind, at) {++ throttleRing.push({ at, kind });++ while (throttleRing.length > RING_CAP) throttleRing.shift();++ }++++ async function audit(action, { reason, kind = null, asn = null, hosting = false, tier = null, shared = false, accounts = null, trace = null } = {}) {++ noteThrottle(reason || action, now());++ if (!registry || typeof registry.audit !== 'function') return;++ try {++ await registry.audit('system', action, 'abuse', `${reason || action}${kind ? `:${kind}` : ''}`, {++ trace,++ metadata: { reason: reason || action, kind, asn, hosting: Boolean(hosting), tier, shared, accounts },++ });++ } catch (err) { log.warn(`[abuse] operator event not written: ${err && err.message}`); }++ }++++ /** Free tokens in the current minute against the last week's per-minute baseline, per ASN, device and input. */++ async function velocityHit(keys, at) {++ const minute = Math.floor(at / MIN_MS);++ const since = minute - 7 * 24 * 60;++ const floor = Math.max(1, Math.floor(Number(cfg.velocityMinTokensPerMin) || 10_000));++ const factor = Math.max(1, Number(cfg.velocityFactor) || 10);++ for (const k of keys) {++ if (!k || !k.key) continue;++ const cur = await db.prepare('SELECT COALESCE(tokens, 0) AS tokens FROM abuse_velocity WHERE kind = ? AND key_hash = ? AND minute = ?').get(k.kind, k.key, minute);++ if (!cur || !Number(cur.tokens)) continue;++ const base = await db.prepare('SELECT COALESCE(SUM(tokens), 0) AS tokens FROM abuse_velocity WHERE kind = ? AND key_hash = ? AND minute >= ? AND minute < ?').get(k.kind, k.key, since, minute);++ const minutes = 7 * 24 * 60 - 1;++ const baseline = Number(base && base.tokens) / minutes;++ const limit = Math.max(floor, baseline * factor);++ if (Number(cur.tokens) >= limit) {++ return { kind: k.kind, tokens: Number(cur.tokens), baseline_per_minute: baseline, limit_per_minute: limit };++ }++ }++ return null;++ }++++ /**++ * Admit a run: track the device, then (for a free run) decide the allowance, the rate, the proof of work and++ * the velocity. Returns the admission recorded on the run context, or null when the run pays its own way.++ *++ * free the run has no payer (ctx.freeUse)++ * anonymous the Exchange's anonymous Try it (no account, no Balance)++ */++ async function admit({ principal = null, ctx = {}, request = null, inputHash = null, free = false, anonymous = false, selfKeys = false } = {}) {++ if (!enabled) return null;++ const at = now();++ const meta = request || {};++ const subject = ctx.onBehalfOf && USR.test(String(ctx.onBehalfOf)) ? String(ctx.onBehalfOf)++ : (ctx.requesterType === 'user' && USR.test(String(ctx.requesterId || '')) ? String(ctx.requesterId) : null);++ const deviceHash = meta.deviceHash && /^[0-9a-f]{64}$/.test(String(meta.deviceHash)) ? String(meta.deviceHash) : null;++ // A device is remembered for every person's run, paid or free: the count is about accounts, not spend.++ if (deviceHash && subject) await touchDevice(deviceHash, subject, null, at);++ if (!free && !anonymous) return null;++ if (ctx.requesterType === 'app') return null;++++ const network = await networkOf(meta, principal);++ let account = { available: false, reason: 'no account' };++ let kudos = 0;++ if (subject) {++ account = await signals.get(subject);++ if (community && typeof community.kudosBonus === 'function') {++ try { kudos = Math.max(0, Number(await community.kudosBonus(subject)) || 0); } catch { kudos = 0; }++ }++ }++ const tierInfo = trust.tierFor({ signals: account, kudos, hosting: network.hosting, base: baseTokens });++ if (deviceHash && subject) await touchDevice(deviceHash, subject, tierInfo.allowance, at);++ const onDevice = deviceHash ? await accountsOn(deviceHash) : { accounts: 0, cap: null };++ const shared = onDevice.accounts > 3;++ const allowance = shared ? Math.min(tierInfo.allowance, onDevice.cap == null ? tierInfo.allowance : onDevice.cap) : tierInfo.allowance;++ const key = shared ? `device:${deviceHash}` : (subject ? `user:${subject}` : `anon:${deviceHash || network.prefixHash || 'unknown'}`);++ const admission = {++ key, day: iso(at).slice(0, 10), subject, anonymous: Boolean(anonymous && !subject),++ allowance, tier: tierInfo.tier, tier_reasons: tierInfo.reasons, shared, accounts: onDevice.accounts,++ deviceHash, prefixHash: network.prefixHash, asn: network.asn, asnHash: network.asnHash,++ hosting: network.hosting, inputKey: inputHash ? keyed('input', String(inputHash)) : null,++ at,++ };++ // L36 Y1: a run that may use the person's own provider key must never be held by the free allowance. The++ // admission is still returned, so a fallback to free capacity is recorded; the read only skips the refusal.++ if (selfKeys) return { ...admission, exempt: 'own_key' };++ const rateKey = deviceHash ? `device:${deviceHash}` : (network.prefixHash ? `prefix:${network.prefixHash}` : `key:${key}`);++ const limit = trust.rateFor({ hosting: network.hosting, normal: cfg.ratePerMin, hostingRate: cfg.hostingRatePerMin });++ if (!countRate(rateKey, limit, at) || (network.asnHash && !countRate(`asn:${network.asnHash}`, limit, at))) {++ await audit('abuse.rate_limited', { reason: network.hosting ? 'hosting' : 'network', kind: 'rate', asn: network.asn, hosting: network.hosting, tier: admission.tier, shared, accounts: onDevice.accounts, trace: ctx.trace || null });++ throw new AiError(429, 'ai.rate_limited', 'Too many free requests from this network or device right now. Wait a minute and try again.', { retry_after_seconds: 60 });++ }++++ if (admission.anonymous && cfg.powRequired) {++ if (!meta.pow) {++ throw new AiError(403, 'ai.pow_required', 'This Try it needs a small proof of work. Ask GET /api/v1/exchange/tryit/challenge for one first.');++ }++ const ok = await pow.verify(meta.pow, { deviceHash });++ if (!ok) throw new AiError(403, 'ai.pow_invalid', 'That proof of work is missing, used, expired or wrong. Ask for a new challenge.');++ }++++ if (subject) {++ const used = await db.prepare('SELECT COALESCE(tokens, 0) AS tokens FROM abuse_daily_usage WHERE subject = ? AND day = ?').get(key, admission.day);++ const spent = Number(used && used.tokens) || 0;++ if (allowance <= 0 || spent >= allowance) {++ const next = Date.parse(`${admission.day}T00:00:00.000Z`) + DAY_MS;++ throw new AiError(402, 'ai.allowance_spent', allowance <= 0++ ? 'The free AI allowance is switched off on this service.'++ : `The free AI allowance for today is spent (${spent} of ${allowance} tokens). It resets at midnight UTC, or use your own key.`,++ { retry_after_seconds: Math.max(1, Math.ceil((next - at) / 1000)), allowance: { tier: admission.tier, tokens: allowance, used: spent, shared } });++ }++ }++++ const keys = [++ { kind: 'asn', key: admission.asnHash },++ { kind: 'device', key: deviceHash },++ { kind: 'input', key: admission.inputKey },++ ].filter((k) => k.key);++ const hit = await velocityHit(keys, at);++ if (hit) {++ await audit('abuse.throttled', { reason: 'velocity', kind: hit.kind, asn: network.asn, hosting: network.hosting, tier: admission.tier, shared, accounts: onDevice.accounts, trace: ctx.trace || null });++ throw new AiError(429, 'ai.abuse_throttled', 'Free use from this network or device is going much faster than usual, so it is paused for a moment. Wait a minute and try again.', {++ retry_after_seconds: 60, abuse: { reason: 'velocity', kind: hit.kind, limit_per_minute: Math.floor(hit.limit_per_minute), baseline_per_minute: Number(hit.baseline_per_minute.toFixed(2)) },++ });++ }++ return admission;++ }++++ /** Record what a finished free run spent: its allowance counter and the three velocity buckets. */++ async function record({ admission, tokens = 0, inputHash = null } = {}) {++ if (!enabled || !admission || !admission.key) return;++ const n = Math.max(0, Math.floor(Number(tokens) || 0));++ if (!n) return;++ const at = now();++ const ts = iso(at);++ const day = admission.day || iso(at).slice(0, 10);++ if (admission.subject) {++ await db.prepare(`INSERT INTO abuse_daily_usage (subject, day, tokens, updated_at) VALUES (?, ?, ?, ?)++ ON CONFLICT (subject, day) DO UPDATE SET tokens = abuse_daily_usage.tokens + excluded.tokens, updated_at = excluded.updated_at`)++ .run(admission.key, day, n, ts);++ }++ const minute = Math.floor(at / MIN_MS);++ const keys = [++ { kind: 'asn', key: admission.asnHash },++ { kind: 'device', key: admission.deviceHash },++ { kind: 'input', key: inputHash ? keyed('input', String(inputHash)) : admission.inputKey },++ ].filter((k) => k.key);++ for (const k of keys) {++ await db.prepare(`INSERT INTO abuse_velocity (kind, key_hash, minute, tokens, updated_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (kind, key_hash, minute) DO UPDATE SET tokens = abuse_velocity.tokens + excluded.tokens, updated_at = excluded.updated_at`)++ .run(k.kind, k.key, minute, n, ts);++ }++ if (Math.random() < 0.01) await db.prepare('DELETE FROM abuse_velocity WHERE minute < ?').run(minute - 8 * 24 * 60).catch(() => {});++ }++++ async function issueChallenge({ meta = {}, principal = null } = {}) {++ if (!enabled) throw new AiError(503, 'ai.abuse_unavailable', 'The free tier is not available on this service.');++ const network = await networkOf(meta, principal);++ const rateKey = meta.deviceHash ? `challenge:device:${meta.deviceHash}` : `challenge:prefix:${network.prefixHash || 'unknown'}`;++ if (!countRate(rateKey, Math.max(1, Number(cfg.powChallengesPerMinute) || 10), now())) {++ throw new AiError(429, 'ai.rate_limited', 'Too many challenge requests right now. Wait a minute and try again.', { retry_after_seconds: 60 });++ }++ return pow.issue({ deviceHash: meta.deviceHash || null, prefixHash: network.prefixHash || null });++ }++++ /** Hourly housekeeping: every table has an expiry; the daily counters keep 30 days for a month's view. */++ async function prune() {++ const at = now();++ const out = {};++ for (const [name, sql, arg] of [++ ['network', 'DELETE FROM abuse_network_seen WHERE expires_at < ?', iso(at)],++ ['pow', 'DELETE FROM abuse_pow WHERE expires_at < ?', iso(at - DAY_MS)],++ ['velocity', 'DELETE FROM abuse_velocity WHERE minute < ?', Math.floor(at / MIN_MS) - 8 * 24 * 60],++ ['daily', 'DELETE FROM abuse_daily_usage WHERE day < ?', iso(at - 30 * DAY_MS).slice(0, 10)],++ ['devices', 'DELETE FROM abuse_devices WHERE last_seen < ?', iso(at - 180 * DAY_MS)],++ ]) {++ try { out[name] = (await db.prepare(sql).run(arg)).changes || 0; } catch (err) { log.warn(`[abuse] prune ${name}: ${err && err.message}`); }++ }++ return out;++ }++++ /** The operator console page's read: recent events, today's counters and the current signal sizes. */++ async function overview({ limit = 50 } = {}) {++ const at = now();++ const day = iso(at).slice(0, 10);++ const since = iso(at - DAY_MS);++ const events = await db.prepare("SELECT id, at, action, target_id, metadata FROM audit_log WHERE action IN ('abuse.throttled', 'abuse.rate_limited') AND at >= ? ORDER BY id DESC LIMIT ?").all(since, Math.min(200, Math.max(1, limit)));++ const usage = await db.prepare('SELECT subject, tokens FROM abuse_daily_usage WHERE day = ? ORDER BY tokens DESC LIMIT ?').all(day, Math.min(100, limit));++ const devices = await db.prepare('SELECT COUNT(*) AS n FROM abuse_devices').get();++ const shared = await db.prepare('SELECT COUNT(*) AS n FROM (SELECT device_hash FROM abuse_devices GROUP BY device_hash HAVING COUNT(*) > 3) x').get();++ const network = await db.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(hosting), 0) AS hosting FROM abuse_network_seen WHERE expires_at > ?').get(iso(at));++ const pow = await db.prepare('SELECT COUNT(*) AS n FROM abuse_pow WHERE used_at IS NULL AND expires_at > ?').get(iso(at));++ return {++ day,++ events: events.map((e) => ({ id: Number(e.id), at: e.at, action: e.action, target: e.target_id, metadata: parseJson(e.metadata, {}) })),++ usage: usage.map((r) => ({ subject: r.subject, tokens: Number(r.tokens) })),++ devices: Number(devices && devices.n) || 0,++ shared_devices: Number(shared && shared.n) || 0,++ network: { prefixes_24h: Number(network && network.n) || 0, hosting: Number(network && network.hosting) || 0 },++ pow: Number(pow && pow.n) || 0,++ asn_table: asn.status(),++ signals: signals.enabled,++ };++ }++++ /** The Prometheus gauge: events in the last 15 minutes, by reason (0 when nothing happened). */++ function gaugeValues() {++ const cutoff = now() - 15 * MIN_MS;++ const out = new Map();++ for (const e of throttleRing) if (e.at >= cutoff) out.set(e.kind, (out.get(e.kind) || 0) + 1);++ return ['velocity', 'rate', 'hosting', 'network'].map((reason) => ({ labels: { reason }, value: out.get(reason) || 0 }));++ }++++ /** A public endpoint only: the anonymous Try it gets its challenge here. */++ function router() {++ const r = express.Router();++ r.post('/api/v1/exchange/tryit/challenge', async (req, res) => {++ try {++ res.json(await issueChallenge({ meta: req.abuse || {}, principal: req.principal || null }));++ } catch (err) {++ sendError(res, err, req.ov, log);++ }++ });++ return r;++ }++++ return {++ enabled, middleware, admit, record, issueChallenge, overview, gaugeValues, router, prune,++ // The tests and the docs use these; nothing else should.++ _test: { signDevice, verifyDevice, keyed, asn, signals, pow, cookieHeader, clientIp, velocityHit, deviceTable: db },++ };++}++++module.exports = { createAbuse, firstAddress, isLoopback, cookiesOf };nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * Plan E5: the trust signals Network supplies through its existing identity read, cached in AI.++ *++ * GET {OV_NETWORK_INTERNAL_URL}/internal/identity/resolve?subject_id=usr_…&trust=1++ * Authorization: a service token with identity.subject.resolve++ *++ * The answer is the usual projection plus `trust`:++ * { account_age_hours, account_age_days, verified, verified_by, passkey, linked_accounts, active_30d }++ *++ * Network computes the facts (server/identity/trust.js): a passkey backup, a linked account, or an account 30++ * days old that was active in the last 30 days. AI never asks for an email or a linked account's identity.++ * A miss is cached briefly and treated as unavailable: the allowance falls back to the new tier, the++ * conservative side, not to a remembered "verified".++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function numberOf(value, max = 1e9) {++ const n = Number(value);++ return Number.isFinite(n) && n >= 0 ? Math.min(max, n) : 0;++}++++/** Only the fields the allowance uses, with sane types; anything else is dropped. */++function normalize(trust) {++ if (!trust || typeof trust !== 'object') return null;++ const by = Array.isArray(trust.verified_by) ? trust.verified_by.filter((x) => typeof x === 'string' && x).slice(0, 8) : [];++ return {++ account_age_hours: numberOf(trust.account_age_hours, 100 * 365 * 24),++ account_age_days: numberOf(trust.account_age_days, 36500),++ verified: trust.verified === true || trust.verified === 1,++ verified_by: by,++ passkey: trust.passkey === true,++ linked_accounts: numberOf(trust.linked_accounts, 100),++ active_30d: trust.active_30d === true,++ };++}++++/**++ * createNetworkSignals({ config, env, fetchImpl, clock, log, tokens }): one cached read per subject.++ * `tokens` is a service-token client (tests pass a stub); production builds one from OV_OAUTH_CLIENT_SECRET.++ */++function createNetworkSignals({ config, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console, tokens = null } = {}) {++ const cfg = (config && config.abuse) || {};++ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');++ const secret = env.OV_OAUTH_CLIENT_SECRET || '';++ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';++ let client = tokens;++ if (!client && secret && network && cfg.network) {++ client = createServiceTokenClient({++ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret,++ scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl,++ });++ }++ const ttlMs = Math.max(1000, Number(cfg.signalsTtlMs) || 300_000);++ const missTtlMs = Math.max(1000, Math.min(ttlMs, Number(cfg.signalsMissTtlMs) || 60_000));++ const timeoutMs = Math.max(100, Number(cfg.signalsTimeoutMs) || 2000);++ const enabled = Boolean(cfg.network && network && client);++ const cache = new Map();++ const inflight = new Map();++++ async function read(subject) {++ const token = await client.getToken({ audience: 'openvibe.network' });++ const url = `${network}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}&trust=1`;++ const res = await fetchImpl(url, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },++ signal: AbortSignal.timeout(timeoutMs),++ });++ if (!res.ok) throw new Error(`Network identity read answered ${res.status}`);++ const body = await res.json().catch(() => null);++ const trust = normalize(body && body.trust);++ if (!trust) throw new Error('Network identity read carried no trust object');++ return { available: true, ...trust };++ }++++ /** The signals for a person subject. Never throws: an outage is `{ available: false }`. */++ async function get(subject) {++ const id = String(subject || '');++ if (!USR.test(id)) return { available: false, reason: 'not a person' };++ if (!enabled) return { available: false, reason: 'disabled' };++ const hit = cache.get(id);++ const now = clock.now();++ if (hit && now - hit.at < (hit.value.available ? ttlMs : missTtlMs)) return hit.value;++ if (inflight.has(id)) return inflight.get(id);++ const p = (async () => {++ let value;++ try { value = await read(id); } catch (err) {++ if (cfg.logMisses !== false) log.warn(`[abuse] the Network trust read failed: ${err && err.message}`);++ value = { available: false, reason: 'network' };++ }++ cache.set(id, { at: now, value });++ inflight.delete(id);++ return value;++ })();++ inflight.set(id, p);++ return p;++ }++++ return { enabled, get, normalize, _cache: cache };++}++++module.exports = { createNetworkSignals, normalize, USR };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * Plan E5: an in-house hashcash challenge for the anonymous Try it (no account), never a third-party captcha.++ *++ * issue(meta) → { challenge, bits, expires_at } a random challenge, stored as sha256(challenge) only++ * verify(meta, token) → boolean one accepted solution, single use++ *++ * A solution is `sha256("<challenge>:<nonce>")` with `bits` leading zero bits. The client finds the nonce; on a++ * phone about 2^18 hashes is roughly a second, and the difficulty is AI_POW_BITS. The challenge carries no++ * address: the row keeps the keyed device and /24–/48 hashes the caller passed in, so an issued challenge can++ * be rate-limited and audited without storing who asked.++ */++const crypto = require('crypto');++++const HEX = '0123456789abcdef';++++/** Leading zero bits of a digest. */++function zeroBits(buffer) {++ let n = 0;++ for (const byte of buffer) {++ if (byte === 0) { n += 8; continue; }++ let b = byte;++ while ((b & 0x80) === 0) { n++; b = (b << 1) & 0xff; }++ return n;++ }++ return n;++}++++function digest(challenge, nonce) {++ return crypto.createHash('sha256').update(`${challenge}:${nonce}`).digest();++}++++/** A nonce that solves the challenge, for tests, the docs and any server-side caller. */++function solve(challenge, bits) {++ for (let nonce = 0; ; nonce++) {++ if (zeroBits(digest(challenge, nonce)) >= bits) return String(nonce);++ }++}++++function createPow({ db, clock = { now: () => Date.now() }, bits = 18, ttlMs = 300_000, log = console } = {}) {++ const ins = db.prepare(`INSERT INTO abuse_pow (challenge_hash, device_hash, prefix_hash, issued_at, expires_at)++ VALUES (?, ?, ?, ?, ?)`);++ const take = db.prepare(`UPDATE abuse_pow SET used_at = ?++ WHERE challenge_hash = ? AND used_at IS NULL AND expires_at > ?`);++ const get = db.prepare('SELECT * FROM abuse_pow WHERE challenge_hash = ?');++ const clean = db.prepare('DELETE FROM abuse_pow WHERE expires_at < ?');++++ /** { challenge, bits, expires_at } and the challenge hash stored; never the raw challenge in the database. */++ async function issue({ deviceHash = null, prefixHash = null } = {}) {++ const challenge = crypto.randomBytes(16).toString('hex');++ const now = clock.now();++ const expires = now + Math.max(30_000, Math.min(3_600_000, Number(ttlMs) || 0));++ await ins.run(crypto.createHash('sha256').update(challenge).digest('hex'), deviceHash, prefixHash, new Date(now).toISOString(), new Date(expires).toISOString());++ // Housekeeping rides the issuance: an expired row is useless and would grow without bound.++ await clean.run(new Date(now - 86_400_000).toISOString()).catch(() => {});++ return { challenge, bits: Math.max(1, Math.min(28, Number(bits) || 18)), expires_at: new Date(expires).toISOString() };++ }++++ /**++ * One accepted solution. `token` is `<challenge>:<nonce>` (the challenge is public; the nonce is the work).++ * The update is the single-use check: two requests racing one solution, only one UPDATE changes a row.++ */++ async function verify(token, { deviceHash = null } = {}) {++ const raw = String(token || '').trim();++ const at = raw.indexOf(':');++ if (at <= 0) return false;++ const challenge = raw.slice(0, at);++ const nonce = raw.slice(at + 1);++ if (!/^[0-9a-f]{16,128}$/i.test(challenge) || !/^[0-9]{1,20}$/.test(nonce)) return false;++ const hash = crypto.createHash('sha256').update(challenge).digest('hex');++ const row = await get.get(hash);++ if (!row || row.used_at) return false;++ const now = clock.now();++ if (Date.parse(row.expires_at) <= now) return false;++ if (!(zeroBits(digest(challenge, nonce)) >= Math.max(1, Number(bits) || 18))) return false;++ const changed = await take.run(new Date(now).toISOString(), hash, new Date(now).toISOString());++ if (!changed || !changed.changes) return false;++ // A solution issued for one device belongs to that device when both sides know one.++ if (row.device_hash && deviceHash && row.device_hash !== deviceHash) return false;++ return true;++ }++++ return { issue, verify, bits: () => Math.max(1, Math.min(28, Number(bits) || 18)), zeroBits, solve };++}++++module.exports = { createPow, zeroBits, solve, digest };nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/**++ * Plan E5: the daily free allowance grows with trust (server/abuse/index.js decides when it is spent).++ *++ * The tiers, as the plan states them:++ *++ * new an account under 24 h old, unverified: a quarter of the base allowance++ * established an account over 7 days old, unverified: half++ * verified a passkey, a linked account, or 30 days with activity: the full allowance++ * contributor a verified volunteer whose kudos (server/community/) buy more: up to one more base++ *++ * A hosting/data-centre ASN (the network signal) is always the new tier and a lower rate, whatever the account++ * says: a data centre is not a person's home, and a farm can make accounts look old. If Network cannot answer,++ * the account is treated as new: the conservative side, never the generous one.++ *++ * The result is a token budget, never money: the allowance caps how many free tokens a day the account (or the++ * accounts sharing one device) may claim. It does not price anything (server/free-allowance.js does that).++ */++++const HOUR = 3600_000;++const DAY = 24 * HOUR;++++const TIERS = Object.freeze({++ new: Object.freeze({ factor: 0.25, label: 'New account (under 24 hours)' }),++ established: Object.freeze({ factor: 0.5, label: 'Established account (over 7 days)' }),++ verified: Object.freeze({ factor: 1, label: 'Verified account' }),++});++++/** A whole, non-negative token count; anything unusable becomes 0. */++function tokensOf(value) {++ const n = Math.floor(Number(value));++ return Number.isFinite(n) && n > 0 ? n : 0;++}++++/**++ * The tier an account is in, from Network's trust signals (server/identity/trust.js) and the AI-side kudos.++ *++ * signals { available, account_age_hours, verified, verified_by, ... } | null++ * kudos the volunteer's free-allowance bonus in tokens (community.kudosBonus), 0 when none++ * hosting the request came from a hosting/data-centre ASN++ * base AI_FREE_DAILY_TOKENS (the full allowance)++ *++ * → { tier, allowance, base, kudos_bonus, reasons, hosting }++ */++function tierFor({ signals = null, kudos = 0, hosting = false, base = 0 } = {}) {++ const full = tokensOf(base);++ const reasons = [];++ let tier;++ if (hosting) {++ tier = 'new';++ reasons.push('the client address is in a hosting/data-centre ASN');++ } else if (signals && signals.available && signals.verified) {++ tier = 'verified';++ reasons.push(`verified: ${(signals.verified_by || []).join(', ') || 'trusted account'}`);++ } else if (signals && signals.available && Number(signals.account_age_hours) >= 7 * 24) {++ tier = 'established';++ reasons.push('the account is over 7 days old');++ } else {++ tier = 'new';++ reasons.push(signals && signals.available ? 'the account is under 24 hours old or unverified' : 'Network could not vouch for the account');++ }++ let allowance = Math.floor(full * TIERS[tier].factor);++ // A volunteer's kudos buy at most one more base allowance, and only on top of the full tier: never a way++ // around a hosting ASN or an unverified account.++ const kudosBonus = tier === 'verified' ? Math.min(full, tokensOf(kudos)) : 0;++ if (kudosBonus) {++ allowance += kudosBonus;++ reasons.push(`kudos add ${kudosBonus} tokens`);++ }++ if (!full) reasons.push('the free allowance is switched off (AI_FREE_DAILY_TOKENS=0)');++ return { tier, allowance, base: full, kudos_bonus: kudosBonus, reasons, hosting: Boolean(hosting) };++}++++/** The lower request rate a hosting ASN gets, whatever the account says (server/abuse/index.js). */++function rateFor({ hosting = false, normal = 60, hostingRate = 20 } = {}) {++ return hosting ? Math.max(1, Math.floor(Number(hostingRate) || 0)) : Math.max(1, Math.floor(Number(normal) || 0));++}++++module.exports = { TIERS, HOUR, DAY, tierFor, rateFor, tokensOf };@@@ -49,6 +49,11 @@ const TABLES = [{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },{ table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },++ // Plan E5: the free allowance's daily counter (keyed `user:<subject>` or `device:<hash>`) and the accounts seen++ // on a device. Both are erased by extraErase below, which must see the device rows first to find the shared++ // counter; listing them here is what makes the guard (and the export count) know they exist.++ { table: 'abuse_daily_usage', subject: 'subject', value: (usr) => `user:${usr}`, file: null, erase: { keep: 'erased by extraErase after the device rows are read' } },++ { table: 'abuse_devices', subject: 'subject', file: null, erase: { keep: 'erased by extraErase after the shared counter is found' } },// The person's own quota (server/quota.js): a limit that only means something while the account lives.{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },// What the person used, counted against their allowance: the spend ledger, kept.@@@ -67,6 +72,17 @@ async function extraExport(db, subject) {/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */async function extraErase(t, subjects, counts) {++ // Plan E5: the free-tier counters. A shared device counter is kept while another account still uses the device;++ // when this person was the last one, the device counter and its velocity bucket go too.++ const hashes = (await t.many('SELECT DISTINCT device_hash FROM abuse_devices WHERE subject = ANY($1::text[])', [subjects])).map((r) => r.device_hash);++ counts.add(counts.erased, 'abuse_devices', await t.exec('DELETE FROM abuse_devices WHERE subject = ANY($1::text[])', [subjects]));++ counts.add(counts.erased, 'abuse_daily_usage', await t.exec('DELETE FROM abuse_daily_usage WHERE subject = ANY($1::text[])', [subjects.map((s) => `user:${s}`)]));++ for (const hash of hashes) {++ const left = Number(await t.value('SELECT COUNT(*)::int FROM abuse_devices WHERE device_hash = $1', [hash])) || 0;++ if (left) continue;++ counts.add(counts.erased, 'abuse_daily_usage', await t.exec('DELETE FROM abuse_daily_usage WHERE subject = $1', [`device:${hash}`]));++ counts.add(counts.erased, 'abuse_velocity', await t.exec("DELETE FROM abuse_velocity WHERE kind = 'device' AND key_hash = $1", [hash]));++ }const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);if (!ids.length) return;counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));@@@ -232,7 +232,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receiptsconst live = b.stream === true ? createEventStream(res) : null;let run;try {-- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,++ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,...(live ? { onDelta: live.event } : {}),...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });if (live) live.open(created.run);@@@ -235,7 +235,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =// delta the upstream sends goes out as it arrives. A delta that came before the stream opened waits for it.const live = b.stream === true ? createLiveStream(res) : null;try {-- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,++ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,...(live ? { onDelta: live.delta } : {}),...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });if (live) live.open(created.run);@@@ -64,7 +64,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);checkNamespace(req, String(body.workflow || ''));const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;-- const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });++ const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, abuse: req.abuse });const run = await runs.explained(await runs.wait(created, waitMs));const status = !created.created ? 200 : (runs.TERMINAL.has(run.status) ? 201 : 202);if (status === 202) res.setHeader('Location', `/api/v1/runs/${run.id}`);@@@ -88,7 +88,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {if (!text) return;if (opened) send('delta', { text }); else early.push(text);};-- const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, onDelta });++ const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, onDelta, abuse: req.abuse });res.status(200);res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');res.setHeader('Cache-Control', 'no-store');@@@ -24,7 +24,7 @@ const pkg = require('../package.json');const { adminRoutes } = require('openvibe-shared/config');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null, balance = null, gpus = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {const app = express();// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });@@@ -36,8 +36,14 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// GET /metrics answers direct loopback callers only (Track O).const metrics = instrument(app, { service: 'ai', release: release.release });app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()-- registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus });++ registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus, abuse });app.use(http.middleware());++ // Plan E5: the signed HttpOnly device id and the request's trust metadata on the model and Exchange APIs.++ // The address/device stay in the request: only keyed hashes reach the database (server/abuse/).++ if (abuse) {++ app.use(['/v1', '/api/v1'], abuse.middleware());++ app.use(abuse.router());++ }// Browser reads of the two public GETs (/stats, /release.json) only: an exact origin from// AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.// Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.@@@ -168,7 +174,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys// The same settings as a page for people (GET /you, plain forms on AI's own person session).app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));}-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions }));++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse }));app.use(runPageRouter({ runs, personSessions, log }));// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,@@@ -12,6 +12,7 @@* NAME through secretRef(); nothing here logs or returns a value.*/++const path = require('path');const { loadGovern } = require('./govern');const { DEFAULT_PROVIDERS: DEFAULT_FREE_PROVIDERS } = require('./free-tier');@@@ -148,6 +149,46 @@ function load(env = process.env) {providers: list(env.AI_FREE_PROVIDERS, DEFAULT_FREE_PROVIDERS),maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1.5)),},++ // ── Plan E5: abuse prevention for the free tier (server/abuse/) ──++ // A signed HttpOnly device id (set by the AI Exchange API host for openvibe.exchange), the local++ // iptoasn.com table (scripts/update-ip2asn.js; never a paid API), the daily allowance the trust tiers++ // scale, and the anomaly limits. Network signals are read from Network's identity route and cached.++ abuse: {++ enabled: bool(env.AI_ABUSE, true),++ // The full (verified) daily allowance in free tokens: input + output. Zero switches the free allowance off.++ dailyTokens: Math.max(0, int(env.AI_FREE_DAILY_TOKENS, 20000)),++ // Network's trust signals (server/abuse/network.js). Off in tests unless a test opts in.++ network: bool(env.AI_ABUSE_NETWORK, nodeEnv !== 'test'),++ signalsTtlMs: Math.max(1000, int(env.AI_ABUSE_SIGNALS_TTL_MS, 300000)),++ signalsMissTtlMs: Math.max(1000, int(env.AI_ABUSE_SIGNALS_MISS_TTL_MS, 60000)),++ signalsTimeoutMs: Math.max(100, int(env.AI_ABUSE_SIGNALS_TIMEOUT_MS, 2000)),++ // The HMAC key for the device cookie and every salted hash. Unset: a per-boot key (device cookies do not++ // survive a restart, and the hashes cannot be linked across boots). Set it in production.++ salt: String(env.AI_ABUSE_SALT || '').trim(),++ // Only a service token naming one of these ids may forward the client address (x-ov-client-ip) or a++ // signed device id (x-ov-device-id). The Exchange is the first-party caller that does.++ firstPartyServices: list(env.AI_FIRST_PARTY_SERVICES, ['exchange']),++ deviceCookieName: String(env.AI_DEVICE_COOKIE_NAME || 'ovai_device').trim(),++ // openvibe.exchange: the API host sets the cookie for the parent domain, so the site and the API share it.++ deviceCookieDomain: String(env.AI_DEVICE_COOKIE_DOMAIN || '').trim(),++ deviceCookieSecure: bool(env.AI_DEVICE_COOKIE_SECURE, baseUrl.startsWith('https://')),++ deviceCookieSameSite: String(env.AI_DEVICE_COOKIE_SAMESITE || 'Lax').trim(),++ ip2asnV4: String(env.AI_IP2ASN_V4 || path.join(__dirname, '..', 'data', 'ip2asn', 'ip2asn-v4.tsv')).trim(),++ ip2asnV6: String(env.AI_IP2ASN_V6 || path.join(__dirname, '..', 'data', 'ip2asn', 'ip2asn-v6.tsv')).trim(),++ // ASNs an operator knows are hosting ones, on top of the organisation-name match (server/abuse/asn.js).++ hostingAsns: list(env.AI_HOSTING_ASNS, []),++ ratePerMin: Math.max(1, int(env.AI_ABUSE_RATE_PER_MIN, 60)),++ hostingRatePerMin: Math.max(1, int(env.AI_ABUSE_HOSTING_RATE_PER_MIN, 20)),++ // Anomaly: free tokens in a minute against the last week's per-minute baseline, per ASN, device and input.++ velocityFactor: Math.max(1, float(env.AI_ABUSE_VELOCITY_FACTOR, 10)),++ velocityMinTokensPerMin: Math.max(1, int(env.AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN, 10000)),++ // Proof of work for the anonymous Try it (no account). Required in production; a test opts in.++ powRequired: bool(env.AI_POW_REQUIRED, nodeEnv !== 'test'),++ powBits: Math.max(8, Math.min(28, int(env.AI_POW_BITS, 18))),++ powTtlMs: Math.max(30000, int(env.AI_POW_TTL_MS, 300000)),++ powChallengesPerMinute: Math.max(1, int(env.AI_POW_CHALLENGES_PER_MIN, 10)),++ },++// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──placement: {// A candidate replaces the route's current placement only when it scores this much better,@@@ -110,7 +110,7 @@ const DEF_KINDS = {};const DAY_MS = 86400000;--function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null }) {++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null }) {const cc = config.console;const creditsOn = Boolean(config.credits && config.credits.enabled);const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);@@@ -600,6 +600,14 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, ereturn res.redirect(303, `/console/cache?done=cache_purged&removed=${removed}`);}));++ // ── Abuse prevention (plan E5) ───────────────────────────++ // A read-only page: the counters, the throttles the module already recorded in the audit log, and the size of++ // the network/device signals. The actions (throttling, allowance) are automatic; this is where a person looks.++ r.get('/console/abuse', read, wrap(async (req, res) => {++ const report = abuse ? await abuse.overview() : null;++ await send(res, 200, pages.abuse({ ...common(req), report }));++ }));++// ── Audit ────────────────────────────────────────────────r.get('/console/audit', read, wrap(async (req, res) => {const { filters, form, bad } = q.parseAuditFilters(req.query);@@@ -146,6 +146,7 @@ const AUDIT_KINDS = Object.freeze({runs: { label: 'Runs', where: "target_type = 'run'" },exchange: { label: 'AI Exchange', where: "target_type IN ('credit_settle', 'routing_incumbent')" },console: { label: 'Console sign-ins and refusals', where: "action ILIKE 'console.%'" },++ abuse: { label: 'Abuse prevention', where: "target_type = 'abuse'" },all: { label: 'Everything', where: null },});@@@ -121,7 +121,7 @@ function table(caption, head, rows, empty = 'None.') {const NAV = [['/console', 'Overview', 'overview'], ['/console/providers', 'Providers', 'providers'], ['/console/routes', 'Routes', 'routes'],['/console/templates', 'Templates', 'templates'], ['/console/workflows', 'Workflows', 'workflows'], ['/console/runs', 'Runs', 'runs'],-- ['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/audit', 'Audit', 'audit'],++ ['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/abuse', 'Abuse', 'abuse'], ['/console/audit', 'Audit', 'audit'],['/console/gpus', 'GPUs', 'gpus'],];@@@ -652,6 +652,27 @@ function metaText(m) {return clip(s, 400);}++function abusePage({ staff, csrf, report }) {++ if (!report) return layout({ title: 'Abuse', section: 'abuse', staff, csrf, body: html`<h1>Abuse prevention</h1><p class="muted">Abuse prevention is not configured on this service.</p>` });++ const n = (v) => (v == null ? '—' : Number(v).toLocaleString('en-US'));++ return layout({++ title: 'Abuse', section: 'abuse', staff, csrf,++ body: html`<h1>Abuse prevention</h1>++<p class="muted m0">Plan E5: the free allowance grows with trust, a signed device id counts accounts per device, a hosting ASN gets the new tier, an in-house proof of work guards the anonymous Try it, and free tokens per minute are checked against the last week. Nothing here stores a raw address or device id.</p>++<div class="cards">++<section class="card"><h2>Signals</h2><p>IP-to-ASN table: <strong>${n(report.asn_table.v4_ranges)}</strong> IPv4 ranges, <strong>${n(report.asn_table.v6_ranges)}</strong> IPv6 ranges, <strong>${n(report.asn_table.hosting_asns)}</strong> operator hosting ASNs. Network trust signals: <strong>${report.signals ? 'on' : 'off'}</strong>.</p>++<p>Prefixes seen in the last 24 h: <strong>${n(report.network.prefixes_24h)}</strong> (<strong>${n(report.network.hosting)}</strong> hosting). Open proof-of-work challenges: <strong>${n(report.pow)}</strong>.</p></section>++<section class="card"><h2>Devices</h2><p>Known device/account pairs: <strong>${n(report.devices)}</strong>. Devices shared by more than three accounts: <strong>${n(report.shared_devices)}</strong>.</p>++<p class="muted">Accounts on one shared device draw from one allowance: the lowest tier among them.</p></section>++<section class="card"><h2>Today</h2><p>Free tokens claimed on <strong>${report.day}</strong> (UTC), by counter:</p>++${table('Daily free allowance', ['Counter', 'Tokens'], report.usage.map((u) => html`<tr><td><code>${u.subject}</code></td><td>${n(u.tokens)}</td></tr>`), 'nothing claimed today.')}++</section></div>++${table('Throttles and refusals in the last 24 h', ['When', 'Action', 'Target', 'Metadata'], report.events.map((e) => html`<tr>++<td>${when(e.at)}</td><td><code>${e.action}</code></td><td><code>${clip(e.target, 80)}</code></td><td class="mono muted">${metaText(e.metadata)}</td></tr>`), 'nothing was throttled or refused.')}++<p class="muted">The live gauge is <code>ai_abuse_throttles{reason}</code> on <code>/metrics</code>; OpenVibe.Host alerts when the total stays high. The full audit trail is on the <a href="/console/audit?kind=abuse">Audit page</a>.</p>`,++ });++}++function audit({ staff, csrf, form, bad, rows, next, names, kinds }) {const keep = { kind: form.kind, action: form.action, actor: form.actor, target_type: form.target_type, target_id: form.target_id };const actor = (a) => (names.get(a) ? html`@${names.get(a)} <code>${a}</code>` : html`<code>${a}</code>`);@@@ -734,4 +755,4 @@ function allowance(cards) {/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };--module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit, exchange, gpus } };++module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, abuse: abusePage, audit, exchange, gpus } };@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null } = {}) {++async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, abuse: givenAbuse = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -66,6 +66,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });++ // Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.++ const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });// Network owns the 18+ account flag; the adult tier checks it before placement.@@@ -121,7 +123,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowstartSettlements() { return credits ? credits.startSettlements() : null; },stopSettlements() { return credits ? credits.stopSettlements() : undefined; },};-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts });++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse });await seed({ registry, quotas, config, env, db });// The seed creates or moves providers from the environment: publish their origins (the schema and every key's// bind follow), so a key is bound to the host it is for from the first boot.@@@ -150,7 +152,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts, balance, gpus });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts, balance, gpus, abuse });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {@@@ -166,7 +168,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst expired = await cache.prune();// Billing readings never sent go with their runs (server/usage-samples.js expireUnsent).const lapsed = await require('./usage-samples').expireUnsent(db, { days: config.runs.retentionDays, now: clock.now() });++ const abusePruned = await abuse.prune();if (pruned || expired || lapsed) log.log(`[ai] retention: ${pruned} old run(s), ${expired} expired cache entr${expired === 1 ? 'y' : 'ies'}, ${lapsed} unsent usage reading(s)`);++ if (Object.values(abusePruned).some((n) => n)) log.log(`[ai] abuse retention: ${Object.entries(abusePruned).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(', ')}`);} catch (err) { log.error(`[ai] retention failed: ${err.message}`); }}, 60 * 60 * 1000);housekeeping.unref?.();@@@ -237,7 +241,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowif (!givenDb) await db.close();}-- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, gpus, close };++ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, gpus, abuse, close };}if (require.main === module) {@@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {}/** AI gauges on the openvibe-shared/metrics registry. */--function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null }) {++function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null, abuse = null }) {registry.gauge({name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],collect: () => {@@@ -111,6 +111,13 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,name: 'ai_provider_balance_usd', help: 'Provider account balance in USD, as last read by the hourly probe', labelNames: ['provider'],collect: () => (balance ? balance.values() : []).map(r => ({ labels: { provider: r.provider }, value: r.usd })),});++ // Plan E5: free-tier requests throttled in the last 15 minutes, by reason (velocity, rate, hosting, network).++ // OpenVibe.Host's prometheus rule alerts when the sum stays high: the automatic throttle is working, and an++ // operator should look at /console/abuse.++ registry.gauge({++ name: 'ai_abuse_throttles', help: 'Free-tier requests throttled in the last 15 minutes, by reason', labelNames: ['reason'],++ collect: () => (abuse ? abuse.gaugeValues() : []),++ });// The GPU provisioner (server/gpu/): a marketplace instance that could not be given back is still running// and still being paid for. The operator alert fires at three tries in a row (OpenVibe.Host's prometheus// rules), so the gauge counts the tries that are still open.@@@ -360,6 +360,17 @@ route('get', '/exchange/usage', {} }) },});++route('post', '/exchange/tryit/challenge', {++ operationId: 'exchangeTryitChallenge', summary: 'A proof-of-work challenge for the anonymous Try it', tags: ['exchange'], security: [],++ description: 'Public. The anonymous Try it (no account, no Balance) must send a solved challenge in x-ov-pow: <challenge>:<nonce>, where sha256("<challenge>:<nonce>") has `bits` leading zero bits. The challenge is single use and expires; AI stores only its hash. The response also sets the signed, HttpOnly `ovai_device` cookie (when it is not already present), which counts accounts per device. A first-party service may forward the client address in x-ov-client-ip.',++ requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {} } } } },++ responses: {++ 200: json('The challenge', { type: 'object', required: ['challenge', 'bits', 'expires_at'], properties: { challenge: str(), bits: integer(), expires_at: str({ format: 'date-time' }) } }),++ 429: problem,++ 503: problem,++ },++});++// ── Action Center ────────────────────────────────────────────────────────────────────────────route('get', '/actions', {operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,@@@ -106,7 +106,7 @@ function routePinRequest(value, principal, wf) {return { provider, model };}--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null }) {++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null }) {const inflight = new Map(); // run id -> { controller, promise, release, caller }const queue = []; // run ids waiting for the workerlet active = 0;@@@ -187,7 +187,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now* principal: { sub, subject: {type, id} }* Returns { run, created, promise? }.*/-- async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null } = {}) {++ async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null } = {}) {if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');const wfKey = String(body.workflow || '');const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);@@@ -352,6 +352,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst now = iso(clock.now());const id = newRunId();ctx.runId = id;++ // Plan E5: the free tier's allowance, device count, network signals and anomaly throttle. Paid runs only++ // teach the device table; a refusal here is a 402 (allowance spent) or a 429 (throttled) before any++ // provider or queue slot is touched.++ if (abuse && requestAbuse) {++ const admission = await abuse.admit({ principal, ctx, request: requestAbuse, inputHash, free: freeUse, anonymous: freeUse && anonymousTryIt, selfKeys });++ if (admission) ctx.abuse = admission;++ }const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));const base = {id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,@@@ -796,6 +803,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now// recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.const usedOwnKey = String(r.provider || '').startsWith('byo:self:');if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });++ // Plan E5: the free tokens this run spent go on its allowance (or its shared device counter) and on the++ // per-minute velocity buckets. A failure here is logged, never allowed to fail an answer already made.++ if (abuse && ctx.abuse && !usedOwnKey && !String(r.provider || '').startsWith('byo:')) {++ try { await abuse.record({ admission: ctx.abuse, tokens: (r.usage.input || 0) + (r.usage.output || 0), inputHash: row.input_hash }); }++ catch (err) { log.warn(`[runs] ${id}: the abuse counters were not written: ${err && err.message}`); }++ }// The record of the run (L40): the tokens with the cache apart, the money and who paid.const published = await publishUsage({ row: await getRow(id), outcome: 'succeeded', usage: r.usage, provider: r.provider, model: r.model, attempts, latencyMs: r.latencyMs, ttftMs: r.ttftMs });// The receipt of the answer (server/exchange/receipts.js): the offer, the price, the tokens, the cache,nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++// Plan E5 proof of work (server/abuse/pow.js): a hashcash challenge, issued and stored as its hash, accepted once.++const assert = require('assert');++const { suite } = require('./helpers');++const { testDb } = require('./db');++const { createPow, zeroBits, solve, digest } = require('../server/abuse/pow');++++const t = suite('abuse-pow');++let tdb;++let now = Date.UTC(2026, 10, 6, 12, 0, 0);++const clock = { now: () => now };++++t.test('a migrated database', async () => { tdb = await testDb(); });++++t.test('leading zero bits', () => {++ assert.strictEqual(zeroBits(Buffer.from([0xff])), 0);++ assert.strictEqual(zeroBits(Buffer.from([0x7f])), 1);++ assert.strictEqual(zeroBits(Buffer.from([0x00, 0x0f])), 12);++ assert.strictEqual(zeroBits(Buffer.from([0x00, 0x00, 0x01])), 23);++});++++t.test('a solved challenge is accepted exactly once', async () => {++ const pow = createPow({ db: tdb.db, clock, bits: 6, ttlMs: 60_000 });++ const issued = await pow.issue({ deviceHash: 'a'.repeat(64) });++ assert.strictEqual(issued.bits, 6);++ const nonce = solve(issued.challenge, 6);++ const token = `${issued.challenge}:${nonce}`;++ assert.strictEqual(await pow.verify(token, { deviceHash: 'a'.repeat(64) }), true);++ assert.strictEqual(await pow.verify(token, { deviceHash: 'a'.repeat(64) }), false, 'single use');++});++++t.test('a wrong nonce, an expired challenge and another device are refused', async () => {++ const pow = createPow({ db: tdb.db, clock, bits: 5, ttlMs: 60_000 });++ const one = await pow.issue({});++ assert.strictEqual(await pow.verify(`${one.challenge}:0`, {}), false);++ const two = await pow.issue({ deviceHash: 'b'.repeat(64) });++ const good = `${two.challenge}:${solve(two.challenge, 5)}`;++ assert.strictEqual(await pow.verify(good, { deviceHash: 'c'.repeat(64) }), false, 'issued for one device');++ const three = await pow.issue({});++ const saved = now;++ now += 61_000;++ assert.strictEqual(await pow.verify(`${three.challenge}:${solve(three.challenge, 5)}`, {}), false, 'expired');++ now = saved;++});++++t.test('the database keeps the challenge hash, never the challenge', async () => {++ const pow = createPow({ db: tdb.db, clock, bits: 4 });++ const issued = await pow.issue({});++ const rows = await tdb.db.prepare('SELECT * FROM abuse_pow').all();++ assert.ok(rows.some((r) => r.challenge_hash === digestHash(issued.challenge)));++ assert.ok(!rows.some((r) => JSON.stringify(r).includes(issued.challenge)));++});++++function digestHash(challenge) {++ return require('crypto').createHash('sha256').update(challenge).digest('hex');++}++++t.test('stop', async () => { await tdb.close(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++// Plan E5 end to end through the run API: the daily allowance is refused at admission, and the anonymous Try it++// needs one proof of work. The module's own behaviour is in test/abuse.test.js.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++const { solve } = require('../server/abuse/pow');++++const t = suite('abuse-runs');++const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++let h;++++t.test('boot: the stub is the free source, Network signals off, a tiny allowance', async () => {++ h = await boot({++ env: {++ AI_FREE_PROVIDERS: 'stub', AI_STUB_FALLBACK: 'true', AI_FREE_DAILY_TOKENS: '4',++ AI_ABUSE_NETWORK: '0', AI_ABUSE_SALT: 'runs-test-salt', AI_ABUSE_RATE_PER_MIN: '1000',++ AI_POW_REQUIRED: '0', AI_PROVIDER_RETRIES: '0',++ },++ });++});++++t.test('a free run spends the day\'s allowance; the next one is refused with 402', async () => {++ const acting = token('exchange', ALL, { extra: { on_behalf_of: PERSON } });++ const body = { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello on my allowance' }] } };++ const first = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: acting, body });++ assert.strictEqual(first.status, 201, first.text);++ assert.strictEqual(first.body.run.status, 'succeeded', first.text);++ const row = await h.db.prepare('SELECT tokens FROM abuse_daily_usage WHERE subject = ?').get(`user:${PERSON}`);++ assert.ok(row && Number(row.tokens) > 0, 'the run recorded its free tokens');++ const second = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: acting, body });++ assert.strictEqual(second.status, 402, second.text);++ assert.strictEqual(second.body.code, 'ai.allowance_spent');++ assert.ok(second.headers.get('retry-after'), 'the refusal says when the UTC day resets');++});++++t.test('stop', async () => { await h.stop(); });++++t.test('boot for the anonymous Try it: proof of work required', async () => {++ h = await boot({++ env: {++ AI_FREE_PROVIDERS: 'stub', AI_STUB_FALLBACK: 'true', AI_FREE_DAILY_TOKENS: '1000',++ AI_ABUSE_NETWORK: '0', AI_ABUSE_SALT: 'pow-test-salt', AI_ABUSE_RATE_PER_MIN: '1000',++ AI_POW_REQUIRED: '1', AI_POW_BITS: '4', AI_PROVIDER_RETRIES: '0',++ },++ });++});++++t.test('without a proof of work the anonymous run is refused; a solved challenge runs once', async () => {++ const exchange = token('exchange', ALL);++ const body = { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello anonymously' }] } };++ const noPow = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: exchange, body });++ assert.strictEqual(noPow.status, 403, noPow.text);++ assert.strictEqual(noPow.body.code, 'ai.pow_required');++++ const challenge = await request(h.base, 'POST', '/api/v1/exchange/tryit/challenge', { tok: exchange, body: {} });++ assert.strictEqual(challenge.status, 200, challenge.text);++ assert.strictEqual(challenge.body.bits, 8);++ const cookie = (challenge.headers.get('set-cookie') || '').split(';')[0];++ assert.ok(/^ovai_device=/.test(cookie), 'the challenge sets the signed device cookie');++ const proof = `${challenge.body.challenge}:${solve(challenge.body.challenge, challenge.body.bits)}`;++ const run = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: exchange, body, headers: { cookie, 'x-ov-pow': proof } });++ assert.strictEqual(run.status, 201, run.text);++ assert.strictEqual(run.body.run.status, 'succeeded', run.text);++ const replay = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: exchange, body, headers: { cookie, 'x-ov-pow': proof } });++ assert.strictEqual(replay.status, 403, replay.text);++ assert.strictEqual(replay.body.code, 'ai.pow_invalid');++});++++t.test('stop anonymous', async () => { await h.stop(); });++++t.run();nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++// Plan E5: the allowance tiers and the IP-to-ASN table (server/abuse/trust.js, server/abuse/asn.js), with no++// database and no network. The HTTP side is test/abuse.test.js and test/abuse-runs.test.js.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { suite, tmpDir, silent } = require('./helpers');++const { tierFor, rateFor, TIERS } = require('../server/abuse/trust');++const asn = require('../server/abuse/asn');++const { normalize } = require('../server/abuse/network');++++const t = suite('abuse-trust');++++t.test('the tier is the plan\'s: new quarter, established half, verified full, kudos on top', () => {++ assert.strictEqual(TIERS.new.factor, 0.25);++ assert.strictEqual(TIERS.established.factor, 0.5);++ assert.strictEqual(TIERS.verified.factor, 1);++ assert.deepStrictEqual(pick(tierFor({ signals: { available: true, account_age_hours: 2, verified: false }, base: 1000 })), { tier: 'new', allowance: 250 });++ assert.deepStrictEqual(pick(tierFor({ signals: { available: true, account_age_hours: 8 * 24, verified: false }, base: 1000 })), { tier: 'established', allowance: 500 });++ assert.deepStrictEqual(pick(tierFor({ signals: { available: true, account_age_hours: 2, verified: true, verified_by: ['passkey'] }, base: 1000 })), { tier: 'verified', allowance: 1000 });++});++++t.test('a volunteer\'s kudos add at most one more base, and only on the full tier', () => {++ const v = tierFor({ signals: { available: true, verified: true }, kudos: 400, base: 1000 });++ assert.strictEqual(v.allowance, 1400);++ assert.ok(v.reasons.some((r) => /kudos add 400/.test(r)));++ const capped = tierFor({ signals: { available: true, verified: true }, kudos: 5000, base: 1000 });++ assert.strictEqual(capped.allowance, 2000, 'never more than one extra base');++ const unverified = tierFor({ signals: { available: true, verified: false, account_age_hours: 1 }, kudos: 5000, base: 1000 });++ assert.strictEqual(unverified.allowance, 250, 'kudos do not lift an unverified account');++});++++t.test('a hosting ASN is the new tier whatever the account says; no Network answer is the conservative side', () => {++ const hosted = tierFor({ signals: { available: true, verified: true }, hosting: true, base: 1000 });++ assert.strictEqual(hosted.tier, 'new');++ assert.strictEqual(hosted.allowance, 250);++ assert.ok(hosted.reasons.some((r) => /hosting/.test(r)));++ const unknown = tierFor({ signals: { available: false }, base: 1000 });++ assert.strictEqual(unknown.tier, 'new');++ assert.strictEqual(unknown.allowance, 250);++ assert.strictEqual(tierFor({ signals: null, base: 0 }).allowance, 0);++});++++t.test('the hosting rate is lower than the ordinary one', () => {++ assert.strictEqual(rateFor({ hosting: false, normal: 60, hostingRate: 20 }), 60);++ assert.strictEqual(rateFor({ hosting: true, normal: 60, hostingRate: 20 }), 20);++ assert.strictEqual(rateFor({ hosting: true, normal: 60, hostingRate: 0 }), 1);++});++++t.test('IPv4 /24 and IPv6 /48 prefixes, including a v4-mapped v6 address', () => {++ assert.deepStrictEqual(asn.prefixOf('192.0.2.123'), { family: 'v4', prefix: '192.0.2.0/24' });++ assert.deepStrictEqual(asn.prefixOf('2001:db8:1:2:3:4:5:6'), { family: 'v6', prefix: '2001:db8:1::/48' });++ assert.deepStrictEqual(asn.prefixOf('::ffff:192.0.2.9'), { family: 'v4', prefix: '192.0.2.0/24' });++ assert.strictEqual(asn.prefixOf('not an address'), null);++ assert.strictEqual(asn.ipv6Text(0n), '::');++ assert.strictEqual(asn.ipv6Text(1n), '::1');++});++++t.test('a local table answers the ASN and marks an org-name hosting provider', () => {++ const dir = tmpDir();++ const v4 = path.join(dir, 'ip2asn-v4.tsv');++ const v6 = path.join(dir, 'ip2asn-v6.tsv');++ fs.writeFileSync(v4, [++ '1.0.0.0\t1.0.0.255\t13335\tUS\tCloudflare, Inc.',++ '5.0.0.0\t5.255.255.255\t64500\tDE\tExample Hosting GmbH',++ '9.9.9.0\t9.9.9.255\t19281\tUS\tQuad9',++ ].join('\n'));++ fs.writeFileSync(v6, '2001:db8::\t2001:db8:ffff:ffff:ffff:ffff:ffff:ffff\t396982\tUS\tGoogle Cloud\n');++ const table = asn.createAsnTable({ v4File: v4, v6File: v6, hostingAsns: [19281], log: silent });++ assert.deepStrictEqual(table.lookup('1.0.0.42'), { asn: 13335, country: 'US', org: 'Cloudflare, Inc.', hosting: true });++ assert.strictEqual(table.lookup('5.4.3.2').hosting, true, 'the organisation name says hosting');++ assert.strictEqual(table.lookup('9.9.9.9').hosting, true, 'an operator-named hosting ASN');++ assert.strictEqual(table.lookup('2001:db8::1').asn, 396982);++ assert.deepStrictEqual(table.lookup('8.8.8.8'), { asn: null, country: null, org: null, hosting: false });++ assert.deepStrictEqual(table.status(), { v4_ranges: 3, v6_ranges: 1, hosting_asns: 1 });++});++++t.test('a missing dataset is empty, not a crash', () => {++ const table = asn.createAsnTable({ v4File: path.join(tmpDir(), 'nope.tsv'), log: silent });++ assert.deepStrictEqual(table.lookup('1.0.0.42'), { asn: null, country: null, org: null, hosting: false });++});++++t.test('Network trust is normalised and never carries a private fact', () => {++ const n = normalize({ account_age_hours: 200, account_age_days: 8, verified: true, verified_by: ['passkey', 7], passkey: true, linked_accounts: 2, active_30d: false, email: '[email protected]' });++ assert.deepStrictEqual(n, { account_age_hours: 200, account_age_days: 8, verified: true, verified_by: ['passkey'], passkey: true, linked_accounts: 2, active_30d: false });++ assert.strictEqual(normalize(null), null);++ assert.strictEqual(normalize({ account_age_hours: -5 }).account_age_hours, 0);++});++++function pick(out) { return { tier: out.tier, allowance: out.allowance }; }++++t.run();nnew file mode 100644@@@ -0,0 +1,191 @@++'use strict';++// Plan E5: the abuse module itself (server/abuse/index.js): device signatures, the trust-tier allowance, a shared++// device allowance, the ASN override, the rate limit, the anomaly throttle, proof of work and the operator view.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { suite, tmpDir, silent } = require('./helpers');++const { testDb } = require('./db');++const { load } = require('../server/config');++const { createAbuse } = require('../server/abuse');++const { solve } = require('../server/abuse/pow');++++const t = suite('abuse');++const USER = (n) => `usr_01JAB2C3D4E5F6G7H8J9K0MN${String(n).padStart(2, '0').slice(-2)}`;++const DAY = 86_400_000;++let tdb;++let now = Date.UTC(2026, 10, 6, 12, 0, 0);++const clock = { now: () => now };++const BASE = {++ NODE_ENV: 'test', AI_ABUSE_SALT: 'a-test-salt', AI_FREE_DAILY_TOKENS: '1000', AI_ABUSE_NETWORK: '0',++ AI_ABUSE_RATE_PER_MIN: '1000', AI_ABUSE_HOSTING_RATE_PER_MIN: '1000', AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN: '10000',++};++++function make(overrides = {}, extra = {}) {++ const env = { ...BASE, ...overrides };++ return createAbuse({++ db: tdb.db, config: load(env), env, clock, log: silent,++ community: { kudosBonus: async () => 0 }, ...extra,++ });++}++++const device = (abuse, id = 'device-one') => abuse._test.keyed('device', id);++++async function clear() {++ for (const table of ['abuse_daily_usage', 'abuse_devices', 'abuse_velocity', 'abuse_network_seen', 'abuse_pow']) {++ await tdb.db.prepare(`DELETE FROM ${table}`).run();++ }++}++++t.test('a migrated database', async () => { tdb = await testDb(); });++++t.test('the signed device id survives a round trip and refuses a tamper', () => {++ const abuse = make();++ const { id, token } = (() => { const id = 'abcdefghijklmnopqrstuvwx'; return { id, token: abuse._test.signDevice(id) }; })();++ assert.strictEqual(abuse._test.verifyDevice(token), id);++ assert.strictEqual(abuse._test.verifyDevice(token.replace(/.$/, '0')), null);++ assert.strictEqual(abuse._test.verifyDevice(`${token}.x`), null);++ assert.strictEqual(abuse._test.verifyDevice(abuse._test.signDevice('abcdefghijklmnop')), 'abcdefghijklmnop');++});++++t.test('a new account gets a quarter, records its tokens, and is refused when the day is spent', async () => {++ await clear();++ const abuse = make();++ const request = { deviceHash: device(abuse, 'allowance') };++ const first = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(1) }, request, free: true });++ assert.strictEqual(first.tier, 'new');++ assert.strictEqual(first.allowance, 250);++ assert.strictEqual(first.key, `user:${USER(1)}`);++ await abuse.record({ admission: first, tokens: 100, inputHash: 'h1' });++ const second = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(1) }, request, free: true });++ assert.strictEqual(second.allowance, 250, 'still the same day');++ await abuse.record({ admission: second, tokens: 200, inputHash: 'h2' });++ await assert.rejects(++ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(1) }, request, free: true }),++ (err) => err.status === 402 && err.code === 'ai.allowance_spent' && /250 tokens/.test(err.detail),++ );++});++++t.test('more than three accounts on one device share one allowance, at the lowest tier', async () => {++ await clear();++ const abuse = make({ AI_FREE_DAILY_TOKENS: '4000' });++ const hash = device(abuse, 'family');++ const keys = [];++ for (let i = 1; i <= 4; i++) {++ const a = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(i) }, request: { deviceHash: hash }, free: true });++ keys.push(a);++ }++ assert.strictEqual(keys[3].accounts, 4);++ assert.strictEqual(keys[3].shared, true, 'the fourth account makes it shared');++ assert.strictEqual(keys[3].key, `device:${hash}`);++ assert.strictEqual(keys[3].allowance, 1000, 'a quarter of 4000, the lowest tier on the device');++ await abuse.record({ admission: keys[3], tokens: 1000, inputHash: 'shared' });++ await assert.rejects(++ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true }),++ (err) => err.status === 402 && err.code === 'ai.allowance_spent',++ 'one account cannot spend a second allowance on the shared device',++ );++});++++t.test('a hosting ASN is the new tier and a lower rate whatever Network says', async () => {++ await clear();++ const dir = tmpDir();++ const v4 = path.join(dir, 'ip2asn-v4.tsv');++ fs.writeFileSync(v4, '5.0.0.0\t5.255.255.255\t64500\tDE\tExample Hosting GmbH\n9.9.9.0\t9.9.9.255\t19281\tUS\tQuad9\n');++ const seen = [];++ const fetchImpl = async (url) => {++ seen.push(url);++ return { ok: true, status: 200, json: async () => ({ subject: { type: 'user', id: USER(1) }, trust: { account_age_hours: 900, account_age_days: 37, verified: true, verified_by: ['passkey'], passkey: true, linked_accounts: 0, active_30d: true } }) };++ };++ const abuse = make({ AI_ABUSE_NETWORK: '1', AI_IP2ASN_V4: v4, AI_IP2ASN_V6: '' }, { fetchImpl, tokens: { getToken: async () => 'service-token' } });++ const exchange = { subject: { type: 'service', id: 'exchange' } };++ const hosted = await abuse.admit({++ principal: exchange, ctx: { requesterType: 'service', requesterId: 'exchange', onBehalfOf: USER(1) },++ request: { explicitIp: '5.1.2.3', deviceHash: device(abuse, 'hosted') }, free: true,++ });++ assert.strictEqual(hosted.asn, 64500);++ assert.strictEqual(hosted.hosting, true);++ assert.strictEqual(hosted.tier, 'new');++ assert.strictEqual(hosted.allowance, 250);++ assert.ok(seen.some((u) => /&trust=1/.test(u)), 'the identity read asked for trust');++ const home = await abuse.admit({++ principal: exchange, ctx: { requesterType: 'service', requesterId: 'exchange', onBehalfOf: USER(1) },++ request: { explicitIp: '9.9.9.9', deviceHash: device(abuse, 'home') }, free: true,++ });++ assert.strictEqual(home.hosting, false);++ assert.strictEqual(home.tier, 'verified');++ assert.strictEqual(home.allowance, 1000);++});++++t.test('the rate limit is per device and per ASN; the hosting rate is lower', async () => {++ await clear();++ const abuse = make({ AI_ABUSE_RATE_PER_MIN: '2', AI_ABUSE_HOSTING_RATE_PER_MIN: '1' });++ const hash = device(abuse, 'rate');++ await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true });++ await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true });++ await assert.rejects(++ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true }),++ (err) => err.status === 429 && err.code === 'ai.rate_limited',++ );++ assert.strictEqual(abuse.gaugeValues().find((g) => g.labels.reason === 'network').value, 1);++});++++t.test('free tokens per minute above the last week\'s baseline are throttled, with an operator event', async () => {++ await clear();++ const audits = [];++ const abuse = make({ AI_FREE_DAILY_TOKENS: '100000' }, { registry: { audit: async (...args) => audits.push(args) } });++ const hash = device(abuse, 'velocity');++ const first = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(3) }, request: { deviceHash: hash }, free: true });++ await abuse.record({ admission: first, tokens: 15000, inputHash: 'same-prompt' });++ await assert.rejects(++ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(3) }, request: { deviceHash: hash }, free: true, inputHash: 'same-prompt' }),++ (err) => err.status === 429 && err.code === 'ai.abuse_throttled' && /paused/.test(err.detail),++ );++ assert.ok(audits.some((a) => a[1] === 'abuse.throttled'), 'the audit log has the throttle');++ assert.strictEqual(abuse.gaugeValues().find((g) => g.labels.reason === 'velocity').value, 1);++});++++t.test('the anonymous Try it needs one proof of work, accepted once', async () => {++ await clear();++ const abuse = make({ AI_POW_REQUIRED: '1', AI_POW_BITS: '4', AI_FREE_DAILY_TOKENS: '1000' });++ const hash = device(abuse, 'anonymous');++ const ctx = { requesterType: 'service', requesterId: 'exchange' };++ await assert.rejects(++ () => abuse.admit({ ctx, request: { deviceHash: hash }, free: true, anonymous: true }),++ (err) => err.status === 403 && err.code === 'ai.pow_required',++ );++ const issued = await abuse.issueChallenge({ meta: { deviceHash: hash } });++ const token = `${issued.challenge}:${solve(issued.challenge, issued.bits)}`;++ const a = await abuse.admit({ ctx, request: { deviceHash: hash, pow: token }, free: true, anonymous: true });++ assert.strictEqual(a.anonymous, true);++ assert.strictEqual(a.subject, null);++ await assert.rejects(++ () => abuse.admit({ ctx, request: { deviceHash: hash, pow: token }, free: true, anonymous: true }),++ (err) => err.status === 403 && err.code === 'ai.pow_invalid',++ );++});++++t.test('the operator view lists recent events and today\'s counters; prune removes expired rows', async () => {++ await clear();++ const abuse = make();++ await tdb.db.prepare(`INSERT INTO audit_log (at, actor, action, target_type, target_id, metadata) VALUES (?, 'system', 'abuse.throttled', 'abuse', 'velocity:input', '{"reason":"velocity","kind":"input"}')`)++ .run(new Date(now - 1000).toISOString());++ await tdb.db.prepare('INSERT INTO abuse_daily_usage (subject, day, tokens, updated_at) VALUES (?, ?, 5, ?)').run(`user:${USER(1)}`, new Date(now).toISOString().slice(0, 10), new Date(now).toISOString());++ const out = await abuse.overview();++ assert.strictEqual(out.events[0].action, 'abuse.throttled');++ assert.strictEqual(out.events[0].metadata.reason, 'velocity');++ assert.deepStrictEqual(out.usage[0], { subject: `user:${USER(1)}`, tokens: 5 });++ const html = require('../server/console/views').pages.abuse({ staff: { subject: USER(1), username: 'op', role: 'owner' }, csrf: 'c', report: out });++ assert.ok(html.includes('Abuse prevention') && html.includes('abuse.throttled'), 'the operator page renders the events');++ await tdb.db.prepare('INSERT INTO abuse_network_seen (prefix_hash, asn, hosting, first_seen, last_seen, expires_at) VALUES (?, 1, 0, ?, ?, ?)').run('x', new Date(now).toISOString(), new Date(now).toISOString(), new Date(now - 1000).toISOString());++ await tdb.db.prepare('INSERT INTO abuse_velocity (kind, key_hash, minute, tokens, updated_at) VALUES (?, ?, ?, 1, ?)').run('input', 'y', 1, new Date(now).toISOString());++ await tdb.db.prepare('INSERT INTO abuse_devices (device_hash, subject, first_seen, last_seen) VALUES (?, ?, ?, ?)').run('z', USER(1), new Date(now - 400 * DAY).toISOString(), new Date(now - 400 * DAY).toISOString());++ const removed = await abuse.prune();++ assert.strictEqual(removed.network, 1);++ assert.strictEqual(removed.velocity, 1);++ assert.strictEqual(removed.devices, 1);++});++++t.test('stop', async () => { await tdb.close(); });++++t.run();@@@ -55,6 +55,10 @@ t.test('an export carries the person\'s sessions and credential hints, and a delawait h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);++ // Plan E5: the free-tier counters are erased with the account (the shared device counter only when its++ // last account goes).++ await h.db.exec("INSERT INTO abuse_daily_usage (subject, day, tokens, updated_at) VALUES ($1, '2026-10-01', 5, '2026-10-01')", [`user:${subject}`]);++ await h.db.exec("INSERT INTO abuse_devices (device_hash, subject, tier_tokens, first_seen, last_seen) VALUES ($1, $2, 1000, '2026-10-01', '2026-10-01')", [`dev_${name}`, subject]);await h.db.exec("INSERT INTO routes (key, version, primary_provider, created_by, created_at) VALUES ($1, 1, 'openai', $2, '2026-10-01')", [`route-${name}`, subject]);}const deliver = async (event) => {@@@ -78,6 +82,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a delconst d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [A])), 0, 'the device row goes');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_daily_usage WHERE subject = $1', [`user:${A}`])), 0, 'the daily counter goes');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [B])), 1, "nobody else's device row");assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');
@@ -165,6 +165,34 @@ AI_MODEL=
@@ -165,6 +165,34 @@ AI_MODEL=
#AI_FREE_PROVIDERS=deepseek,local,community
#AI_FREE_PROVIDERS=deepseek,local,community
#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1.5
#AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK=1.5
+# ── Abuse prevention for the free tier (plan E5; server/abuse/, docs/abuse-prevention.md) ──
+# The full daily allowance (verified tier); a new account gets a quarter, over 7 days half, kudos add up to
+# one more full. Zero turns the free allowance off. The salt keys the device cookie and every hash; set it.
+#AI_FREE_DAILY_TOKENS=20000
+#AI_ABUSE=1
+#AI_ABUSE_SALT=
+# Network trust signals (account age, passkey, links, 30-day activity) through the identity route.
+#AI_ABUSE_NETWORK=1
+# Only these service-token ids may forward x-ov-client-ip / x-ov-device-id (the Exchange).
+#AI_FIRST_PARTY_SERVICES=exchange
+# Set for openvibe.exchange so the site and api.openvibe.exchange share the signed device cookie.
+#AI_DEVICE_COOKIE_DOMAIN=.openvibe.exchange
+# The local monthly iptoasn.com table (node scripts/update-ip2asn.js); never a paid API.
+#AI_IP2ASN_V4=/srv/openvibe/ai/data/ip2asn/ip2asn-v4.tsv
+#AI_IP2ASN_V6=/srv/openvibe/ai/data/ip2asn/ip2asn-v6.tsv
+#ASNs an operator knows are hosting ones, on top of the organisation-name match.
+#AI_HOSTING_ASNS=
+#AI_ABUSE_RATE_PER_MIN=60
+#AI_ABUSE_HOSTING_RATE_PER_MIN=20
+# Free tokens per minute against the week's per-minute baseline, per ASN, device and input bucket.
+#AI_ABUSE_VELOCITY_FACTOR=10
+#AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN=10000
+# In-house hashcash for the anonymous Try it (no account); required in production, off in tests.
+#AI_POW_REQUIRED=1
+#AI_POW_BITS=18
+#AI_POW_TTL_MS=300000
+#AI_POW_CHALLENGES_PER_MIN=10
+
# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})
# Optional local HTTP seam provider (POST {operation, request} -> {text, json, usage})
#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete
#AI_HTTP_SEAM_URL=http://127.0.0.1:4799/complete
# A local model server speaking the OpenAI API (llama.cpp llama-server, Ollama /v1): provider `local`, route
# A local model server speaking the OpenAI API (llama.cpp llama-server, Ollama /v1): provider `local`, route
@@ -613,6 +613,18 @@ not capped this way. The check is admission, not pricing: it never changes a pri
@@ -613,6 +613,18 @@ not capped this way. The check is admission, not pricing: it never changes a pri
The Exchange catalog marks each offer `free_tier` by the same two rules (distinct from `free`, which means
The Exchange catalog marks each offer `free_tier` by the same two rules (distinct from `free`, which means
prepaid capacity with no per-token price).
prepaid capacity with no per-token price).
+Abuse prevention for the free tier (plan E5; `server/abuse/`, `docs/abuse-prevention.md`, privacy note
+`docs/privacy.md`): the daily free allowance is `AI_FREE_DAILY_TOKENS` (default 20,000) scaled by trust — ¼ for a
+new or unvouched account, ½ after 7 days, full when Network says a passkey, a linked account or 30 days with
+activity verifies it, plus up to one extra full for a volunteer's kudos. A **hosting/data-centre ASN** is always
+the new tier and a lower rate; the address is looked up in a local monthly iptoasn.com table and only the ASN and
+an HMAC of its `/24`–`/48` are kept, for 24 h. A signed `HttpOnly` device cookie counts accounts per device, and
+more than three accounts on one device share one allowance at the lowest tier. The anonymous Try it needs one
+in-house hashcash proof of work (`POST /api/v1/exchange/tryit/challenge`). Every finished free run feeds a
+per-minute velocity check per ASN, device and input bucket against the last week's baseline; above it the run is
+`429 ai.abuse_throttled` and an `abuse.throttled` row goes to `/console/abuse` and the
+`ai_abuse_throttles` gauge (OpenVibe.Host alerts on it). No raw IP, device id or prompt is stored.
+
Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers
Tier budgets (T6 step 8, decision D5; `server/govern.js`, off unless `AI_GOVERN_TIERS=1`): the SaaS tiers
`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider
`free`, `paid` and `staff` on `openvibe-sdk/govern`, counted in Valkey. Before a run touches a provider
(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two
(and before `reserve()` counts it), it holds an estimate of the units `ai-token` and `ai-usd` against two
@@ -816,6 +828,8 @@ mix vectors from different providers (re-embed when the provider changes).
@@ -816,6 +828,8 @@ mix vectors from different providers (re-embed when the provider changes).
| Hourly DeepSeek balance gauge, no key in logs, off under a drill | `test/provider-balance.test.js` |
| Hourly DeepSeek balance gauge, no key in logs, off under a drill | `test/provider-balance.test.js` |
| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
| Quotas refuse with 429 + Retry-After **before** any provider call; per-service, attribution, cost caps | `test/quota.test.js` |
| Free allowance: free up to the card's allowance then fully priced, reset per day/month (never for `none`), per subject, never past the cap under concurrent claims, absent at 0 | `test/free-allowance.test.js` |
| Free allowance: free up to the card's allowance then fully priced, reset per day/month (never for `none`), per subject, never past the cap under concurrent claims, absent at 0 | `test/free-allowance.test.js` |
+| Abuse prevention (plan E5): allowance by trust tier, kudos bonus, hosting override, `/24`–`/48` hashes, ASN table, signed device cookie, >3 accounts share one allowance, rate limit, per-minute velocity against the week's baseline, single-use proof of work, operator events and prune | `test/abuse-trust.test.js`, `test/abuse.test.js`, `test/abuse-pow.test.js` |
+| The run path refuses a spent daily allowance (402) and the anonymous Try it without/with a used proof of work (403) | `test/abuse-runs.test.js` |
| Tier budgets: enforced per subject and per project and reset per window, two concurrent runs never both take the last unit, the free allowance's numbers unchanged on the Valkey store, one reading per accounted run from govern's `onUsage`, Valkey unreachable (paid 503, free admitted), off by default | `test/govern-tiers.test.js` |
| Tier budgets: enforced per subject and per project and reset per window, two concurrent runs never both take the last unit, the free allowance's numbers unchanged on the Valkey store, one reading per accounted run from govern's `onUsage`, Valkey unreachable (paid 503, free admitted), off by default | `test/govern-tiers.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| The cache never crosses requester, actor, target or attribution scope | `test/cache.test.js` |
| Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` |
| Queue caps: one caller cannot fill the run queue (per-caller and global 429 `queue.full` + Retry-After) | `test/queue.test.js` |
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+# Abuse prevention for the free tier (plan E5)
+
+OpenVibe.AI's free tier is the owner's DeepSeek key, local models and community volunteers. The allowance is
+small, so mass sign-ups must not farm it. This is what stands in the way, and exactly what it stores.
+
+Code: `server/abuse/`. Migration: `0034_abuse.sql`. Tests: `test/abuse-trust.test.js`, `test/abuse-pow.test.js`,
+`test/abuse.test.js`, `test/abuse-runs.test.js`; Network's signals: `test/identity-trust.test.js`.
+
+## 1. The allowance grows with trust
+
+`curl GET /internal/identity/resolve?subject_id=…&trust=1` on Network (server token,
+`identity.subject.resolve`) adds a `trust` object to the usual projection: account age, whether a Locker passkey
+exists, **how many** accounts are linked, whether the account is 30 days old and active, and
+`verified`/`verified_by`. No email, no linked site ids, no last-seen timestamp leaves Network.
+
+| Tier | Condition | Allowance |
+|---|---|---|
+| new | under 24 h, unverified, or Network cannot vouch for it | ¼ of `AI_FREE_DAILY_TOKENS` |
+| established | over 7 days, unverified | ½ |
+| verified | a passkey, a linked account, or 30 days with activity | full |
+| contributor | verified, with community kudos | full + up to one extra full (kudos tokens) |
+
+A hosting/data-centre ASN is always the new tier, whatever the account says. The counter is per UTC day, per
+account, or per device when the device is shared (below). At the limit the run is refused **before** a provider is
+touched: `402 ai.allowance_spent` with the reset time and how many tokens were used. This is admission, separate
+from the provider `free_allowance` in `server/free-allowance.js` (which is pricing only).
+
+`AI_FREE_DAILY_TOKENS` (default 20 000) is the full tier. Zero turns the free allowance off.
+
+## 2. Network signals: ASN, never the address
+
+A client address reaches AI from the loopback proxy, or in `x-ov-client-ip` from a first-party service token
+(`AI_FIRST_PARTY_SERVICES`, default `exchange`); any other caller's header is ignored. AI maps it with a local
+monthly table — `scripts/update-ip2asn.js` downloads iptoasn.com's free TSV files to `data/ip2asn/`, never a paid
+API — and keeps only:
+
+* the ASN number and whether its organisation is a hosting/data-centre one, and
+* an HMAC-SHA256 of the address's `/24` (IPv4) or `/48` (IPv6), with `AI_ABUSE_SALT`.
+
+That row expires after **24 hours**. `AI_HOSTING_ASNS` adds ASNs the organisation-name match misses. A hosting
+request gets the new tier and `AI_ABUSE_HOSTING_RATE_PER_MIN` (default 20/min) instead of
+`AI_ABUSE_RATE_PER_MIN` (default 60/min).
+
+## 3. Devices
+
+The API sets `ovai_device` — a signed, `HttpOnly`, `SameSite=Lax`, `Secure` cookie, `Domain` from
+`AI_DEVICE_COOKIE_DOMAIN` (set it to `.openvibe.exchange` so the site and `api.openvibe.exchange` share it). The
+Exchange may instead forward the cookie's signed value in `x-ov-device-id` from its own server; the signature is
+what matters, so a raw id is refused. A first-party service forwards the client address in `x-ov-client-ip` only
+when it holds a first-party token.
+
+Accounts are counted per device. At **more than three** accounts, they share one allowance: the counter key is the
+device, and its cap is the lowest tier among the accounts on it. A device row is pruned 180 days after it was last
+seen.
+
+## 4. Proof of work for the anonymous Try it
+
+No account, no Balance: `POST /api/v1/exchange/tryit/challenge` returns a random challenge, `AI_POW_BITS` (default
+18, about a second on a phone; clamped to 8–28) and a five-minute expiry. The client sends
+`x-ov-pow: <challenge>:<nonce>` where `sha256("<challenge>:<nonce>")` has that many leading zero bits. AI stores
+only `sha256(challenge)` and accepts one solution once. `AI_POW_REQUIRED` is on in production and off in tests.
+
+## 5. Anomaly throttling
+
+Every finished free run adds its tokens (input + output) to three per-minute buckets: its ASN, its device, and an
+HMAC of the run's input hash (a repeated scripted request). Before a run, the current minute is compared with the
+last week's per-minute baseline for that bucket. Above `AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN` (default 10 000) or
+10× the baseline, the run is refused with `429 ai.abuse_throttled` and a plain retry-in-a-minute message, and an
+`abuse.throttled` row goes to the operator audit log. The rate limit above records `abuse.rate_limited` the same
+way.
+
+Operators see both on `/console/abuse`, and the gauge `ai_abuse_throttles{reason}` (events in the last 15 minutes)
+is on `/metrics`. OpenVibe.Host's `deploy/prometheus/openvibe-rules.yml` raises `OpenVibeAiAbuseThrottling`
+(severity ticket) above 20 in 15 minutes.
+
+## Privacy: what is kept, for how long
+
+| Data | Where | Kept |
+|---|---|---|
+| Raw client IP | request memory only | not written; gone when the request ends |
+| ASN + HMAC of `/24` or `/48` | `abuse_network_seen` | 24 hours after last seen |
+| Raw device id | the person's browser cookie | their browser controls it; signed with `AI_ABUSE_SALT` |
+| HMAC of the device id | `abuse_devices` | 180 days after last seen |
+| Free tokens per account/device | `abuse_daily_usage` | 30 days |
+| Free tokens per minute per ASN/device/input bucket | `abuse_velocity` | 8 days (seven-day baseline plus a day) |
+| Proof-of-work challenge hash | `abuse_pow` | one day after expiry, then pruned |
+| Network trust facts | AI process memory | 5 minutes (a miss: 1 minute) |
+| Throttle/rate events | `audit_log` (`target_type='abuse'`) | with the audit log; ASN, tier and counts only, never an address, device id or prompt |
+
+The salt is `AI_ABUSE_SALT`; without it a per-boot random key is used, so hashes cannot be linked across boots and
+device cookies are re-issued after a restart. Set it in production. Nothing in these tables can be turned back into
+an address, a device or a prompt without the salt, and the salt never leaves the service.
+
+The IP-to-ASN files are public data (`iptoasn.com`), not a person's data. The table is replaced monthly by the
+job, and the service loads it lazily.
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+# Privacy notes (service data)
+
+This is the engineering note for what OpenVibe.AI keeps. The public legal text is served by
+`openvibe-shared/legal` (`/privacy`); the facts here must match the code, and a change to one is a change to both.
+
+## A run
+
+A run keeps the prompt (unless the workflow is passthrough or `history: false`), the answer, the model and why it
+was chosen, tokens, cost, payer and the receipt. Retention follows `AI_RUN_RETENTION_DAYS`; the receipt goes with
+the run. A person's own provider key is encrypted and never logged. See `README.md` ("Runs", "Presets, receipts,
+BYOK and the privacy label").
+
+## Abuse prevention (plan E5)
+
+The free tier's controls are described in full in [`abuse-prevention.md`](abuse-prevention.md#privacy-what-is-kept-for-how-long):
+
+- **No raw client IP is stored.** AI uses the address only in memory to look up an ASN in a local table, then
+ keeps the ASN and an HMAC-SHA256 of the `/24` (IPv4) or `/48` (IPv6) for 24 hours.
+- **No raw device id is stored.** The browser holds a signed `HttpOnly` cookie; AI stores only an HMAC of it, for
+ 180 days after the device was last seen.
+- **The anonymous Try it's proof of work** is stored as `sha256(challenge)`, accepted once, and pruned a day after
+ it expires.
+- **Counters**: free tokens per account/device for 30 days; free tokens per minute per ASN, device and input
+ bucket for 8 days. No prompt is stored by the abuse code; the input bucket is an HMAC of the run's existing
+ input hash.
+- **Network's trust signals** (account age, a passkey, a link count, 30-day activity) are read through the
+ existing identity route and cached in memory for five minutes; AI never sees an email or which sites are linked.
+- **Operator events** (`audit_log`, `target_type='abuse'`) carry the reason, ASN, tier and counts, never an
+ address, a device id or a prompt, and are visible only in the operator console (`/console/abuse`,
+ `/console/audit`).
+
+`AI_ABUSE_SALT` keys every one of those hashes and signs the device cookie. Without it, a per-boot key is used:
+the rows cannot be linked across boots and device cookies are re-issued, which is the safe default. Set it in
+production.
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+-- phase: expand
+-- Plan E5 (abuse prevention): allowances by trust, device and network signals, proof of work and anomaly
+-- throttling (server/abuse/). Nothing here is a raw client address or a raw device id: the rows hold only a
+-- keyed hash of a /24 or /48 prefix, the ASN that prefix belonged to, and a keyed hash of the device id.
+-- Every row has an expiry or is a counter that is pruned by the module's own housekeeping.
+--
+-- abuse_daily_usage free tokens a person (or the accounts sharing one device) spent on a UTC day
+-- abuse_devices accounts seen on one device, and the allowance tier each one gave the device
+-- abuse_network_seen an ASN and a keyed /24 or /48 hash, for 24 h, so a repeated request is not looked up twice
+-- abuse_velocity free tokens per minute per ASN, device and input bucket (the anomaly baseline)
+-- abuse_pow an issued hashcash challenge (its hash only), single use
+--
+-- Expand only: an older release never reads these tables and nothing here changes an existing one.
+CREATE TABLE IF NOT EXISTS abuse_daily_usage (
+ subject text COLLATE "C" NOT NULL, -- user:<subject> | device:<keyed device hash> | anon:<keyed hash>
+ day text COLLATE "C" NOT NULL, -- UTC date, YYYY-MM-DD
+ tokens bigint NOT NULL DEFAULT 0, -- free tokens claimed, input + output
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (subject, day)
+);
+
+CREATE TABLE IF NOT EXISTS abuse_devices (
+ device_hash text COLLATE "C" NOT NULL, -- keyed hash of the device id, never the id
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ tier_tokens bigint, -- the allowance this account gave the device (null until it used free capacity)
+ first_seen text COLLATE "C" NOT NULL,
+ last_seen text COLLATE "C" NOT NULL,
+ PRIMARY KEY (device_hash, subject)
+);
+CREATE INDEX IF NOT EXISTS idx_abuse_devices_last_seen ON abuse_devices (last_seen);
+
+CREATE TABLE IF NOT EXISTS abuse_network_seen (
+ prefix_hash text COLLATE "C" PRIMARY KEY, -- keyed hash of the IPv4 /24 or IPv6 /48, never the address
+ asn bigint,
+ hosting bigint NOT NULL DEFAULT 0,
+ first_seen text COLLATE "C" NOT NULL,
+ last_seen text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL -- the row is kept for 24 h, then pruned
+);
+CREATE INDEX IF NOT EXISTS idx_abuse_network_expires ON abuse_network_seen (expires_at);
+
+CREATE TABLE IF NOT EXISTS abuse_velocity (
+ kind text COLLATE "C" NOT NULL, -- asn | device | input
+ key_hash text COLLATE "C" NOT NULL, -- keyed hash of the ASN, the device id or the run's input hash
+ minute bigint NOT NULL, -- epoch ms truncated to the minute
+ tokens bigint NOT NULL DEFAULT 0,
+ updated_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (kind, key_hash, minute)
+);
+CREATE INDEX IF NOT EXISTS idx_abuse_velocity_minute ON abuse_velocity (minute);
+
+CREATE TABLE IF NOT EXISTS abuse_pow (
+ challenge_hash text COLLATE "C" PRIMARY KEY, -- sha256 of the issued challenge
+ device_hash text COLLATE "C",
+ prefix_hash text COLLATE "C",
+ issued_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ used_at text COLLATE "C" -- single use: the first accepted solution sets it
+);
+CREATE INDEX IF NOT EXISTS idx_abuse_pow_expires ON abuse_pow (expires_at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Plan E5: download iptoasn.com's free monthly IP-to-ASN table into data/ip2asn/ (never a paid API).
+ *
+ * node scripts/update-ip2asn.js # data/ip2asn/ip2asn-v4.tsv and -v6.tsv
+ * node scripts/update-ip2asn.js --dir DIR # somewhere else (AI_IP2ASN_V4 / AI_IP2ASN_V6 point there)
+ *
+ * The files are the source data (range_start, range_end, ASN, country, org), tab separated and gzipped. The
+ * service reads them lazily (server/abuse/asn.js); a download replaces the old file only after the new one has
+ * been checked, so a failed run never leaves a half-written table. Run it monthly (iptoasn publishes monthly):
+ *
+ * 17 4 1 * * cd /srv/openvibe/ai && node scripts/update-ip2asn.js >> data/ip2asn/update.log 2>&1
+ */
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+
+const SOURCES = [
+ { name: 'ip2asn-v4.tsv', url: 'https://iptoasn.com/data/ip2asn-v4.tsv.gz', family: 4 },
+ { name: 'ip2asn-v6.tsv', url: 'https://iptoasn.com/data/ip2asn-v6.tsv.gz', family: 6 },
+];
+
+function arg(name) {
+ const at = process.argv.indexOf(name);
+ return at > 0 && process.argv[at + 1] ? process.argv[at + 1] : null;
+}
+
+async function download(source) {
+ const res = await fetch(source.url, { headers: { 'user-agent': 'openvibe-ai/ip2asn-update (+https://openvibe.services)' }, signal: AbortSignal.timeout(120_000) });
+ if (!res.ok) throw new Error(`${source.url} answered ${res.status}`);
+ const gz = Buffer.from(await res.arrayBuffer());
+ if (gz.length < 1024 || gz[0] !== 0x1f || gz[1] !== 0x8b) throw new Error(`${source.url} did not answer a gzip file`);
+ const text = zlib.gunzipSync(gz).toString('utf8');
+ const lines = text.split(/\r?\n/).filter((l) => l && !l.startsWith('#'));
+ if (lines.length < 1000) throw new Error(`${source.url} returned only ${lines.length} rows`);
+ const first = lines[0].split('\t');
+ if (first.length < 5) throw new Error(`${source.url} rows do not have five columns`);
+ // A quick sanity check on every 1000th row: at least one row must parse as an address range, so an HTML
+ // error page or an empty snapshot never replaces a working table.
+ const bad = lines.slice(0, 2000).filter((line) => line.split('\t').length < 5).length;
+ if (bad) throw new Error(`${source.url}: ${bad} malformed rows in the first 2000`);
+ return { text, rows: lines.length };
+}
+
+async function main() {
+ const dir = path.resolve(arg('--dir') || process.env.AI_IP2ASN_DIR || path.join(__dirname, '..', 'data', 'ip2asn'));
+ fs.mkdirSync(dir, { recursive: true });
+ for (const source of SOURCES) {
+ process.stdout.write(`[ip2asn] ${source.url}\n`);
+ const got = await download(source);
+ const target = path.join(dir, source.name);
+ const tmp = `${target}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, got.text);
+ fs.renameSync(tmp, target);
+ process.stdout.write(`[ip2asn] ${target}: ${got.rows} rows, ${(got.text.length / 1024 / 1024).toFixed(1)} MB\n`);
+ }
+ process.stdout.write(`[ip2asn] done at ${new Date().toISOString()}\n`);
+}
+
+main().catch((err) => {
+ process.stderr.write(`[ip2asn] failed: ${err && err.stack ? err.stack : err}\n`);
+ process.exit(1);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
+'use strict';
+/**
+ * Plan E5: a local IP-to-ASN table, with no API call (scripts/update-ip2asn.js downloads iptoasn.com's free
+ * monthly TSV files into data/ip2asn/). The module answers two questions about a client address:
+ *
+ * lookup(ip) → { asn, country, org, hosting } which ASN the address is in, and whether it is a hosting one
+ * prefixOf(ip) → { family, prefix } the IPv4 /24 or IPv6 /48 the address belongs to
+ *
+ * Nothing here keeps the address: the caller hashes the prefix with its own salt and stores the ASN and that
+ * hash only (server/abuse/index.js, migrations/0034_abuse.sql).
+ *
+ * A hosting ASN is one whose organisation name says so (hosting, cloud, data centre, a known provider), or one
+ * an operator names in AI_HOSTING_ASNS. The list is deliberately broad: a false positive costs a smaller free
+ * allowance, a false negative lets a data-centre farm through.
+ */
+const fs = require('fs');
+const net = require('net');
+
+const HOSTING_ORG = /(hosting|hosted|cloud|data[ -]?cent(er|re)|datacenter|colocat|colo\b|dedicated|vps|virtual server|serverhub|leaseweb|m247|datacamp|cdn77|equinix|fastly|cloudflare|amazon|aws|google|microsoft|azure|digitalocean|hetzner|ovh|linode|akamai|vultr|choopa|contabo|oracle|alibaba|aliyun|tencent|scaleway|upcloud|ionos|1&1|rack[ -]?space|kamatera|aiven|linode|hostkey|psychz|quadranet|hivelocity|servarica|incero|frantech|buyvm)/i;
+
+/** An IPv4 address as an unsigned 32-bit number, or null. */
+function ipv4ToInt(value) {
+ const s = String(value || '').trim();
+ const parts = s.split('.');
+ if (parts.length !== 4) return null;
+ let out = 0;
+ for (const part of parts) {
+ if (!/^\d{1,3}$/.test(part)) return null;
+ const n = Number(part);
+ if (n > 255) return null;
+ out = (out * 256) + n;
+ }
+ return out >>> 0;
+}
+
+/** An IPv6 address as a BigInt, or null. An embedded IPv4 tail (::ffff:192.0.2.1) is accepted. */
+function ipv6ToBigInt(value) {
+ let s = String(value || '').trim().toLowerCase();
+ if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
+ s = s.split('%')[0]; // zone id, never part of the address
+ if (!s || !s.includes(':')) return null;
+ if (s.includes('.')) {
+ const at = s.lastIndexOf(':');
+ const v4 = ipv4ToInt(s.slice(at + 1));
+ if (v4 == null) return null;
+ s = `${s.slice(0, at + 1)}${((v4 >>> 16) & 0xffff).toString(16)}:${(v4 & 0xffff).toString(16)}`;
+ }
+ const halves = s.split('::');
+ if (halves.length > 2) return null;
+ const head = halves[0] ? halves[0].split(':') : [];
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
+ if (halves.length === 1 && (head.length !== 8 || tail.length)) return null;
+ if (halves.length === 2 && head.length + tail.length > 8) return null;
+ const groups = [];
+ for (let i = 0; i < 8; i++) {
+ if (i < head.length) groups.push(head[i]);
+ else if (i >= 8 - tail.length) groups.push(tail[i - (8 - tail.length)]);
+ else groups.push('0');
+ }
+ let out = 0n;
+ for (const g of groups) {
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
+ out = (out << 16n) | BigInt(parseInt(g, 16));
+ }
+ return out;
+}
+
+/** { family: 'v4' | 'v6', value } for an address, or null. A v4-mapped IPv6 address is treated as v4. */
+function parseIp(value) {
+ const s = String(value || '').trim();
+ if (net.isIP(s) === 4) return { family: 'v4', value: ipv4ToInt(s) };
+ if (net.isIP(s) !== 6) return null;
+ const big = ipv6ToBigInt(s);
+ if (big == null) return null;
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(s);
+ if (mapped) {
+ const v4 = ipv4ToInt(mapped[1]);
+ if (v4 != null) return { family: 'v4', value: v4 };
+ }
+ return { family: 'v6', value: big };
+}
+
+/** The IPv4 /24 or IPv6 /48 an address belongs to, as text (never the address). Null when unparseable. */
+function prefixOf(value) {
+ const p = parseIp(value);
+ if (!p) return null;
+ if (p.family === 'v4') {
+ const base = p.value & 0xffffff00;
+ return { family: 'v4', prefix: `${[(base >>> 24) & 255, (base >>> 16) & 255, (base >>> 8) & 255, 0].join('.')}/24` };
+ }
+ const base = (p.value >> 80n) << 80n;
+ return { family: 'v6', prefix: `${ipv6Text(base)}/48` };
+}
+
+/** BigInt IPv6 → compressed-enough text (the :: rule, lower case). */
+function ipv6Text(value) {
+ const groups = [];
+ for (let i = 7; i >= 0; i--) groups.push(Number((value >> BigInt(i * 16)) & 0xffffn));
+ let bestAt = -1;
+ let bestLen = 0;
+ for (let i = 0; i < 8;) {
+ if (groups[i] !== 0) { i++; continue; }
+ let j = i;
+ while (j < 8 && groups[j] === 0) j++;
+ if (j - i > bestLen) { bestAt = i; bestLen = j - i; }
+ i = j;
+ }
+ const hex = groups.map((g) => g.toString(16));
+ if (bestLen < 2) return hex.join(':');
+ return `${hex.slice(0, bestAt).join(':')}::${hex.slice(bestAt + bestLen).join(':')}`;
+}
+
+function hostingOf({ asn, org } = {}, explicit) {
+ if (asn != null && explicit && explicit.has(Number(asn))) return true;
+ return Boolean(org && HOSTING_ORG.test(String(org)));
+}
+
+/** The parsed rows of one iptoasn file. Missing/unreadable files are an empty table, never a crash. */
+function readRows(file, family, log) {
+ if (!file) return [];
+ let text;
+ try { text = fs.readFileSync(file, 'utf8'); } catch (err) {
+ if (err && err.code !== 'ENOENT') log.warn(`[abuse] cannot read ${file}: ${err.message}`);
+ return [];
+ }
+ const out = [];
+ let bad = 0;
+ for (const line of text.split(/\r?\n/)) {
+ if (!line || line.startsWith('#')) continue;
+ const cols = line.split('\t');
+ if (cols.length < 5) { bad++; continue; }
+ const start = family === 'v4' ? ipv4ToInt(cols[0]) : ipv6ToBigInt(cols[0]);
+ const end = family === 'v4' ? ipv4ToInt(cols[1]) : ipv6ToBigInt(cols[1]);
+ const asn = Number(cols[2]);
+ if (start == null || end == null || !Number.isFinite(asn)) { bad++; continue; }
+ // The org is the last column; an org with a tab in it stays one org.
+ out.push({ start, end, asn, country: cols[3] || '', org: cols.slice(4).join(' ').trim() });
+ }
+ if (bad) log.warn(`[abuse] ${file}: ${bad} malformed rows ignored`);
+ out.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
+ return out;
+}
+
+function makeTable(rows, family) {
+ if (!rows.length) return null;
+ if (family === 'v4') {
+ const starts = new Uint32Array(rows.length);
+ const ends = new Uint32Array(rows.length);
+ const asns = new Uint32Array(rows.length);
+ const orgs = new Array(rows.length);
+ const countries = new Array(rows.length);
+ for (let i = 0; i < rows.length; i++) {
+ starts[i] = rows[i].start; ends[i] = rows[i].end; asns[i] = rows[i].asn; orgs[i] = rows[i].org; countries[i] = rows[i].country;
+ }
+ return { family, starts, ends, asns, orgs, countries, n: rows.length };
+ }
+ return { family, rows, n: rows.length };
+}
+
+function findV4(table, value) {
+ let lo = 0;
+ let hi = table.starts.length - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1;
+ if (value < table.starts[mid]) hi = mid - 1;
+ else if (value > table.ends[mid]) lo = mid + 1;
+ else return { asn: table.asns[mid], country: table.countries[mid], org: table.orgs[mid] };
+ }
+ return null;
+}
+
+function findV6(table, value) {
+ const rows = table.rows;
+ let lo = 0;
+ let hi = rows.length - 1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1;
+ if (value < rows[mid].start) hi = mid - 1;
+ else if (value > rows[mid].end) lo = mid + 1;
+ else return { asn: rows[mid].asn, country: rows[mid].country, org: rows[mid].org };
+ }
+ return null;
+}
+
+/**
+ * createAsnTable({ v4File, v6File, hostingAsns, log }): lazy, process-wide and read-only. `lookup(ip)` never
+ * keeps the address and answers `{ asn: null, country: null, org: null, hosting: false }` when the table has
+ * no row (a missing dataset, a private address, a new range).
+ */
+function createAsnTable({ v4File = '', v6File = '', hostingAsns = [], log = console } = {}) {
+ const explicit = new Set((hostingAsns || []).map(Number).filter((n) => Number.isFinite(n)));
+ let loaded = false;
+ let v4 = null;
+ let v6 = null;
+ function load() {
+ if (loaded) return;
+ loaded = true;
+ v4 = makeTable(readRows(v4File, 'v4', log), 'v4');
+ v6 = makeTable(readRows(v6File, 'v6', log), 'v6');
+ if (!v4 && !v6) log.warn(`[abuse] no IP-to-ASN table loaded (${v4File || '-'}, ${v6File || '-'}): ASN signals are off until scripts/update-ip2asn.js runs`);
+ }
+ function lookup(ip) {
+ load();
+ const p = parseIp(ip);
+ if (!p) return { asn: null, country: null, org: null, hosting: false };
+ const row = p.family === 'v4' ? (v4 && findV4(v4, p.value)) : (v6 && findV6(v6, p.value));
+ if (!row) return { asn: null, country: null, org: null, hosting: false };
+ return { ...row, hosting: hostingOf(row, explicit) };
+ }
+ return {
+ lookup,
+ prefixOf,
+ hostingOf: (row) => hostingOf(row, explicit),
+ status: () => { load(); return { v4_ranges: v4 ? v4.n : 0, v6_ranges: v6 ? v6.n : 0, hosting_asns: explicit.size }; },
+ };
+}
+
+module.exports = { createAsnTable, parseIp, prefixOf, ipv4ToInt, ipv6ToBigInt, ipv6Text, hostingOf, HOSTING_ORG };
new file mode 100644
new file mode 100644
@@ -0,0 +1,435 @@
@@ -0,0 +1,435 @@
+'use strict';
+/**
+ * Plan E5: abuse prevention for the free tier (OpenVibe.AI's server; the Exchange is the service token `exchange`).
+ *
+ * The pieces, each with its own seam in this module:
+ *
+ * trust server/abuse/trust.js Network's signals decide the daily free allowance: a quarter for a
+ * new account, half after 7 days, the full one when verified, plus kudos
+ * network server/abuse/asn.js a client address becomes an ASN and a salted /24 or /48 hash; a hosting
+ * ASN gets the new tier and a lower rate. The address is never stored
+ * device this module a signed, HttpOnly device cookie (or a first-party service forwarding
+ * it); more than three accounts on one device share one allowance
+ * proof server/abuse/pow.js an in-house hashcash challenge for the anonymous Try it, single use
+ * anomaly this module free tokens per minute against the last week's baseline, per ASN,
+ * device and input bucket; above it a 429 and an operator event
+ *
+ * What is stored (migrations/0034_abuse.sql, docs/abuse-prevention.md): keyed hashes of the device id and the
+ * /24–/48 prefix, the ASN, token counters and the challenge hash. No raw address, no raw device id, no prompt.
+ * The prefix row lives 24 h; velocity rows are kept for the 7-day baseline; daily counters are per UTC day.
+ */
+const crypto = require('crypto');
+const net = require('net');
+const express = require('express');
+const { AiError, iso, parseJson, sendError } = require('../util');
+const trust = require('./trust');
+const asnLib = require('./asn');
+const { createNetworkSignals } = require('./network');
+const { createPow } = require('./pow');
+
+const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DAY_MS = 86_400_000;
+const MIN_MS = 60_000;
+const RING_CAP = 2000;
+const COOKIE_MAX_AGE_S = 365 * 24 * 3600;
+
+function isLoopback(address) {
+ const a = String(address || '');
+ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
+}
+
+/** The first address of an `x-forwarded-for`-shaped value (still never stored). */
+function firstAddress(value) {
+ const v = String(value || '').split(',')[0].trim();
+ return net.isIP(v) ? v : null;
+}
+
+function cookiesOf(header) {
+ const out = {};
+ for (const part of String(header || '').split(';')) {
+ const at = part.indexOf('=');
+ if (at > 0) out[part.slice(0, at).trim()] = part.slice(at + 1).trim();
+ }
+ return out;
+}
+
+function createAbuse({ db, config, env = process.env, clock = { now: () => Date.now() }, log = console, registry = null, community = null, fetchImpl = globalThis.fetch, tokens = null } = {}) {
+ const cfg = (config && config.abuse) || {};
+ const enabled = cfg.enabled !== false;
+ // A per-boot salt when the owner has not set one: hashes and device signatures then cannot be linked across
+ // restarts (and a cookie issued by the previous boot is replaced), which is the safe default. Production should
+ // set AI_ABUSE_SALT so signed device cookies survive a deploy.
+ const salt = String(cfg.salt || '').trim() || crypto.randomBytes(32).toString('hex');
+ if (enabled && !String(cfg.salt || '').trim() && config && config.isProduction) {
+ log.warn('[abuse] AI_ABUSE_SALT is not set: hashes cannot be linked across restarts and device cookies are re-issued on deploy');
+ }
+ const keyed = (kind, value) => crypto.createHmac('sha256', salt).update(`${kind}:${value}`).digest('hex');
+ const now = () => clock.now();
+ const baseTokens = Math.max(0, Math.floor(Number(cfg.dailyTokens) || 0));
+ const firstParty = new Set((cfg.firstPartyServices || ['exchange']).map((s) => String(s).trim().toLowerCase()).filter(Boolean));
+ const asn = asnLib.createAsnTable({
+ v4File: cfg.ip2asnV4 || '', v6File: cfg.ip2asnV6 || '', hostingAsns: cfg.hostingAsns || [], log,
+ });
+ const signals = createNetworkSignals({ config, env, fetchImpl, clock, log, tokens });
+ const pow = createPow({ db, clock, bits: cfg.powBits, ttlMs: cfg.powTtlMs, log });
+ const cookieName = String(cfg.deviceCookieName || 'ovai_device').trim() || 'ovai_device';
+ const cookieDomain = String(cfg.deviceCookieDomain || '').trim();
+ const cookieSecure = cfg.deviceCookieSecure !== false;
+ const cookieSameSite = cfg.deviceCookieSameSite || 'Lax';
+
+ // ── Signed device ids ─────────────────────────────────
+ function signDevice(id) {
+ const body = Buffer.from(id, 'utf8').toString('base64url');
+ return `d1.${body}.${keyed('device-sign', body).slice(0, 43)}`;
+ }
+ function verifyDevice(token) {
+ const raw = String(token || '').trim();
+ const m = /^d1\.([A-Za-z0-9_-]{8,64})\.([A-Za-z0-9_-]{16,64})$/.exec(raw);
+ if (!m) return null;
+ const want = keyed('device-sign', m[1]).slice(0, 43);
+ const have = m[2];
+ if (have.length !== want.length) return null;
+ try {
+ if (!crypto.timingSafeEqual(Buffer.from(have), Buffer.from(want))) return null;
+ } catch { return null; }
+ let id;
+ try { id = Buffer.from(m[1], 'base64url').toString('utf8'); } catch { return null; }
+ return /^[A-Za-z0-9_-]{16,64}$/.test(id) ? id : null;
+ }
+ function newDevice() {
+ const id = crypto.randomBytes(18).toString('base64url');
+ return { id, token: signDevice(id) };
+ }
+ function cookieHeader(token) {
+ const parts = [`${cookieName}=${token}`, 'Path=/', 'HttpOnly', `SameSite=${cookieSameSite}`, `Max-Age=${COOKIE_MAX_AGE_S}`];
+ if (cookieDomain) parts.push(`Domain=${cookieDomain}`);
+ if (cookieSecure) parts.push('Secure');
+ return parts.join('; ');
+ }
+
+ // ── Request metadata: no address is kept here, only what a trust decision needs ──
+ const rate = new Map(); // key -> { minute, count }
+ const throttleRing = []; // { at, kind }
+
+ function middleware() {
+ return (req, res, next) => {
+ try {
+ const cookies = cookiesOf(req.headers.cookie);
+ const forwarded = String(req.headers['x-ov-device-id'] || '').trim();
+ let id = verifyDevice(forwarded) || verifyDevice(cookies[cookieName]);
+ if (!id) {
+ const made = newDevice();
+ id = made.id;
+ // A Domain cookie is only valid from a host inside that domain: ai.openvibe.services must not
+ // try to set .openvibe.exchange. Elsewhere the request still gets a device for this call.
+ const host = String(req.hostname || '').toLowerCase();
+ const base = cookieDomain.replace(/^\./, '');
+ if (!base || host === base || host.endsWith(`.${base}`)) {
+ const prev = res.getHeader('Set-Cookie');
+ res.setHeader('Set-Cookie', prev ? [].concat(prev, cookieHeader(made.token)) : [cookieHeader(made.token)]);
+ }
+ }
+ req.abuse = {
+ deviceHash: keyed('device', id),
+ explicitIp: firstAddress(req.headers['x-ov-client-ip']),
+ reqIp: req.ip || null,
+ loopback: isLoopback(req.socket && req.socket.remoteAddress),
+ pow: String(req.headers['x-ov-pow'] || '').trim() || null,
+ at: now(),
+ };
+ } catch (err) {
+ log.warn(`[abuse] request metadata: ${err && err.message}`);
+ req.abuse = { at: now() };
+ }
+ return next();
+ };
+ }
+
+ /** The address a trust decision may use: a first-party service's header, or the loopback proxy's own answer. */
+ function clientIp(meta, principal) {
+ if (!meta) return null;
+ const service = principal && principal.subject && principal.subject.type === 'service' ? principal.subject.id : null;
+ if (meta.explicitIp && service && firstParty.has(String(service).toLowerCase())) return meta.explicitIp;
+ if (meta.loopback) return firstAddress(meta.reqIp);
+ return null;
+ }
+
+ async function touchNetwork(prefixHash, info, at) {
+ const ts = iso(at);
+ await db.prepare(`INSERT INTO abuse_network_seen (prefix_hash, asn, hosting, first_seen, last_seen, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (prefix_hash) DO UPDATE SET asn = excluded.asn, hosting = excluded.hosting,
+ last_seen = excluded.last_seen, expires_at = excluded.expires_at`)
+ .run(prefixHash, info.asn == null ? null : Number(info.asn), info.hosting ? 1 : 0, ts, ts, iso(at + DAY_MS));
+ if (Math.random() < 0.01) await db.prepare('DELETE FROM abuse_network_seen WHERE expires_at < ?').run(ts).catch(() => {});
+ }
+
+ async function networkOf(meta, principal) {
+ const ip = clientIp(meta, principal);
+ if (!ip) return { asn: null, hosting: false, prefixHash: null, asnHash: null };
+ const look = asn.lookup(ip);
+ const prefix = asnLib.prefixOf(ip);
+ const prefixHash = prefix ? keyed('prefix', prefix.prefix) : null;
+ if (prefixHash) await touchNetwork(prefixHash, look, now());
+ return {
+ asn: look.asn == null ? null : Number(look.asn), hosting: Boolean(look.hosting), prefixHash,
+ asnHash: look.asn == null ? (prefixHash ? keyed('asn', `unknown:${prefixHash}`) : null) : keyed('asn', String(look.asn)),
+ };
+ }
+
+ async function touchDevice(deviceHash, subject, allowance, at) {
+ if (!deviceHash || !subject) return;
+ const ts = iso(at);
+ await db.prepare(`INSERT INTO abuse_devices (device_hash, subject, tier_tokens, first_seen, last_seen)
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT (device_hash, subject) DO UPDATE SET last_seen = excluded.last_seen,
+ tier_tokens = COALESCE(excluded.tier_tokens, abuse_devices.tier_tokens)`)
+ .run(deviceHash, subject, allowance == null ? null : Math.max(0, Math.floor(Number(allowance) || 0)), ts, ts);
+ if (Math.random() < 0.01) await db.prepare('DELETE FROM abuse_devices WHERE last_seen < ?').run(iso(at - 180 * DAY_MS)).catch(() => {});
+ }
+
+ async function accountsOn(deviceHash) {
+ const row = await db.prepare('SELECT COUNT(*) AS n, MIN(tier_tokens) AS cap FROM abuse_devices WHERE device_hash = ?').get(deviceHash);
+ return { accounts: Number(row && row.n) || 0, cap: row && row.cap != null ? Number(row.cap) : null };
+ }
+
+ function countRate(key, limit, at) {
+ if (!key) return true;
+ const minute = Math.floor(at / MIN_MS);
+ const hit = rate.get(key);
+ if (!hit || hit.minute !== minute) {
+ rate.set(key, { minute, count: 1 });
+ if (rate.size > 20_000) for (const [k, v] of rate) if (v.minute < minute - 2) rate.delete(k);
+ return true;
+ }
+ hit.count++;
+ return hit.count <= limit;
+ }
+
+ function noteThrottle(kind, at) {
+ throttleRing.push({ at, kind });
+ while (throttleRing.length > RING_CAP) throttleRing.shift();
+ }
+
+ async function audit(action, { reason, kind = null, asn = null, hosting = false, tier = null, shared = false, accounts = null, trace = null } = {}) {
+ noteThrottle(reason || action, now());
+ if (!registry || typeof registry.audit !== 'function') return;
+ try {
+ await registry.audit('system', action, 'abuse', `${reason || action}${kind ? `:${kind}` : ''}`, {
+ trace,
+ metadata: { reason: reason || action, kind, asn, hosting: Boolean(hosting), tier, shared, accounts },
+ });
+ } catch (err) { log.warn(`[abuse] operator event not written: ${err && err.message}`); }
+ }
+
+ /** Free tokens in the current minute against the last week's per-minute baseline, per ASN, device and input. */
+ async function velocityHit(keys, at) {
+ const minute = Math.floor(at / MIN_MS);
+ const since = minute - 7 * 24 * 60;
+ const floor = Math.max(1, Math.floor(Number(cfg.velocityMinTokensPerMin) || 10_000));
+ const factor = Math.max(1, Number(cfg.velocityFactor) || 10);
+ for (const k of keys) {
+ if (!k || !k.key) continue;
+ const cur = await db.prepare('SELECT COALESCE(tokens, 0) AS tokens FROM abuse_velocity WHERE kind = ? AND key_hash = ? AND minute = ?').get(k.kind, k.key, minute);
+ if (!cur || !Number(cur.tokens)) continue;
+ const base = await db.prepare('SELECT COALESCE(SUM(tokens), 0) AS tokens FROM abuse_velocity WHERE kind = ? AND key_hash = ? AND minute >= ? AND minute < ?').get(k.kind, k.key, since, minute);
+ const minutes = 7 * 24 * 60 - 1;
+ const baseline = Number(base && base.tokens) / minutes;
+ const limit = Math.max(floor, baseline * factor);
+ if (Number(cur.tokens) >= limit) {
+ return { kind: k.kind, tokens: Number(cur.tokens), baseline_per_minute: baseline, limit_per_minute: limit };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Admit a run: track the device, then (for a free run) decide the allowance, the rate, the proof of work and
+ * the velocity. Returns the admission recorded on the run context, or null when the run pays its own way.
+ *
+ * free the run has no payer (ctx.freeUse)
+ * anonymous the Exchange's anonymous Try it (no account, no Balance)
+ */
+ async function admit({ principal = null, ctx = {}, request = null, inputHash = null, free = false, anonymous = false, selfKeys = false } = {}) {
+ if (!enabled) return null;
+ const at = now();
+ const meta = request || {};
+ const subject = ctx.onBehalfOf && USR.test(String(ctx.onBehalfOf)) ? String(ctx.onBehalfOf)
+ : (ctx.requesterType === 'user' && USR.test(String(ctx.requesterId || '')) ? String(ctx.requesterId) : null);
+ const deviceHash = meta.deviceHash && /^[0-9a-f]{64}$/.test(String(meta.deviceHash)) ? String(meta.deviceHash) : null;
+ // A device is remembered for every person's run, paid or free: the count is about accounts, not spend.
+ if (deviceHash && subject) await touchDevice(deviceHash, subject, null, at);
+ if (!free && !anonymous) return null;
+ if (ctx.requesterType === 'app') return null;
+
+ const network = await networkOf(meta, principal);
+ let account = { available: false, reason: 'no account' };
+ let kudos = 0;
+ if (subject) {
+ account = await signals.get(subject);
+ if (community && typeof community.kudosBonus === 'function') {
+ try { kudos = Math.max(0, Number(await community.kudosBonus(subject)) || 0); } catch { kudos = 0; }
+ }
+ }
+ const tierInfo = trust.tierFor({ signals: account, kudos, hosting: network.hosting, base: baseTokens });
+ if (deviceHash && subject) await touchDevice(deviceHash, subject, tierInfo.allowance, at);
+ const onDevice = deviceHash ? await accountsOn(deviceHash) : { accounts: 0, cap: null };
+ const shared = onDevice.accounts > 3;
+ const allowance = shared ? Math.min(tierInfo.allowance, onDevice.cap == null ? tierInfo.allowance : onDevice.cap) : tierInfo.allowance;
+ const key = shared ? `device:${deviceHash}` : (subject ? `user:${subject}` : `anon:${deviceHash || network.prefixHash || 'unknown'}`);
+ const admission = {
+ key, day: iso(at).slice(0, 10), subject, anonymous: Boolean(anonymous && !subject),
+ allowance, tier: tierInfo.tier, tier_reasons: tierInfo.reasons, shared, accounts: onDevice.accounts,
+ deviceHash, prefixHash: network.prefixHash, asn: network.asn, asnHash: network.asnHash,
+ hosting: network.hosting, inputKey: inputHash ? keyed('input', String(inputHash)) : null,
+ at,
+ };
+ // L36 Y1: a run that may use the person's own provider key must never be held by the free allowance. The
+ // admission is still returned, so a fallback to free capacity is recorded; the read only skips the refusal.
+ if (selfKeys) return { ...admission, exempt: 'own_key' };
+ const rateKey = deviceHash ? `device:${deviceHash}` : (network.prefixHash ? `prefix:${network.prefixHash}` : `key:${key}`);
+ const limit = trust.rateFor({ hosting: network.hosting, normal: cfg.ratePerMin, hostingRate: cfg.hostingRatePerMin });
+ if (!countRate(rateKey, limit, at) || (network.asnHash && !countRate(`asn:${network.asnHash}`, limit, at))) {
+ await audit('abuse.rate_limited', { reason: network.hosting ? 'hosting' : 'network', kind: 'rate', asn: network.asn, hosting: network.hosting, tier: admission.tier, shared, accounts: onDevice.accounts, trace: ctx.trace || null });
+ throw new AiError(429, 'ai.rate_limited', 'Too many free requests from this network or device right now. Wait a minute and try again.', { retry_after_seconds: 60 });
+ }
+
+ if (admission.anonymous && cfg.powRequired) {
+ if (!meta.pow) {
+ throw new AiError(403, 'ai.pow_required', 'This Try it needs a small proof of work. Ask GET /api/v1/exchange/tryit/challenge for one first.');
+ }
+ const ok = await pow.verify(meta.pow, { deviceHash });
+ if (!ok) throw new AiError(403, 'ai.pow_invalid', 'That proof of work is missing, used, expired or wrong. Ask for a new challenge.');
+ }
+
+ if (subject) {
+ const used = await db.prepare('SELECT COALESCE(tokens, 0) AS tokens FROM abuse_daily_usage WHERE subject = ? AND day = ?').get(key, admission.day);
+ const spent = Number(used && used.tokens) || 0;
+ if (allowance <= 0 || spent >= allowance) {
+ const next = Date.parse(`${admission.day}T00:00:00.000Z`) + DAY_MS;
+ throw new AiError(402, 'ai.allowance_spent', allowance <= 0
+ ? 'The free AI allowance is switched off on this service.'
+ : `The free AI allowance for today is spent (${spent} of ${allowance} tokens). It resets at midnight UTC, or use your own key.`,
+ { retry_after_seconds: Math.max(1, Math.ceil((next - at) / 1000)), allowance: { tier: admission.tier, tokens: allowance, used: spent, shared } });
+ }
+ }
+
+ const keys = [
+ { kind: 'asn', key: admission.asnHash },
+ { kind: 'device', key: deviceHash },
+ { kind: 'input', key: admission.inputKey },
+ ].filter((k) => k.key);
+ const hit = await velocityHit(keys, at);
+ if (hit) {
+ await audit('abuse.throttled', { reason: 'velocity', kind: hit.kind, asn: network.asn, hosting: network.hosting, tier: admission.tier, shared, accounts: onDevice.accounts, trace: ctx.trace || null });
+ throw new AiError(429, 'ai.abuse_throttled', 'Free use from this network or device is going much faster than usual, so it is paused for a moment. Wait a minute and try again.', {
+ retry_after_seconds: 60, abuse: { reason: 'velocity', kind: hit.kind, limit_per_minute: Math.floor(hit.limit_per_minute), baseline_per_minute: Number(hit.baseline_per_minute.toFixed(2)) },
+ });
+ }
+ return admission;
+ }
+
+ /** Record what a finished free run spent: its allowance counter and the three velocity buckets. */
+ async function record({ admission, tokens = 0, inputHash = null } = {}) {
+ if (!enabled || !admission || !admission.key) return;
+ const n = Math.max(0, Math.floor(Number(tokens) || 0));
+ if (!n) return;
+ const at = now();
+ const ts = iso(at);
+ const day = admission.day || iso(at).slice(0, 10);
+ if (admission.subject) {
+ await db.prepare(`INSERT INTO abuse_daily_usage (subject, day, tokens, updated_at) VALUES (?, ?, ?, ?)
+ ON CONFLICT (subject, day) DO UPDATE SET tokens = abuse_daily_usage.tokens + excluded.tokens, updated_at = excluded.updated_at`)
+ .run(admission.key, day, n, ts);
+ }
+ const minute = Math.floor(at / MIN_MS);
+ const keys = [
+ { kind: 'asn', key: admission.asnHash },
+ { kind: 'device', key: admission.deviceHash },
+ { kind: 'input', key: inputHash ? keyed('input', String(inputHash)) : admission.inputKey },
+ ].filter((k) => k.key);
+ for (const k of keys) {
+ await db.prepare(`INSERT INTO abuse_velocity (kind, key_hash, minute, tokens, updated_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (kind, key_hash, minute) DO UPDATE SET tokens = abuse_velocity.tokens + excluded.tokens, updated_at = excluded.updated_at`)
+ .run(k.kind, k.key, minute, n, ts);
+ }
+ if (Math.random() < 0.01) await db.prepare('DELETE FROM abuse_velocity WHERE minute < ?').run(minute - 8 * 24 * 60).catch(() => {});
+ }
+
+ async function issueChallenge({ meta = {}, principal = null } = {}) {
+ if (!enabled) throw new AiError(503, 'ai.abuse_unavailable', 'The free tier is not available on this service.');
+ const network = await networkOf(meta, principal);
+ const rateKey = meta.deviceHash ? `challenge:device:${meta.deviceHash}` : `challenge:prefix:${network.prefixHash || 'unknown'}`;
+ if (!countRate(rateKey, Math.max(1, Number(cfg.powChallengesPerMinute) || 10), now())) {
+ throw new AiError(429, 'ai.rate_limited', 'Too many challenge requests right now. Wait a minute and try again.', { retry_after_seconds: 60 });
+ }
+ return pow.issue({ deviceHash: meta.deviceHash || null, prefixHash: network.prefixHash || null });
+ }
+
+ /** Hourly housekeeping: every table has an expiry; the daily counters keep 30 days for a month's view. */
+ async function prune() {
+ const at = now();
+ const out = {};
+ for (const [name, sql, arg] of [
+ ['network', 'DELETE FROM abuse_network_seen WHERE expires_at < ?', iso(at)],
+ ['pow', 'DELETE FROM abuse_pow WHERE expires_at < ?', iso(at - DAY_MS)],
+ ['velocity', 'DELETE FROM abuse_velocity WHERE minute < ?', Math.floor(at / MIN_MS) - 8 * 24 * 60],
+ ['daily', 'DELETE FROM abuse_daily_usage WHERE day < ?', iso(at - 30 * DAY_MS).slice(0, 10)],
+ ['devices', 'DELETE FROM abuse_devices WHERE last_seen < ?', iso(at - 180 * DAY_MS)],
+ ]) {
+ try { out[name] = (await db.prepare(sql).run(arg)).changes || 0; } catch (err) { log.warn(`[abuse] prune ${name}: ${err && err.message}`); }
+ }
+ return out;
+ }
+
+ /** The operator console page's read: recent events, today's counters and the current signal sizes. */
+ async function overview({ limit = 50 } = {}) {
+ const at = now();
+ const day = iso(at).slice(0, 10);
+ const since = iso(at - DAY_MS);
+ const events = await db.prepare("SELECT id, at, action, target_id, metadata FROM audit_log WHERE action IN ('abuse.throttled', 'abuse.rate_limited') AND at >= ? ORDER BY id DESC LIMIT ?").all(since, Math.min(200, Math.max(1, limit)));
+ const usage = await db.prepare('SELECT subject, tokens FROM abuse_daily_usage WHERE day = ? ORDER BY tokens DESC LIMIT ?').all(day, Math.min(100, limit));
+ const devices = await db.prepare('SELECT COUNT(*) AS n FROM abuse_devices').get();
+ const shared = await db.prepare('SELECT COUNT(*) AS n FROM (SELECT device_hash FROM abuse_devices GROUP BY device_hash HAVING COUNT(*) > 3) x').get();
+ const network = await db.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(hosting), 0) AS hosting FROM abuse_network_seen WHERE expires_at > ?').get(iso(at));
+ const pow = await db.prepare('SELECT COUNT(*) AS n FROM abuse_pow WHERE used_at IS NULL AND expires_at > ?').get(iso(at));
+ return {
+ day,
+ events: events.map((e) => ({ id: Number(e.id), at: e.at, action: e.action, target: e.target_id, metadata: parseJson(e.metadata, {}) })),
+ usage: usage.map((r) => ({ subject: r.subject, tokens: Number(r.tokens) })),
+ devices: Number(devices && devices.n) || 0,
+ shared_devices: Number(shared && shared.n) || 0,
+ network: { prefixes_24h: Number(network && network.n) || 0, hosting: Number(network && network.hosting) || 0 },
+ pow: Number(pow && pow.n) || 0,
+ asn_table: asn.status(),
+ signals: signals.enabled,
+ };
+ }
+
+ /** The Prometheus gauge: events in the last 15 minutes, by reason (0 when nothing happened). */
+ function gaugeValues() {
+ const cutoff = now() - 15 * MIN_MS;
+ const out = new Map();
+ for (const e of throttleRing) if (e.at >= cutoff) out.set(e.kind, (out.get(e.kind) || 0) + 1);
+ return ['velocity', 'rate', 'hosting', 'network'].map((reason) => ({ labels: { reason }, value: out.get(reason) || 0 }));
+ }
+
+ /** A public endpoint only: the anonymous Try it gets its challenge here. */
+ function router() {
+ const r = express.Router();
+ r.post('/api/v1/exchange/tryit/challenge', async (req, res) => {
+ try {
+ res.json(await issueChallenge({ meta: req.abuse || {}, principal: req.principal || null }));
+ } catch (err) {
+ sendError(res, err, req.ov, log);
+ }
+ });
+ return r;
+ }
+
+ return {
+ enabled, middleware, admit, record, issueChallenge, overview, gaugeValues, router, prune,
+ // The tests and the docs use these; nothing else should.
+ _test: { signDevice, verifyDevice, keyed, asn, signals, pow, cookieHeader, clientIp, velocityHit, deviceTable: db },
+ };
+}
+
+module.exports = { createAbuse, firstAddress, isLoopback, cookiesOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * Plan E5: the trust signals Network supplies through its existing identity read, cached in AI.
+ *
+ * GET {OV_NETWORK_INTERNAL_URL}/internal/identity/resolve?subject_id=usr_…&trust=1
+ * Authorization: a service token with identity.subject.resolve
+ *
+ * The answer is the usual projection plus `trust`:
+ * { account_age_hours, account_age_days, verified, verified_by, passkey, linked_accounts, active_30d }
+ *
+ * Network computes the facts (server/identity/trust.js): a passkey backup, a linked account, or an account 30
+ * days old that was active in the last 30 days. AI never asks for an email or a linked account's identity.
+ * A miss is cached briefly and treated as unavailable: the allowance falls back to the new tier, the
+ * conservative side, not to a remembered "verified".
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function numberOf(value, max = 1e9) {
+ const n = Number(value);
+ return Number.isFinite(n) && n >= 0 ? Math.min(max, n) : 0;
+}
+
+/** Only the fields the allowance uses, with sane types; anything else is dropped. */
+function normalize(trust) {
+ if (!trust || typeof trust !== 'object') return null;
+ const by = Array.isArray(trust.verified_by) ? trust.verified_by.filter((x) => typeof x === 'string' && x).slice(0, 8) : [];
+ return {
+ account_age_hours: numberOf(trust.account_age_hours, 100 * 365 * 24),
+ account_age_days: numberOf(trust.account_age_days, 36500),
+ verified: trust.verified === true || trust.verified === 1,
+ verified_by: by,
+ passkey: trust.passkey === true,
+ linked_accounts: numberOf(trust.linked_accounts, 100),
+ active_30d: trust.active_30d === true,
+ };
+}
+
+/**
+ * createNetworkSignals({ config, env, fetchImpl, clock, log, tokens }): one cached read per subject.
+ * `tokens` is a service-token client (tests pass a stub); production builds one from OV_OAUTH_CLIENT_SECRET.
+ */
+function createNetworkSignals({ config, env = process.env, fetchImpl = globalThis.fetch, clock = { now: () => Date.now() }, log = console, tokens = null } = {}) {
+ const cfg = (config && config.abuse) || {};
+ const network = String((config && config.networkInternalUrl) || '').replace(/\/+$/, '');
+ const secret = env.OV_OAUTH_CLIENT_SECRET || '';
+ const clientId = (config && config.console && config.console.clientId) || env.OV_OAUTH_CLIENT_ID || 'ai';
+ let client = tokens;
+ if (!client && secret && network && cfg.network) {
+ client = createServiceTokenClient({
+ tokenUrl: `${network}/oauth/token`, clientId, clientSecret: secret,
+ scope: { 'openvibe.network': 'identity.subject.resolve' }, fetch: fetchImpl,
+ });
+ }
+ const ttlMs = Math.max(1000, Number(cfg.signalsTtlMs) || 300_000);
+ const missTtlMs = Math.max(1000, Math.min(ttlMs, Number(cfg.signalsMissTtlMs) || 60_000));
+ const timeoutMs = Math.max(100, Number(cfg.signalsTimeoutMs) || 2000);
+ const enabled = Boolean(cfg.network && network && client);
+ const cache = new Map();
+ const inflight = new Map();
+
+ async function read(subject) {
+ const token = await client.getToken({ audience: 'openvibe.network' });
+ const url = `${network}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}&trust=1`;
+ const res = await fetchImpl(url, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!res.ok) throw new Error(`Network identity read answered ${res.status}`);
+ const body = await res.json().catch(() => null);
+ const trust = normalize(body && body.trust);
+ if (!trust) throw new Error('Network identity read carried no trust object');
+ return { available: true, ...trust };
+ }
+
+ /** The signals for a person subject. Never throws: an outage is `{ available: false }`. */
+ async function get(subject) {
+ const id = String(subject || '');
+ if (!USR.test(id)) return { available: false, reason: 'not a person' };
+ if (!enabled) return { available: false, reason: 'disabled' };
+ const hit = cache.get(id);
+ const now = clock.now();
+ if (hit && now - hit.at < (hit.value.available ? ttlMs : missTtlMs)) return hit.value;
+ if (inflight.has(id)) return inflight.get(id);
+ const p = (async () => {
+ let value;
+ try { value = await read(id); } catch (err) {
+ if (cfg.logMisses !== false) log.warn(`[abuse] the Network trust read failed: ${err && err.message}`);
+ value = { available: false, reason: 'network' };
+ }
+ cache.set(id, { at: now, value });
+ inflight.delete(id);
+ return value;
+ })();
+ inflight.set(id, p);
+ return p;
+ }
+
+ return { enabled, get, normalize, _cache: cache };
+}
+
+module.exports = { createNetworkSignals, normalize, USR };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * Plan E5: an in-house hashcash challenge for the anonymous Try it (no account), never a third-party captcha.
+ *
+ * issue(meta) → { challenge, bits, expires_at } a random challenge, stored as sha256(challenge) only
+ * verify(meta, token) → boolean one accepted solution, single use
+ *
+ * A solution is `sha256("<challenge>:<nonce>")` with `bits` leading zero bits. The client finds the nonce; on a
+ * phone about 2^18 hashes is roughly a second, and the difficulty is AI_POW_BITS. The challenge carries no
+ * address: the row keeps the keyed device and /24–/48 hashes the caller passed in, so an issued challenge can
+ * be rate-limited and audited without storing who asked.
+ */
+const crypto = require('crypto');
+
+const HEX = '0123456789abcdef';
+
+/** Leading zero bits of a digest. */
+function zeroBits(buffer) {
+ let n = 0;
+ for (const byte of buffer) {
+ if (byte === 0) { n += 8; continue; }
+ let b = byte;
+ while ((b & 0x80) === 0) { n++; b = (b << 1) & 0xff; }
+ return n;
+ }
+ return n;
+}
+
+function digest(challenge, nonce) {
+ return crypto.createHash('sha256').update(`${challenge}:${nonce}`).digest();
+}
+
+/** A nonce that solves the challenge, for tests, the docs and any server-side caller. */
+function solve(challenge, bits) {
+ for (let nonce = 0; ; nonce++) {
+ if (zeroBits(digest(challenge, nonce)) >= bits) return String(nonce);
+ }
+}
+
+function createPow({ db, clock = { now: () => Date.now() }, bits = 18, ttlMs = 300_000, log = console } = {}) {
+ const ins = db.prepare(`INSERT INTO abuse_pow (challenge_hash, device_hash, prefix_hash, issued_at, expires_at)
+ VALUES (?, ?, ?, ?, ?)`);
+ const take = db.prepare(`UPDATE abuse_pow SET used_at = ?
+ WHERE challenge_hash = ? AND used_at IS NULL AND expires_at > ?`);
+ const get = db.prepare('SELECT * FROM abuse_pow WHERE challenge_hash = ?');
+ const clean = db.prepare('DELETE FROM abuse_pow WHERE expires_at < ?');
+
+ /** { challenge, bits, expires_at } and the challenge hash stored; never the raw challenge in the database. */
+ async function issue({ deviceHash = null, prefixHash = null } = {}) {
+ const challenge = crypto.randomBytes(16).toString('hex');
+ const now = clock.now();
+ const expires = now + Math.max(30_000, Math.min(3_600_000, Number(ttlMs) || 0));
+ await ins.run(crypto.createHash('sha256').update(challenge).digest('hex'), deviceHash, prefixHash, new Date(now).toISOString(), new Date(expires).toISOString());
+ // Housekeeping rides the issuance: an expired row is useless and would grow without bound.
+ await clean.run(new Date(now - 86_400_000).toISOString()).catch(() => {});
+ return { challenge, bits: Math.max(1, Math.min(28, Number(bits) || 18)), expires_at: new Date(expires).toISOString() };
+ }
+
+ /**
+ * One accepted solution. `token` is `<challenge>:<nonce>` (the challenge is public; the nonce is the work).
+ * The update is the single-use check: two requests racing one solution, only one UPDATE changes a row.
+ */
+ async function verify(token, { deviceHash = null } = {}) {
+ const raw = String(token || '').trim();
+ const at = raw.indexOf(':');
+ if (at <= 0) return false;
+ const challenge = raw.slice(0, at);
+ const nonce = raw.slice(at + 1);
+ if (!/^[0-9a-f]{16,128}$/i.test(challenge) || !/^[0-9]{1,20}$/.test(nonce)) return false;
+ const hash = crypto.createHash('sha256').update(challenge).digest('hex');
+ const row = await get.get(hash);
+ if (!row || row.used_at) return false;
+ const now = clock.now();
+ if (Date.parse(row.expires_at) <= now) return false;
+ if (!(zeroBits(digest(challenge, nonce)) >= Math.max(1, Number(bits) || 18))) return false;
+ const changed = await take.run(new Date(now).toISOString(), hash, new Date(now).toISOString());
+ if (!changed || !changed.changes) return false;
+ // A solution issued for one device belongs to that device when both sides know one.
+ if (row.device_hash && deviceHash && row.device_hash !== deviceHash) return false;
+ return true;
+ }
+
+ return { issue, verify, bits: () => Math.max(1, Math.min(28, Number(bits) || 18)), zeroBits, solve };
+}
+
+module.exports = { createPow, zeroBits, solve, digest };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/**
+ * Plan E5: the daily free allowance grows with trust (server/abuse/index.js decides when it is spent).
+ *
+ * The tiers, as the plan states them:
+ *
+ * new an account under 24 h old, unverified: a quarter of the base allowance
+ * established an account over 7 days old, unverified: half
+ * verified a passkey, a linked account, or 30 days with activity: the full allowance
+ * contributor a verified volunteer whose kudos (server/community/) buy more: up to one more base
+ *
+ * A hosting/data-centre ASN (the network signal) is always the new tier and a lower rate, whatever the account
+ * says: a data centre is not a person's home, and a farm can make accounts look old. If Network cannot answer,
+ * the account is treated as new: the conservative side, never the generous one.
+ *
+ * The result is a token budget, never money: the allowance caps how many free tokens a day the account (or the
+ * accounts sharing one device) may claim. It does not price anything (server/free-allowance.js does that).
+ */
+
+const HOUR = 3600_000;
+const DAY = 24 * HOUR;
+
+const TIERS = Object.freeze({
+ new: Object.freeze({ factor: 0.25, label: 'New account (under 24 hours)' }),
+ established: Object.freeze({ factor: 0.5, label: 'Established account (over 7 days)' }),
+ verified: Object.freeze({ factor: 1, label: 'Verified account' }),
+});
+
+/** A whole, non-negative token count; anything unusable becomes 0. */
+function tokensOf(value) {
+ const n = Math.floor(Number(value));
+ return Number.isFinite(n) && n > 0 ? n : 0;
+}
+
+/**
+ * The tier an account is in, from Network's trust signals (server/identity/trust.js) and the AI-side kudos.
+ *
+ * signals { available, account_age_hours, verified, verified_by, ... } | null
+ * kudos the volunteer's free-allowance bonus in tokens (community.kudosBonus), 0 when none
+ * hosting the request came from a hosting/data-centre ASN
+ * base AI_FREE_DAILY_TOKENS (the full allowance)
+ *
+ * → { tier, allowance, base, kudos_bonus, reasons, hosting }
+ */
+function tierFor({ signals = null, kudos = 0, hosting = false, base = 0 } = {}) {
+ const full = tokensOf(base);
+ const reasons = [];
+ let tier;
+ if (hosting) {
+ tier = 'new';
+ reasons.push('the client address is in a hosting/data-centre ASN');
+ } else if (signals && signals.available && signals.verified) {
+ tier = 'verified';
+ reasons.push(`verified: ${(signals.verified_by || []).join(', ') || 'trusted account'}`);
+ } else if (signals && signals.available && Number(signals.account_age_hours) >= 7 * 24) {
+ tier = 'established';
+ reasons.push('the account is over 7 days old');
+ } else {
+ tier = 'new';
+ reasons.push(signals && signals.available ? 'the account is under 24 hours old or unverified' : 'Network could not vouch for the account');
+ }
+ let allowance = Math.floor(full * TIERS[tier].factor);
+ // A volunteer's kudos buy at most one more base allowance, and only on top of the full tier: never a way
+ // around a hosting ASN or an unverified account.
+ const kudosBonus = tier === 'verified' ? Math.min(full, tokensOf(kudos)) : 0;
+ if (kudosBonus) {
+ allowance += kudosBonus;
+ reasons.push(`kudos add ${kudosBonus} tokens`);
+ }
+ if (!full) reasons.push('the free allowance is switched off (AI_FREE_DAILY_TOKENS=0)');
+ return { tier, allowance, base: full, kudos_bonus: kudosBonus, reasons, hosting: Boolean(hosting) };
+}
+
+/** The lower request rate a hosting ASN gets, whatever the account says (server/abuse/index.js). */
+function rateFor({ hosting = false, normal = 60, hostingRate = 20 } = {}) {
+ return hosting ? Math.max(1, Math.floor(Number(hostingRate) || 0)) : Math.max(1, Math.floor(Number(normal) || 0));
+}
+
+module.exports = { TIERS, HOUR, DAY, tierFor, rateFor, tokensOf };
@@ -49,6 +49,11 @@ const TABLES = [
@@ -49,6 +49,11 @@ const TABLES = [
{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },
{ table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },
+ // Plan E5: the free allowance's daily counter (keyed `user:<subject>` or `device:<hash>`) and the accounts seen
+ // on a device. Both are erased by extraErase below, which must see the device rows first to find the shared
+ // counter; listing them here is what makes the guard (and the export count) know they exist.
+ { table: 'abuse_daily_usage', subject: 'subject', value: (usr) => `user:${usr}`, file: null, erase: { keep: 'erased by extraErase after the device rows are read' } },
+ { table: 'abuse_devices', subject: 'subject', file: null, erase: { keep: 'erased by extraErase after the shared counter is found' } },
// The person's own quota (server/quota.js): a limit that only means something while the account lives.
// The person's own quota (server/quota.js): a limit that only means something while the account lives.
{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
{ table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
// What the person used, counted against their allowance: the spend ledger, kept.
// What the person used, counted against their allowance: the spend ledger, kept.
@@ -67,6 +72,17 @@ async function extraExport(db, subject) {
@@ -67,6 +72,17 @@ async function extraExport(db, subject) {
/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */
/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */
async function extraErase(t, subjects, counts) {
async function extraErase(t, subjects, counts) {
+ // Plan E5: the free-tier counters. A shared device counter is kept while another account still uses the device;
+ // when this person was the last one, the device counter and its velocity bucket go too.
+ const hashes = (await t.many('SELECT DISTINCT device_hash FROM abuse_devices WHERE subject = ANY($1::text[])', [subjects])).map((r) => r.device_hash);
+ counts.add(counts.erased, 'abuse_devices', await t.exec('DELETE FROM abuse_devices WHERE subject = ANY($1::text[])', [subjects]));
+ counts.add(counts.erased, 'abuse_daily_usage', await t.exec('DELETE FROM abuse_daily_usage WHERE subject = ANY($1::text[])', [subjects.map((s) => `user:${s}`)]));
+ for (const hash of hashes) {
+ const left = Number(await t.value('SELECT COUNT(*)::int FROM abuse_devices WHERE device_hash = $1', [hash])) || 0;
+ if (left) continue;
+ counts.add(counts.erased, 'abuse_daily_usage', await t.exec('DELETE FROM abuse_daily_usage WHERE subject = $1', [`device:${hash}`]));
+ counts.add(counts.erased, 'abuse_velocity', await t.exec("DELETE FROM abuse_velocity WHERE kind = 'device' AND key_hash = $1", [hash]));
+ }
const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);
const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);
if (!ids.length) return;
if (!ids.length) return;
counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));
counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));
@@ -232,7 +232,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
@@ -232,7 +232,7 @@ function messagesRouter({ runs, auth, config, registry, presets = null, receipts
const live = b.stream === true ? createEventStream(res) : null;
const live = b.stream === true ? createEventStream(res) : null;
let run;
let run;
try {
try {
- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,
+ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
...(live ? { onDelta: live.event } : {}),
...(live ? { onDelta: live.event } : {}),
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
if (live) live.open(created.run);
if (live) live.open(created.run);
@@ -235,7 +235,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
@@ -235,7 +235,7 @@ function openaiRouter({ runs, auth, config, registry, presets = null, receipts =
// delta the upstream sends goes out as it arrives. A delta that came before the stream opened waits for it.
// delta the upstream sends goes out as it arrives. A delta that came before the stream opened waits for it.
const live = b.stream === true ? createLiveStream(res) : null;
const live = b.stream === true ? createLiveStream(res) : null;
try {
try {
- const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId,
+ const created = await runs.create(body, req.principal, { trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, abuse: req.abuse,
...(live ? { onDelta: live.delta } : {}),
...(live ? { onDelta: live.delta } : {}),
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
...(live && config.modelCatalog && config.modelCatalog.enabled ? { onRoute: (info) => live.route(info) } : {}) });
if (live) live.open(created.run);
if (live) live.open(created.run);
@@ -64,7 +64,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -64,7 +64,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
if (req.principal.kind === 'app') body = appBody(req.principal, body, req.body);
checkNamespace(req, String(body.workflow || ''));
checkNamespace(req, String(body.workflow || ''));
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
const idem = body.idempotency_key || req.ov.idempotencyKey || undefined;
- const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId });
+ const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, abuse: req.abuse });
const run = await runs.explained(await runs.wait(created, waitMs));
const run = await runs.explained(await runs.wait(created, waitMs));
const status = !created.created ? 200 : (runs.TERMINAL.has(run.status) ? 201 : 202);
const status = !created.created ? 200 : (runs.TERMINAL.has(run.status) ? 201 : 202);
if (status === 202) res.setHeader('Location', `/api/v1/runs/${run.id}`);
if (status === 202) res.setHeader('Location', `/api/v1/runs/${run.id}`);
@@ -88,7 +88,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
@@ -88,7 +88,7 @@ function runsRouter({ runs, registry, auth, config, log = console }) {
if (!text) return;
if (!text) return;
if (opened) send('delta', { text }); else early.push(text);
if (opened) send('delta', { text }); else early.push(text);
};
};
- const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, onDelta });
+ const created = await runs.create({ ...body, idempotency_key: idem }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, onDelta, abuse: req.abuse });
res.status(200);
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
@@ -24,7 +24,7 @@ const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null, balance = null, gpus = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, settings = null, benchEvents = null, runBudgets = null, memory = null, memoryImport = null, community = null, presets = null, receipts = null, balance = null, gpus = null, abuse = null }) {
const app = express();
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
require('openvibe-shared/agent-layer').mount(app, { descriptor: require('./agent-descriptor') });
@@ -36,8 +36,14 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -36,8 +36,14 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// GET /metrics answers direct loopback callers only (Track O).
// GET /metrics answers direct loopback callers only (Track O).
const metrics = instrument(app, { service: 'ai', release: release.release });
const metrics = instrument(app, { service: 'ai', release: release.release });
app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()
app.use(require('openvibe-sdk/service').telemetryMiddleware); // request telemetry: a no-op until server/index.js calls telemetry.init()
- registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus });
+ registerAiGauges(metrics.registry, { runs, registry, pool, balance, gpus, abuse });
app.use(http.middleware());
app.use(http.middleware());
+ // Plan E5: the signed HttpOnly device id and the request's trust metadata on the model and Exchange APIs.
+ // The address/device stay in the request: only keyed hashes reach the database (server/abuse/).
+ if (abuse) {
+ app.use(['/v1', '/api/v1'], abuse.middleware());
+ app.use(abuse.router());
+ }
// Browser reads of the two public GETs (/stats, /release.json) only: an exact origin from
// Browser reads of the two public GETs (/stats, /release.json) only: an exact origin from
// AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.
// AI_CORS_ORIGINS is echoed back, never credentials, and OPTIONS answers their preflight.
// Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.
// Token (/api/v1/*) and console (/console, /auth/*) routes never reach this branch.
@@ -168,7 +174,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -168,7 +174,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log, machines: pool && pool.machines }));
}
}
- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions }));
+ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* NAME through secretRef(); nothing here logs or returns a value.
* NAME through secretRef(); nothing here logs or returns a value.
*/
*/
+const path = require('path');
const { loadGovern } = require('./govern');
const { loadGovern } = require('./govern');
const { DEFAULT_PROVIDERS: DEFAULT_FREE_PROVIDERS } = require('./free-tier');
const { DEFAULT_PROVIDERS: DEFAULT_FREE_PROVIDERS } = require('./free-tier');
@@ -148,6 +149,46 @@ function load(env = process.env) {
@@ -148,6 +149,46 @@ function load(env = process.env) {
providers: list(env.AI_FREE_PROVIDERS, DEFAULT_FREE_PROVIDERS),
providers: list(env.AI_FREE_PROVIDERS, DEFAULT_FREE_PROVIDERS),
maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1.5)),
maxOutputPricePerMtok: Math.max(0, float(env.AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK, 1.5)),
},
},
+ // ── Plan E5: abuse prevention for the free tier (server/abuse/) ──
+ // A signed HttpOnly device id (set by the AI Exchange API host for openvibe.exchange), the local
+ // iptoasn.com table (scripts/update-ip2asn.js; never a paid API), the daily allowance the trust tiers
+ // scale, and the anomaly limits. Network signals are read from Network's identity route and cached.
+ abuse: {
+ enabled: bool(env.AI_ABUSE, true),
+ // The full (verified) daily allowance in free tokens: input + output. Zero switches the free allowance off.
+ dailyTokens: Math.max(0, int(env.AI_FREE_DAILY_TOKENS, 20000)),
+ // Network's trust signals (server/abuse/network.js). Off in tests unless a test opts in.
+ network: bool(env.AI_ABUSE_NETWORK, nodeEnv !== 'test'),
+ signalsTtlMs: Math.max(1000, int(env.AI_ABUSE_SIGNALS_TTL_MS, 300000)),
+ signalsMissTtlMs: Math.max(1000, int(env.AI_ABUSE_SIGNALS_MISS_TTL_MS, 60000)),
+ signalsTimeoutMs: Math.max(100, int(env.AI_ABUSE_SIGNALS_TIMEOUT_MS, 2000)),
+ // The HMAC key for the device cookie and every salted hash. Unset: a per-boot key (device cookies do not
+ // survive a restart, and the hashes cannot be linked across boots). Set it in production.
+ salt: String(env.AI_ABUSE_SALT || '').trim(),
+ // Only a service token naming one of these ids may forward the client address (x-ov-client-ip) or a
+ // signed device id (x-ov-device-id). The Exchange is the first-party caller that does.
+ firstPartyServices: list(env.AI_FIRST_PARTY_SERVICES, ['exchange']),
+ deviceCookieName: String(env.AI_DEVICE_COOKIE_NAME || 'ovai_device').trim(),
+ // openvibe.exchange: the API host sets the cookie for the parent domain, so the site and the API share it.
+ deviceCookieDomain: String(env.AI_DEVICE_COOKIE_DOMAIN || '').trim(),
+ deviceCookieSecure: bool(env.AI_DEVICE_COOKIE_SECURE, baseUrl.startsWith('https://')),
+ deviceCookieSameSite: String(env.AI_DEVICE_COOKIE_SAMESITE || 'Lax').trim(),
+ ip2asnV4: String(env.AI_IP2ASN_V4 || path.join(__dirname, '..', 'data', 'ip2asn', 'ip2asn-v4.tsv')).trim(),
+ ip2asnV6: String(env.AI_IP2ASN_V6 || path.join(__dirname, '..', 'data', 'ip2asn', 'ip2asn-v6.tsv')).trim(),
+ // ASNs an operator knows are hosting ones, on top of the organisation-name match (server/abuse/asn.js).
+ hostingAsns: list(env.AI_HOSTING_ASNS, []),
+ ratePerMin: Math.max(1, int(env.AI_ABUSE_RATE_PER_MIN, 60)),
+ hostingRatePerMin: Math.max(1, int(env.AI_ABUSE_HOSTING_RATE_PER_MIN, 20)),
+ // Anomaly: free tokens in a minute against the last week's per-minute baseline, per ASN, device and input.
+ velocityFactor: Math.max(1, float(env.AI_ABUSE_VELOCITY_FACTOR, 10)),
+ velocityMinTokensPerMin: Math.max(1, int(env.AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN, 10000)),
+ // Proof of work for the anonymous Try it (no account). Required in production; a test opts in.
+ powRequired: bool(env.AI_POW_REQUIRED, nodeEnv !== 'test'),
+ powBits: Math.max(8, Math.min(28, int(env.AI_POW_BITS, 18))),
+ powTtlMs: Math.max(30000, int(env.AI_POW_TTL_MS, 300000)),
+ powChallengesPerMinute: Math.max(1, int(env.AI_POW_CHALLENGES_PER_MIN, 10)),
+ },
+
// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──
// ── Placement (T6 provider router): openvibe-sdk/placement orders a route's capability pool ──
placement: {
placement: {
// A candidate replaces the route's current placement only when it scores this much better,
// A candidate replaces the route's current placement only when it scores this much better,
@@ -110,7 +110,7 @@ const DEF_KINDS = {
@@ -110,7 +110,7 @@ const DEF_KINDS = {
};
};
const DAY_MS = 86400000;
const DAY_MS = 86400000;
-function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null }) {
+function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null, settings = null, personSessions = null, abuse = null }) {
const cc = config.console;
const cc = config.console;
const creditsOn = Boolean(config.credits && config.credits.enabled);
const creditsOn = Boolean(config.credits && config.credits.enabled);
const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
const routingOn = Boolean(config.routingEvidence && config.routingEvidence.enabled);
@@ -600,6 +600,14 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -600,6 +600,14 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
return res.redirect(303, `/console/cache?done=cache_purged&removed=${removed}`);
return res.redirect(303, `/console/cache?done=cache_purged&removed=${removed}`);
}));
}));
+ // ── Abuse prevention (plan E5) ───────────────────────────
+ // A read-only page: the counters, the throttles the module already recorded in the audit log, and the size of
+ // the network/device signals. The actions (throttling, allowance) are automatic; this is where a person looks.
+ r.get('/console/abuse', read, wrap(async (req, res) => {
+ const report = abuse ? await abuse.overview() : null;
+ await send(res, 200, pages.abuse({ ...common(req), report }));
+ }));
+
// ── Audit ────────────────────────────────────────────────
// ── Audit ────────────────────────────────────────────────
r.get('/console/audit', read, wrap(async (req, res) => {
r.get('/console/audit', read, wrap(async (req, res) => {
const { filters, form, bad } = q.parseAuditFilters(req.query);
const { filters, form, bad } = q.parseAuditFilters(req.query);
@@ -146,6 +146,7 @@ const AUDIT_KINDS = Object.freeze({
@@ -146,6 +146,7 @@ const AUDIT_KINDS = Object.freeze({
runs: { label: 'Runs', where: "target_type = 'run'" },
runs: { label: 'Runs', where: "target_type = 'run'" },
exchange: { label: 'AI Exchange', where: "target_type IN ('credit_settle', 'routing_incumbent')" },
exchange: { label: 'AI Exchange', where: "target_type IN ('credit_settle', 'routing_incumbent')" },
console: { label: 'Console sign-ins and refusals', where: "action ILIKE 'console.%'" },
console: { label: 'Console sign-ins and refusals', where: "action ILIKE 'console.%'" },
+ abuse: { label: 'Abuse prevention', where: "target_type = 'abuse'" },
all: { label: 'Everything', where: null },
all: { label: 'Everything', where: null },
});
});
@@ -121,7 +121,7 @@ function table(caption, head, rows, empty = 'None.') {
@@ -121,7 +121,7 @@ function table(caption, head, rows, empty = 'None.') {
const NAV = [
const NAV = [
['/console', 'Overview', 'overview'], ['/console/providers', 'Providers', 'providers'], ['/console/routes', 'Routes', 'routes'],
['/console', 'Overview', 'overview'], ['/console/providers', 'Providers', 'providers'], ['/console/routes', 'Routes', 'routes'],
['/console/templates', 'Templates', 'templates'], ['/console/workflows', 'Workflows', 'workflows'], ['/console/runs', 'Runs', 'runs'],
['/console/templates', 'Templates', 'templates'], ['/console/workflows', 'Workflows', 'workflows'], ['/console/runs', 'Runs', 'runs'],
- ['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/audit', 'Audit', 'audit'],
+ ['/console/quotas', 'Quotas', 'quotas'], ['/console/usage', 'Usage', 'usage'], ['/console/cache', 'Cache', 'cache'], ['/console/abuse', 'Abuse', 'abuse'], ['/console/audit', 'Audit', 'audit'],
['/console/gpus', 'GPUs', 'gpus'],
['/console/gpus', 'GPUs', 'gpus'],
];
];
@@ -652,6 +652,27 @@ function metaText(m) {
@@ -652,6 +652,27 @@ function metaText(m) {
return clip(s, 400);
return clip(s, 400);
}
}
+function abusePage({ staff, csrf, report }) {
+ if (!report) return layout({ title: 'Abuse', section: 'abuse', staff, csrf, body: html`<h1>Abuse prevention</h1><p class="muted">Abuse prevention is not configured on this service.</p>` });
+ const n = (v) => (v == null ? '—' : Number(v).toLocaleString('en-US'));
+ return layout({
+ title: 'Abuse', section: 'abuse', staff, csrf,
+ body: html`<h1>Abuse prevention</h1>
+<p class="muted m0">Plan E5: the free allowance grows with trust, a signed device id counts accounts per device, a hosting ASN gets the new tier, an in-house proof of work guards the anonymous Try it, and free tokens per minute are checked against the last week. Nothing here stores a raw address or device id.</p>
+<div class="cards">
+<section class="card"><h2>Signals</h2><p>IP-to-ASN table: <strong>${n(report.asn_table.v4_ranges)}</strong> IPv4 ranges, <strong>${n(report.asn_table.v6_ranges)}</strong> IPv6 ranges, <strong>${n(report.asn_table.hosting_asns)}</strong> operator hosting ASNs. Network trust signals: <strong>${report.signals ? 'on' : 'off'}</strong>.</p>
+<p>Prefixes seen in the last 24 h: <strong>${n(report.network.prefixes_24h)}</strong> (<strong>${n(report.network.hosting)}</strong> hosting). Open proof-of-work challenges: <strong>${n(report.pow)}</strong>.</p></section>
+<section class="card"><h2>Devices</h2><p>Known device/account pairs: <strong>${n(report.devices)}</strong>. Devices shared by more than three accounts: <strong>${n(report.shared_devices)}</strong>.</p>
+<p class="muted">Accounts on one shared device draw from one allowance: the lowest tier among them.</p></section>
+<section class="card"><h2>Today</h2><p>Free tokens claimed on <strong>${report.day}</strong> (UTC), by counter:</p>
+${table('Daily free allowance', ['Counter', 'Tokens'], report.usage.map((u) => html`<tr><td><code>${u.subject}</code></td><td>${n(u.tokens)}</td></tr>`), 'nothing claimed today.')}
+</section></div>
+${table('Throttles and refusals in the last 24 h', ['When', 'Action', 'Target', 'Metadata'], report.events.map((e) => html`<tr>
+<td>${when(e.at)}</td><td><code>${e.action}</code></td><td><code>${clip(e.target, 80)}</code></td><td class="mono muted">${metaText(e.metadata)}</td></tr>`), 'nothing was throttled or refused.')}
+<p class="muted">The live gauge is <code>ai_abuse_throttles{reason}</code> on <code>/metrics</code>; OpenVibe.Host alerts when the total stays high. The full audit trail is on the <a href="/console/audit?kind=abuse">Audit page</a>.</p>`,
+ });
+}
+
function audit({ staff, csrf, form, bad, rows, next, names, kinds }) {
function audit({ staff, csrf, form, bad, rows, next, names, kinds }) {
const keep = { kind: form.kind, action: form.action, actor: form.actor, target_type: form.target_type, target_id: form.target_id };
const keep = { kind: form.kind, action: form.action, actor: form.actor, target_type: form.target_type, target_id: form.target_id };
const actor = (a) => (names.get(a) ? html`@${names.get(a)} <code>${a}</code>` : html`<code>${a}</code>`);
const actor = (a) => (names.get(a) ? html`@${names.get(a)} <code>${a}</code>` : html`<code>${a}</code>`);
@@ -734,4 +755,4 @@ function allowance(cards) {
@@ -734,4 +755,4 @@ function allowance(cards) {
/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */
/** The price and latency cells, as plain HTML strings for the public /stats page (server/home.js renderStats). */
const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };
const statsFormat = { ms, rate, price: (cards) => render(price(cards)), allowance: (cards) => render(allowance(cards)) };
-module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, audit, exchange, gpus } };
+module.exports = { CSP, html, esc, safeUrl, statsFormat, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, runReceipt, quotas, usage, cache, abuse: abusePage, audit, exchange, gpus } };
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null } = {}) {
+async function start({ config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, abuse: givenAbuse = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -66,6 +66,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -66,6 +66,8 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a
// Community AI offers (the design's "Offer to the network"; plan E4): read Network's user-owned offers, verify a
// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.
// machine before traffic, and keep its seller ledger. Empty without a Network service secret or Bot.
const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });
const community = givenCommunity || require('./community').createCommunity({ db, config, env, machines, clock, fetchImpl, log });
+ // Plan E5: the free tier's allowance by trust, device and network signals, proof of work and anomaly throttling.
+ const abuse = givenAbuse || require('./abuse').createAbuse({ db, config, env, clock, fetchImpl, log, registry, community });
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
// BENCH-11: Bench's ratings as routing evidence, and the opted-in tie rule inside placement.
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
const bench = createBenchEvidence({ db, registry, config, clock, fetchImpl, log });
// Network owns the 18+ account flag; the adult tier checks it before placement.
// Network owns the 18+ account flag; the adult tier checks it before placement.
@@ -121,7 +123,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -121,7 +123,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
startSettlements() { return credits ? credits.startSettlements() : null; },
startSettlements() { return credits ? credits.startSettlements() : null; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
stopSettlements() { return credits ? credits.stopSettlements() : undefined; },
};
};
- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts });
+ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets, credits: creditsRef, community, presets, receipts, abuse });
await seed({ registry, quotas, config, env, db });
await seed({ registry, quotas, config, env, db });
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// The seed creates or moves providers from the environment: publish their origins (the schema and every key's
// bind follow), so a key is bound to the host it is for from the first boot.
// bind follow), so a key is bound to the host it is for from the first boot.
@@ -150,7 +152,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -150,7 +152,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts, balance, gpus });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, settings, benchEvents, runBudgets, memory, memoryImport, community, presets, receipts, balance, gpus, abuse });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
@@ -166,7 +168,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -166,7 +168,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const expired = await cache.prune();
const expired = await cache.prune();
// Billing readings never sent go with their runs (server/usage-samples.js expireUnsent).
// Billing readings never sent go with their runs (server/usage-samples.js expireUnsent).
const lapsed = await require('./usage-samples').expireUnsent(db, { days: config.runs.retentionDays, now: clock.now() });
const lapsed = await require('./usage-samples').expireUnsent(db, { days: config.runs.retentionDays, now: clock.now() });
+ const abusePruned = await abuse.prune();
if (pruned || expired || lapsed) log.log(`[ai] retention: ${pruned} old run(s), ${expired} expired cache entr${expired === 1 ? 'y' : 'ies'}, ${lapsed} unsent usage reading(s)`);
if (pruned || expired || lapsed) log.log(`[ai] retention: ${pruned} old run(s), ${expired} expired cache entr${expired === 1 ? 'y' : 'ies'}, ${lapsed} unsent usage reading(s)`);
+ if (Object.values(abusePruned).some((n) => n)) log.log(`[ai] abuse retention: ${Object.entries(abusePruned).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(', ')}`);
} catch (err) { log.error(`[ai] retention failed: ${err.message}`); }
} catch (err) { log.error(`[ai] retention failed: ${err.message}`); }
}, 60 * 60 * 1000);
}, 60 * 60 * 1000);
housekeeping.unref?.();
housekeeping.unref?.();
@@ -237,7 +241,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -237,7 +241,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, gpus, close };
+ return { config, db, registry, pool, quotas, tiers, cache, fetcher, engine, runs, keys, keyLoaded, auth, app, server, bench, benchEvents, memory, memoryJobs, credits: creditsRef, settings, vault, community, gpus, abuse, close };
}
}
if (require.main === module) {
if (require.main === module) {
@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
@@ -88,7 +88,7 @@ function createAiReadiness({ db, registry, pool, keys, runs, release = null }) {
}
}
/** AI gauges on the openvibe-shared/metrics registry. */
/** AI gauges on the openvibe-shared/metrics registry. */
-function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null }) {
+function registerAiGauges(registry, { runs, registry: ai, pool, balance = null, gpus = null, abuse = null }) {
registry.gauge({
registry.gauge({
name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],
name: 'ai_runs', help: 'Runs waiting for a slot (queued) and holding one (running)', labelNames: ['state'],
collect: () => {
collect: () => {
@@ -111,6 +111,13 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,
@@ -111,6 +111,13 @@ function registerAiGauges(registry, { runs, registry: ai, pool, balance = null,
name: 'ai_provider_balance_usd', help: 'Provider account balance in USD, as last read by the hourly probe', labelNames: ['provider'],
name: 'ai_provider_balance_usd', help: 'Provider account balance in USD, as last read by the hourly probe', labelNames: ['provider'],
collect: () => (balance ? balance.values() : []).map(r => ({ labels: { provider: r.provider }, value: r.usd })),
collect: () => (balance ? balance.values() : []).map(r => ({ labels: { provider: r.provider }, value: r.usd })),
});
});
+ // Plan E5: free-tier requests throttled in the last 15 minutes, by reason (velocity, rate, hosting, network).
+ // OpenVibe.Host's prometheus rule alerts when the sum stays high: the automatic throttle is working, and an
+ // operator should look at /console/abuse.
+ registry.gauge({
+ name: 'ai_abuse_throttles', help: 'Free-tier requests throttled in the last 15 minutes, by reason', labelNames: ['reason'],
+ collect: () => (abuse ? abuse.gaugeValues() : []),
+ });
// The GPU provisioner (server/gpu/): a marketplace instance that could not be given back is still running
// The GPU provisioner (server/gpu/): a marketplace instance that could not be given back is still running
// and still being paid for. The operator alert fires at three tries in a row (OpenVibe.Host's prometheus
// and still being paid for. The operator alert fires at three tries in a row (OpenVibe.Host's prometheus
// rules), so the gauge counts the tries that are still open.
// rules), so the gauge counts the tries that are still open.
@@ -360,6 +360,17 @@ route('get', '/exchange/usage', {
@@ -360,6 +360,17 @@ route('get', '/exchange/usage', {
} }) },
} }) },
});
});
+route('post', '/exchange/tryit/challenge', {
+ operationId: 'exchangeTryitChallenge', summary: 'A proof-of-work challenge for the anonymous Try it', tags: ['exchange'], security: [],
+ description: 'Public. The anonymous Try it (no account, no Balance) must send a solved challenge in x-ov-pow: <challenge>:<nonce>, where sha256("<challenge>:<nonce>") has `bits` leading zero bits. The challenge is single use and expires; AI stores only its hash. The response also sets the signed, HttpOnly `ovai_device` cookie (when it is not already present), which counts accounts per device. A first-party service may forward the client address in x-ov-client-ip.',
+ requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: {} } } } },
+ responses: {
+ 200: json('The challenge', { type: 'object', required: ['challenge', 'bits', 'expires_at'], properties: { challenge: str(), bits: integer(), expires_at: str({ format: 'date-time' }) } }),
+ 429: problem,
+ 503: problem,
+ },
+});
+
// ── Action Center ────────────────────────────────────────────────────────────────────────────
// ── Action Center ────────────────────────────────────────────────────────────────────────────
route('get', '/actions', {
route('get', '/actions', {
operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,
operationId: 'listActions', summary: 'The Action Center items for one person', tags: ['actions'], security: SERVICE,
@@ -106,7 +106,7 @@ function routePinRequest(value, principal, wf) {
@@ -106,7 +106,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
return { provider, model };
}
}
-function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null }) {
+function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null, credits = null, community = null, presets = null, receipts = null, abuse = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
const queue = []; // run ids waiting for the worker
let active = 0;
let active = 0;
@@ -187,7 +187,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -187,7 +187,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
* principal: { sub, subject: {type, id} }
* principal: { sub, subject: {type, id} }
* Returns { run, created, promise? }.
* Returns { run, created, promise? }.
*/
*/
- async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null } = {}) {
+ async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null } = {}) {
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
const wfKey = String(body.workflow || '');
const wfKey = String(body.workflow || '');
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
@@ -352,6 +352,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -352,6 +352,13 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const now = iso(clock.now());
const now = iso(clock.now());
const id = newRunId();
const id = newRunId();
ctx.runId = id;
ctx.runId = id;
+ // Plan E5: the free tier's allowance, device count, network signals and anomaly throttle. Paid runs only
+ // teach the device table; a refusal here is a 402 (allowance spent) or a 429 (throttled) before any
+ // provider or queue slot is touched.
+ if (abuse && requestAbuse) {
+ const admission = await abuse.admit({ principal, ctx, request: requestAbuse, inputHash, free: freeUse, anonymous: freeUse && anonymousTryIt, selfKeys });
+ if (admission) ctx.abuse = admission;
+ }
const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));
const kept = retainedInput(input, wf, Boolean(config.debugRawLog && options.debug));
const base = {
const base = {
id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,
id, workflow_key: wf.key, workflow_version: wf.version, template_key: tpl ? tpl.key : null, template_version: tpl ? tpl.version : null,
@@ -796,6 +803,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -796,6 +803,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.
// recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.
const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
+ // Plan E5: the free tokens this run spent go on its allowance (or its shared device counter) and on the
+ // per-minute velocity buckets. A failure here is logged, never allowed to fail an answer already made.
+ if (abuse && ctx.abuse && !usedOwnKey && !String(r.provider || '').startsWith('byo:')) {
+ try { await abuse.record({ admission: ctx.abuse, tokens: (r.usage.input || 0) + (r.usage.output || 0), inputHash: row.input_hash }); }
+ catch (err) { log.warn(`[runs] ${id}: the abuse counters were not written: ${err && err.message}`); }
+ }
// The record of the run (L40): the tokens with the cache apart, the money and who paid.
// The record of the run (L40): the tokens with the cache apart, the money and who paid.
const published = await publishUsage({ row: await getRow(id), outcome: 'succeeded', usage: r.usage, provider: r.provider, model: r.model, attempts, latencyMs: r.latencyMs, ttftMs: r.ttftMs });
const published = await publishUsage({ row: await getRow(id), outcome: 'succeeded', usage: r.usage, provider: r.provider, model: r.model, attempts, latencyMs: r.latencyMs, ttftMs: r.ttftMs });
// The receipt of the answer (server/exchange/receipts.js): the offer, the price, the tokens, the cache,
// The receipt of the answer (server/exchange/receipts.js): the offer, the price, the tokens, the cache,
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+// Plan E5 proof of work (server/abuse/pow.js): a hashcash challenge, issued and stored as its hash, accepted once.
+const assert = require('assert');
+const { suite } = require('./helpers');
+const { testDb } = require('./db');
+const { createPow, zeroBits, solve, digest } = require('../server/abuse/pow');
+
+const t = suite('abuse-pow');
+let tdb;
+let now = Date.UTC(2026, 10, 6, 12, 0, 0);
+const clock = { now: () => now };
+
+t.test('a migrated database', async () => { tdb = await testDb(); });
+
+t.test('leading zero bits', () => {
+ assert.strictEqual(zeroBits(Buffer.from([0xff])), 0);
+ assert.strictEqual(zeroBits(Buffer.from([0x7f])), 1);
+ assert.strictEqual(zeroBits(Buffer.from([0x00, 0x0f])), 12);
+ assert.strictEqual(zeroBits(Buffer.from([0x00, 0x00, 0x01])), 23);
+});
+
+t.test('a solved challenge is accepted exactly once', async () => {
+ const pow = createPow({ db: tdb.db, clock, bits: 6, ttlMs: 60_000 });
+ const issued = await pow.issue({ deviceHash: 'a'.repeat(64) });
+ assert.strictEqual(issued.bits, 6);
+ const nonce = solve(issued.challenge, 6);
+ const token = `${issued.challenge}:${nonce}`;
+ assert.strictEqual(await pow.verify(token, { deviceHash: 'a'.repeat(64) }), true);
+ assert.strictEqual(await pow.verify(token, { deviceHash: 'a'.repeat(64) }), false, 'single use');
+});
+
+t.test('a wrong nonce, an expired challenge and another device are refused', async () => {
+ const pow = createPow({ db: tdb.db, clock, bits: 5, ttlMs: 60_000 });
+ const one = await pow.issue({});
+ assert.strictEqual(await pow.verify(`${one.challenge}:0`, {}), false);
+ const two = await pow.issue({ deviceHash: 'b'.repeat(64) });
+ const good = `${two.challenge}:${solve(two.challenge, 5)}`;
+ assert.strictEqual(await pow.verify(good, { deviceHash: 'c'.repeat(64) }), false, 'issued for one device');
+ const three = await pow.issue({});
+ const saved = now;
+ now += 61_000;
+ assert.strictEqual(await pow.verify(`${three.challenge}:${solve(three.challenge, 5)}`, {}), false, 'expired');
+ now = saved;
+});
+
+t.test('the database keeps the challenge hash, never the challenge', async () => {
+ const pow = createPow({ db: tdb.db, clock, bits: 4 });
+ const issued = await pow.issue({});
+ const rows = await tdb.db.prepare('SELECT * FROM abuse_pow').all();
+ assert.ok(rows.some((r) => r.challenge_hash === digestHash(issued.challenge)));
+ assert.ok(!rows.some((r) => JSON.stringify(r).includes(issued.challenge)));
+});
+
+function digestHash(challenge) {
+ return require('crypto').createHash('sha256').update(challenge).digest('hex');
+}
+
+t.test('stop', async () => { await tdb.close(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+// Plan E5 end to end through the run API: the daily allowance is refused at admission, and the anonymous Try it
+// needs one proof of work. The module's own behaviour is in test/abuse.test.js.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+const { solve } = require('../server/abuse/pow');
+
+const t = suite('abuse-runs');
+const PERSON = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+let h;
+
+t.test('boot: the stub is the free source, Network signals off, a tiny allowance', async () => {
+ h = await boot({
+ env: {
+ AI_FREE_PROVIDERS: 'stub', AI_STUB_FALLBACK: 'true', AI_FREE_DAILY_TOKENS: '4',
+ AI_ABUSE_NETWORK: '0', AI_ABUSE_SALT: 'runs-test-salt', AI_ABUSE_RATE_PER_MIN: '1000',
+ AI_POW_REQUIRED: '0', AI_PROVIDER_RETRIES: '0',
+ },
+ });
+});
+
+t.test('a free run spends the day\'s allowance; the next one is refused with 402', async () => {
+ const acting = token('exchange', ALL, { extra: { on_behalf_of: PERSON } });
+ const body = { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello on my allowance' }] } };
+ const first = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: acting, body });
+ assert.strictEqual(first.status, 201, first.text);
+ assert.strictEqual(first.body.run.status, 'succeeded', first.text);
+ const row = await h.db.prepare('SELECT tokens FROM abuse_daily_usage WHERE subject = ?').get(`user:${PERSON}`);
+ assert.ok(row && Number(row.tokens) > 0, 'the run recorded its free tokens');
+ const second = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: acting, body });
+ assert.strictEqual(second.status, 402, second.text);
+ assert.strictEqual(second.body.code, 'ai.allowance_spent');
+ assert.ok(second.headers.get('retry-after'), 'the refusal says when the UTC day resets');
+});
+
+t.test('stop', async () => { await h.stop(); });
+
+t.test('boot for the anonymous Try it: proof of work required', async () => {
+ h = await boot({
+ env: {
+ AI_FREE_PROVIDERS: 'stub', AI_STUB_FALLBACK: 'true', AI_FREE_DAILY_TOKENS: '1000',
+ AI_ABUSE_NETWORK: '0', AI_ABUSE_SALT: 'pow-test-salt', AI_ABUSE_RATE_PER_MIN: '1000',
+ AI_POW_REQUIRED: '1', AI_POW_BITS: '4', AI_PROVIDER_RETRIES: '0',
+ },
+ });
+});
+
+t.test('without a proof of work the anonymous run is refused; a solved challenge runs once', async () => {
+ const exchange = token('exchange', ALL);
+ const body = { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello anonymously' }] } };
+ const noPow = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: exchange, body });
+ assert.strictEqual(noPow.status, 403, noPow.text);
+ assert.strictEqual(noPow.body.code, 'ai.pow_required');
+
+ const challenge = await request(h.base, 'POST', '/api/v1/exchange/tryit/challenge', { tok: exchange, body: {} });
+ assert.strictEqual(challenge.status, 200, challenge.text);
+ assert.strictEqual(challenge.body.bits, 8);
+ const cookie = (challenge.headers.get('set-cookie') || '').split(';')[0];
+ assert.ok(/^ovai_device=/.test(cookie), 'the challenge sets the signed device cookie');
+ const proof = `${challenge.body.challenge}:${solve(challenge.body.challenge, challenge.body.bits)}`;
+ const run = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: exchange, body, headers: { cookie, 'x-ov-pow': proof } });
+ assert.strictEqual(run.status, 201, run.text);
+ assert.strictEqual(run.body.run.status, 'succeeded', run.text);
+ const replay = await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: exchange, body, headers: { cookie, 'x-ov-pow': proof } });
+ assert.strictEqual(replay.status, 403, replay.text);
+ assert.strictEqual(replay.body.code, 'ai.pow_invalid');
+});
+
+t.test('stop anonymous', async () => { await h.stop(); });
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+// Plan E5: the allowance tiers and the IP-to-ASN table (server/abuse/trust.js, server/abuse/asn.js), with no
+// database and no network. The HTTP side is test/abuse.test.js and test/abuse-runs.test.js.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { suite, tmpDir, silent } = require('./helpers');
+const { tierFor, rateFor, TIERS } = require('../server/abuse/trust');
+const asn = require('../server/abuse/asn');
+const { normalize } = require('../server/abuse/network');
+
+const t = suite('abuse-trust');
+
+t.test('the tier is the plan\'s: new quarter, established half, verified full, kudos on top', () => {
+ assert.strictEqual(TIERS.new.factor, 0.25);
+ assert.strictEqual(TIERS.established.factor, 0.5);
+ assert.strictEqual(TIERS.verified.factor, 1);
+ assert.deepStrictEqual(pick(tierFor({ signals: { available: true, account_age_hours: 2, verified: false }, base: 1000 })), { tier: 'new', allowance: 250 });
+ assert.deepStrictEqual(pick(tierFor({ signals: { available: true, account_age_hours: 8 * 24, verified: false }, base: 1000 })), { tier: 'established', allowance: 500 });
+ assert.deepStrictEqual(pick(tierFor({ signals: { available: true, account_age_hours: 2, verified: true, verified_by: ['passkey'] }, base: 1000 })), { tier: 'verified', allowance: 1000 });
+});
+
+t.test('a volunteer\'s kudos add at most one more base, and only on the full tier', () => {
+ const v = tierFor({ signals: { available: true, verified: true }, kudos: 400, base: 1000 });
+ assert.strictEqual(v.allowance, 1400);
+ assert.ok(v.reasons.some((r) => /kudos add 400/.test(r)));
+ const capped = tierFor({ signals: { available: true, verified: true }, kudos: 5000, base: 1000 });
+ assert.strictEqual(capped.allowance, 2000, 'never more than one extra base');
+ const unverified = tierFor({ signals: { available: true, verified: false, account_age_hours: 1 }, kudos: 5000, base: 1000 });
+ assert.strictEqual(unverified.allowance, 250, 'kudos do not lift an unverified account');
+});
+
+t.test('a hosting ASN is the new tier whatever the account says; no Network answer is the conservative side', () => {
+ const hosted = tierFor({ signals: { available: true, verified: true }, hosting: true, base: 1000 });
+ assert.strictEqual(hosted.tier, 'new');
+ assert.strictEqual(hosted.allowance, 250);
+ assert.ok(hosted.reasons.some((r) => /hosting/.test(r)));
+ const unknown = tierFor({ signals: { available: false }, base: 1000 });
+ assert.strictEqual(unknown.tier, 'new');
+ assert.strictEqual(unknown.allowance, 250);
+ assert.strictEqual(tierFor({ signals: null, base: 0 }).allowance, 0);
+});
+
+t.test('the hosting rate is lower than the ordinary one', () => {
+ assert.strictEqual(rateFor({ hosting: false, normal: 60, hostingRate: 20 }), 60);
+ assert.strictEqual(rateFor({ hosting: true, normal: 60, hostingRate: 20 }), 20);
+ assert.strictEqual(rateFor({ hosting: true, normal: 60, hostingRate: 0 }), 1);
+});
+
+t.test('IPv4 /24 and IPv6 /48 prefixes, including a v4-mapped v6 address', () => {
+ assert.deepStrictEqual(asn.prefixOf('192.0.2.123'), { family: 'v4', prefix: '192.0.2.0/24' });
+ assert.deepStrictEqual(asn.prefixOf('2001:db8:1:2:3:4:5:6'), { family: 'v6', prefix: '2001:db8:1::/48' });
+ assert.deepStrictEqual(asn.prefixOf('::ffff:192.0.2.9'), { family: 'v4', prefix: '192.0.2.0/24' });
+ assert.strictEqual(asn.prefixOf('not an address'), null);
+ assert.strictEqual(asn.ipv6Text(0n), '::');
+ assert.strictEqual(asn.ipv6Text(1n), '::1');
+});
+
+t.test('a local table answers the ASN and marks an org-name hosting provider', () => {
+ const dir = tmpDir();
+ const v4 = path.join(dir, 'ip2asn-v4.tsv');
+ const v6 = path.join(dir, 'ip2asn-v6.tsv');
+ fs.writeFileSync(v4, [
+ '1.0.0.0\t1.0.0.255\t13335\tUS\tCloudflare, Inc.',
+ '5.0.0.0\t5.255.255.255\t64500\tDE\tExample Hosting GmbH',
+ '9.9.9.0\t9.9.9.255\t19281\tUS\tQuad9',
+ ].join('\n'));
+ fs.writeFileSync(v6, '2001:db8::\t2001:db8:ffff:ffff:ffff:ffff:ffff:ffff\t396982\tUS\tGoogle Cloud\n');
+ const table = asn.createAsnTable({ v4File: v4, v6File: v6, hostingAsns: [19281], log: silent });
+ assert.deepStrictEqual(table.lookup('1.0.0.42'), { asn: 13335, country: 'US', org: 'Cloudflare, Inc.', hosting: true });
+ assert.strictEqual(table.lookup('5.4.3.2').hosting, true, 'the organisation name says hosting');
+ assert.strictEqual(table.lookup('9.9.9.9').hosting, true, 'an operator-named hosting ASN');
+ assert.strictEqual(table.lookup('2001:db8::1').asn, 396982);
+ assert.deepStrictEqual(table.lookup('8.8.8.8'), { asn: null, country: null, org: null, hosting: false });
+ assert.deepStrictEqual(table.status(), { v4_ranges: 3, v6_ranges: 1, hosting_asns: 1 });
+});
+
+t.test('a missing dataset is empty, not a crash', () => {
+ const table = asn.createAsnTable({ v4File: path.join(tmpDir(), 'nope.tsv'), log: silent });
+ assert.deepStrictEqual(table.lookup('1.0.0.42'), { asn: null, country: null, org: null, hosting: false });
+});
+
+t.test('Network trust is normalised and never carries a private fact', () => {
+ const n = normalize({ account_age_hours: 200, account_age_days: 8, verified: true, verified_by: ['passkey', 7], passkey: true, linked_accounts: 2, active_30d: false, email: '[email protected]' });
+ assert.deepStrictEqual(n, { account_age_hours: 200, account_age_days: 8, verified: true, verified_by: ['passkey'], passkey: true, linked_accounts: 2, active_30d: false });
+ assert.strictEqual(normalize(null), null);
+ assert.strictEqual(normalize({ account_age_hours: -5 }).account_age_hours, 0);
+});
+
+function pick(out) { return { tier: out.tier, allowance: out.allowance }; }
+
+t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -0,0 +1,191 @@
+'use strict';
+// Plan E5: the abuse module itself (server/abuse/index.js): device signatures, the trust-tier allowance, a shared
+// device allowance, the ASN override, the rate limit, the anomaly throttle, proof of work and the operator view.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { suite, tmpDir, silent } = require('./helpers');
+const { testDb } = require('./db');
+const { load } = require('../server/config');
+const { createAbuse } = require('../server/abuse');
+const { solve } = require('../server/abuse/pow');
+
+const t = suite('abuse');
+const USER = (n) => `usr_01JAB2C3D4E5F6G7H8J9K0MN${String(n).padStart(2, '0').slice(-2)}`;
+const DAY = 86_400_000;
+let tdb;
+let now = Date.UTC(2026, 10, 6, 12, 0, 0);
+const clock = { now: () => now };
+const BASE = {
+ NODE_ENV: 'test', AI_ABUSE_SALT: 'a-test-salt', AI_FREE_DAILY_TOKENS: '1000', AI_ABUSE_NETWORK: '0',
+ AI_ABUSE_RATE_PER_MIN: '1000', AI_ABUSE_HOSTING_RATE_PER_MIN: '1000', AI_ABUSE_VELOCITY_MIN_TOKENS_PER_MIN: '10000',
+};
+
+function make(overrides = {}, extra = {}) {
+ const env = { ...BASE, ...overrides };
+ return createAbuse({
+ db: tdb.db, config: load(env), env, clock, log: silent,
+ community: { kudosBonus: async () => 0 }, ...extra,
+ });
+}
+
+const device = (abuse, id = 'device-one') => abuse._test.keyed('device', id);
+
+async function clear() {
+ for (const table of ['abuse_daily_usage', 'abuse_devices', 'abuse_velocity', 'abuse_network_seen', 'abuse_pow']) {
+ await tdb.db.prepare(`DELETE FROM ${table}`).run();
+ }
+}
+
+t.test('a migrated database', async () => { tdb = await testDb(); });
+
+t.test('the signed device id survives a round trip and refuses a tamper', () => {
+ const abuse = make();
+ const { id, token } = (() => { const id = 'abcdefghijklmnopqrstuvwx'; return { id, token: abuse._test.signDevice(id) }; })();
+ assert.strictEqual(abuse._test.verifyDevice(token), id);
+ assert.strictEqual(abuse._test.verifyDevice(token.replace(/.$/, '0')), null);
+ assert.strictEqual(abuse._test.verifyDevice(`${token}.x`), null);
+ assert.strictEqual(abuse._test.verifyDevice(abuse._test.signDevice('abcdefghijklmnop')), 'abcdefghijklmnop');
+});
+
+t.test('a new account gets a quarter, records its tokens, and is refused when the day is spent', async () => {
+ await clear();
+ const abuse = make();
+ const request = { deviceHash: device(abuse, 'allowance') };
+ const first = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(1) }, request, free: true });
+ assert.strictEqual(first.tier, 'new');
+ assert.strictEqual(first.allowance, 250);
+ assert.strictEqual(first.key, `user:${USER(1)}`);
+ await abuse.record({ admission: first, tokens: 100, inputHash: 'h1' });
+ const second = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(1) }, request, free: true });
+ assert.strictEqual(second.allowance, 250, 'still the same day');
+ await abuse.record({ admission: second, tokens: 200, inputHash: 'h2' });
+ await assert.rejects(
+ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(1) }, request, free: true }),
+ (err) => err.status === 402 && err.code === 'ai.allowance_spent' && /250 tokens/.test(err.detail),
+ );
+});
+
+t.test('more than three accounts on one device share one allowance, at the lowest tier', async () => {
+ await clear();
+ const abuse = make({ AI_FREE_DAILY_TOKENS: '4000' });
+ const hash = device(abuse, 'family');
+ const keys = [];
+ for (let i = 1; i <= 4; i++) {
+ const a = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(i) }, request: { deviceHash: hash }, free: true });
+ keys.push(a);
+ }
+ assert.strictEqual(keys[3].accounts, 4);
+ assert.strictEqual(keys[3].shared, true, 'the fourth account makes it shared');
+ assert.strictEqual(keys[3].key, `device:${hash}`);
+ assert.strictEqual(keys[3].allowance, 1000, 'a quarter of 4000, the lowest tier on the device');
+ await abuse.record({ admission: keys[3], tokens: 1000, inputHash: 'shared' });
+ await assert.rejects(
+ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true }),
+ (err) => err.status === 402 && err.code === 'ai.allowance_spent',
+ 'one account cannot spend a second allowance on the shared device',
+ );
+});
+
+t.test('a hosting ASN is the new tier and a lower rate whatever Network says', async () => {
+ await clear();
+ const dir = tmpDir();
+ const v4 = path.join(dir, 'ip2asn-v4.tsv');
+ fs.writeFileSync(v4, '5.0.0.0\t5.255.255.255\t64500\tDE\tExample Hosting GmbH\n9.9.9.0\t9.9.9.255\t19281\tUS\tQuad9\n');
+ const seen = [];
+ const fetchImpl = async (url) => {
+ seen.push(url);
+ return { ok: true, status: 200, json: async () => ({ subject: { type: 'user', id: USER(1) }, trust: { account_age_hours: 900, account_age_days: 37, verified: true, verified_by: ['passkey'], passkey: true, linked_accounts: 0, active_30d: true } }) };
+ };
+ const abuse = make({ AI_ABUSE_NETWORK: '1', AI_IP2ASN_V4: v4, AI_IP2ASN_V6: '' }, { fetchImpl, tokens: { getToken: async () => 'service-token' } });
+ const exchange = { subject: { type: 'service', id: 'exchange' } };
+ const hosted = await abuse.admit({
+ principal: exchange, ctx: { requesterType: 'service', requesterId: 'exchange', onBehalfOf: USER(1) },
+ request: { explicitIp: '5.1.2.3', deviceHash: device(abuse, 'hosted') }, free: true,
+ });
+ assert.strictEqual(hosted.asn, 64500);
+ assert.strictEqual(hosted.hosting, true);
+ assert.strictEqual(hosted.tier, 'new');
+ assert.strictEqual(hosted.allowance, 250);
+ assert.ok(seen.some((u) => /&trust=1/.test(u)), 'the identity read asked for trust');
+ const home = await abuse.admit({
+ principal: exchange, ctx: { requesterType: 'service', requesterId: 'exchange', onBehalfOf: USER(1) },
+ request: { explicitIp: '9.9.9.9', deviceHash: device(abuse, 'home') }, free: true,
+ });
+ assert.strictEqual(home.hosting, false);
+ assert.strictEqual(home.tier, 'verified');
+ assert.strictEqual(home.allowance, 1000);
+});
+
+t.test('the rate limit is per device and per ASN; the hosting rate is lower', async () => {
+ await clear();
+ const abuse = make({ AI_ABUSE_RATE_PER_MIN: '2', AI_ABUSE_HOSTING_RATE_PER_MIN: '1' });
+ const hash = device(abuse, 'rate');
+ await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true });
+ await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true });
+ await assert.rejects(
+ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(2) }, request: { deviceHash: hash }, free: true }),
+ (err) => err.status === 429 && err.code === 'ai.rate_limited',
+ );
+ assert.strictEqual(abuse.gaugeValues().find((g) => g.labels.reason === 'network').value, 1);
+});
+
+t.test('free tokens per minute above the last week\'s baseline are throttled, with an operator event', async () => {
+ await clear();
+ const audits = [];
+ const abuse = make({ AI_FREE_DAILY_TOKENS: '100000' }, { registry: { audit: async (...args) => audits.push(args) } });
+ const hash = device(abuse, 'velocity');
+ const first = await abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(3) }, request: { deviceHash: hash }, free: true });
+ await abuse.record({ admission: first, tokens: 15000, inputHash: 'same-prompt' });
+ await assert.rejects(
+ () => abuse.admit({ ctx: { requesterType: 'user', requesterId: USER(3) }, request: { deviceHash: hash }, free: true, inputHash: 'same-prompt' }),
+ (err) => err.status === 429 && err.code === 'ai.abuse_throttled' && /paused/.test(err.detail),
+ );
+ assert.ok(audits.some((a) => a[1] === 'abuse.throttled'), 'the audit log has the throttle');
+ assert.strictEqual(abuse.gaugeValues().find((g) => g.labels.reason === 'velocity').value, 1);
+});
+
+t.test('the anonymous Try it needs one proof of work, accepted once', async () => {
+ await clear();
+ const abuse = make({ AI_POW_REQUIRED: '1', AI_POW_BITS: '4', AI_FREE_DAILY_TOKENS: '1000' });
+ const hash = device(abuse, 'anonymous');
+ const ctx = { requesterType: 'service', requesterId: 'exchange' };
+ await assert.rejects(
+ () => abuse.admit({ ctx, request: { deviceHash: hash }, free: true, anonymous: true }),
+ (err) => err.status === 403 && err.code === 'ai.pow_required',
+ );
+ const issued = await abuse.issueChallenge({ meta: { deviceHash: hash } });
+ const token = `${issued.challenge}:${solve(issued.challenge, issued.bits)}`;
+ const a = await abuse.admit({ ctx, request: { deviceHash: hash, pow: token }, free: true, anonymous: true });
+ assert.strictEqual(a.anonymous, true);
+ assert.strictEqual(a.subject, null);
+ await assert.rejects(
+ () => abuse.admit({ ctx, request: { deviceHash: hash, pow: token }, free: true, anonymous: true }),
+ (err) => err.status === 403 && err.code === 'ai.pow_invalid',
+ );
+});
+
+t.test('the operator view lists recent events and today\'s counters; prune removes expired rows', async () => {
+ await clear();
+ const abuse = make();
+ await tdb.db.prepare(`INSERT INTO audit_log (at, actor, action, target_type, target_id, metadata) VALUES (?, 'system', 'abuse.throttled', 'abuse', 'velocity:input', '{"reason":"velocity","kind":"input"}')`)
+ .run(new Date(now - 1000).toISOString());
+ await tdb.db.prepare('INSERT INTO abuse_daily_usage (subject, day, tokens, updated_at) VALUES (?, ?, 5, ?)').run(`user:${USER(1)}`, new Date(now).toISOString().slice(0, 10), new Date(now).toISOString());
+ const out = await abuse.overview();
+ assert.strictEqual(out.events[0].action, 'abuse.throttled');
+ assert.strictEqual(out.events[0].metadata.reason, 'velocity');
+ assert.deepStrictEqual(out.usage[0], { subject: `user:${USER(1)}`, tokens: 5 });
+ const html = require('../server/console/views').pages.abuse({ staff: { subject: USER(1), username: 'op', role: 'owner' }, csrf: 'c', report: out });
+ assert.ok(html.includes('Abuse prevention') && html.includes('abuse.throttled'), 'the operator page renders the events');
+ await tdb.db.prepare('INSERT INTO abuse_network_seen (prefix_hash, asn, hosting, first_seen, last_seen, expires_at) VALUES (?, 1, 0, ?, ?, ?)').run('x', new Date(now).toISOString(), new Date(now).toISOString(), new Date(now - 1000).toISOString());
+ await tdb.db.prepare('INSERT INTO abuse_velocity (kind, key_hash, minute, tokens, updated_at) VALUES (?, ?, ?, 1, ?)').run('input', 'y', 1, new Date(now).toISOString());
+ await tdb.db.prepare('INSERT INTO abuse_devices (device_hash, subject, first_seen, last_seen) VALUES (?, ?, ?, ?)').run('z', USER(1), new Date(now - 400 * DAY).toISOString(), new Date(now - 400 * DAY).toISOString());
+ const removed = await abuse.prune();
+ assert.strictEqual(removed.network, 1);
+ assert.strictEqual(removed.velocity, 1);
+ assert.strictEqual(removed.devices, 1);
+});
+
+t.test('stop', async () => { await tdb.close(); });
+
+t.run();
@@ -55,6 +55,10 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -55,6 +55,10 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);
await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);
+ // Plan E5: the free-tier counters are erased with the account (the shared device counter only when its
+ // last account goes).
+ await h.db.exec("INSERT INTO abuse_daily_usage (subject, day, tokens, updated_at) VALUES ($1, '2026-10-01', 5, '2026-10-01')", [`user:${subject}`]);
+ await h.db.exec("INSERT INTO abuse_devices (device_hash, subject, tier_tokens, first_seen, last_seen) VALUES ($1, $2, 1000, '2026-10-01', '2026-10-01')", [`dev_${name}`, subject]);
await h.db.exec("INSERT INTO routes (key, version, primary_provider, created_by, created_at) VALUES ($1, 1, 'openai', $2, '2026-10-01')", [`route-${name}`, subject]);
await h.db.exec("INSERT INTO routes (key, version, primary_provider, created_by, created_at) VALUES ($1, 1, 'openai', $2, '2026-10-01')", [`route-${name}`, subject]);
}
}
const deliver = async (event) => {
const deliver = async (event) => {
@@ -78,6 +82,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
@@ -78,6 +82,9 @@ t.test('an export carries the person\'s sessions and credential hints, and a del
const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [A])), 0, 'the device row goes');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_daily_usage WHERE subject = $1', [`user:${A}`])), 0, 'the daily counter goes');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM abuse_devices WHERE subject = $1', [B])), 1, "nobody else's device row");
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');
assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');