From f91abeed50a3a74dd34c04229b5ec6f44100fae7 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Thu, 27 Aug 2026 15:29:41 -0400 Subject: [PATCH] Add feedback persistence to the app store --- agent/instructions.md | 1 + agent/tools/give_feedback.ts | 35 + db/README.md | 2 +- db/migrations/0001_great_senator_kelly.sql | 23 + db/migrations/meta/0001_snapshot.json | 736 +++++++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema/application.ts | 55 ++ db/services/feedback.ts | 56 ++ lib/feedback.ts | 83 +++ tests/database-migration.test.ts | 33 +- tests/database-schema.test.ts | 19 + tests/feedback.test.ts | 85 +++ tests/services.test.ts | 81 ++- 13 files changed, 1185 insertions(+), 31 deletions(-) create mode 100644 agent/tools/give_feedback.ts create mode 100644 db/migrations/0001_great_senator_kelly.sql create mode 100644 db/migrations/meta/0001_snapshot.json create mode 100644 db/services/feedback.ts create mode 100644 lib/feedback.ts create mode 100644 tests/feedback.test.ts diff --git a/agent/instructions.md b/agent/instructions.md index 27d0f93d..cf52f0ac 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -30,6 +30,7 @@ The main conversation is the control plane. When the `agent` tool is available, # Coordination - Use `sendMessage` for every user-facing message: direct answers, questions, task acknowledgements, progress updates, blockers, and final synthesis. Do not address the user in ordinary assistant text before or after the tool call. A successful call completes that update: never repeat the same message in a turn, and end the turn unless you have distinct new information to send. +- Use `give_feedback` only when an authenticated user explicitly wants to submit product feedback, report a bug, or suggest an improvement. Confirm briefly with `sendMessage` after it is saved. - Answer conversational, clarifying, and quick informational requests directly. - When `agent` is available, delegate browser execution and other substantial multi-step work instead of performing it in the main conversation. Start independent tasks together so they can run in parallel. - Give each worker a bounded objective, expected output, relevant constraints, and all context it needs; workers do not see the parent conversation. diff --git a/agent/tools/give_feedback.ts b/agent/tools/give_feedback.ts new file mode 100644 index 00000000..f3113147 --- /dev/null +++ b/agent/tools/give_feedback.ts @@ -0,0 +1,35 @@ +import { defineTool } from "eve/tools"; +import { + feedbackIdempotencyKey, + feedbackInputSchema, + normalizeFeedback, +} from "../../lib/feedback.js"; +import { saveFeedback } from "../../db/services/feedback.js"; +import { scopeFromPrincipal } from "../../lib/access-scope.js"; + +export default defineTool({ + description: + "Store feedback this authenticated user explicitly wants to give about Local Vault Assistant, including a bug report, improvement idea, compliment, or general product feedback. Do not use for ordinary requests or inferred dissatisfaction.", + inputSchema: feedbackInputSchema, + async execute(input, ctx) { + const caller = ctx.session.auth.current ?? ctx.session.auth.initiator; + if (!caller) throw new Error("An authenticated user is required."); + + const feedback = normalizeFeedback(input.feedback); + const saved = await saveFeedback(scopeFromPrincipal(caller), { + ...input, + feedback, + idempotencyKey: feedbackIdempotencyKey({ + ...input, + feedback, + sessionId: ctx.session.id, + turnId: ctx.session.turn.id, + }), + sessionId: ctx.session.id, + toolCallId: ctx.callId, + turnId: ctx.session.turn.id, + }); + + return { category: saved.category, feedbackId: saved.id, saved: true }; + }, +}); diff --git a/db/README.md b/db/README.md index 2d441258..2396254c 100644 --- a/db/README.md +++ b/db/README.md @@ -1,6 +1,6 @@ # Application database -This directory owns the eight application tables and their domain query +This directory owns the nine application tables and their domain query services. Better Auth continues to own and migrate its tables independently in `auth.ts`. diff --git a/db/migrations/0001_great_senator_kelly.sql b/db/migrations/0001_great_senator_kelly.sql new file mode 100644 index 00000000..7dde7a11 --- /dev/null +++ b/db/migrations/0001_great_senator_kelly.sql @@ -0,0 +1,23 @@ +CREATE TABLE "feedback" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "created_by_user_id" text NOT NULL, + "eve_session_id" text NOT NULL, + "eve_turn_id" text NOT NULL, + "tool_call_id" text NOT NULL, + "category" text DEFAULT 'general' NOT NULL, + "feedback" text NOT NULL, + "status" text DEFAULT 'new' NOT NULL, + "idempotency_key" text NOT NULL, + "created_at" text NOT NULL, + "updated_at" text NOT NULL, + CONSTRAINT "feedback_category_check" CHECK ("feedback"."category" IN ('general', 'bug', 'idea', 'compliment')), + CONSTRAINT "feedback_content_check" CHECK (length(btrim("feedback"."feedback")) BETWEEN 1 AND 4000), + CONSTRAINT "feedback_status_check" CHECK ("feedback"."status" IN ('new', 'reviewed', 'archived')) +); +--> statement-breakpoint +ALTER TABLE "feedback" ADD CONSTRAINT "feedback_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feedback" ADD CONSTRAINT "feedback_session_id_fkey" FOREIGN KEY ("eve_session_id") REFERENCES "public"."agent_sessions"("session_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "feedback_workspace_idempotency_idx" ON "feedback" USING btree ("workspace_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "feedback_review_queue_idx" ON "feedback" USING btree ("status","created_at" DESC NULLS FIRST);--> statement-breakpoint +CREATE INDEX "feedback_workspace_created_idx" ON "feedback" USING btree ("workspace_id","created_at" DESC NULLS FIRST); \ No newline at end of file diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..cbca1bf4 --- /dev/null +++ b/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,736 @@ +{ + "id": "206a36f8-23b4-4612-8b54-9fdc2aac3937", + "prevId": "600bd78f-0a4a-42a9-8f97-50ab2f8db307", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_sessions_workspace_idx": { + "name": "agent_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_membership_fkey": { + "name": "agent_sessions_membership_fkey", + "tableFrom": "agent_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.browser_sessions": { + "name": "browser_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "browser_sessions_workspace_idx": { + "name": "browser_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "browser_sessions_membership_fkey": { + "name": "browser_sessions_membership_fkey", + "tableFrom": "browser_sessions", + "tableTo": "workspace_memberships", + "columnsFrom": ["workspace_id", "created_by_user_id"], + "columnsTo": ["workspace_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chats_workspace_updated_idx": { + "name": "chats_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_workspace_id_fkey": { + "name": "chats_workspace_id_fkey", + "tableFrom": "chats", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chats_input_tokens_check": { + "name": "chats_input_tokens_check", + "value": "\"chats\".\"input_tokens\" >= 0" + }, + "chats_output_tokens_check": { + "name": "chats_output_tokens_check", + "value": "\"chats\".\"output_tokens\" >= 0" + }, + "chats_cost_usd_check": { + "name": "chats_cost_usd_check", + "value": "\"chats\".\"cost_usd\" IS NULL OR \"chats\".\"cost_usd\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.encrypted_secrets": { + "name": "encrypted_secrets", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_secrets_workspace_id_fkey": { + "name": "encrypted_secrets_workspace_id_fkey", + "tableFrom": "encrypted_secrets", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_secrets_pkey": { + "name": "encrypted_secrets_pkey", + "columns": ["workspace_id", "namespace", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "encrypted_secrets_namespace_check": { + "name": "encrypted_secrets_namespace_check", + "value": "\"encrypted_secrets\".\"namespace\" = 'vault'" + } + }, + "isRLSEnabled": false + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eve_session_id": { + "name": "eve_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "eve_turn_id": { + "name": "eve_turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "feedback_workspace_idempotency_idx": { + "name": "feedback_workspace_idempotency_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_review_queue_idx": { + "name": "feedback_review_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_workspace_created_idx": { + "name": "feedback_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_workspace_id_fkey": { + "name": "feedback_workspace_id_fkey", + "tableFrom": "feedback", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_session_id_fkey": { + "name": "feedback_session_id_fkey", + "tableFrom": "feedback", + "tableTo": "agent_sessions", + "columnsFrom": ["eve_session_id"], + "columnsTo": ["session_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "feedback_category_check": { + "name": "feedback_category_check", + "value": "\"feedback\".\"category\" IN ('general', 'bug', 'idea', 'compliment')" + }, + "feedback_content_check": { + "name": "feedback_content_check", + "value": "length(btrim(\"feedback\".\"feedback\")) BETWEEN 1 AND 4000" + }, + "feedback_status_check": { + "name": "feedback_status_check", + "value": "\"feedback\".\"status\" IN ('new', 'reviewed', 'archived')" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "settings_workspace_id_fkey": { + "name": "settings_workspace_id_fkey", + "tableFrom": "settings", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "settings_pkey": { + "name": "settings_pkey", + "columns": ["workspace_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "settings_key_check": { + "name": "settings_key_check", + "value": "\"settings\".\"key\" = 'gateway_model'" + } + }, + "isRLSEnabled": false + }, + "public.vault_items": { + "name": "vault_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vault_items_workspace_updated_idx": { + "name": "vault_items_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vault_items_workspace_id_fkey": { + "name": "vault_items_workspace_id_fkey", + "tableFrom": "vault_items", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "vault_items_kind_check": { + "name": "vault_items_kind_check", + "value": "\"vault_items\".\"kind\" IN ('login', 'payment', 'address', 'phone', 'identity', 'token')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_memberships": { + "name": "workspace_memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_memberships_workspace_id_fkey": { + "name": "workspace_memberships_workspace_id_fkey", + "tableFrom": "workspace_memberships", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_memberships_pkey": { + "name": "workspace_memberships_pkey", + "columns": ["workspace_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_memberships_role_check": { + "name": "workspace_memberships_role_check", + "value": "\"workspace_memberships\".\"role\" = 'owner'" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 4e142e99..287fb3f7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1787854370085, "tag": "0000_fluffy_the_spike", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787858796405, + "tag": "0001_great_senator_kelly", + "breakpoints": true } ] } diff --git a/db/schema/application.ts b/db/schema/application.ts index 4dfd2c2b..9984ac4b 100644 --- a/db/schema/application.ts +++ b/db/schema/application.ts @@ -8,6 +8,7 @@ import { pgTable, primaryKey, text, + uniqueIndex, } from "drizzle-orm/pg-core"; export const workspaces = pgTable("workspaces", { @@ -110,6 +111,60 @@ export const agentSessions = pgTable( ] ); +export const feedbackEntries = pgTable( + "feedback", + { + id: text("id").primaryKey(), + workspaceId: text("workspace_id").notNull(), + createdByUserId: text("created_by_user_id").notNull(), + eveSessionId: text("eve_session_id").notNull(), + eveTurnId: text("eve_turn_id").notNull(), + toolCallId: text("tool_call_id").notNull(), + category: text("category").notNull().default("general"), + feedback: text("feedback").notNull(), + status: text("status").notNull().default("new"), + idempotencyKey: text("idempotency_key").notNull(), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + }, + (table) => [ + foreignKey({ + name: "feedback_workspace_id_fkey", + columns: [table.workspaceId], + foreignColumns: [workspaces.id], + }).onDelete("cascade"), + foreignKey({ + name: "feedback_session_id_fkey", + columns: [table.eveSessionId], + foreignColumns: [agentSessions.sessionId], + }).onDelete("cascade"), + check( + "feedback_category_check", + sql`${table.category} IN ('general', 'bug', 'idea', 'compliment')` + ), + check( + "feedback_content_check", + sql`length(btrim(${table.feedback})) BETWEEN 1 AND 4000` + ), + check( + "feedback_status_check", + sql`${table.status} IN ('new', 'reviewed', 'archived')` + ), + uniqueIndex("feedback_workspace_idempotency_idx").on( + table.workspaceId, + table.idempotencyKey + ), + index("feedback_review_queue_idx").on( + table.status, + table.createdAt.desc().nullsFirst() + ), + index("feedback_workspace_created_idx").on( + table.workspaceId, + table.createdAt.desc().nullsFirst() + ), + ] +); + export const browserSessions = pgTable( "browser_sessions", { diff --git a/db/services/feedback.ts b/db/services/feedback.ts new file mode 100644 index 00000000..2576eacf --- /dev/null +++ b/db/services/feedback.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { AccessScope } from "@/lib/access-scope"; +import { feedbackRecordSchema, type FeedbackSubmission } from "@/lib/feedback"; +import { agentSessions, db, feedbackEntries } from "@/db"; + +export async function saveFeedback( + scope: AccessScope, + submission: FeedbackSubmission +) { + const claimedSessions = await db + .select({ sessionId: agentSessions.sessionId }) + .from(agentSessions) + .where( + and( + eq(agentSessions.sessionId, submission.sessionId), + eq(agentSessions.workspaceId, scope.workspaceId), + eq(agentSessions.createdByUserId, scope.userId) + ) + ) + .limit(1); + if (!claimedSessions[0]) { + throw new Error( + "The authenticated feedback scope does not match this conversation." + ); + } + + const now = new Date().toISOString(); + const rows = await db + .insert(feedbackEntries) + .values({ + category: submission.category, + createdAt: now, + createdByUserId: scope.userId, + eveSessionId: submission.sessionId, + eveTurnId: submission.turnId, + feedback: submission.feedback, + id: randomUUID(), + idempotencyKey: submission.idempotencyKey, + toolCallId: submission.toolCallId, + updatedAt: now, + workspaceId: scope.workspaceId, + }) + .onConflictDoUpdate({ + target: [feedbackEntries.workspaceId, feedbackEntries.idempotencyKey], + set: { idempotencyKey: submission.idempotencyKey }, + }) + .returning({ + category: feedbackEntries.category, + createdAt: feedbackEntries.createdAt, + feedback: feedbackEntries.feedback, + id: feedbackEntries.id, + status: feedbackEntries.status, + }); + return feedbackRecordSchema.parse(rows[0]); +} diff --git a/lib/feedback.ts b/lib/feedback.ts new file mode 100644 index 00000000..96ec7331 --- /dev/null +++ b/lib/feedback.ts @@ -0,0 +1,83 @@ +import { createHash } from "node:crypto"; +import { z } from "zod"; + +const MAX_FEEDBACK_LENGTH = 4_000; + +const SECRET_PATTERNS: readonly (readonly [RegExp, string])[] = [ + [/\b\d{6}\b/gu, "[six-digit code redacted]"], + [/\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9_-]{12,}\b/gu, "[api key redacted]"], + [/\bsk-(?:proj-)?[A-Za-z0-9_-]{12,}\b/gu, "[api key redacted]"], + [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/gu, "[github token redacted]"], + [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, "[aws key redacted]"], + [/\bAIza[A-Za-z0-9_-]{30,}\b/gu, "[google api key redacted]"], + [/\bbu_[A-Za-z0-9_-]{20,}\b/gu, "[browser api key redacted]"], + [/\bxox[baprs]-[A-Za-z0-9-]{20,}\b/gu, "[slack token redacted]"], + [/\b(?:bearer\s+)[A-Za-z0-9._~+/-]+=*\b/giu, "Bearer [token redacted]"], + [ + /(["'])(password|passcode|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|client[_ -]?secret|private[_ -]?key)\1\s*:\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)/giu, + '$1$2$1: "[credential redacted]"', + ], + [ + /\b(password|passcode|api[_ -]?key|access[_ -]?token|refresh[_ -]?token|client[_ -]?secret|private[_ -]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + "$1=[credential redacted]", + ], + [ + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/gu, + "[private key redacted]", + ], + [ + /\b([A-Z][A-Z0-9_]*(?:PASSWORD|PASSCODE|SECRET|TOKEN|API_KEY|PRIVATE_KEY|DATABASE_URL))\s*=\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/giu, + "$1=[credential redacted]", + ], + [ + /\b([a-z][a-z0-9+.-]*:\/\/[^/\s:@]+):[^@\s/]+@/giu, + "$1:[credential redacted]@", + ], + [/\b(?:\d[ -]*?){13,19}\b/gu, "[payment number redacted]"], +]; + +const feedbackCategorySchema = z.enum(["general", "bug", "idea", "compliment"]); + +export const feedbackInputSchema = z.object({ + category: feedbackCategorySchema.default("general"), + feedback: z.string().trim().min(1).max(MAX_FEEDBACK_LENGTH), +}); + +export const feedbackRecordSchema = z.object({ + category: feedbackCategorySchema, + createdAt: z.string(), + feedback: z.string(), + id: z.string(), + status: z.enum(["new", "reviewed", "archived"]), +}); + +export type FeedbackSubmission = z.infer & { + readonly idempotencyKey: string; + readonly sessionId: string; + readonly toolCallId: string; + readonly turnId: string; +}; + +export function normalizeFeedback(value: string): string { + let feedback = value.trim().slice(0, MAX_FEEDBACK_LENGTH); + for (const [pattern, replacement] of SECRET_PATTERNS) { + feedback = feedback.replace(pattern, replacement); + } + feedback = feedback.slice(0, MAX_FEEDBACK_LENGTH).trim(); + if (!feedback) throw new Error("Feedback cannot be empty."); + return feedback; +} + +export function feedbackIdempotencyKey( + input: Pick< + FeedbackSubmission, + "category" | "feedback" | "sessionId" | "turnId" + > +): string { + const digest = createHash("sha256") + .update(input.category) + .update("\0") + .update(normalizeFeedback(input.feedback)) + .digest("hex"); + return `give-feedback:${input.sessionId}:${input.turnId}:${digest}`; +} diff --git a/tests/database-migration.test.ts b/tests/database-migration.test.ts index 4dbcc82d..0123e4f1 100644 --- a/tests/database-migration.test.ts +++ b/tests/database-migration.test.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { PGlite } from "@electric-sql/pglite"; import { afterEach, describe, expect, it } from "vitest"; @@ -12,8 +12,7 @@ describe("application database migration", () => { it("creates a validated schema and is idempotent on an empty database", async () => { const database = createDatabase(); - await applyInitialMigration(database); - await applyInitialMigration(database); + await applyMigrations(database, { repeatInitial: true }); const tables = await database.query<{ count: number }>( `SELECT count(*)::int AS count @@ -27,12 +26,13 @@ describe("application database migration", () => { 'agent_sessions', 'browser_sessions', 'chats', - 'encrypted_secrets' + 'encrypted_secrets', + 'feedback' )` ); const pendingConstraints = await pendingConstraintCount(database); - expect(tables.rows[0]?.count).toBe(8); + expect(tables.rows[0]?.count).toBe(9); expect(pendingConstraints).toBe(0); }); @@ -65,7 +65,7 @@ describe("application database migration", () => { ); `); - await applyInitialMigration(database); + await applyMigrations(database); const vault = await database.query<{ id: string; kind: string }>( "SELECT id, kind FROM vault_items WHERE id = 'legacy-item'" @@ -109,11 +109,22 @@ function createDatabase() { return database; } -async function applyInitialMigration(database: PGlite) { - const migration = await readFile( - new URL("../db/migrations/0000_fluffy_the_spike.sql", import.meta.url), - "utf8" - ); +async function applyMigrations( + database: PGlite, + { repeatInitial = false }: { repeatInitial?: boolean } = {} +) { + const directory = new URL("../db/migrations/", import.meta.url); + const migrations = (await readdir(directory)) + .filter((name) => name.endsWith(".sql")) + .sort(); + for (const [index, migration] of migrations.entries()) { + const sql = await readFile(new URL(migration, directory), "utf8"); + if (repeatInitial && index === 0) await executeMigration(database, sql); + await executeMigration(database, sql); + } +} + +async function executeMigration(database: PGlite, migration: string) { for (const statement of migration.split("--> statement-breakpoint")) { if (statement.trim()) await database.exec(statement); } diff --git a/tests/database-schema.test.ts b/tests/database-schema.test.ts index 31596841..38149e32 100644 --- a/tests/database-schema.test.ts +++ b/tests/database-schema.test.ts @@ -7,6 +7,7 @@ import { browserSessions, chats, encryptedSecrets, + feedbackEntries, settings, vaultItems, workspaceMemberships, @@ -25,6 +26,7 @@ describe("application database schema", () => { browserSessions, chats, encryptedSecrets, + feedbackEntries, ].map((table) => getTableConfig(table).name) ).toEqual([ "workspaces", @@ -35,6 +37,7 @@ describe("application database schema", () => { "browser_sessions", "chats", "encrypted_secrets", + "feedback", ]); }); @@ -66,6 +69,7 @@ describe("application database schema", () => { settings, chats, encryptedSecrets, + feedbackEntries, ]) { expect( getTableConfig(table).foreignKeys.some((foreignKey) => @@ -74,6 +78,20 @@ describe("application database schema", () => { ).toBe(true); } }); + + it("anchors feedback to an owned workspace and claimed session", () => { + const foreignKey = getTableConfig(feedbackEntries).foreignKeys.find( + (candidate) => candidate.getName() === "feedback_session_id_fkey" + ); + const reference = foreignKey?.reference(); + + expect(reference?.columns.map((column) => column.name)).toEqual([ + "eve_session_id", + ]); + expect(reference?.foreignColumns.map((column) => column.name)).toEqual([ + "session_id", + ]); + }); }); describe("migration deployment policy", () => { @@ -143,6 +161,7 @@ describe("migration deployment policy", () => { [ "browsers", "chats", + "feedback", "scope", "secrets", "sessions", diff --git a/tests/feedback.test.ts b/tests/feedback.test.ts new file mode 100644 index 00000000..80d0348b --- /dev/null +++ b/tests/feedback.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { feedbackIdempotencyKey, normalizeFeedback } from "../lib/feedback"; + +describe("feedback", () => { + it("trims feedback and redacts obvious secrets", () => { + const feedback = normalizeFeedback( + " the login exposed code 123456 and card 4242 4242 4242 4242 " + ); + + expect(feedback.startsWith("the login")).toBe(true); + expect(feedback).not.toMatch(/123456/u); + expect(feedback).not.toMatch(/4242 4242/u); + }); + + it("rejects empty feedback", () => { + expect(() => normalizeFeedback(" ")).toThrow(/cannot be empty/iu); + }); + + it("keeps redaction expansion within the database limit", () => { + const feedback = normalizeFeedback( + Array.from({ length: 571 }, () => "123456").join(" ") + ); + + expect(feedback.length).toBeLessThanOrEqual(4_000); + expect(feedback).not.toMatch(/123456/u); + }); + + it("redacts labeled credentials, provider tokens, and private keys", () => { + const feedback = normalizeFeedback( + [ + "password=hunter2", + '"client_secret": "secret-value-123"', + "api_key=plain-api-key-value", + "sk-proj-abcdefghijklmnopqrstuv", + "ghp_abcdefghijklmnopqrstuvwxyz123456", + "AKIAABCDEFGHIJKLMNOP", + "-----BEGIN PRIVATE KEY-----\nvery-private\n-----END PRIVATE KEY-----", + ].join("\n") + ); + + for (const secret of [ + "hunter2", + "secret-value-123", + "plain-api-key-value", + "abcdefghijklmnopqrstuv", + "abcdefghijklmnopqrstuvwxyz123456", + "AKIAABCDEFGHIJKLMNOP", + "very-private", + ]) { + expect(feedback).not.toContain(secret); + } + }); + + it("redacts environment secrets and connection URL credentials", () => { + const feedback = normalizeFeedback( + [ + "DATABASE_URL=postgresql://alice:swordfish@example.com/app", + "cache failed at redis://bob:hunter2@cache.example.com/0", + ].join("\n") + ); + + expect(feedback).not.toContain("swordfish"); + expect(feedback).not.toContain("hunter2"); + expect(feedback).toContain( + "redis://bob:[credential redacted]@cache.example.com/0" + ); + }); + + it("binds replay keys to durable content", () => { + const input = { + category: "bug" as const, + feedback: "the browser result was stale", + sessionId: "session-1", + turnId: "turn-1", + }; + + expect(feedbackIdempotencyKey(input)).toBe(feedbackIdempotencyKey(input)); + expect(feedbackIdempotencyKey(input)).not.toBe( + feedbackIdempotencyKey({ + ...input, + feedback: "the browser timed out", + }) + ); + }); +}); diff --git a/tests/services.test.ts b/tests/services.test.ts index 5fb8949d..938c1280 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { PGlite } from "@electric-sql/pglite"; import { drizzle } from "drizzle-orm/pglite"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -17,7 +17,7 @@ describe("database services", () => { it("preserves workspace ownership across application domains", async () => { const client = new PGlite(); databases.push(client); - await applyInitialMigration(client); + await applyMigrations(client); const pgliteDatabase = drizzle(client, { schema }); Object.assign(pgliteDatabase, { @@ -30,16 +30,25 @@ describe("database services", () => { const database = pgliteDatabase as unknown as Database; vi.doMock("@/db", () => ({ ...schema, db: database })); - const [browsers, chats, secrets, sessions, settings, scope, vault] = - await Promise.all([ - import("@/db/services/browsers"), - import("@/db/services/chats"), - import("@/db/services/secrets"), - import("@/db/services/sessions"), - import("@/db/services/settings"), - import("@/db/services/scope"), - import("@/db/services/vault"), - ]); + const [ + browsers, + chats, + feedback, + secrets, + sessions, + settings, + scope, + vault, + ] = await Promise.all([ + import("@/db/services/browsers"), + import("@/db/services/chats"), + import("@/db/services/feedback"), + import("@/db/services/secrets"), + import("@/db/services/sessions"), + import("@/db/services/settings"), + import("@/db/services/scope"), + import("@/db/services/vault"), + ]); const alice = { userId: "alice", workspaceId: "workspace:alice" }; const bob = { userId: "bob", workspaceId: "workspace:bob" }; @@ -58,6 +67,37 @@ describe("database services", () => { expect(await sessions.isSessionOwned(alice, "session-alice")).toBe(true); expect(await sessions.isSessionOwned(bob, "session-alice")).toBe(false); + const feedbackSubmission = { + category: "bug" as const, + feedback: "the browser timed out", + idempotencyKey: "give-feedback:session-alice:turn-1:digest", + sessionId: "session-alice", + toolCallId: "call-1", + turnId: "turn-1", + }; + const firstFeedback = await feedback.saveFeedback( + alice, + feedbackSubmission + ); + const replayedFeedback = await feedback.saveFeedback( + alice, + feedbackSubmission + ); + + expect(replayedFeedback.id).toBe(firstFeedback.id); + expect(firstFeedback).toMatchObject({ + category: "bug", + feedback: "the browser timed out", + status: "new", + }); + await expect( + feedback.saveFeedback(bob, feedbackSubmission) + ).rejects.toThrow(/scope does not match/iu); + const persistedFeedback = await client.query<{ count: number }>( + "SELECT count(*)::int AS count FROM feedback" + ); + expect(persistedFeedback.rows[0]?.count).toBe(1); + await chats.saveChat(alice, { sessionId: "session-alice", title: "Initial title", @@ -141,12 +181,15 @@ describe("database services", () => { }); }); -async function applyInitialMigration(database: PGlite) { - const migration = await readFile( - new URL("../db/migrations/0000_fluffy_the_spike.sql", import.meta.url), - "utf8" - ); - for (const statement of migration.split("--> statement-breakpoint")) { - if (statement.trim()) await database.exec(statement); +async function applyMigrations(database: PGlite) { + const directory = new URL("../db/migrations/", import.meta.url); + const migrations = (await readdir(directory)) + .filter((name) => name.endsWith(".sql")) + .sort(); + for (const migration of migrations) { + const sql = await readFile(new URL(migration, directory), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + if (statement.trim()) await database.exec(statement); + } } }