From 3265a01db9b12128509c0814b10c325824ed9af5 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Mon, 19 Oct 2026 10:36:32 +0200 Subject: [PATCH 1/3] feat(studio): meter prompts on the server and count limits in postgres Every POST /api/studio/turn now spends a prompt before the proxy forwards it. Guests get 5 a day per network address, an IPv6 address counted by its /64, so a cleared or private browser starts no fresh allowance. Signed-in users get 75 a day by user id. Both reset at 00:00 UTC, and a turn the harness does not start gets its prompt back (src/lib/studio/spendPrompt.ts). The proxy forwards only turn, interrupt, cancel and composition by exact name, so no encoded path reaches the turn route unmetered. The per-IP limiter for /api and /mcp moves from Redis, which production never had, to the limit_counter table (migration 0017). It counts on a two-connection pool that fails open within 2 s. ioredis and REDIS_URL are removed. --- .env.example | 4 +- .gitignore | 2 - README.md | 2 +- db/connection.ts | 12 +- db/migrations/0017_limit_counter.sql | 7 + db/migrations/meta/0017_snapshot.json | 1067 +++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema.ts | 18 + db/test/limit-counter.test.ts | 190 +++ deploy/README.md | 3 +- docs/analytics.md | 8 +- package.json | 1 - pnpm-lock.yaml | 46 - src/app/api/v1/studio/[...path]/route.ts | 56 +- src/components/organisms/Studio/GuestWall.tsx | 6 +- src/components/organisms/Studio/Studio.tsx | 15 +- src/instrumentation.ts | 4 - src/lib/db/index.ts | 42 +- src/lib/proxy/forward.ts | 14 +- src/lib/proxy/limitCounter.ts | 83 ++ src/lib/proxy/rateLimit.ts | 75 +- src/lib/studio/promptAllowance.ts | 12 + src/lib/studio/spendPrompt.ts | 170 +++ src/lib/studio/test/spendPrompt.test.ts | 120 ++ 24 files changed, 1813 insertions(+), 151 deletions(-) create mode 100644 db/migrations/0017_limit_counter.sql create mode 100644 db/migrations/meta/0017_snapshot.json create mode 100644 db/test/limit-counter.test.ts create mode 100644 src/lib/proxy/limitCounter.ts create mode 100644 src/lib/studio/promptAllowance.ts create mode 100644 src/lib/studio/spendPrompt.ts create mode 100644 src/lib/studio/test/spendPrompt.test.ts diff --git a/.env.example b/.env.example index 09dd6bd..4ac96ef 100644 --- a/.env.example +++ b/.env.example @@ -85,9 +85,7 @@ SENTRY_AUTH_TOKEN= # The DFO APIs and the MCP servers are mirrored 1:1 under this site's domain # (/api/search|datasets|graph/* -> dfo-apis, /api/studio/* -> dfo-ai, and # /mcp -> dfo-ai's combined MCP server at /mcp/). /api/* and /mcp have a -# per-IP rate limit shared through Redis. Without REDIS_URL the proxy runs -# without limits and logs one warning at startup. -REDIS_URL= +# per-IP rate limit, counted in the website database (table limit_counter). # RATE_LIMIT_WINDOW_S=60 # RATE_LIMIT_API=120 # RATE_LIMIT_PIN=10 diff --git a/.gitignore b/.gitignore index 58f1170..f0e73d5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,5 @@ CLAUDE.md *.pem npm-debug.log* -# local redis (rate-limit dev runs) -dump.rdb /.claude /.idea diff --git a/README.md b/README.md index 0ddf39c..2c96018 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Around it: frontend contract, the storage/Slack/email sinks and their config, and the local dev loop. - `docs/analytics.md` — usage events (the `event` table and what each - surface records), Sentry, and the guest Studio limit. + surface records), Sentry, and the Studio prompt allowance. ## Repo map diff --git a/db/connection.ts b/db/connection.ts index 41fe53f..a191c19 100644 --- a/db/connection.ts +++ b/db/connection.ts @@ -4,7 +4,7 @@ import { IpAddressTypes, type DriverOptions, } from '@google-cloud/cloud-sql-connector'; -import { Pool } from 'pg'; +import { Pool, type PoolConfig } from 'pg'; /** * The env var contract the migrate entrypoint and dfo-infra's Terraform @@ -214,8 +214,14 @@ export function getCloudSqlRuntimeStream(): DriverOptions['stream'] { * import or build. * - URL: `connectionString` may be `undefined` here (no env at build); pg * only fails to connect with it later. + * + * `sizing` overrides the pool size and adds timeouts, for a pool that must + * give up fast (src/lib/db/index.ts's counter pool). */ -export function buildRuntimePool(env: EnvLike = process.env): Pool { +export function buildRuntimePool( + env: EnvLike = process.env, + sizing: Pick = {}, +): Pool { if (isCloudSqlConfigured(env)) { return new Pool({ stream: () => getCloudSqlRuntimeStream()(), @@ -223,6 +229,7 @@ export function buildRuntimePool(env: EnvLike = process.env): Pool { database: env.WEBSITE_DB_NAME, max: 5, idleTimeoutMillis: 30_000, + ...sizing, }); } @@ -230,5 +237,6 @@ export function buildRuntimePool(env: EnvLike = process.env): Pool { connectionString: env.DATABASE_URL, max: 5, idleTimeoutMillis: 30_000, + ...sizing, }); } diff --git a/db/migrations/0017_limit_counter.sql b/db/migrations/0017_limit_counter.sql new file mode 100644 index 0000000..52485bb --- /dev/null +++ b/db/migrations/0017_limit_counter.sql @@ -0,0 +1,7 @@ +CREATE TABLE "limit_counter" ( + "key" text PRIMARY KEY NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "count" integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX "limit_counter_window_start_idx" ON "limit_counter" USING btree ("window_start"); \ No newline at end of file diff --git a/db/migrations/meta/0017_snapshot.json b/db/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000..9aa5d94 --- /dev/null +++ b/db/migrations/meta/0017_snapshot.json @@ -0,0 +1,1067 @@ +{ + "id": "ab576e2a-72ab-416b-98c1-9290023712db", + "prevId": "0e9c12de-2e86-4b06-a567-f7a48bbf4ae6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.conversation": { + "name": "conversation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turn_count": { + "name": "turn_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "conversation_user_updated_idx": { + "name": "conversation_user_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "conversation_user_id_user_id_fk": { + "name": "conversation_user_id_user_id_fk", + "tableFrom": "conversation", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "event_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "props": { + "name": "props", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "event_created_at_idx": { + "name": "event_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_name_created_at_idx": { + "name": "event_name_created_at_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "feedback_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page": { + "name": "page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "feedback_created_at_idx": { + "name": "feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "feedback_message_length": { + "name": "feedback_message_length", + "value": "char_length(\"feedback\".\"message\") BETWEEN 1 AND 5000" + } + }, + "isRLSEnabled": false + }, + "public.limit_counter": { + "name": "limit_counter", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "limit_counter_window_start_idx": { + "name": "limit_counter_window_start_idx", + "columns": [ + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roadmap_vote": { + "name": "roadmap_vote", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roadmap_vote_entry_idx": { + "name": "roadmap_vote_entry_idx", + "columns": [ + { + "expression": "entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "roadmap_vote_user_id_user_id_fk": { + "name": "roadmap_vote_user_id_user_id_fk", + "tableFrom": "roadmap_vote", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "roadmap_vote_user_id_entry_id_pk": { + "name": "roadmap_vote_user_id_entry_id_pk", + "columns": [ + "user_id", + "entry_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share": { + "name": "share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "indexes": { + "share_user_idx": { + "name": "share_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "share_user_id_user_id_fk": { + "name": "share_user_id_user_id_fk", + "tableFrom": "share", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_card_result": { + "name": "share_card_result", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "card": { + "name": "card", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retryable": { + "name": "retryable", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "retry_after_s": { + "name": "retry_after_s", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fresh_until": { + "name": "fresh_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "share_card_result_share_id_share_id_fk": { + "name": "share_card_result_share_id_share_id_fk", + "tableFrom": "share_card_result", + "tableTo": "share", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "share_card_result_share_id_card_id_pk": { + "name": "share_card_result_share_id_card_id_pk", + "columns": [ + "share_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.share_ref": { + "name": "share_ref", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "part_index": { + "name": "part_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "spec_id": { + "name": "spec_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity": { + "name": "identity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entity_groups": { + "name": "entity_groups", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "variable_text": { + "name": "variable_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_version": { + "name": "content_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifact_digest": { + "name": "artifact_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "share_ref_share_id_share_id_fk": { + "name": "share_ref_share_id_share_id_fk", + "tableFrom": "share_ref", + "tableTo": "share", + "columnsFrom": [ + "share_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "share_ref_share_id_part_index_pk": { + "name": "share_ref_share_id_part_index_pk", + "columns": [ + "share_id", + "part_index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "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": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index de1103f..4ff5540 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1791773358935, "tag": "0016_rename_share_ref_entity_group_dcid", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1792396643977, + "tag": "0017_limit_counter", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index 1ea93d0..27530c1 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -220,3 +220,21 @@ export const event = pgTable( ); export type EventRow = typeof event.$inferSelect; + +/** + * One counter per limiter key (src/lib/proxy/limitCounter): the per-address + * limits on /api and /mcp and the Studio's daily prompt allowances. `key` + * names the allowance and the caller (`api:203.0.113.7`, + * `studio-user:`); `count` is the hits in the window that started + * at `windowStart`. The next hit after the window ends starts the row over, + * and rows idle for two days are deleted. + */ +export const limitCounter = pgTable( + 'limit_counter', + { + key: text('key').primaryKey(), + windowStart: timestamp('window_start', { withTimezone: true }).notNull(), + count: integer('count').notNull(), + }, + (table) => [index('limit_counter_window_start_idx').on(table.windowStart)], +); diff --git a/db/test/limit-counter.test.ts b/db/test/limit-counter.test.ts new file mode 100644 index 0000000..a67ee34 --- /dev/null +++ b/db/test/limit-counter.test.ts @@ -0,0 +1,190 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { countHit, pruneCounters } from '../../src/lib/proxy/limitCounter.ts'; +import { refundPrompt, spendPrompt } from '../../src/lib/studio/spendPrompt.ts'; +import { GUEST_PROMPTS, USER_DAILY_PROMPTS } from '../../src/lib/studio/promptAllowance.ts'; +import { applyMigrations } from '../apply-migrations.ts'; +import { createTestDatabase } from './harness.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); + +const DAY_S = 86_400; + +/** + * The shared limiter (src/lib/proxy/limitCounter) and the Studio's prompt + * allowances on top of it (src/lib/studio/spendPrompt), run as the runtime + * role on a migrated database, so migration 0017's table and the default + * grants are exercised too. + */ +test('limit counters and Studio prompt allowances', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + const db = drizzle(database.runtimePool()); + const admin = database.adminPool(); + + const windowStartOf = async (key: string) => + ( + await admin.query<{ window_start: Date }>( + 'select window_start from limit_counter where key = $1', + [key], + ) + ).rows[0]?.window_start; + + await t.test('the call past the limit in a window is refused', async () => { + const verdicts = []; + + for (let i = 0; i < 4; i++) { + verdicts.push(await countHit(db, 'api:203.0.113.7', 3, 60)); + } + + assert.deepEqual( + verdicts.map(({ allowed, remaining }) => [allowed, remaining]), + [ + [true, 2], + [true, 1], + [true, 0], + [false, 0], + ], + ); + assert.ok(verdicts.every(({ reset }) => reset > 0 && reset <= 60)); + assert.equal((await countHit(db, 'api:203.0.113.8', 3, 60)).allowed, true); + }); + + await t.test('a row from an earlier window starts over', async () => { + await countHit(db, 'api:198.51.100.1', 1, 60); + await admin.query( + `update limit_counter set window_start = window_start - interval '60 seconds' + where key = $1`, + ['api:198.51.100.1'], + ); + + assert.equal((await countHit(db, 'api:198.51.100.1', 1, 60)).allowed, true); + }); + + await t.test('a hit never moves a row back a window', async () => { + // Another instance's hit has already opened the next window. + await countHit(db, 'api:198.51.100.2', 2, 60); + await admin.query( + `update limit_counter set window_start = window_start + interval '60 seconds' + where key = $1`, + ['api:198.51.100.2'], + ); + const ahead = await windowStartOf('api:198.51.100.2'); + + assert.equal((await countHit(db, 'api:198.51.100.2', 2, 60)).remaining, 0); + assert.deepEqual(await windowStartOf('api:198.51.100.2'), ahead); + }); + + await t.test('concurrent hits on one key lose none', async () => { + const handles = Array.from({ length: 6 }, () => drizzle(database.runtimePool())); + const verdicts = await Promise.all( + handles.map((handle) => countHit(handle, 'api:192.0.2.1', 100, 60)), + ); + + assert.deepEqual( + verdicts.map(({ remaining }) => remaining).sort((a, b) => a - b), + [94, 95, 96, 97, 98, 99], + ); + }); + + await t.test('a day-long window ends at 00:00 UTC', async () => { + await countHit(db, 'studio-user:window', 1, DAY_S); + const start = await windowStartOf('studio-user:window'); + + assert.equal(start?.toISOString().slice(11), '00:00:00.000Z'); + }); + + await t.test('pruning deletes counters idle for two days and keeps the rest', async () => { + await countHit(db, 'api:192.0.2.50', 1, 60); + await countHit(db, 'api:192.0.2.51', 1, 60); + await admin.query( + `update limit_counter set window_start = now() - interval '3 days' where key = $1`, + ['api:192.0.2.50'], + ); + + await pruneCounters(db); + + const { rows } = await admin.query<{ key: string }>( + `select key from limit_counter where key like 'api:192.0.2.5%' order by key`, + ); + assert.deepEqual( + rows.map(({ key }) => key), + ['api:192.0.2.51'], + ); + }); + + await t.test('a guest past the allowance is refused whatever the browser', async () => { + // spendPrompt reads nothing from the browser, so a cleared or + // private one is the same caller as long as the address is. + for (let i = 0; i < GUEST_PROMPTS; i++) { + assert.equal(await spendPrompt(db, { address: '203.0.113.20' }), null); + } + + const refusal = await spendPrompt(db, { address: '203.0.113.20' }); + + assert.equal(refusal?.error, 'resource_exhausted'); + assert.equal(refusal?.retry, 'do_not_retry'); + assert.ok(refusal!.retry_after_seconds! > 0 && refusal!.retry_after_seconds! <= DAY_S); + assert.equal(await spendPrompt(db, { address: '203.0.113.21' }), null); + }); + + await t.test('a guest rotating addresses inside one IPv6 /64 is refused', async () => { + for (let i = 0; i < GUEST_PROMPTS; i++) { + assert.equal(await spendPrompt(db, { address: `2001:db8:1:2::${i + 1}` }), null); + } + + const refusal = await spendPrompt(db, { address: '2001:db8:1:2::ffff' }); + + assert.equal(refusal?.error, 'resource_exhausted'); + }); + + await t.test('a refunded prompt is spent again, within its own window only', async () => { + const guest = { address: '203.0.113.30' }; + + for (let i = 0; i < GUEST_PROMPTS; i++) { + await spendPrompt(db, guest); + } + + await refundPrompt(db, guest); + assert.equal(await spendPrompt(db, guest), null); + assert.equal((await spendPrompt(db, guest))?.error, 'resource_exhausted'); + + // A refund that lands after the row moved to a new day gives nothing. + await admin.query( + `update limit_counter set window_start = window_start + interval '1 day' + where key = $1`, + ['studio-guest:203.0.113.30'], + ); + await refundPrompt(db, guest); + const { rows } = await admin.query<{ count: number }>( + 'select count from limit_counter where key = $1', + ['studio-guest:203.0.113.30'], + ); + assert.equal(rows[0]?.count, GUEST_PROMPTS + 1); + }); + + await t.test('a signed-in user past the daily allowance is refused', async () => { + // Signing in after the guest wall opens the user's own allowance. + assert.equal(await spendPrompt(db, { userId: 'user-a' }), null); + + for (let i = 1; i < USER_DAILY_PROMPTS; i++) { + await spendPrompt(db, { userId: 'user-a' }); + } + + const refusal = await spendPrompt(db, { userId: 'user-a' }); + + assert.equal(refusal?.error, 'resource_exhausted'); + assert.equal(refusal?.retry, 'retry_after_delay'); + assert.ok(refusal!.retry_after_seconds! > 0 && refusal!.retry_after_seconds! <= DAY_S); + assert.equal(await spendPrompt(db, { userId: 'user-b' }), null); + }); +}); diff --git a/deploy/README.md b/deploy/README.md index cc2b65b..754aead 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -131,13 +131,12 @@ The app defines the definitive contract once it exists. Known so far: | `SEARCH_API_BASE` | The `dfo-apis` Search, Datasets and graph APIs; required, a production server without it refuses to start; plain env var set by infra | | `MCP_API_TOKEN` | Bearer token the `/mcp` proxy sends to the harness; infra mounts it from Secret Manager (`dfo-dev-mcp-api-token`, the harness's own secret) | | `CALLER_HMAC_KEY` | HMAC key for the client-IP digest `/mcp` sends as `x-dfo-caller`; unset, none is sent; secret set by infra | -| `REDIS_URL` | Redis behind the per-address rate limits on `/api` and `/mcp`; unset, both are off and startup logs a warning | | `RATE_LIMIT_WINDOW_S` | Rate-limit window in seconds; default 60 | | `RATE_LIMIT_API` | Calls per window per address on `/api`; default 120 | | `RATE_LIMIT_PIN` | Calls per window per address on `/api/download-pin`; default 10 | | `RATE_LIMIT_MCP` | Requests per window per address on `/mcp`; default 300, above `/api` because a vendor's address can carry many users | | `OPENAI_APPS_CHALLENGE` | OpenAI's domain-verification token; `/.well-known/openai-apps-challenge` serves it as plain text, 404 while unset | -| `TRUSTED_PROXY_HOPS` | Which `X-Forwarded-For` entry is the client, counted from the right; keys the `/api` and `/mcp` limits and the caller digest; default 0 (Cloud Run's); +1 per load balancer | +| `TRUSTED_PROXY_HOPS` | Which `X-Forwarded-For` entry is the client, counted from the right; keys the `/api` and `/mcp` limits, the guest Studio allowance and the caller digest; default 0 (Cloud Run's); +1 per load balancer | | `WEBSITE_DB_INSTANCE_CONNECTION_NAME` | Cloud SQL instance the connector dials over PSC; set by infra | | `WEBSITE_DB_USER` | Runtime IAM database user; set by infra | | `WEBSITE_DB_NAME` | Database name; set by infra | diff --git a/docs/analytics.md b/docs/analytics.md index 4e6e8a7..09d0900 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -54,6 +54,10 @@ Enhanced measurement records page views on App Router navigations (history chang To check it on production, open the network panel and filter on `google-analytics.com/g/collect`. Before consent the requests carry `gcs=G100`, and after accepting in the banner they carry `gcs=G111`. The visit then shows in GA under Reports → Realtime. Localhost and dev make no `gtag/js` request. -## Guest Studio +## Studio prompt allowance -The Studio is open without an account: `GUEST_LIMIT` (5, `src/components/organisms/Studio/GuestWall.tsx`) prompts, counted in localStorage, then the prompt is replaced by a sign-in wall. Front-end only by design — a cleared localStorage resets it. Guests get no history, saving or sharing (all need an account). +A Studio prompt is one turn (`POST /api/studio/turn`). The proxy spends one from the caller's daily allowance before it forwards the turn (`src/lib/studio/spendPrompt.ts`). A guest gets 5 a day per network address, an IPv6 address counted by its /64, so guests behind one shared address share them. A signed-in user gets 75 a day, counted by user id. Both reset at 00:00 UTC. The numbers live in `src/lib/studio/promptAllowance.ts` and the counts in the `limit_counter` table. A turn past its allowance gets a 429 `resource_exhausted`, whose detail the chat shows as the reply. A turn whose allowance the database cannot read within 5 seconds gets a 503. A turn the harness answers with anything but success gives its prompt back, since no model ran. The proxy forwards only the four routes the browser calls (`turn`, `interrupt`, `cancel`, `composition`) and answers 404 to any other path, so no encoding of a path reaches the turn route without spending a prompt. + +The browser also counts a guest's prompts in localStorage and replaces the prompt with a sign-in wall at 5. Clearing it brings the prompt back but not the allowance. Guests get no history, saving or sharing (all need an account). + +In local dev every guest comes from the same address and so shares one allowance. Sign in, or delete the `studio-guest:` rows from `limit_counter`. diff --git a/package.json b/package.json index 6e4b593..dc1b5ed 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "classnames": "^2.5.1", "drizzle-orm": "^0.45.2", "html-to-image": "^1.11.13", - "ioredis": "^6", "lowlight": "^3.3.0", "mermaid": "^12.1.0", "next": "16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b0ae06..ad4ce0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: html-to-image: specifier: ^1.11.13 version: 1.11.13 - ioredis: - specifier: ^6 - version: 6.0.0 lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -1582,9 +1579,6 @@ packages: cpu: [x64] os: [win32] - '@ioredis/commands@2.0.0': - resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3276,10 +3270,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - cluster-key-slot@1.1.1: - resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} - engines: {node: '>=0.10.0'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3606,10 +3596,6 @@ packages: delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} - denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4367,10 +4353,6 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ioredis@6.0.0: - resolution: {integrity: sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==} - engines: {node: '>=20.0.0'} - is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -5353,10 +5335,6 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5590,9 +5568,6 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} - standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -7440,8 +7415,6 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true - '@ioredis/commands@2.0.0': {} - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -9138,8 +9111,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - cluster-key-slot@1.1.1: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9481,8 +9452,6 @@ snapshots: dependencies: robust-predicates: 3.0.3 - denque@2.1.0: {} - dequal@2.0.3: {} detect-libc@2.1.2: @@ -10450,17 +10419,6 @@ snapshots: internmap@2.0.3: {} - ioredis@6.0.0: - dependencies: - '@ioredis/commands': 2.0.0 - cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@10.2.2) - denque: 2.1.0 - redis-errors: 1.2.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -11615,8 +11573,6 @@ snapshots: readdirp@5.1.1: {} - redis-errors@1.2.0: {} - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -11965,8 +11921,6 @@ snapshots: dependencies: type-fest: 0.7.1 - standard-as-callback@2.1.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 diff --git a/src/app/api/v1/studio/[...path]/route.ts b/src/app/api/v1/studio/[...path]/route.ts index fff4bb9..385aa9d 100644 --- a/src/app/api/v1/studio/[...path]/route.ts +++ b/src/app/api/v1/studio/[...path]/route.ts @@ -1,6 +1,15 @@ import { NextRequest } from 'next/server'; -import { forward, STUDIO_API_BASE } from '@/lib/proxy/forward'; +import { getUser } from '@/lib/auth/session'; +import { db } from '@/lib/db'; +import { clientIp, forward, STUDIO_API_BASE } from '@/lib/proxy/forward'; +import { + meterTurn, + refundPrompt, + spendPrompt, + studioRoute, + type Prompter, +} from '@/lib/studio/spendPrompt'; // The studio-v1 harness (`/api/studio/*` on dfo-ai), mirrored under the // site's domain — see src/lib/proxy/forward. The harness wants @@ -8,16 +17,45 @@ import { forward, STUDIO_API_BASE } from '@/lib/proxy/forward'; // never reaches a client. Any non-empty token satisfies the local server. const STUDIO_TOKEN = process.env.STUDIO_TOKEN ?? 'dev'; -const proxy = (req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => - params.then(({ path }) => - forward(req, { - surface: 'api', - target: `${STUDIO_API_BASE}/api/studio/${path.join('/')}${req.nextUrl.search}`, - bearer: STUDIO_TOKEN, - unreachable: 'The studio agent is unreachable. Is it running? (just dev in dfo-ai)', - }), +const giveBack = (prompter: Prompter) => + refundPrompt(db, prompter).catch((error) => + console.error('studio: a prompt was not given back', error), ); +// Only the routes the browser calls go through (studioRoute). A turn spends +// one prompt of the caller's daily allowance, and meterTurn +// (src/lib/studio/spendPrompt) gives it back when the turn does not start. +const proxy = async (req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { + const route = studioRoute((await params).path); + + if (!route) { + return Response.json( + { error: 'not_found', retry: 'do_not_retry', detail: 'There is no such Studio route.' }, + { status: 404 }, + ); + } + + const options = { + surface: 'api', + target: `${STUDIO_API_BASE}/api/studio/${route}${req.nextUrl.search}`, + bearer: STUDIO_TOKEN, + unreachable: 'The studio agent is unreachable. Is it running? (just dev in dfo-ai)', + } as const; + + if (req.method !== 'POST' || route !== 'turn') { + return forward(req, options); + } + + const spend = async () => { + const user = await getUser(); + const prompter: Prompter = user ? { userId: user.id } : { address: clientIp(req) }; + + return { prompter, refusal: await spendPrompt(db, prompter) }; + }; + + return meterTurn(spend, giveBack, (admit) => forward(req, { ...options, admit })); +}; + export const GET = proxy; export const POST = proxy; export const OPTIONS = proxy; diff --git a/src/components/organisms/Studio/GuestWall.tsx b/src/components/organisms/Studio/GuestWall.tsx index 2b82f6d..4e52afd 100644 --- a/src/components/organisms/Studio/GuestWall.tsx +++ b/src/components/organisms/Studio/GuestWall.tsx @@ -5,9 +5,7 @@ import { useRouter } from 'next/navigation'; import styles from './GuestWall.module.scss'; import Button from '@/components/atoms/Button/Button'; - -// Free prompts before a guest has to sign in. -export const GUEST_LIMIT = 5; +import { GUEST_PROMPTS } from '@/lib/studio/promptAllowance'; /** * What a guest sees in place of the prompt once the free prompts are spent: @@ -20,7 +18,7 @@ const GuestWall = () => { return (

- You've used your {GUEST_LIMIT} free prompts. Sign in to keep exploring + You've used your {GUEST_PROMPTS} free prompts. Sign in to keep exploring Studio.