diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index 9263b9ae5..2d8753d7f 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -71,12 +71,20 @@ export type FreebuffSpurStatus = | 'suspicious' | 'failed' +export type FreebuffScamalyticsStatus = + | 'not_checked' + | 'clean' + | 'suspicious' + | 'failed' + export type FreebuffPrivacyDecision = | 'allowed_clean' | 'ipinfo_suspicious_spur_clean' | 'corroborated_block' | 'cloudflare_tor_block' | 'spur_failed_limited' + | 'scamalytics_failed_limited' + | 'scamalytics_suspicious_limited' | 'ipinfo_failed_limited' | 'limited_other' @@ -87,6 +95,8 @@ export type FreebuffPrivacyProviderDecision = | 'ipinfo_failed' | 'ipinfo_only' | 'spur_failed' + | 'scamalytics_failed' + | 'scamalytics_only' | 'corroborated_soft' | 'corroborated_hard' diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 8396b7ce7..9b1ff5f61 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -6,7 +6,8 @@ - Server secrets: validated in `packages/internal/src/env-schema.ts` (used via `@codebuff/internal/env`). - Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase. - `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic. -- `SPUR_TOKEN` is required; VPN/proxy/Tor/residential-proxy privacy signals use Spur Context API corroboration. In allowlisted countries, a successful clean Spur result overrides IPinfo privacy signals back to full access, while suspicious or failed Spur lookups fall back to limited access. Cloudflare Tor country detection remains a hard block. +- `SPUR_TOKEN` is required; VPN/proxy/Tor/residential-proxy privacy signals use Spur Context API corroboration. +- `SCAMALYTICS_API_KEY` is required; when IPinfo reports privacy or hosting/service signals, free-mode gating also checks Scamalytics for a fraud score and proxy/Tor/VPN evidence. In allowlisted countries, full access requires both Spur and Scamalytics to return clean follow-up results. Provider failures, Scamalytics outages/API errors, ambiguous results, VPN/generic-proxy signals, and hosting/datacenter signals fall back to limited access. Residential proxy is blocked only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score, as are Cloudflare Tor or Tor corroborated by another provider. - `CODEBUFF_FULL_TELEMETRY=true` or `CODEBUFF_FULL_TELEMETRY_IDS=user-id,email@example.com` disables client analytics sampling for targeted debugging. Use sparingly because it can send full CLI log payloads. diff --git a/docs/freebuff-waiting-room.md b/docs/freebuff-waiting-room.md index bc9cfc988..83f5f9de9 100644 --- a/docs/freebuff-waiting-room.md +++ b/docs/freebuff-waiting-room.md @@ -181,7 +181,7 @@ All endpoints authenticate via the standard `Authorization: Bearer ` or - Existing active+unexpired row, **different model** → reject with `model_locked` (HTTP 409); `active_instance_id` is **not** rotated so the other CLI stays valid. Client must DELETE the session before switching. - Existing active+expired row → reset to queued with fresh `queued_at` and the requested `model` (re-queue at back). -Before any of those state transitions, the handler requires a resolved country and IPinfo/Spur privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy signals still receive full access when Spur returns clean context, and fall back to limited access when Spur reports suspicious context or lookup fails. IPinfo lookup failures fail closed into limited access. Cloudflare Tor country detection remains a hard block. +Before any of those state transitions, the handler requires a resolved country and IPinfo privacy classification. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy/hosting/service signals trigger paid follow-up checks with Spur and Scamalytics. Full access is restored when both follow-up providers return clean context; suspicious or failed follow-up checks fall back to limited access. A Scamalytics outage/API error is treated as a transient limited decision, not a hard block. The server records a 0-100 privacy risk score for observability/cache rows; named/recent IPinfo anonymizer observations raise that score, while generic Scamalytics third-party proxy labels do not override a low top-level Scamalytics score by themselves. VPN, generic proxy, and hosting/datacenter signals limit access when follow-up providers do not clear them. Residential proxy signals hard-block only when Scamalytics also reports residential/proxy evidence or a medium+ fraud score. Cloudflare Tor country detection or Tor corroborated by another provider is also hard-blocked by the IP-intelligence gate. Response shapes: diff --git a/packages/internal/src/db/migrations/0055_glossy_gertrude_yorkes.sql b/packages/internal/src/db/migrations/0055_glossy_gertrude_yorkes.sql new file mode 100644 index 000000000..a1d68d7d0 --- /dev/null +++ b/packages/internal/src/db/migrations/0055_glossy_gertrude_yorkes.sql @@ -0,0 +1,5 @@ +ALTER TABLE "free_mode_country_access_cache" ADD COLUMN "scamalytics_ip_privacy_signals" text[];--> statement-breakpoint +ALTER TABLE "free_mode_country_access_cache" ADD COLUMN "scamalytics_status" text;--> statement-breakpoint +ALTER TABLE "free_mode_country_access_cache" ADD COLUMN "scamalytics_score" integer;--> statement-breakpoint +ALTER TABLE "free_mode_country_access_cache" ADD COLUMN "scamalytics_risk" text;--> statement-breakpoint +ALTER TABLE "free_mode_country_access_cache" ADD COLUMN "risk_score" integer; diff --git a/packages/internal/src/db/migrations/meta/0055_snapshot.json b/packages/internal/src/db/migrations/meta/0055_snapshot.json new file mode 100644 index 000000000..e025ea649 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0055_snapshot.json @@ -0,0 +1,3391 @@ +{ + "id": "fffbacec-ce15-4625-9adb-5c5a45bb9c3e", + "prevId": "65b385f2-68c6-4a6c-b41e-c3d781d4d9c5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": ["imp_url"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": ["publisher_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": ["publisher_id", "id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": ["agent_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": ["user_id", "type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_mode_country_access_cache": { + "name": "free_mode_country_access_cache", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spur_ip_privacy_signals": { + "name": "spur_ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spur_status": { + "name": "spur_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scamalytics_ip_privacy_signals": { + "name": "scamalytics_ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scamalytics_status": { + "name": "scamalytics_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scamalytics_score": { + "name": "scamalytics_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scamalytics_risk": { + "name": "scamalytics_risk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risk_score": { + "name": "risk_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "privacy_decision": { + "name": "privacy_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "privacy_provider_decision": { + "name": "privacy_provider_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_mode_country_cache_expires_at": { + "name": "idx_free_mode_country_cache_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_mode_country_access_cache_user_id_user_id_fk": { + "name": "free_mode_country_access_cache_user_id_user_id_fk", + "tableFrom": "free_mode_country_access_cache", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "free_mode_country_access_cache_user_id_client_ip_hash_pk": { + "name": "free_mode_country_access_cache_user_id_client_ip_hash_pk", + "columns": ["user_id", "client_ip_hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_tier": { + "name": "access_tier", + "type": "freebuff_access_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_checked_at": { + "name": "country_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session_admit": { + "name": "free_session_admit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_tier": { + "name": "access_tier", + "type": "freebuff_access_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session_units": { + "name": "session_units", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + } + }, + "indexes": { + "idx_free_session_admit_user_model_time": { + "name": "idx_free_session_admit_user_model_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "admitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_admit_user_id_user_id_fk": { + "name": "free_session_admit_user_id_user_id_fk", + "tableFrom": "free_session_admit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_customer_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": ["org_id", "feature"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": ["accepted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": ["org_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": ["approved_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": ["referrer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": ["referred_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": ["referrer_id", "referred_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_auth_hash": { + "name": "cli_auth_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_cli_auth_code_idx": { + "name": "session_cli_auth_code_idx", + "columns": [ + { + "expression": "fingerprint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cli_auth_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"session\".\"cli_auth_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": ["fingerprint_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_customer_id"] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": ["referral_code"] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": ["discord_id"] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": ["handle"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": ["pending", "completed"] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": ["running", "completed", "failed", "cancelled"] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": ["running", "completed", "skipped"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["anthropic", "gemini", "openai"] + }, + "public.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": ["queued", "active"] + }, + "public.freebuff_access_tier": { + "name": "freebuff_access_tier", + "schema": "public", + "values": ["full", "limited"] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": ["owner", "admin", "member"] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": ["web", "pat", "cli"] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 9bae2f8c7..91e6898f0 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1779339183837, "tag": "0054_clumsy_robin_chapel", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1779661395724, + "tag": "0055_glossy_gertrude_yorkes", + "breakpoints": true } ] } diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 9bcdcf818..9fd30f313 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -24,6 +24,7 @@ import type { FreebuffIpPrivacySignal, FreebuffPrivacyDecision, FreebuffPrivacyProviderDecision, + FreebuffScamalyticsStatus, FreebuffSpurStatus, } from '@codebuff/common/types/freebuff-session' @@ -944,12 +945,21 @@ export const freeModeCountryAccessCache = pgTable( .array() .$type(), spur_status: text('spur_status').$type(), - privacy_decision: text('privacy_decision').$type< - FreebuffPrivacyDecision | null - >(), - privacy_provider_decision: text('privacy_provider_decision').$type< - FreebuffPrivacyProviderDecision | null - >(), + scamalytics_ip_privacy_signals: text('scamalytics_ip_privacy_signals') + .array() + .$type(), + scamalytics_status: text( + 'scamalytics_status', + ).$type(), + scamalytics_score: integer('scamalytics_score'), + scamalytics_risk: text('scamalytics_risk'), + risk_score: integer('risk_score'), + privacy_decision: text( + 'privacy_decision', + ).$type(), + privacy_provider_decision: text( + 'privacy_provider_decision', + ).$type(), checked_at: timestamp('checked_at', { mode: 'date', withTimezone: true, diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 54aa3a9b8..644426b87 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -17,6 +17,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ GRAVITY_API_KEY: z.string().min(1), IPINFO_TOKEN: z.string().min(1), SPUR_TOKEN: z.string().min(1), + SCAMALYTICS_API_KEY: z.string().min(1), // ZeroClick tenant API key used for server-side offer fallback requests. ZEROCLICK_API_KEY: z.string().min(1).optional(), // BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad. @@ -110,6 +111,7 @@ export const serverProcessEnv: ServerInput = { GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, IPINFO_TOKEN: process.env.IPINFO_TOKEN, SPUR_TOKEN: process.env.SPUR_TOKEN, + SCAMALYTICS_API_KEY: process.env.SCAMALYTICS_API_KEY, ZEROCLICK_API_KEY: process.env.ZEROCLICK_API_KEY, CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY, PORT: process.env.PORT, diff --git a/packages/internal/src/env.ts b/packages/internal/src/env.ts index ca4bd25c3..c8dc62ea5 100644 --- a/packages/internal/src/env.ts +++ b/packages/internal/src/env.ts @@ -25,6 +25,7 @@ if (isCI) { ensureEnvDefault('GRAVITY_API_KEY', 'test') ensureEnvDefault('IPINFO_TOKEN', 'test') ensureEnvDefault('SPUR_TOKEN', 'test') + ensureEnvDefault('SCAMALYTICS_API_KEY', 'test') ensureEnvDefault('PORT', '4242') ensureEnvDefault('DATABASE_URL', 'postgres://user:pass@localhost:5432/db') ensureEnvDefault('CODEBUFF_GITHUB_ID', 'test-id') diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 566516441..6df1d2f7e 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -672,6 +672,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { ipPrivacy: { signals: ['vpn', 'hosting'] }, spurIpPrivacy: { signals: ['vpn'] }, spurStatus: 'suspicious', + scamalyticsIpPrivacy: null, + scamalyticsStatus: 'failed', + scamalyticsScore: null, + scamalyticsRisk: null, hasClientIp: true, clientIpHash: 'test-ip-hash', }), @@ -695,8 +699,8 @@ describe('/api/v1/chat/completions POST endpoint', () => { countryCode: 'US', ipPrivacySignals: ['vpn', 'hosting'], spurStatus: 'suspicious', - privacyDecision: 'corroborated_block', - privacyProviderDecision: 'corroborated_hard', + privacyDecision: 'scamalytics_failed_limited', + privacyProviderDecision: 'scamalytics_failed', privacyHardBlocked: false, }) }, diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 76aa89248..7d23f5ecd 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -105,6 +105,7 @@ import { getFreeModeAccessTier, getFreeModePrivacyDecision, getFreeModePrivacyProviderDecision, + getFreeModeRiskScore, shouldHardBlockFreeModeAccess, } from '@/server/free-mode-country' @@ -347,6 +348,7 @@ export async function postChatCompletions(params: { fetch, ipinfoToken: env.IPINFO_TOKEN, spurToken: env.SPUR_TOKEN, + scamalyticsApiKey: env.SCAMALYTICS_API_KEY, ipHashSecret: env.NEXTAUTH_SECRET, allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', forceLimited: @@ -358,6 +360,7 @@ export async function postChatCompletions(params: { const privacyDecision = getFreeModePrivacyDecision(countryAccess) const privacyProviderDecision = getFreeModePrivacyProviderDecision(countryAccess) + const privacyRiskScore = getFreeModeRiskScore(countryAccess) if (!countryAccess.allowed || sampleFreebuffSuccess) { logger.info( @@ -369,6 +372,12 @@ export async function postChatCompletions(params: { ipPrivacySignals: countryAccess.ipPrivacy?.signals, spurIpPrivacySignals: countryAccess.spurIpPrivacy?.signals, spurStatus: countryAccess.spurStatus, + scamalyticsIpPrivacySignals: + countryAccess.scamalyticsIpPrivacy?.signals, + scamalyticsStatus: countryAccess.scamalyticsStatus, + scamalyticsScore: countryAccess.scamalyticsScore, + scamalyticsRisk: countryAccess.scamalyticsRisk, + privacyRiskScore, privacyDecision, privacyProviderDecision, privacyHardBlocked: hardBlocked, @@ -395,6 +404,12 @@ export async function postChatCompletions(params: { ipPrivacySignals: countryAccess.ipPrivacy?.signals, spurIpPrivacySignals: countryAccess.spurIpPrivacy?.signals, spurStatus: countryAccess.spurStatus, + scamalyticsIpPrivacySignals: + countryAccess.scamalyticsIpPrivacy?.signals, + scamalyticsStatus: countryAccess.scamalyticsStatus, + scamalyticsScore: countryAccess.scamalyticsScore, + scamalyticsRisk: countryAccess.scamalyticsRisk, + privacyRiskScore, privacyDecision, privacyProviderDecision, privacyHardBlocked: hardBlocked, @@ -421,7 +436,9 @@ export async function postChatCompletions(params: { privacyDecision, privacyProviderDecision, privacyHardBlocked: hardBlocked, + privacyRiskScore, spurStatus: countryAccess.spurStatus, + scamalyticsStatus: countryAccess.scamalyticsStatus, }) if (!countryAccess.allowed) { @@ -435,6 +452,12 @@ export async function postChatCompletions(params: { ipPrivacySignals: countryAccess.ipPrivacy?.signals, spurIpPrivacySignals: countryAccess.spurIpPrivacy?.signals, spurStatus: countryAccess.spurStatus, + scamalyticsIpPrivacySignals: + countryAccess.scamalyticsIpPrivacy?.signals, + scamalyticsStatus: countryAccess.scamalyticsStatus, + scamalyticsScore: countryAccess.scamalyticsScore, + scamalyticsRisk: countryAccess.scamalyticsRisk, + privacyRiskScore, privacyDecision, privacyProviderDecision, privacyHardBlocked: hardBlocked, diff --git a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts index 46ad2763c..7b54bd53f 100644 --- a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts +++ b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts @@ -20,6 +20,12 @@ const NOT_CHECKED_SPUR_CONTEXT = { spurIpPrivacy: null, spurStatus: 'not_checked' as const, } +const NOT_CHECKED_SCAMALYTICS_CONTEXT = { + scamalyticsIpPrivacy: null, + scamalyticsStatus: 'not_checked' as const, + scamalyticsScore: null, + scamalyticsRisk: null, +} function testCountryAccess(req: NextRequest): FreeModeCountryAccess { const cfCountry = req.headers.get('cf-ipcountry')?.toUpperCase() ?? null @@ -37,6 +43,7 @@ function testCountryAccess(req: NextRequest): FreeModeCountryAccess { geoipCountry: null, ipPrivacy: cfCountry === 'T1' ? { signals: ['tor'] } : null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp, clientIpHash: hasClientIp ? 'test-ip-hash' : null, } @@ -50,6 +57,7 @@ function testCountryAccess(req: NextRequest): FreeModeCountryAccess { geoipCountry: null, ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp, clientIpHash: hasClientIp ? 'test-ip-hash' : null, } @@ -63,6 +71,7 @@ function testCountryAccess(req: NextRequest): FreeModeCountryAccess { geoipCountry: null, ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp, clientIpHash: 'test-ip-hash', } @@ -75,6 +84,7 @@ function testCountryAccess(req: NextRequest): FreeModeCountryAccess { geoipCountry: null, ipPrivacy: { signals: [] }, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp, clientIpHash: 'test-ip-hash', } @@ -324,6 +334,7 @@ describe('POST /api/v1/freebuff/session', () => { ipPrivacy: { signals: ['vpn', 'hosting'] }, spurIpPrivacy: { signals: ['vpn'] }, spurStatus: 'suspicious', + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: true, clientIpHash: 'test-ip-hash', }), @@ -368,6 +379,10 @@ describe('POST /api/v1/freebuff/session', () => { ipPrivacy: { signals: ['hosting'] }, spurIpPrivacy: { signals: [] }, spurStatus: 'clean', + scamalyticsIpPrivacy: { signals: [] }, + scamalyticsStatus: 'clean', + scamalyticsScore: 10, + scamalyticsRisk: 'low', hasClientIp: true, clientIpHash: 'test-ip-hash', }), @@ -451,6 +466,10 @@ describe('GET /api/v1/freebuff/session', () => { ipPrivacy: { signals: ['hosting'] }, spurIpPrivacy: { signals: [] }, spurStatus: 'clean', + scamalyticsIpPrivacy: { signals: [] }, + scamalyticsStatus: 'clean', + scamalyticsScore: 10, + scamalyticsRisk: 'low', hasClientIp: true, clientIpHash: 'test-ip-hash', }), @@ -487,9 +506,10 @@ describe('GET /api/v1/freebuff/session', () => { blockReason: 'anonymous_network', cfCountry: 'US', geoipCountry: null, - ipPrivacy: { signals: ['res_proxy'] }, - spurIpPrivacy: { signals: ['res_proxy'] }, + ipPrivacy: { signals: ['vpn'] }, + spurIpPrivacy: { signals: ['proxy'] }, spurStatus: 'suspicious', + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: true, clientIpHash: 'test-ip-hash', }), @@ -500,7 +520,7 @@ describe('GET /api/v1/freebuff/session', () => { expect(body.status).toBe('none') expect(body.accessTier).toBe('limited') expect(body.countryBlockReason).toBe('anonymous_network') - expect(body.ipPrivacySignals).toEqual(['res_proxy']) + expect(body.ipPrivacySignals).toEqual(['vpn']) expect(sessionDeps.rows.size).toBe(0) }) diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 81eec27ed..16ea9559c 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -42,6 +42,7 @@ async function getCountryAccess( options: { ipinfoToken: env.IPINFO_TOKEN, spurToken: env.SPUR_TOKEN, + scamalyticsApiKey: env.SCAMALYTICS_API_KEY, ipHashSecret: env.NEXTAUTH_SECRET, allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', forceLimited: diff --git a/web/src/server/__tests__/free-mode-country-access-cache.test.ts b/web/src/server/__tests__/free-mode-country-access-cache.test.ts index 005240d2f..841cc0242 100644 --- a/web/src/server/__tests__/free-mode-country-access-cache.test.ts +++ b/web/src/server/__tests__/free-mode-country-access-cache.test.ts @@ -38,6 +38,10 @@ function allowedAccess(): FreeModeCountryAccess { ipPrivacy: { signals: [] }, spurIpPrivacy: null, spurStatus: 'not_checked', + scamalyticsIpPrivacy: null, + scamalyticsStatus: 'not_checked', + scamalyticsScore: null, + scamalyticsRisk: null, hasClientIp: true, clientIpHash, } @@ -133,6 +137,11 @@ describe('free mode country access cache', () => { ipHashSecret, lookupIpPrivacy: async () => ({ signals: ['vpn'] }), lookupSpurIpPrivacy: async () => ({ signals: ['vpn'] }), + lookupScamalyticsIpRisk: async () => ({ + signals: ['hosting'], + score: 60, + risk: 'medium', + }), }, cacheStore, now, @@ -146,9 +155,9 @@ describe('free mode country access cache', () => { access, now, }) - expect(expiresAtForCountryAccess(access, now).getTime() - now.getTime()).toBe( - FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS, - ) + expect( + expiresAtForCountryAccess(access, now).getTime() - now.getTime(), + ).toBe(FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS) }) test('stores transient limited decisions when Spur fails after hard IPinfo signals', async () => { @@ -181,9 +190,45 @@ describe('free mode country access cache', () => { access, now, }) - expect(expiresAtForCountryAccess(access, now).getTime() - now.getTime()).toBe( - FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS, - ) + expect( + expiresAtForCountryAccess(access, now).getTime() - now.getTime(), + ).toBe(FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS) + }) + + test('stores transient limited decisions when Scamalytics fails after hard IPinfo signals', async () => { + const cacheStore: FreeModeCountryAccessCacheStore = { + get: mock(async () => null), + set: mock(async () => {}), + } + + const access = await getCachedFreeModeCountryAccess({ + userId, + req: makeReq({ + 'cf-ipcountry': 'US', + 'cf-connecting-ip': clientIp, + }), + options: { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + ipHashSecret, + lookupIpPrivacy: async () => ({ signals: ['vpn'] }), + lookupSpurIpPrivacy: async () => ({ signals: ['vpn'] }), + lookupScamalyticsIpRisk: async () => null, + }, + cacheStore, + now, + }) + + expect(access.allowed).toBe(false) + expect(access.scamalyticsStatus).toBe('failed') + expect(cacheStore.set).toHaveBeenCalledWith({ + userId, + access, + now, + }) + expect( + expiresAtForCountryAccess(access, now).getTime() - now.getTime(), + ).toBe(FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS) }) test('stores allowed decisions when clean Spur context clears a hard IPinfo signal', async () => { @@ -204,6 +249,11 @@ describe('free mode country access cache', () => { ipHashSecret, lookupIpPrivacy: async () => ({ signals: ['vpn'] }), lookupSpurIpPrivacy: async () => ({ signals: [] }), + lookupScamalyticsIpRisk: async () => ({ + signals: [], + score: 10, + risk: 'low', + }), }, cacheStore, now, @@ -224,6 +274,7 @@ describe('free mode country access cache', () => { country_block_reason: 'anonymous_network', ip_privacy_signals: ['vpn'], spur_status: null, + scamalytics_status: null, }), ).toBe(true) expect( @@ -231,6 +282,7 @@ describe('free mode country access cache', () => { country_block_reason: 'anonymous_network', ip_privacy_signals: ['vpn'], spur_status: 'failed', + scamalytics_status: 'failed', }), ).toBe(false) expect( @@ -238,6 +290,7 @@ describe('free mode country access cache', () => { country_block_reason: 'anonymous_network', ip_privacy_signals: ['hosting'], spur_status: null, + scamalytics_status: null, }), ).toBe(false) }) diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index b29b59536..a39b56466 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -5,9 +5,12 @@ import { getFreeModePrivacyProviderDecision, getFreeModePrivacyDecision, getFreeModeCountryAccess, + getFreeModeRiskScore, shouldHardBlockFreeModeAccess, lookupIpinfoPrivacy, + lookupScamalyticsIpRisk, lookupSpurIpPrivacy, + privacySignalsFromScamalytics, privacySignalsFromSpur, } from '../free-mode-country' @@ -160,6 +163,11 @@ describe('free mode country access', () => { lookupSpurIpPrivacy: async () => ({ signals: [], }), + lookupScamalyticsIpRisk: async () => ({ + signals: [], + score: 10, + risk: 'low', + }), }, ) expect(access.allowed).toBe(true) @@ -168,13 +176,15 @@ describe('free mode country access', () => { expect(access.ipPrivacy?.signals).toEqual(['vpn']) expect(access.spurIpPrivacy?.signals).toEqual([]) expect(access.spurStatus).toBe('clean') + expect(access.scamalyticsStatus).toBe('clean') + expect(access.scamalyticsScore).toBe(10) expect(getFreeModePrivacyDecision(access)).toBe( 'ipinfo_suspicious_spur_clean', ) expect(getFreeModePrivacyProviderDecision(access)).toBe('ipinfo_only') }) - test('allows allowlisted countries when Spur does not corroborate IPinfo residential proxy detection', async () => { + test('allows allowlisted countries when follow-up providers clear IPinfo residential proxy detection', async () => { const access = await getFreeModeCountryAccess( makeReq({ 'cf-ipcountry': 'US', @@ -189,6 +199,11 @@ describe('free mode country access', () => { lookupSpurIpPrivacy: async () => ({ signals: [], }), + lookupScamalyticsIpRisk: async () => ({ + signals: [], + score: 10, + risk: 'low', + }), }, ) expect(access.allowed).toBe(true) @@ -196,6 +211,8 @@ describe('free mode country access', () => { expect(access.ipPrivacy?.signals).toEqual(['res_proxy']) expect(access.spurIpPrivacy?.signals).toEqual([]) expect(access.spurStatus).toBe('clean') + expect(getFreeModeRiskScore(access)).toBe(70) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) }) test('allows allowlisted countries when Spur does not corroborate IPinfo hosting or service detection', async () => { @@ -213,6 +230,11 @@ describe('free mode country access', () => { lookupSpurIpPrivacy: async () => ({ signals: [], }), + lookupScamalyticsIpRisk: async () => ({ + signals: [], + score: 10, + risk: 'low', + }), }, ) expect(access.allowed).toBe(true) @@ -242,9 +264,11 @@ describe('free mode country access', () => { expect(vpnAccess.allowed).toBe(false) expect(vpnAccess.spurStatus).toBe('suspicious') expect(shouldHardBlockFreeModeAccess(vpnAccess)).toBe(false) - expect(getFreeModePrivacyDecision(vpnAccess)).toBe('corroborated_block') + expect(getFreeModePrivacyDecision(vpnAccess)).toBe( + 'scamalytics_failed_limited', + ) expect(getFreeModePrivacyProviderDecision(vpnAccess)).toBe( - 'corroborated_hard', + 'scamalytics_failed', ) const anonymousOnlyAccess = await getFreeModeCountryAccess( @@ -267,6 +291,214 @@ describe('free mode country access', () => { expect(shouldHardBlockFreeModeAccess(anonymousOnlyAccess)).toBe(false) }) + test('keeps suspicious traffic limited when Scamalytics does not clear IPinfo signals', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['vpn'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: [], + }), + lookupScamalyticsIpRisk: async () => ({ + signals: ['hosting'], + score: 80, + risk: 'high', + }), + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(access.spurStatus).toBe('clean') + expect(access.scamalyticsStatus).toBe('suspicious') + expect(getFreeModeRiskScore(access)).toBe(80) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) + }) + + test('keeps corroborated high-score VPN/proxy traffic limited', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['vpn'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: ['proxy'], + }), + lookupScamalyticsIpRisk: async () => ({ + signals: ['vpn'], + score: 90, + risk: 'very high', + }), + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(getFreeModeRiskScore(access)).toBe(90) + expect(getFreeModePrivacyDecision(access)).toBe( + 'scamalytics_suspicious_limited', + ) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) + }) + + test('hard-blocks Tor when corroborated by another provider', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['tor'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: ['vpn'], + }), + lookupScamalyticsIpRisk: async () => ({ + signals: ['tor'], + score: 90, + risk: 'very high', + }), + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(getFreeModeRiskScore(access)).toBe(100) + expect(getFreeModePrivacyDecision(access)).toBe('corroborated_block') + expect(shouldHardBlockFreeModeAccess(access)).toBe(true) + }) + + test('hard-blocks residential proxy when Scamalytics also corroborates it', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['res_proxy'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: ['proxy'], + }), + lookupScamalyticsIpRisk: async () => ({ + signals: [], + score: 60, + risk: 'medium', + }), + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(getFreeModeRiskScore(access)).toBe(95) + expect(getFreeModePrivacyDecision(access)).toBe('corroborated_block') + expect(shouldHardBlockFreeModeAccess(access)).toBe(true) + }) + + test('keeps IPinfo and Spur residential proxy corroboration limited when Scamalytics is clean', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['res_proxy'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: ['proxy'], + }), + lookupScamalyticsIpRisk: async () => ({ + signals: [], + score: 20, + risk: 'low', + }), + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(getFreeModeRiskScore(access)).toBe(75) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) + }) + + test('keeps Scamalytics outages limited instead of hard-blocked', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['res_proxy'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: ['proxy'], + }), + lookupScamalyticsIpRisk: async () => { + throw new Error('Scamalytics unavailable') + }, + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(access.scamalyticsStatus).toBe('failed') + expect(getFreeModePrivacyDecision(access)).toBe( + 'scamalytics_failed_limited', + ) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) + }) + + test('treats Scamalytics API errors as limited, not blocked', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + spurToken: 'test-spur-token', + lookupIpPrivacy: async () => ({ + signals: ['vpn'], + }), + lookupSpurIpPrivacy: async () => ({ + signals: ['vpn'], + }), + lookupScamalyticsIpRisk: async () => null, + }, + ) + + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(access.scamalyticsStatus).toBe('failed') + expect(getFreeModeRiskScore(access)).toBe(75) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) + }) + test('keeps IPinfo VPN/proxy detections in limited mode when Spur lookup fails', async () => { const access = await getFreeModeCountryAccess( makeReq({ @@ -338,6 +570,9 @@ describe('free mode country access', () => { requestedUrl = String(url) return Response.json({ anonymous: { + name: 'ExampleVPN', + last_seen: '2026-05-23', + percent_days_seen: 63, is_proxy: false, is_relay: true, is_tor: true, @@ -358,6 +593,9 @@ describe('free mode country access', () => { expect(requestedUrl).toContain('https://api.ipinfo.io/lookup/') expect(privacy).toEqual({ signals: ['tor', 'relay', 'res_proxy', 'hosting', 'anonymous'], + providerName: 'ExampleVPN', + lastSeen: '2026-05-23', + percentDaysSeen: 63, }) }) @@ -393,6 +631,9 @@ describe('free mode country access', () => { expect(privacy).toEqual({ signals: ['anonymous'], + providerName: null, + lastSeen: null, + percentDaysSeen: null, }) }) @@ -437,6 +678,69 @@ describe('free mode country access', () => { }) }) + test('parses Scamalytics fraud score and proxy signals', async () => { + let requestedUrl = '' + const fetch = async (url: string | URL | Request) => { + requestedUrl = String(url) + return Response.json({ + scamalytics: { + status: 'ok', + scamalytics_score: 88, + scamalytics_risk: 'high', + scamalytics_proxy: { + is_vpn: true, + is_datacenter: true, + is_apple_icloud_private_relay: true, + }, + }, + external_datasources: { + ip2proxy: { + proxy_type: 'PUB', + }, + }, + }) + } + + const risk = await lookupScamalyticsIpRisk({ + ip: '198.51.100.46', + apiKey: 'scamalytics-token', + fetch: fetch as unknown as typeof globalThis.fetch, + }) + + expect(requestedUrl).toBe( + 'https://api11.scamalytics.com/v3/codebuff/?key=scamalytics-token&ip=198.51.100.46', + ) + expect(risk).toEqual({ + signals: ['vpn', 'relay', 'hosting'], + score: 88, + risk: 'high', + }) + }) + + test('parses Scamalytics datasource VPN/Tor types without treating generic proxy labels as hard evidence', () => { + expect( + privacySignalsFromScamalytics({ + external_datasources: { + ip2proxy: { proxy_type: 'VPN' }, + ip2proxy_lite: { proxy_type: 'PUB', usage_type: 'DCH' }, + x4bnet: { is_tor: true }, + }, + }), + ).toEqual(['vpn', 'hosting', 'tor']) + }) + + test('parses top-level Scamalytics proxy evidence', () => { + expect( + privacySignalsFromScamalytics({ + scamalytics: { + scamalytics_proxy: { + is_proxy: true, + }, + }, + }), + ).toEqual(['proxy']) + }) + test('parses Tor from Spur tunnel operator context', () => { expect( privacySignalsFromSpur({ @@ -539,6 +843,9 @@ describe('free mode country access', () => { expect(privacy).toEqual({ signals: ['service', 'anonymous'], + providerName: 'Privacy Provider', + lastSeen: null, + percentDaysSeen: null, }) }) }) diff --git a/web/src/server/free-mode-country-access-cache.ts b/web/src/server/free-mode-country-access-cache.ts index 691ac8e0a..07ed05add 100644 --- a/web/src/server/free-mode-country-access-cache.ts +++ b/web/src/server/free-mode-country-access-cache.ts @@ -8,6 +8,7 @@ import { getFreeModeCountryAccess, getFreeModePrivacyDecision, getFreeModePrivacyProviderDecision, + getFreeModeRiskScore, hasHardBlockedPrivacySignal, hashClientIp, IPINFO_PRIVACY_CACHE_TTL_MS, @@ -51,12 +52,15 @@ export function shouldCacheCountryAccess( export function shouldIgnoreCountryAccessCacheRow( row: Pick< typeof schema.freeModeCountryAccessCache.$inferSelect, - 'country_block_reason' | 'ip_privacy_signals' | 'spur_status' + | 'country_block_reason' + | 'ip_privacy_signals' + | 'spur_status' + | 'scamalytics_status' >, ): boolean { return ( row.country_block_reason === 'anonymous_network' && - row.spur_status === null && + (row.spur_status === null || row.scamalytics_status === null) && hasHardBlockedPrivacySignal( row.ip_privacy_signals ? { signals: row.ip_privacy_signals } : null, ) @@ -78,7 +82,7 @@ export function expiresAtForCountryAccess( ttlMs = FREE_MODE_COUNTRY_CACHE_ALLOWED_TTL_MS } else if ( access.blockReason === 'anonymous_network' && - access.spurStatus === 'failed' + (access.spurStatus === 'failed' || access.scamalyticsStatus === 'failed') ) { ttlMs = FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS } else if (access.blockReason === 'anonymous_network') { @@ -99,12 +103,21 @@ function countryAccessFromCacheRow( cfCountry: row.cf_country, geoipCountry: row.geoip_country, ipPrivacy: row.ip_privacy_signals - ? { signals: row.ip_privacy_signals } + ? { + signals: row.ip_privacy_signals, + } : null, spurIpPrivacy: row.spur_ip_privacy_signals ? { signals: row.spur_ip_privacy_signals } : null, spurStatus: row.spur_status ?? 'not_checked', + scamalyticsIpPrivacy: row.scamalytics_ip_privacy_signals + ? { signals: row.scamalytics_ip_privacy_signals } + : null, + scamalyticsStatus: row.scamalytics_status ?? 'not_checked', + scamalyticsScore: row.scamalytics_score, + scamalyticsRisk: row.scamalytics_risk, + riskScore: row.risk_score, hasClientIp: true, clientIpHash: row.client_ip_hash, } @@ -137,6 +150,7 @@ export const dbFreeModeCountryAccessCacheStore: FreeModeCountryAccessCacheStore const expiresAt = expiresAtForCountryAccess(access, now) const privacyDecision = getFreeModePrivacyDecision(access) const privacyProviderDecision = getFreeModePrivacyProviderDecision(access) + const riskScore = getFreeModeRiskScore(access) await db .insert(schema.freeModeCountryAccessCache) .values({ @@ -150,6 +164,12 @@ export const dbFreeModeCountryAccessCacheStore: FreeModeCountryAccessCacheStore ip_privacy_signals: access.ipPrivacy?.signals ?? null, spur_ip_privacy_signals: access.spurIpPrivacy?.signals ?? null, spur_status: access.spurStatus, + scamalytics_ip_privacy_signals: + access.scamalyticsIpPrivacy?.signals ?? null, + scamalytics_status: access.scamalyticsStatus, + scamalytics_score: access.scamalyticsScore, + scamalytics_risk: access.scamalyticsRisk, + risk_score: riskScore, privacy_decision: privacyDecision, privacy_provider_decision: privacyProviderDecision, checked_at: now, @@ -171,6 +191,12 @@ export const dbFreeModeCountryAccessCacheStore: FreeModeCountryAccessCacheStore ip_privacy_signals: access.ipPrivacy?.signals ?? null, spur_ip_privacy_signals: access.spurIpPrivacy?.signals ?? null, spur_status: access.spurStatus, + scamalytics_ip_privacy_signals: + access.scamalyticsIpPrivacy?.signals ?? null, + scamalytics_status: access.scamalyticsStatus, + scamalytics_score: access.scamalyticsScore, + scamalytics_risk: access.scamalyticsRisk, + risk_score: riskScore, privacy_decision: privacyDecision, privacy_provider_decision: privacyProviderDecision, checked_at: now, diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 1b0845e80..a16a68df4 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -13,6 +13,7 @@ import type { FreebuffIpPrivacySignal, FreebuffPrivacyDecision, FreebuffPrivacyProviderDecision, + FreebuffScamalyticsStatus, FreebuffSpurStatus, } from '@codebuff/common/types/freebuff-session' @@ -55,6 +56,9 @@ export type FreeModeIpPrivacySignal = FreebuffIpPrivacySignal export type FreeModeIpPrivacy = { signals: FreeModeIpPrivacySignal[] + providerName?: string | null + lastSeen?: string | null + percentDaysSeen?: number | null } export type FreeModeCountryAccess = { @@ -66,6 +70,11 @@ export type FreeModeCountryAccess = { ipPrivacy: FreeModeIpPrivacy | null spurIpPrivacy: FreeModeIpPrivacy | null spurStatus: FreebuffSpurStatus + scamalyticsIpPrivacy: FreeModeIpPrivacy | null + scamalyticsStatus: FreebuffScamalyticsStatus + scamalyticsScore: number | null + scamalyticsRisk: string | null + riskScore?: number | null hasClientIp: boolean clientIpHash: string | null } @@ -78,6 +87,15 @@ export type LookupSpurIpPrivacyFn = ( ip: string, ) => Promise +export type FreeModeScamalyticsIpRisk = FreeModeIpPrivacy & { + score: number | null + risk: string | null +} + +export type LookupScamalyticsIpRiskFn = ( + ip: string, +) => Promise + export function getFreeModeAccessTier( countryAccess: Pick, ): FreebuffAccessTier { @@ -87,9 +105,12 @@ export function getFreeModeAccessTier( export type FreeModeCountryAccessOptions = { lookupIpPrivacy?: LookupIpPrivacyFn lookupSpurIpPrivacy?: LookupSpurIpPrivacyFn + lookupScamalyticsIpRisk?: LookupScamalyticsIpRiskFn fetch?: typeof globalThis.fetch ipinfoToken: string spurToken: string + scamalyticsApiKey?: string + scamalyticsUser?: string ipHashSecret?: string allowLocalhost?: boolean /** Dev-only escape hatch: when true (and `allowLocalhost` is also true), @@ -123,6 +144,13 @@ const spurPrivacyCache = new Map< string, { expiresAt: number; privacy: FreeModeIpPrivacy | null } >() +const scamalyticsPrivacyCache = new Map< + string, + { expiresAt: number; risk: FreeModeScamalyticsIpRisk | null } +>() + +const SCAMALYTICS_DEFAULT_USER = 'codebuff' +export const SCAMALYTICS_LIMITED_RISK_SCORE = 50 const FREE_MODE_LIMITED_PRIVACY_SIGNALS = new Set([ ...FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS, @@ -138,13 +166,186 @@ export function hasHardBlockedPrivacySignal( return ipPrivacy?.signals.some(isFreebuffHardBlockedPrivacySignal) ?? false } -export function shouldHardBlockFreeModeAccess( +function hasTorPrivacySignal( + ipPrivacy: FreeModeIpPrivacy | null | undefined, +): boolean { + return ipPrivacy?.signals.includes('tor') ?? false +} + +function hasResidentialProxySignal( + ipPrivacy: FreeModeIpPrivacy | null | undefined, +): boolean { + return ipPrivacy?.signals.includes('res_proxy') ?? false +} + +function hasCorroboratedTorSignal( + countryAccess: Partial< + Pick< + FreeModeCountryAccess, + 'ipPrivacy' | 'spurIpPrivacy' | 'scamalyticsIpPrivacy' + > + >, +): boolean { + return ( + hasTorPrivacySignal(countryAccess.ipPrivacy) && + (hasTorPrivacySignal(countryAccess.spurIpPrivacy) || + hasTorPrivacySignal(countryAccess.scamalyticsIpPrivacy)) + ) +} + +function hasCorroboratedResidentialProxySignal( + countryAccess: Partial< + Pick< + FreeModeCountryAccess, + | 'ipPrivacy' + | 'spurIpPrivacy' + | 'scamalyticsIpPrivacy' + | 'scamalyticsScore' + > + >, +): boolean { + const ipinfoResidentialProxy = hasResidentialProxySignal( + countryAccess.ipPrivacy, + ) + const spurResidentialProxy = hasResidentialProxySignal( + countryAccess.spurIpPrivacy, + ) + const scamalyticsResidentialProxy = hasResidentialProxySignal( + countryAccess.scamalyticsIpPrivacy, + ) + const scamalyticsCorroborates = + scamalyticsResidentialProxy || + hasHardBlockedPrivacySignal(countryAccess.scamalyticsIpPrivacy) || + (countryAccess.scamalyticsScore ?? 0) >= SCAMALYTICS_LIMITED_RISK_SCORE + + return ( + (ipinfoResidentialProxy && scamalyticsCorroborates) || + (spurResidentialProxy && scamalyticsCorroborates) || + (scamalyticsResidentialProxy && + (hasHardBlockedPrivacySignal(countryAccess.ipPrivacy) || + hasHardBlockedPrivacySignal(countryAccess.spurIpPrivacy))) + ) +} + +function maxPrivacySignalRisk( + ipPrivacy: FreeModeIpPrivacy | null | undefined, +): number { + let risk = 0 + const hasHardSignal = ipPrivacy?.signals.some( + isFreebuffHardBlockedPrivacySignal, + ) + for (const signal of ipPrivacy?.signals ?? []) { + if (signal === 'tor') risk = Math.max(risk, 100) + else if (isFreebuffHardBlockedPrivacySignal(signal)) { + risk = Math.max(risk, 70) + } else if (signal === 'anonymous' || signal === 'relay') { + risk = Math.max(risk, 55) + } else if (signal === 'hosting' || signal === 'service') { + risk = Math.max(risk, 40) + } + } + if (ipPrivacy?.providerName && hasHardSignal) { + risk = Math.max(risk, 80) + } + if ( + hasHardSignal && + typeof ipPrivacy?.percentDaysSeen === 'number' && + ipPrivacy.percentDaysSeen >= 50 + ) { + risk = Math.max(risk, 85) + } + if (ipPrivacy?.lastSeen && hasHardSignal) { + const lastSeenMs = Date.parse(ipPrivacy.lastSeen) + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000 + if (Number.isFinite(lastSeenMs) && Date.now() - lastSeenMs <= sevenDaysMs) { + risk = Math.max(risk, 85) + } + } + return risk +} + +export function getFreeModeRiskScore( countryAccess: Pick< FreeModeCountryAccess, - 'cfCountry' + | 'blockReason' + | 'cfCountry' + | 'ipPrivacy' + | 'spurIpPrivacy' + | 'spurStatus' + | 'scamalyticsIpPrivacy' + | 'scamalyticsStatus' + | 'scamalyticsScore' + | 'riskScore' >, +): number { + if (typeof countryAccess.riskScore === 'number') { + return countryAccess.riskScore + } + + if (countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY) return 100 + + let score = 0 + if (countryAccess.blockReason === 'country_not_allowed') score = 35 + if ( + countryAccess.blockReason === 'missing_client_ip' || + countryAccess.blockReason === 'unresolved_client_ip' || + countryAccess.blockReason === 'anonymized_or_unknown_country' + ) { + score = Math.max(score, 50) + } + if (countryAccess.blockReason === 'ip_privacy_lookup_failed') { + score = Math.max(score, 55) + } + + score = Math.max(score, maxPrivacySignalRisk(countryAccess.ipPrivacy)) + score = Math.max(score, maxPrivacySignalRisk(countryAccess.spurIpPrivacy)) + score = Math.max( + score, + maxPrivacySignalRisk(countryAccess.scamalyticsIpPrivacy), + ) + if (countryAccess.spurStatus === 'failed') score = Math.max(score, 55) + if (countryAccess.spurStatus === 'suspicious') score = Math.max(score, 75) + if (countryAccess.scamalyticsStatus === 'failed') { + score = Math.max(score, 55) + } + if (countryAccess.scamalyticsStatus === 'suspicious') { + score = Math.max( + score, + countryAccess.scamalyticsScore ?? SCAMALYTICS_LIMITED_RISK_SCORE, + ) + } + if (typeof countryAccess.scamalyticsScore === 'number') { + score = Math.max(score, countryAccess.scamalyticsScore) + } + if (hasCorroboratedTorSignal(countryAccess)) { + score = Math.max(score, 95) + } + if (hasCorroboratedResidentialProxySignal(countryAccess)) { + score = Math.max(score, 95) + } + + return Math.min(100, Math.max(0, Math.round(score))) +} + +export function shouldHardBlockFreeModeAccess( + countryAccess: Pick & + Partial< + Pick< + FreeModeCountryAccess, + | 'blockReason' + | 'ipPrivacy' + | 'spurIpPrivacy' + | 'scamalyticsIpPrivacy' + | 'scamalyticsScore' + > + >, ): boolean { - return countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY + if (countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY) return true + if (countryAccess.blockReason !== 'anonymous_network') return false + return ( + hasCorroboratedTorSignal(countryAccess) || + hasCorroboratedResidentialProxySignal(countryAccess) + ) } export function getFreeModePrivacyDecision( @@ -156,6 +357,9 @@ export function getFreeModePrivacyDecision( | 'ipPrivacy' | 'spurIpPrivacy' | 'spurStatus' + | 'scamalyticsIpPrivacy' + | 'scamalyticsStatus' + | 'scamalyticsScore' >, ): FreebuffPrivacyDecision { if (countryAccess.allowed) { @@ -171,15 +375,18 @@ export function getFreeModePrivacyDecision( return 'ipinfo_failed_limited' } if (countryAccess.blockReason === 'anonymous_network') { - if ( - hasHardBlockedPrivacySignal(countryAccess.ipPrivacy) && - hasHardBlockedPrivacySignal(countryAccess.spurIpPrivacy) - ) { + if (shouldHardBlockFreeModeAccess(countryAccess)) { return 'corroborated_block' } if (countryAccess.spurStatus === 'failed') { return 'spur_failed_limited' } + if (countryAccess.scamalyticsStatus === 'failed') { + return 'scamalytics_failed_limited' + } + if (countryAccess.scamalyticsStatus === 'suspicious') { + return 'scamalytics_suspicious_limited' + } } return 'limited_other' } @@ -192,6 +399,7 @@ export function getFreeModePrivacyProviderDecision( | 'ipPrivacy' | 'spurIpPrivacy' | 'spurStatus' + | 'scamalyticsStatus' >, ): FreebuffPrivacyProviderDecision { if (countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY) { @@ -209,6 +417,15 @@ export function getFreeModePrivacyProviderDecision( if (countryAccess.spurStatus === 'failed') { return 'spur_failed' } + if (countryAccess.scamalyticsStatus === 'failed') { + return 'scamalytics_failed' + } + if ( + countryAccess.spurStatus === 'clean' && + countryAccess.scamalyticsStatus === 'suspicious' + ) { + return 'scamalytics_only' + } if (countryAccess.spurStatus === 'clean') { return 'ipinfo_only' } @@ -279,6 +496,22 @@ function setSpurPrivacyCache( }) } +function setScamalyticsPrivacyCache( + ip: string, + risk: FreeModeScamalyticsIpRisk | null, +): void { + while (scamalyticsPrivacyCache.size >= IPINFO_PRIVACY_CACHE_MAX_ENTRIES) { + const oldestIp = scamalyticsPrivacyCache.keys().next().value + if (!oldestIp) break + scamalyticsPrivacyCache.delete(oldestIp) + } + + scamalyticsPrivacyCache.set(ip, { + expiresAt: Date.now() + IPINFO_PRIVACY_CACHE_TTL_MS, + risk, + }) +} + function privacySignalsFromIpinfo( data: Record, ): FreeModeIpPrivacySignal[] { @@ -307,6 +540,33 @@ function privacySignalsFromIpinfo( return signals } +function privacyMetadataFromIpinfo( + data: Record, +): Pick { + const anonymous = + data.anonymous && typeof data.anonymous === 'object' + ? (data.anonymous as Record) + : {} + + return { + providerName: + typeof anonymous.name === 'string' && anonymous.name.length > 0 + ? anonymous.name + : typeof data.service === 'string' && data.service.length > 0 + ? data.service + : null, + lastSeen: + typeof anonymous.last_seen === 'string' && anonymous.last_seen.length > 0 + ? anonymous.last_seen + : null, + percentDaysSeen: + typeof anonymous.percent_days_seen === 'number' && + Number.isFinite(anonymous.percent_days_seen) + ? anonymous.percent_days_seen + : null, + } +} + function pushUniqueSignal( signals: FreeModeIpPrivacySignal[], signal: FreeModeIpPrivacySignal, @@ -380,6 +640,95 @@ export function privacySignalsFromSpur( return signals } +function pushScamalyticsProxyType( + signals: FreeModeIpPrivacySignal[], + proxyType: unknown, + includeGenericProxy: boolean, +): void { + if (typeof proxyType !== 'string') return + const normalized = proxyType.toUpperCase() + if (normalized === 'TOR') { + pushUniqueSignal(signals, 'tor') + } else if (normalized === 'VPN') { + pushUniqueSignal(signals, 'vpn') + } else if ( + includeGenericProxy && + (normalized === 'PUB' || + normalized === 'WEB' || + normalized.includes('PROXY')) + ) { + pushUniqueSignal(signals, 'proxy') + } else if (normalized === 'DCH' || normalized === 'SES') { + pushUniqueSignal(signals, 'hosting') + } +} + +function scamalyticsRoot( + data: Record, +): Record { + return data.scamalytics && typeof data.scamalytics === 'object' + ? (data.scamalytics as Record) + : data +} + +function numberFromScamalyticsValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +export function privacySignalsFromScamalytics( + data: Record, +): FreeModeIpPrivacySignal[] { + const root = scamalyticsRoot(data) + const signals: FreeModeIpPrivacySignal[] = [] + const proxy = + root.scamalytics_proxy && typeof root.scamalytics_proxy === 'object' + ? (root.scamalytics_proxy as Record) + : {} + + if (proxy.is_vpn === true) pushUniqueSignal(signals, 'vpn') + if (proxy.is_tor === true) pushUniqueSignal(signals, 'tor') + if (proxy.is_proxy === true || proxy.is_public_proxy === true) { + pushUniqueSignal(signals, 'proxy') + } + if (proxy.is_web_proxy === true) pushUniqueSignal(signals, 'proxy') + if (proxy.is_residential_proxy === true || proxy.is_res_proxy === true) { + pushUniqueSignal(signals, 'res_proxy') + } + if (proxy.is_apple_icloud_private_relay === true) { + pushUniqueSignal(signals, 'relay') + } + if ( + proxy.is_datacenter === true || + proxy.is_amazon_aws === true || + proxy.is_google === true + ) { + pushUniqueSignal(signals, 'hosting') + } + + const external = + data.external_datasources && typeof data.external_datasources === 'object' + ? (data.external_datasources as Record) + : {} + for (const source of Object.values(external)) { + if (!source || typeof source !== 'object') continue + const sourceRecord = source as Record + if (sourceRecord.is_vpn === true) pushUniqueSignal(signals, 'vpn') + if (sourceRecord.is_tor === true) pushUniqueSignal(signals, 'tor') + if (sourceRecord.is_datacenter === true) { + pushUniqueSignal(signals, 'hosting') + } + pushScamalyticsProxyType(signals, sourceRecord.proxy_type, false) + pushScamalyticsProxyType(signals, sourceRecord.usage_type, false) + } + + return signals +} + export async function lookupIpinfoPrivacy(params: { ip: string token: string @@ -401,6 +750,7 @@ export async function lookupIpinfoPrivacy(params: { const signals = privacySignalsFromIpinfo(data) const privacy = { signals, + ...privacyMetadataFromIpinfo(data), } setIpinfoPrivacyCache(params.ip, privacy) return privacy @@ -436,6 +786,53 @@ export async function lookupSpurIpPrivacy(params: { return privacy } +export async function lookupScamalyticsIpRisk(params: { + ip: string + user?: string + apiKey: string + fetch: typeof globalThis.fetch +}): Promise { + const cached = scamalyticsPrivacyCache.get(params.ip) + if (cached && cached.expiresAt > Date.now()) { + return cached.risk + } + + if (!params.apiKey) return null + + const user = params.user ?? SCAMALYTICS_DEFAULT_USER + const response = await params.fetch( + `https://api11.scamalytics.com/v3/${encodeURIComponent( + user, + )}/?key=${encodeURIComponent(params.apiKey)}&ip=${encodeURIComponent( + params.ip, + )}`, + ) + if (!response.ok) { + return null + } + + const data = (await response.json()) as Record + const root = scamalyticsRoot(data) + if (root.status && root.status !== 'ok') { + return null + } + + const risk = { + signals: privacySignalsFromScamalytics(data), + score: + numberFromScamalyticsValue(root.scamalytics_score) ?? + numberFromScamalyticsValue(root.score), + risk: + typeof root.scamalytics_risk === 'string' + ? root.scamalytics_risk + : typeof root.risk === 'string' + ? root.risk + : null, + } + setScamalyticsPrivacyCache(params.ip, risk) + return risk +} + async function lookupSpurPrivacyStatus( clientIp: string, options: FreeModeCountryAccessOptions, @@ -461,11 +858,49 @@ async function lookupSpurPrivacyStatus( } } +async function lookupScamalyticsStatus( + clientIp: string, + options: FreeModeCountryAccessOptions, +): Promise<{ + risk: FreeModeScamalyticsIpRisk | null + status: FreebuffScamalyticsStatus +}> { + try { + const risk = options.lookupScamalyticsIpRisk + ? await options.lookupScamalyticsIpRisk(clientIp) + : await lookupScamalyticsIpRisk({ + ip: clientIp, + user: options.scamalyticsUser, + apiKey: options.scamalyticsApiKey ?? '', + fetch: options.fetch ?? globalThis.fetch, + }) + if (!risk) return { risk: null, status: 'failed' } + const score = risk.score ?? 0 + return { + risk, + status: + hasHardBlockedPrivacySignal(risk) || + score >= SCAMALYTICS_LIMITED_RISK_SCORE + ? 'suspicious' + : 'clean', + } + } catch { + return { risk: null, status: 'failed' } + } +} + const NOT_CHECKED_SPUR_CONTEXT = { spurIpPrivacy: null, spurStatus: 'not_checked' as const, } +const NOT_CHECKED_SCAMALYTICS_CONTEXT = { + scamalyticsIpPrivacy: null, + scamalyticsStatus: 'not_checked' as const, + scamalyticsScore: null, + scamalyticsRisk: null, +} + export async function getFreeModeCountryAccess( req: NextRequest, options: FreeModeCountryAccessOptions, @@ -493,6 +928,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: { signals: [] }, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: Boolean(clientIp), // Null hash skips the country-access cache so toggling the env var // takes effect immediately without evicting prior allowed=true rows. @@ -507,6 +943,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: { signals: [] }, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: Boolean(clientIp), clientIpHash, } @@ -522,6 +959,7 @@ export async function getFreeModeCountryAccess( ipPrivacy: cfCountry === CLOUDFLARE_TOR_COUNTRY ? { signals: ['tor'] } : null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: Boolean(clientIp), clientIpHash, } @@ -535,6 +973,7 @@ export async function getFreeModeCountryAccess( cfCountry, geoipCountry: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: Boolean(clientIp), clientIpHash, } @@ -547,6 +986,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: false, clientIpHash, } @@ -561,6 +1001,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: true, clientIpHash, } @@ -571,6 +1012,7 @@ export async function getFreeModeCountryAccess( cfCountry: null, geoipCountry, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: true, clientIpHash, } @@ -583,6 +1025,7 @@ export async function getFreeModeCountryAccess( blockReason: 'country_not_allowed', ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, clientIpHash, } } @@ -596,6 +1039,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, hasClientIp: false, clientIpHash, } @@ -621,6 +1065,7 @@ export async function getFreeModeCountryAccess( blockReason: 'ip_privacy_lookup_failed', ipPrivacy: null, ...NOT_CHECKED_SPUR_CONTEXT, + ...NOT_CHECKED_SCAMALYTICS_CONTEXT, clientIpHash, } } @@ -630,10 +1075,28 @@ export async function getFreeModeCountryAccess( FREE_MODE_LIMITED_PRIVACY_SIGNALS.has(signal), ) ) { - const { privacy: spurIpPrivacy, status: spurStatus } = - await lookupSpurPrivacyStatus(clientIp, options) + const [ + { privacy: spurIpPrivacy, status: spurStatus }, + { risk: scamalyticsIpRisk, status: scamalyticsStatus }, + ] = await Promise.all([ + lookupSpurPrivacyStatus(clientIp, options), + lookupScamalyticsStatus(clientIp, options), + ]) + const scamalyticsContext = { + scamalyticsIpPrivacy: scamalyticsIpRisk + ? { signals: scamalyticsIpRisk.signals } + : null, + scamalyticsStatus, + scamalyticsScore: scamalyticsIpRisk?.score ?? null, + scamalyticsRisk: scamalyticsIpRisk?.risk ?? null, + } - if (spurIpPrivacy && spurStatus === 'clean') { + if ( + spurIpPrivacy && + spurStatus === 'clean' && + scamalyticsIpRisk && + scamalyticsStatus === 'clean' + ) { return { ...baseAccess, allowed: true, @@ -641,6 +1104,7 @@ export async function getFreeModeCountryAccess( ipPrivacy, spurIpPrivacy, spurStatus, + ...scamalyticsContext, clientIpHash, } } @@ -652,6 +1116,7 @@ export async function getFreeModeCountryAccess( ipPrivacy, spurIpPrivacy, spurStatus, + ...scamalyticsContext, clientIpHash, } }