From 292a6701669e5cf9fe580f8e40e7a31d351eaaf1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 27 Apr 2026 14:08:47 -0700 Subject: [PATCH 1/3] Harden freebuff country gating --- common/src/types/freebuff-session.ts | 1 + docs/freebuff-waiting-room.md | 10 + .../db/migrations/0047_tough_silver_fox.sql | 7 + .../src/db/migrations/meta/0047_snapshot.json | 3349 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/internal/src/db/schema.ts | 21 + web/src/app/api/v1/chat/completions/_post.ts | 1 + .../session/__tests__/session.test.ts | 72 +- .../app/api/v1/freebuff/session/_handlers.ts | 75 +- .../__tests__/free-mode-country.test.ts | 51 +- web/src/server/free-mode-country.ts | 53 +- web/src/server/free-session/public-api.ts | 21 +- web/src/server/free-session/store.ts | 41 +- web/src/server/free-session/types.ts | 21 + 14 files changed, 3686 insertions(+), 44 deletions(-) create mode 100644 packages/internal/src/db/migrations/0047_tough_silver_fox.sql create mode 100644 packages/internal/src/db/migrations/meta/0047_snapshot.json diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index eff5abff7..31fc4c87e 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -27,6 +27,7 @@ export type FreebuffCountryBlockReason = | 'anonymous_network' | 'missing_client_ip' | 'unresolved_client_ip' + | 'ip_privacy_lookup_failed' export type FreebuffIpPrivacySignal = | 'anonymous' diff --git a/docs/freebuff-waiting-room.md b/docs/freebuff-waiting-room.md index 353bfb046..2d1bc292a 100644 --- a/docs/freebuff-waiting-room.md +++ b/docs/freebuff-waiting-room.md @@ -68,6 +68,13 @@ CREATE TABLE free_session ( status free_session_status NOT NULL, active_instance_id text NOT NULL, model text NOT NULL, + country_code text, + cf_country text, + geoip_country text, + country_block_reason text, + ip_privacy_signals text[], + client_ip_hash text, + country_checked_at timestamptz, queued_at timestamptz NOT NULL DEFAULT now(), admitted_at timestamptz, expires_at timestamptz, @@ -87,6 +94,7 @@ Migrations: `packages/internal/src/db/migrations/0043_vengeful_boomer.sql` (init - **PK on `user_id`** is the structural enforcement of "one session per account". No app-logic race can produce two rows for one user. - **`active_instance_id`** rotates on every `POST /session` call. This is how we enforce one-CLI-at-a-time (see [Single-instance enforcement](#single-instance-enforcement)). - **`model` column.** Populated by the POST handler; determines which queue the row belongs to while queued and is fixed for the life of an active session. Switching models while an active session is live is rejected (`model_locked`, 409). +- **Country/privacy columns.** Populated from the POST `/session` country gate so active-session audits can see the resolved country, Cloudflare country header, GeoIP fallback country, IPinfo privacy signals, and a keyed hash of the client IP. Raw IPs are not stored. - **All timestamps server-supplied.** The client never sends `queued_at`, `admitted_at`, or `expires_at` — they are either `DEFAULT now()` or computed server-side during admission. - **FK CASCADE on user delete** keeps the table clean without a background job. @@ -170,6 +178,8 @@ 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 allowlisted country and a successful IPinfo privacy check. IPinfo `anonymous`, `vpn`, `proxy`, `tor`, `relay`, `res_proxy`, `hosting`, and `service` signals are blocked; privacy lookup failures fail closed. + Response shapes: ```jsonc diff --git a/packages/internal/src/db/migrations/0047_tough_silver_fox.sql b/packages/internal/src/db/migrations/0047_tough_silver_fox.sql new file mode 100644 index 000000000..a7d74f259 --- /dev/null +++ b/packages/internal/src/db/migrations/0047_tough_silver_fox.sql @@ -0,0 +1,7 @@ +ALTER TABLE "free_session" ADD COLUMN "country_code" text;--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "cf_country" text;--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "geoip_country" text;--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "country_block_reason" text;--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "ip_privacy_signals" text[];--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "client_ip_hash" text;--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "country_checked_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0047_snapshot.json b/packages/internal/src/db/migrations/meta/0047_snapshot.json new file mode 100644 index 000000000..e3595d19f --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0047_snapshot.json @@ -0,0 +1,3349 @@ +{ + "id": "2ffc0154-8a10-49e5-8c2c-bdb2e842b239", + "prevId": "3bf6a16c-2fd6-4c9d-a395-f4ca2c080a3c", + "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_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 + }, + "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 + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "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 + }, + "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": {}, + "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.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": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 78747c831..1b1cd510d 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -330,6 +330,13 @@ "when": 1776898844362, "tag": "0046_cloudy_firedrake", "breakpoints": true + }, + { + "idx": 47, + "version": "7", + "when": 1777317033289, + "tag": "0047_tough_silver_fox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 6fed8a703..b152c2a91 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -19,6 +19,10 @@ import { ReferralStatusValues } from '../types/referral' import type { SQL } from 'drizzle-orm' import type { AdapterAccount } from 'next-auth/adapters' +import type { + FreebuffCountryBlockReason, + FreebuffIpPrivacySignal, +} from '@codebuff/common/types/freebuff-session' export const ReferralStatus = pgEnum('referral_status', [ ReferralStatusValues[0], @@ -836,6 +840,23 @@ export const freeSession = pgTable( * its own queue (admission picks one queued user per model per tick) and * the model is fixed for the life of an active session. */ model: text('model').notNull(), + /** Resolved country/privacy metadata from the latest successful + * free-session POST country gate. Raw IP is not stored; `client_ip_hash` + * is HMAC-SHA256 with the server auth secret for correlation only. */ + country_code: text('country_code'), + cf_country: text('cf_country'), + geoip_country: text('geoip_country'), + country_block_reason: text( + 'country_block_reason', + ).$type(), + ip_privacy_signals: text('ip_privacy_signals') + .array() + .$type(), + client_ip_hash: text('client_ip_hash'), + country_checked_at: timestamp('country_checked_at', { + mode: 'date', + withTimezone: true, + }), queued_at: timestamp('queued_at', { mode: 'date', withTimezone: true, diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index ca252682f..b49a30aba 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -259,6 +259,7 @@ export async function postChatCompletions(params: { const countryAccess = await getFreeModeCountryAccess(req, { fetch, ipinfoToken: env.IPINFO_TOKEN, + ipHashSecret: env.NEXTAUTH_SECRET, }) logger.info( 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 3e08ef944..8b0d592c4 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 @@ -9,12 +9,68 @@ import { } from '../_handlers' import type { FreebuffSessionDeps } from '../_handlers' +import type { FreeModeCountryAccess } from '@/server/free-mode-country' import type { SessionDeps } from '@/server/free-session/public-api' import type { InternalSessionRow } from '@/server/free-session/types' import type { NextRequest } from 'next/server' const DEFAULT_MODEL = 'minimax/minimax-m2.7' +function testCountryAccess(req: NextRequest): FreeModeCountryAccess { + const cfCountry = req.headers.get('cf-ipcountry')?.toUpperCase() ?? null + const hasClientIp = Boolean( + req.headers.get('x-forwarded-for') ?? + req.headers.get('cf-connecting-ip') ?? + req.headers.get('x-real-ip'), + ) + if (cfCountry === 'T1' || cfCountry === 'XX') { + return { + allowed: false, + countryCode: null, + blockReason: 'anonymized_or_unknown_country', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp, + clientIpHash: hasClientIp ? 'test-ip-hash' : null, + } + } + if (!cfCountry || !hasClientIp) { + return { + allowed: false, + countryCode: null, + blockReason: 'missing_client_ip', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp, + clientIpHash: hasClientIp ? 'test-ip-hash' : null, + } + } + if (cfCountry !== 'US') { + return { + allowed: false, + countryCode: cfCountry, + blockReason: 'country_not_allowed', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp, + clientIpHash: 'test-ip-hash', + } + } + return { + allowed: true, + countryCode: cfCountry, + blockReason: null, + cfCountry, + geoipCountry: null, + ipPrivacy: { signals: [] }, + hasClientIp, + clientIpHash: 'test-ip-hash', + } +} + function makeReq( apiKey: string | null, opts: { @@ -71,12 +127,19 @@ function makeSessionDeps(overrides: Partial = {}): SessionDeps & { endSession: async (userId) => { rows.delete(userId) }, - joinOrTakeOver: async ({ userId, model, now }) => { + joinOrTakeOver: async ({ userId, model, now, countryAccess }) => { const r: InternalSessionRow = { user_id: userId, status: 'queued', active_instance_id: `inst-${++instanceCounter}`, model, + country_code: countryAccess?.countryCode ?? null, + cf_country: countryAccess?.cfCountry ?? null, + geoip_country: countryAccess?.geoipCountry ?? null, + country_block_reason: countryAccess?.blockReason ?? null, + ip_privacy_signals: countryAccess?.ipPrivacySignals ?? null, + client_ip_hash: countryAccess?.clientIpHash ?? null, + country_checked_at: countryAccess?.checkedAt ?? null, queued_at: now, admitted_at: null, expires_at: null, @@ -104,6 +167,7 @@ function makeDeps( ): FreebuffSessionDeps { return { logger: LOGGER as unknown as FreebuffSessionDeps['logger'], + getCountryAccess: async (req) => testCountryAccess(req), getUserInfoFromApiKey: (async () => userId ? { id: userId, banned: opts.banned ?? false } @@ -141,6 +205,12 @@ describe('POST /api/v1/freebuff/session', () => { const body = await resp.json() expect(body.status).toBe('queued') expect(body.instanceId).toBe('inst-1') + expect(sessionDeps.rows.get('u1')).toMatchObject({ + country_code: 'US', + cf_country: 'US', + ip_privacy_signals: [], + client_ip_hash: 'test-ip-hash', + }) }) test('returns disabled when waiting room flag is off', async () => { diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 3418f188b..89af544f7 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -9,6 +9,8 @@ import { import { getFreeModeCountryAccess } from '@/server/free-mode-country' import { extractApiKeyFromHeader } from '@/util/auth' +import type { FreeModeCountryAccess } from '@/server/free-mode-country' +import type { FreeSessionCountryAccessMetadata } from '@/server/free-session/types' import type { SessionDeps } from '@/server/free-session/public-api' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { Logger } from '@codebuff/common/types/contracts/logger' @@ -23,24 +25,60 @@ import type { NextRequest } from 'next/server' * `country_blocked` status and would tight-poll on an unrecognized 200 * body — fall into their existing `!resp.ok` error path and back off on * the 10s error retry cadence. The new CLI parses the 403 body directly. */ -async function countryBlockedResponse( +type GetCountryAccessFn = (req: NextRequest) => Promise + +async function getCountryAccess( req: NextRequest, -): Promise { - const countryAccess = await getFreeModeCountryAccess(req, { - ipinfoToken: env.IPINFO_TOKEN, - }) - if (countryAccess.allowed) return null - return NextResponse.json( - { - status: 'country_blocked', - countryCode: countryAccess.countryCode ?? 'UNKNOWN', - countryBlockReason: countryAccess.blockReason, - ipPrivacySignals: countryAccess.ipPrivacy?.signals, - }, - { status: 403 }, + deps: FreebuffSessionDeps, +): Promise { + return ( + deps.getCountryAccess?.(req) ?? + getFreeModeCountryAccess(req, { + ipinfoToken: env.IPINFO_TOKEN, + ipHashSecret: env.NEXTAUTH_SECRET, + }) ) } +function toSessionCountryAccess( + countryAccess: FreeModeCountryAccess, +): FreeSessionCountryAccessMetadata { + return { + countryCode: countryAccess.countryCode, + cfCountry: countryAccess.cfCountry, + geoipCountry: countryAccess.geoipCountry, + blockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals ?? null, + clientIpHash: countryAccess.clientIpHash, + checkedAt: new Date(), + } +} + +async function countryBlockedResponse( + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise< + | { response: NextResponse; countryAccess: FreeModeCountryAccess } + | { response: null; countryAccess: FreeModeCountryAccess } +> { + const countryAccess = await getCountryAccess(req, deps) + if (countryAccess.allowed) { + return { response: null, countryAccess } + } + return { + response: NextResponse.json( + { + status: 'country_blocked', + countryCode: countryAccess.countryCode ?? 'UNKNOWN', + countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, + }, + { status: 403 }, + ), + countryAccess, + } +} + /** Header the CLI uses to identify which instance is polling. Used by GET to * detect when another CLI on the same account has rotated the id. */ export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id' @@ -51,6 +89,7 @@ export interface FreebuffSessionDeps { getUserInfoFromApiKey: GetUserInfoFromApiKeyFn logger: Logger sessionDeps?: SessionDeps + getCountryAccess?: GetCountryAccessFn } type AuthResult = @@ -133,7 +172,10 @@ export async function postFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const blocked = await countryBlockedResponse(req) + const { response: blocked, countryAccess } = await countryBlockedResponse( + req, + deps, + ) if (blocked) return blocked const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? '' @@ -144,6 +186,7 @@ export async function postFreebuffSession( userEmail: auth.userEmail, userBanned: auth.userBanned, model: requestedModel, + countryAccess: toSessionCountryAccess(countryAccess), deps: deps.sessionDeps, }) // model_locked / model_unavailable are 409 so they're distinguishable @@ -177,7 +220,7 @@ export async function getFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const blocked = await countryBlockedResponse(req) + const { response: blocked } = await countryBlockedResponse(req, deps) if (blocked) return blocked try { diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index 6026c3e01..277e2dd05 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -87,6 +87,26 @@ describe('free mode country access', () => { expect(access.hasClientIp).toBe(true) }) + test('prefers CF-Connecting-IP over X-Forwarded-For', async () => { + let checkedIp = '' + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'cf-connecting-ip': '203.0.113.10', + 'x-forwarded-for': '198.51.100.42', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async (ip) => { + checkedIp = ip + return { signals: [] } + }, + }, + ) + expect(access.allowed).toBe(true) + expect(checkedIp).toBe('203.0.113.10') + }) + test('blocks allowlisted countries when the client IP is an anonymous network', async () => { const access = await getFreeModeCountryAccess( makeReq({ @@ -124,7 +144,7 @@ describe('free mode country access', () => { expect(access.ipPrivacy?.signals).toEqual(['res_proxy']) }) - test('allows allowlisted countries when IPinfo only reports hosting or service', async () => { + test('blocks allowlisted countries when IPinfo reports hosting or service', async () => { const access = await getFreeModeCountryAccess( makeReq({ 'cf-ipcountry': 'US', @@ -137,8 +157,8 @@ describe('free mode country access', () => { }), }, ) - expect(access.allowed).toBe(true) - expect(access.blockReason).toBe(null) + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') expect(access.ipPrivacy?.signals).toEqual(['hosting', 'service']) }) @@ -159,7 +179,7 @@ describe('free mode country access', () => { expect(access.blockReason).toBe(null) }) - test('allows allowlisted countries when privacy lookup fails', async () => { + test('blocks allowlisted countries when privacy lookup fails', async () => { const access = await getFreeModeCountryAccess( makeReq({ 'cf-ipcountry': 'US', @@ -172,8 +192,8 @@ describe('free mode country access', () => { }, }, ) - expect(access.allowed).toBe(true) - expect(access.blockReason).toBe(null) + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('ip_privacy_lookup_failed') expect(access.ipPrivacy).toBe(null) }) @@ -202,10 +222,27 @@ describe('free mode country access', () => { expect(requestedUrl).toContain('https://api.ipinfo.io/lookup/') expect(privacy).toEqual({ - signals: ['tor', 'relay', 'res_proxy', 'hosting'], + signals: ['tor', 'relay', 'res_proxy', 'hosting', 'anonymous'], }) }) + test('hashes client IP when a hash secret is provided', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + ipHashSecret: 'secret', + lookupIpPrivacy: async () => ({ signals: [] }), + }, + ) + expect(access.allowed).toBe(true) + expect(access.clientIpHash).toHaveLength(64) + expect(access.clientIpHash).not.toContain('203.0.113.10') + }) + test('blocks generic IPinfo anonymous results without a specific signal', async () => { const fetch = async () => Response.json({ diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 84c210348..a9eb6fdf0 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -1,3 +1,5 @@ +import { createHmac } from 'node:crypto' + import geoip from 'geoip-lite' import type { NextRequest } from 'next/server' @@ -42,6 +44,7 @@ export type FreeModeCountryAccess = { geoipCountry: string | null ipPrivacy: FreeModeIpPrivacy | null hasClientIp: boolean + clientIpHash: string | null } export type LookupIpPrivacyFn = ( @@ -52,6 +55,7 @@ type FreeModeCountryAccessOptions = { lookupIpPrivacy?: LookupIpPrivacyFn fetch?: typeof globalThis.fetch ipinfoToken: string + ipHashSecret?: string } type ResolvedCountryAccess = Omit< @@ -75,18 +79,30 @@ const FREE_MODE_BLOCKED_PRIVACY_SIGNALS = new Set([ 'tor', 'relay', 'res_proxy', + 'hosting', + 'service', ]) export function extractClientIp(req: NextRequest): string | undefined { + const cfConnectingIp = req.headers.get('cf-connecting-ip')?.trim() + if (cfConnectingIp) return cfConnectingIp + + const realIp = req.headers.get('x-real-ip')?.trim() + if (realIp) return realIp + const forwardedFor = req.headers.get('x-forwarded-for') if (forwardedFor) { return forwardedFor.split(',')[0].trim() } - return ( - req.headers.get('cf-connecting-ip') ?? - req.headers.get('x-real-ip') ?? - undefined - ) + return undefined +} + +function hashClientIp( + clientIp: string | undefined, + secret: string | undefined, +): string | null { + if (!clientIp || !secret) return null + return createHmac('sha256', secret).update(clientIp).digest('hex') } function setIpinfoPrivacyCache( @@ -134,10 +150,7 @@ function privacySignalsFromIpinfo( ) { signals.push('service') } - if ( - data.is_anonymous === true && - !signals.some((signal) => FREE_MODE_BLOCKED_PRIVACY_SIGNALS.has(signal)) - ) { + if (data.is_anonymous === true) { signals.push('anonymous') } return signals @@ -194,6 +207,7 @@ export async function getFreeModeCountryAccess( ): Promise { const cfCountry = req.headers.get('cf-ipcountry')?.toUpperCase() ?? null const clientIp = extractClientIp(req) + const clientIpHash = hashClientIp(clientIp, options.ipHashSecret) if (cfCountry && CLOUDFLARE_ANONYMIZED_OR_UNKNOWN_COUNTRIES.has(cfCountry)) { return { @@ -204,6 +218,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, hasClientIp: Boolean(clientIp), + clientIpHash, } } @@ -215,6 +230,7 @@ export async function getFreeModeCountryAccess( cfCountry, geoipCountry: null, hasClientIp: Boolean(clientIp), + clientIpHash, } } else if (!clientIp) { return { @@ -225,6 +241,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, hasClientIp: false, + clientIpHash, } } else { const geoipCountry = geoip.lookup(clientIp)?.country ?? null @@ -237,6 +254,7 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, hasClientIp: true, + clientIpHash, } } @@ -245,6 +263,7 @@ export async function getFreeModeCountryAccess( cfCountry: null, geoipCountry, hasClientIp: true, + clientIpHash, } } @@ -254,6 +273,7 @@ export async function getFreeModeCountryAccess( allowed: false, blockReason: 'country_not_allowed', ipPrivacy: null, + clientIpHash, } } @@ -266,12 +286,23 @@ export async function getFreeModeCountryAccess( geoipCountry: null, ipPrivacy: null, hasClientIp: false, + clientIpHash, } } const ipPrivacy = await getIpPrivacy(clientIp, options) + if (!ipPrivacy) { + return { + ...baseAccess, + allowed: false, + blockReason: 'ip_privacy_lookup_failed', + ipPrivacy: null, + clientIpHash, + } + } + if ( - ipPrivacy?.signals.some((signal) => + ipPrivacy.signals.some((signal) => FREE_MODE_BLOCKED_PRIVACY_SIGNALS.has(signal), ) ) { @@ -280,6 +311,7 @@ export async function getFreeModeCountryAccess( allowed: false, blockReason: 'anonymous_network', ipPrivacy, + clientIpHash, } } @@ -288,5 +320,6 @@ export async function getFreeModeCountryAccess( allowed: true, blockReason: null, ipPrivacy, + clientIpHash, } } diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index 422795e3a..528cd4ab3 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -29,7 +29,11 @@ import type { FreebuffSessionRateLimit, FreebuffSessionServerResponse, } from '@codebuff/common/types/freebuff-session' -import type { InternalSessionRow, SessionStateResponse } from './types' +import type { + FreeSessionCountryAccessMetadata, + InternalSessionRow, + SessionStateResponse, +} from './types' /** * Per-model admission rate limits. Keyed by freebuff model id; a model not @@ -87,6 +91,7 @@ export interface SessionDeps { userId: string model: string now: Date + countryAccess?: FreeSessionCountryAccessMetadata }) => Promise endSession: (userId: string) => Promise queueDepthsByModel: () => Promise> @@ -225,6 +230,7 @@ export async function requestSession(params: { userId: string model: string userEmail?: string | null | undefined + countryAccess?: FreeSessionCountryAccessMetadata /** True if the account is banned. Short-circuited here so banned bots never * create a queued row — otherwise they inflate `queueDepth` between the * 15s admission ticks that run `evictBanned`. */ @@ -296,6 +302,7 @@ export async function requestSession(params: { userId: params.userId, model, now, + countryAccess: params.countryAccess, }) } catch (err) { if (err instanceof FreeSessionModelLockedError) { @@ -495,7 +502,8 @@ export async function checkSessionAdmissible(params: { return { ok: false, code: 'waiting_room_required', - message: 'No active free session. Call POST /api/v1/freebuff/session first.', + message: + 'No active free session. Call POST /api/v1/freebuff/session first.', } } @@ -503,7 +511,8 @@ export async function checkSessionAdmissible(params: { return { ok: false, code: 'waiting_room_queued', - message: 'You are in the waiting room. Poll GET /api/v1/freebuff/session for your position.', + message: + 'You are in the waiting room. Poll GET /api/v1/freebuff/session for your position.', } } @@ -518,7 +527,8 @@ export async function checkSessionAdmissible(params: { return { ok: false, code: 'session_expired', - message: 'Your free session has expired. Re-join the waiting room via POST /api/v1/freebuff/session.', + message: + 'Your free session has expired. Re-join the waiting room via POST /api/v1/freebuff/session.', } } @@ -526,7 +536,8 @@ export async function checkSessionAdmissible(params: { return { ok: false, code: 'session_superseded', - message: 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + message: + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', } } diff --git a/web/src/server/free-session/store.ts b/web/src/server/free-session/store.ts index ee034cbd7..8831ad7a8 100644 --- a/web/src/server/free-session/store.ts +++ b/web/src/server/free-session/store.ts @@ -6,7 +6,10 @@ import { and, asc, count, eq, gte, lt, sql } from 'drizzle-orm' import { FREEBUFF_ADMISSION_LOCK_ID } from './config' import type { FireworksHealth } from './fireworks-health' -import type { InternalSessionRow } from './types' +import type { + FreeSessionCountryAccessMetadata, + InternalSessionRow, +} from './types' /** Generate a cryptographically random instance id (token). */ export function newInstanceId(): string { @@ -51,13 +54,30 @@ export class FreeSessionModelLockedError extends Error { } } +function countryAccessColumns( + countryAccess: FreeSessionCountryAccessMetadata | undefined, +) { + if (!countryAccess) return {} + return { + country_code: countryAccess.countryCode, + cf_country: countryAccess.cfCountry, + geoip_country: countryAccess.geoipCountry, + country_block_reason: countryAccess.blockReason, + ip_privacy_signals: countryAccess.ipPrivacySignals, + client_ip_hash: countryAccess.clientIpHash, + country_checked_at: countryAccess.checkedAt, + } +} + export async function joinOrTakeOver(params: { userId: string model: string now: Date + countryAccess?: FreeSessionCountryAccessMetadata }): Promise { - const { userId, model, now } = params + const { userId, model, now, countryAccess } = params const nextInstanceId = newInstanceId() + const countryAccessUpdate = countryAccessColumns(countryAccess) // postgres-js does NOT coerce raw JS Date values when they're interpolated // inside a `sql\`...\`` fragment (the column-type hint that Drizzle's @@ -93,6 +113,7 @@ export async function joinOrTakeOver(params: { status: 'queued', active_instance_id: nextInstanceId, model, + ...countryAccessUpdate, queued_at: now, created_at: now, updated_at: now, @@ -108,6 +129,7 @@ export async function joinOrTakeOver(params: { WHEN ${activeUnexpired} AND NOT (${sameModel}) THEN ${schema.freeSession.active_instance_id} ELSE ${nextInstanceId} END`, + ...countryAccessUpdate, updated_at: now, status: sql`CASE WHEN ${activeUnexpired} THEN 'active'::free_session_status ELSE 'queued'::free_session_status END`, // Keep model when active+unexpired (locked); switch otherwise. @@ -256,7 +278,10 @@ export async function queuePositionFor(params: { * Rows whose `expires_at` is in the past but still inside `expires_at + grace` * are kept so an in-flight agent run can finish. Safe to call repeatedly. */ -export async function sweepExpired(now: Date, graceMs: number): Promise { +export async function sweepExpired( + now: Date, + graceMs: number, +): Promise { const cutoff = new Date(now.getTime() - graceMs) const deleted = await db .delete(schema.freeSession) @@ -314,7 +339,10 @@ export async function admitFromQueue(params: { sessionLengthMs: number now: Date health: FireworksHealth -}): Promise<{ admitted: InternalSessionRow[]; skipped: FireworksHealth | null }> { +}): Promise<{ + admitted: InternalSessionRow[] + skipped: FireworksHealth | null +}> { const { model, sessionLengthMs, now, health } = params if (health !== 'healthy') { @@ -345,7 +373,10 @@ export async function admitFromQueue(params: { eq(schema.freeSession.model, model), ), ) - .orderBy(asc(schema.freeSession.queued_at), asc(schema.freeSession.user_id)) + .orderBy( + asc(schema.freeSession.queued_at), + asc(schema.freeSession.user_id), + ) .limit(1) .for('update', { skipLocked: true }) diff --git a/web/src/server/free-session/types.ts b/web/src/server/free-session/types.ts index f46a3ad52..eff3eb134 100644 --- a/web/src/server/free-session/types.ts +++ b/web/src/server/free-session/types.ts @@ -1,4 +1,8 @@ import type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session' +import type { + FreebuffCountryBlockReason, + FreebuffIpPrivacySignal, +} from '@codebuff/common/types/freebuff-session' export type FreeSessionStatus = 'queued' | 'active' @@ -17,9 +21,26 @@ export interface InternalSessionRow { active_instance_id: string /** Freebuff model id this row is queued for (or locked to, once active). */ model: string + country_code?: string | null + cf_country?: string | null + geoip_country?: string | null + country_block_reason?: FreebuffCountryBlockReason | null + ip_privacy_signals?: FreebuffIpPrivacySignal[] | null + client_ip_hash?: string | null + country_checked_at?: Date | null queued_at: Date admitted_at: Date | null expires_at: Date | null created_at: Date updated_at: Date } + +export interface FreeSessionCountryAccessMetadata { + countryCode: string | null + cfCountry: string | null + geoipCountry: string | null + blockReason: FreebuffCountryBlockReason | null + ipPrivacySignals: FreebuffIpPrivacySignal[] | null + clientIpHash: string | null + checkedAt: Date +} From 8233a78d76d8e0746b755f5d11e85f6d1a62853e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 27 Apr 2026 14:22:12 -0700 Subject: [PATCH 2/3] Address Freebuff gating review comments --- .../session/__tests__/session.test.ts | 44 ++++++++++++++- .../app/api/v1/freebuff/session/_handlers.ts | 54 +++++++++++++++---- web/src/server/free-mode-country.ts | 44 ++++++--------- 3 files changed, 102 insertions(+), 40 deletions(-) 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 8b0d592c4..4c55a6458 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 @@ -163,11 +163,15 @@ const LOGGER = { function makeDeps( sessionDeps: SessionDeps, userId: string | null, - opts: { banned?: boolean } = {}, + opts: { + banned?: boolean + getCountryAccess?: FreebuffSessionDeps['getCountryAccess'] + } = {}, ): FreebuffSessionDeps { return { logger: LOGGER as unknown as FreebuffSessionDeps['logger'], - getCountryAccess: async (req) => testCountryAccess(req), + getCountryAccess: + opts.getCountryAccess ?? (async (req) => testCountryAccess(req)), getUserInfoFromApiKey: (async () => userId ? { id: userId, banned: opts.banned ?? false } @@ -332,6 +336,42 @@ describe('GET /api/v1/freebuff/session', () => { expect(body.countryBlockReason).toBe('country_not_allowed') }) + test('skips country recheck on GET when the stored check is recent', async () => { + const sessionDeps = makeSessionDeps() + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'queued', + active_instance_id: 'inst-1', + model: DEFAULT_MODEL, + country_code: 'US', + cf_country: 'US', + geoip_country: null, + country_block_reason: null, + ip_privacy_signals: [], + client_ip_hash: 'test-ip-hash', + country_checked_at: new Date('2026-04-17T11:45:00Z'), + queued_at: new Date('2026-04-17T11:45:00Z'), + admitted_at: null, + expires_at: null, + created_at: new Date('2026-04-17T11:45:00Z'), + updated_at: new Date('2026-04-17T11:45:00Z'), + }) + let countryChecks = 0 + const resp = await getFreebuffSession( + makeReq('ok', { cfCountry: 'FR' }), + makeDeps(sessionDeps, 'u1', { + getCountryAccess: async (req) => { + countryChecks++ + return testCountryAccess(req) + }, + }), + ) + const body = await resp.json() + expect(resp.status).toBe(200) + expect(body.status).toBe('queued') + expect(countryChecks).toBe(0) + }) + test('returns banned 403 on GET for banned user', async () => { const sessionDeps = makeSessionDeps() const resp = await getFreebuffSession( diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 89af544f7..7c6442f20 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -6,11 +6,19 @@ import { getSessionState, requestSession, } from '@/server/free-session/public-api' -import { getFreeModeCountryAccess } from '@/server/free-mode-country' +import { getSessionRow as getStoredSessionRow } from '@/server/free-session/store' +import { + FREE_MODE_ALLOWED_COUNTRIES, + getFreeModeCountryAccess, + IPINFO_PRIVACY_CACHE_TTL_MS, +} from '@/server/free-mode-country' import { extractApiKeyFromHeader } from '@/util/auth' import type { FreeModeCountryAccess } from '@/server/free-mode-country' -import type { FreeSessionCountryAccessMetadata } from '@/server/free-session/types' +import type { + FreeSessionCountryAccessMetadata, + InternalSessionRow, +} from '@/server/free-session/types' import type { SessionDeps } from '@/server/free-session/public-api' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { Logger } from '@codebuff/common/types/contracts/logger' @@ -57,10 +65,10 @@ function toSessionCountryAccess( async function countryBlockedResponse( req: NextRequest, deps: FreebuffSessionDeps, -): Promise< - | { response: NextResponse; countryAccess: FreeModeCountryAccess } - | { response: null; countryAccess: FreeModeCountryAccess } -> { +): Promise<{ + response: NextResponse | null + countryAccess: FreeModeCountryAccess +}> { const countryAccess = await getCountryAccess(req, deps) if (countryAccess.allowed) { return { response: null, countryAccess } @@ -79,6 +87,32 @@ async function countryBlockedResponse( } } +function hasRecentAllowedCountryCheck( + row: InternalSessionRow | null, + now: Date, +): boolean { + if (!row?.country_checked_at || row.country_block_reason !== null) { + return false + } + if (!row.country_code || !FREE_MODE_ALLOWED_COUNTRIES.has(row.country_code)) { + return false + } + return ( + now.getTime() - row.country_checked_at.getTime() < + IPINFO_PRIVACY_CACHE_TTL_MS + ) +} + +async function shouldSkipGetCountryCheck( + userId: string, + deps: FreebuffSessionDeps, +): Promise { + const getSessionRow = deps.sessionDeps?.getSessionRow ?? getStoredSessionRow + const row = await getSessionRow(userId) + const now = deps.sessionDeps?.now?.() ?? new Date() + return hasRecentAllowedCountryCheck(row, now) +} + /** Header the CLI uses to identify which instance is polling. Used by GET to * detect when another CLI on the same account has rotated the id. */ export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id' @@ -220,10 +254,12 @@ export async function getFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const { response: blocked } = await countryBlockedResponse(req, deps) - if (blocked) return blocked - try { + if (!(await shouldSkipGetCountryCheck(auth.userId, deps))) { + const { response: blocked } = await countryBlockedResponse(req, deps) + if (blocked) return blocked + } + const claimedInstanceId = req.headers.get(FREEBUFF_INSTANCE_HEADER) ?? undefined const state = await getSessionState({ diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index a9eb6fdf0..4ad90219c 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -65,7 +65,7 @@ type ResolvedCountryAccess = Omit< countryCode: string } -const IPINFO_PRIVACY_CACHE_TTL_MS = 30 * 60 * 1000 +export const IPINFO_PRIVACY_CACHE_TTL_MS = 30 * 60 * 1000 const IPINFO_PRIVACY_CACHE_MAX_ENTRIES = 5000 const ipinfoPrivacyCache = new Map< string, @@ -109,13 +109,6 @@ function setIpinfoPrivacyCache( ip: string, privacy: FreeModeIpPrivacy | null, ): void { - const now = Date.now() - for (const [cachedIp, cached] of ipinfoPrivacyCache) { - if (cached.expiresAt <= now) { - ipinfoPrivacyCache.delete(cachedIp) - } - } - while (ipinfoPrivacyCache.size >= IPINFO_PRIVACY_CACHE_MAX_ENTRIES) { const oldestIp = ipinfoPrivacyCache.keys().next().value if (!oldestIp) break @@ -123,7 +116,7 @@ function setIpinfoPrivacyCache( } ipinfoPrivacyCache.set(ip, { - expiresAt: now + IPINFO_PRIVACY_CACHE_TTL_MS, + expiresAt: Date.now() + IPINFO_PRIVACY_CACHE_TTL_MS, privacy, }) } @@ -182,25 +175,6 @@ export async function lookupIpinfoPrivacy(params: { return privacy } -async function getIpPrivacy( - clientIp: string | undefined, - options: FreeModeCountryAccessOptions, -): Promise { - if (!clientIp) return null - try { - if (options.lookupIpPrivacy) { - return await options.lookupIpPrivacy(clientIp) - } - return await lookupIpinfoPrivacy({ - ip: clientIp, - token: options.ipinfoToken, - fetch: options.fetch ?? globalThis.fetch, - }) - } catch { - return null - } -} - export async function getFreeModeCountryAccess( req: NextRequest, options: FreeModeCountryAccessOptions, @@ -290,7 +264,19 @@ export async function getFreeModeCountryAccess( } } - const ipPrivacy = await getIpPrivacy(clientIp, options) + let ipPrivacy: FreeModeIpPrivacy | null + try { + ipPrivacy = options.lookupIpPrivacy + ? await options.lookupIpPrivacy(clientIp) + : await lookupIpinfoPrivacy({ + ip: clientIp, + token: options.ipinfoToken, + fetch: options.fetch ?? globalThis.fetch, + }) + } catch { + ipPrivacy = null + } + if (!ipPrivacy) { return { ...baseAccess, From 4d6914bccd6cb85384dd8088add46d091c256b18 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 27 Apr 2026 15:08:54 -0700 Subject: [PATCH 3/3] Fix chat completions country gate tests --- .../app/api/v1/chat/completions/__tests__/completions.test.ts | 4 ++++ 1 file changed, 4 insertions(+) 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 2cee130f0..e0b531c70 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 @@ -140,6 +140,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { // Mock global fetch to return OpenRouter-like responses mockFetch = (async (url: any, options: any) => { + if (String(url).startsWith('https://api.ipinfo.io/lookup/')) { + return Response.json({}) + } + if (!options?.body) { throw new Error('Missing request body') }