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..f16e918 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,21 +214,52 @@ 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). + * + * Every pool gets an `'error'` listener. pg-pool emits `'error'` when an + * idle client fails (Cloud SQL or a proxy dropping the connection), and an + * emitter with no listener throws, which would crash the server. pg-pool + * has already removed the dead client by then, so the listener only logs. + * The next query opens a fresh connection. */ -export function buildRuntimePool(env: EnvLike = process.env): Pool { - if (isCloudSqlConfigured(env)) { - return new Pool({ - stream: () => getCloudSqlRuntimeStream()(), - user: env.WEBSITE_DB_USER, - database: env.WEBSITE_DB_NAME, - max: 5, - idleTimeoutMillis: 30_000, - }); - } +export function buildRuntimePool( + env: EnvLike = process.env, + sizing: Pick< + PoolConfig, + 'max' | 'connectionTimeoutMillis' | 'query_timeout' | 'statement_timeout' + > = {}, +): Pool { + const pool = isCloudSqlConfigured(env) + ? new Pool({ + stream: () => getCloudSqlRuntimeStream()(), + user: env.WEBSITE_DB_USER, + database: env.WEBSITE_DB_NAME, + max: 5, + idleTimeoutMillis: 30_000, + ...sizing, + }) + : new Pool({ + connectionString: env.DATABASE_URL, + max: 5, + idleTimeoutMillis: 30_000, + ...sizing, + }); + + pool.on('error', (error) => { + console.error('idle database client failed:', error); + }); + + return pool; +} - return new Pool({ - connectionString: env.DATABASE_URL, - max: 5, - idleTimeoutMillis: 30_000, +/** A small, bounded pool for rate and allowance counters. */ +export function buildCounterPool(env: EnvLike = process.env): Pool { + return buildRuntimePool(env, { + max: 2, + connectionTimeoutMillis: 2_000, + query_timeout: 500, + statement_timeout: 400, }); } 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/counter-pool.test.ts b/db/test/counter-pool.test.ts new file mode 100644 index 0000000..fc151c9 --- /dev/null +++ b/db/test/counter-pool.test.ts @@ -0,0 +1,117 @@ +import path from 'node:path'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drizzle } from 'drizzle-orm/node-postgres'; + +import { buildCounterPool, buildRuntimePool } from '../connection.ts'; +import { applyMigrations } from '../apply-migrations.ts'; +import { createTestDatabase } from './harness.ts'; +import { countHit } from '../../src/lib/proxy/limitCounter.ts'; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../migrations'); + +const causeOf = (error: Error): Error => { + const cause = Reflect.get(error, 'cause'); + assert.ok(cause instanceof Error); + + return cause; +}; + +const testUrl = (database: { + databaseName: string; + runtimePassword: string; + runtimeUser: string; +}): string => { + const host = process.env.PGHOST ?? 'localhost'; + const port = process.env.PGPORT ?? '5433'; + + return `postgres://${database.runtimeUser}:${database.runtimePassword}@${host}:${port}/${database.databaseName}`; +}; + +test('the counter pool bounds locked and queued counter work without blocking the main pool', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + await applyMigrations(database.migratorPool(), { + migrationsFolder: MIGRATIONS_FOLDER, + runtimeDbUser: database.runtimeUser, + migratorDbUser: database.migratorUser, + }); + + await t.test( + 'a locked counter statement times out and the main pool keeps serving', + async (t) => { + const url = testUrl(database); + const counterPool = buildCounterPool({ DATABASE_URL: url }); + const mainPool = buildRuntimePool({ DATABASE_URL: url }); + + const counterDb = drizzle(counterPool); + const mainDb = drizzle(mainPool); + const key = 'api:locked-counter'; + await countHit(mainDb, key, 10, 60); + + const blocker = database.runtimePool(); + await blocker.query('begin'); + t.after(async () => { + await blocker.query('rollback'); + await Promise.all([counterPool.end(), mainPool.end()]); + }); + await blocker.query('select 1 from limit_counter where key = $1 for update', [key]); + + assert.deepEqual((await counterPool.query('show statement_timeout')).rows, [ + { statement_timeout: '400ms' }, + ]); + + let rejection: unknown; + try { + await countHit(counterDb, key, 10, 60); + } catch (error) { + rejection = error; + } + assert.ok(rejection instanceof Error); + const cause = causeOf(rejection); + assert.equal(Reflect.get(cause, 'code'), '57014'); + assert.match(cause.message, /statement timeout/i); + assert.deepEqual((await mainPool.query('select 1 as one')).rows, [{ one: 1 }]); + assert.equal( + ( + await database.adminPool().query<{ count: number }>( + `select count(*)::int as count + from pg_stat_activity + where datname = $1 and wait_event_type = 'Lock'`, + [database.databaseName], + ) + ).rows[0]?.count, + 0, + ); + + // The row remains locked, so this proves a timed-out counter does not + // make the counter pool wait behind it before serving another key. + assert.equal((await countHit(counterDb, 'api:after-timeout', 10, 60)).allowed, true); + }, + ); + + await t.test( + 'a counter queued behind both pool connections fails within the connection bound', + async (t) => { + const counterPool = buildCounterPool({ DATABASE_URL: testUrl(database) }); + const held = await Promise.all([counterPool.connect(), counterPool.connect()]); + t.after(async () => { + for (const client of held) { + client.release(); + } + await counterPool.end(); + }); + + const started = Date.now(); + await assert.rejects( + () => countHit(drizzle(counterPool), 'api:queued-counter', 10, 60), + (error: unknown) => + error instanceof Error && /timeout/i.test(causeOf(error).message), + ); + const elapsed = Date.now() - started; + + assert.ok(elapsed >= 1_500 && elapsed < 4_000, `queued for ${elapsed} ms`); + }, + ); +}); diff --git a/db/test/limit-counter.test.ts b/db/test/limit-counter.test.ts new file mode 100644 index 0000000..c0955bd --- /dev/null +++ b/db/test/limit-counter.test.ts @@ -0,0 +1,267 @@ +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, + refundReservation, + reserveHit, +} 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 runtimePool = database.runtimePool(); + const db = drizzle(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( + 'concurrent reservations stop at the limit and refused reservations spend nothing', + async () => { + const key = 'studio-user:atomic-reservation'; + const handles = Array.from({ length: 6 }, () => drizzle(database.runtimePool())); + const reservations = await Promise.all( + handles.map((handle) => reserveHit(handle, key, 2, 60)), + ); + const admitted = reservations.filter((reservation) => reservation.admitted); + + assert.equal(admitted.length, 2); + assert.equal(reservations.filter((reservation) => !reservation.admitted).length, 4); + assert.equal( + ( + await admin.query<{ count: number }>( + 'select count from limit_counter where key = $1', + [key], + ) + ).rows[0]?.count, + 2, + ); + + const first = admitted[0]!; + await refundReservation(db, first.receipt); + + assert.equal((await reserveHit(db, key, 2, 60)).admitted, true); + }, + ); + + await t.test('a reservation receipt cannot refund a new-day spend', async () => { + const key = 'studio-user:window-receipt'; + await admin.query('create schema review_clock'); + await admin.query(`create function review_clock.now() returns timestamptz + language sql stable as $$ select current_setting('review.clock')::timestamptz $$`); + await admin.query(`grant usage on schema review_clock to "${database.runtimeUser}"`); + await runtimePool.query('set search_path to review_clock, public, pg_catalog'); + + const setClock = (clock: string) => + runtimePool.query("select set_config('review.clock', $1, false)", [clock]); + + try { + await setClock('2026-10-20T23:59:59Z'); + const first = await reserveHit(db, key, 2, DAY_S); + assert.ok(first.admitted); + + // The first turn reports failure after a new-day turn has already + // reserved its prompt. + await setClock('2026-10-21T00:00:01Z'); + assert.equal((await reserveHit(db, key, 2, DAY_S)).admitted, true); + await refundReservation(db, first.receipt); + + assert.equal( + ( + await admin.query<{ count: number }>( + 'select count from limit_counter where key = $1', + [key], + ) + ).rows[0]?.count, + 1, + ); + } finally { + await runtimePool.query('reset search_path'); + } + }); + + 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' })).refusal, null); + } + + const refusal = (await spendPrompt(db, { address: '203.0.113.20' })).refusal; + + 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' })).refusal, 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}` })).refusal, + null, + ); + } + + const refusal = (await spendPrompt(db, { address: '2001:db8:1:2::ffff' })).refusal; + + 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' }; + + const firstSpend = await spendPrompt(db, guest); + assert.ok(firstSpend.receipt); + + for (let i = 1; i < GUEST_PROMPTS; i++) { + await spendPrompt(db, guest); + } + + await refundPrompt(db, firstSpend.receipt); + assert.equal((await spendPrompt(db, guest)).refusal, null); + assert.equal((await spendPrompt(db, guest)).refusal?.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, firstSpend.receipt); + 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); + }); + + 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' })).refusal, null); + + for (let i = 1; i < USER_DAILY_PROMPTS; i++) { + await spendPrompt(db, { userId: 'user-a' }); + } + + const refusal = (await spendPrompt(db, { userId: 'user-a' })).refusal; + + 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' })).refusal, null); + }); +}); diff --git a/db/test/pool-idle-error.test.ts b/db/test/pool-idle-error.test.ts new file mode 100644 index 0000000..69c0d53 --- /dev/null +++ b/db/test/pool-idle-error.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildRuntimePool } from '../connection.ts'; +import { createTestDatabase } from './harness.ts'; + +/** + * pg-pool emits 'error' on the pool when an idle client's backend goes + * away. Without a listener Node throws and the server dies, so + * `buildRuntimePool` attaches one. The backend is terminated from the + * admin pool. The test fails with an uncaught 'error' if the listener is + * missing. + */ +test('a dropped idle connection is logged and the pool keeps serving', async (t) => { + const database = await createTestDatabase(); + t.after(() => database.teardown()); + + const logged = Promise.withResolvers(); + t.mock.method(console, 'error', (...args: unknown[]) => logged.resolve(args)); + const host = process.env.PGHOST ?? 'localhost'; + const port = process.env.PGPORT ?? '5433'; + const url = `postgres://${database.runtimeUser}:${database.runtimePassword}@${host}:${port}/${database.databaseName}`; + const pool = buildRuntimePool({ DATABASE_URL: url }); + t.after(() => pool.end()); + + const { + rows: [{ pid }], + } = await pool.query<{ pid: number }>('select pg_backend_pid() as pid'); + + await database.adminPool().query('select pg_terminate_backend($1)', [pid]); + + const [, error] = await logged.promise; + assert.ok(error instanceof Error); + assert.deepEqual((await pool.query('select 1 as one')).rows, [{ one: 1 }]); +}); 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 51c47e1..b949aa1 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -102,6 +102,10 @@ The site writes one JSON line to stdout for each page view and API call, which C The diagnostic event carries `RSC` and `Next-Router-Prefetch` headers before Next.js normalises them. Logging leaves request bodies unread, so API and MCP payloads stream to their handlers in full. A Next.js proxy would buffer those bodies and truncate them at its default 10 MB limit. -## 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 atomically reserves one from the caller's daily allowance before it forwards the turn (`src/lib/studio/spendPrompt.ts`). Refused requests consume no allowance. 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. Allowance and rate-limit queries use a separate two-connection pool with a 400 ms database statement limit, a 500 ms client query limit, and a 2 s connection limit, so they cannot occupy the pool used for sessions and history. An allowance that cannot be read gets a 503. A turn the harness answers with anything but success gives its reservation back in its original counter window, 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..69febb0 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 { counterDb } 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 = (receipt: Parameters[1]) => + refundPrompt(counterDb, receipt).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 spendPrompt(counterDb, 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.