diff --git a/api/.dev.vars.example b/api/.dev.vars.example index c21e2d5d4..51453b5ad 100644 --- a/api/.dev.vars.example +++ b/api/.dev.vars.example @@ -1,2 +1,3 @@ FPX_DATABASE_URL=file:fpx.db -FPX_LOG_LEVEL=debug \ No newline at end of file +FPX_LOG_LEVEL=debug +FPX_AUTH_BASE_URL=http://localhost:3578 \ No newline at end of file diff --git a/api/drizzle/0015_icy_thunderball.sql b/api/drizzle/0015_icy_thunderball.sql new file mode 100644 index 000000000..02f3e7cdf --- /dev/null +++ b/api/drizzle/0015_icy_thunderball.sql @@ -0,0 +1,8 @@ +CREATE TABLE `tokens` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `value` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tokens_value_unique` ON `tokens` (`value`); \ No newline at end of file diff --git a/api/drizzle/0016_puzzling_black_bolt.sql b/api/drizzle/0016_puzzling_black_bolt.sql new file mode 100644 index 000000000..ec6d0cc61 --- /dev/null +++ b/api/drizzle/0016_puzzling_black_bolt.sql @@ -0,0 +1 @@ +ALTER TABLE `tokens` ADD `expires` text; \ No newline at end of file diff --git a/api/drizzle/0017_busy_whizzer.sql b/api/drizzle/0017_busy_whizzer.sql new file mode 100644 index 000000000..7938047a2 --- /dev/null +++ b/api/drizzle/0017_busy_whizzer.sql @@ -0,0 +1 @@ +ALTER TABLE `tokens` RENAME COLUMN `expires` TO `expires_at`; \ No newline at end of file diff --git a/api/drizzle/meta/0015_snapshot.json b/api/drizzle/meta/0015_snapshot.json new file mode 100644 index 000000000..5eab23ce3 --- /dev/null +++ b/api/drizzle/meta/0015_snapshot.json @@ -0,0 +1,519 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4f324fb8-dc00-4757-8f6a-16261824a1e6", + "prevId": "5897188d-e0e3-45b6-9023-532b91137314", + "tables": { + "app_requests": { + "name": "app_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_query_params": { + "name": "request_query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_path_params": { + "name": "request_path_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_route": { + "name": "request_route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_responses": { + "name": "app_responses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers": { + "name": "response_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_details": { + "name": "failure_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_failure": { + "name": "is_failure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_responses_request_id_app_requests_id_fk": { + "name": "app_responses_request_id_app_requests_id_fk", + "tableFrom": "app_responses", + "tableTo": "app_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_routes": { + "name": "app_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler_type": { + "name": "handler_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentlyRegistered": { + "name": "currentlyRegistered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "registration_order": { + "name": "registration_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": -1 + }, + "route_origin": { + "name": "route_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovered'" + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "mizu_logs": { + "name": "mizu_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored": { + "name": "ignored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caller_location": { + "name": "caller_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "matching_issues": { + "name": "matching_issues", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "otel_spans": { + "name": "otel_spans", + "columns": { + "inner": { + "name": "inner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "settings_key_unique": { + "name": "settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tokens": { + "name": "tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "tokens_value_unique": { + "name": "tokens_value_unique", + "columns": [ + "value" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/0016_snapshot.json b/api/drizzle/meta/0016_snapshot.json new file mode 100644 index 000000000..9ed73c1b0 --- /dev/null +++ b/api/drizzle/meta/0016_snapshot.json @@ -0,0 +1,526 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "582e3253-223d-4ea3-9193-dab3b5982765", + "prevId": "4f324fb8-dc00-4757-8f6a-16261824a1e6", + "tables": { + "app_requests": { + "name": "app_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_query_params": { + "name": "request_query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_path_params": { + "name": "request_path_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_route": { + "name": "request_route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_responses": { + "name": "app_responses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers": { + "name": "response_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_details": { + "name": "failure_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_failure": { + "name": "is_failure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_responses_request_id_app_requests_id_fk": { + "name": "app_responses_request_id_app_requests_id_fk", + "tableFrom": "app_responses", + "tableTo": "app_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_routes": { + "name": "app_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler_type": { + "name": "handler_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentlyRegistered": { + "name": "currentlyRegistered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "registration_order": { + "name": "registration_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": -1 + }, + "route_origin": { + "name": "route_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovered'" + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "mizu_logs": { + "name": "mizu_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored": { + "name": "ignored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caller_location": { + "name": "caller_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "matching_issues": { + "name": "matching_issues", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "otel_spans": { + "name": "otel_spans", + "columns": { + "inner": { + "name": "inner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "settings_key_unique": { + "name": "settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tokens": { + "name": "tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "tokens_value_unique": { + "name": "tokens_value_unique", + "columns": [ + "value" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/0017_snapshot.json b/api/drizzle/meta/0017_snapshot.json new file mode 100644 index 000000000..f0a76758c --- /dev/null +++ b/api/drizzle/meta/0017_snapshot.json @@ -0,0 +1,528 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bbfc33b5-2b2e-474b-bc89-e8737c795e1c", + "prevId": "582e3253-223d-4ea3-9193-dab3b5982765", + "tables": { + "app_requests": { + "name": "app_requests", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_method": { + "name": "request_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_url": { + "name": "request_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_headers": { + "name": "request_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_query_params": { + "name": "request_query_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_path_params": { + "name": "request_path_params", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_route": { + "name": "request_route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_responses": { + "name": "app_responses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time": { + "name": "response_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers": { + "name": "response_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_details": { + "name": "failure_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_failure": { + "name": "is_failure", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_responses_request_id_app_requests_id_fk": { + "name": "app_responses_request_id_app_requests_id_fk", + "tableFrom": "app_responses", + "tableTo": "app_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "app_routes": { + "name": "app_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "handler_type": { + "name": "handler_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currentlyRegistered": { + "name": "currentlyRegistered", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "registration_order": { + "name": "registration_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": -1 + }, + "route_origin": { + "name": "route_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovered'" + }, + "openapi_spec": { + "name": "openapi_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "mizu_logs": { + "name": "mizu_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored": { + "name": "ignored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caller_location": { + "name": "caller_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "matching_issues": { + "name": "matching_issues", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "otel_spans": { + "name": "otel_spans", + "columns": { + "inner": { + "name": "inner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "span_id": { + "name": "span_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "settings_key_unique": { + "name": "settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tokens": { + "name": "tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "tokens_value_unique": { + "name": "tokens_value_unique", + "columns": [ + "value" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"tokens\".\"expires\"": "\"tokens\".\"expires_at\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/api/drizzle/meta/_journal.json b/api/drizzle/meta/_journal.json index 564bf358a..b0ae4ba50 100644 --- a/api/drizzle/meta/_journal.json +++ b/api/drizzle/meta/_journal.json @@ -106,6 +106,27 @@ "when": 1725289614651, "tag": "0014_worthless_mystique", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1728908824196, + "tag": "0015_icy_thunderball", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1729561882265, + "tag": "0016_puzzling_black_bolt", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1729561913984, + "tag": "0017_busy_whizzer", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/api/src/app.ts b/api/src/app.ts index cf6a6c85e..0b906fb22 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -9,6 +9,7 @@ import logger from "./logger.js"; import type * as webhoncType from "./lib/webhonc/index.js"; import appRoutes from "./routes/app-routes.js"; +import auth from "./routes/auth.js"; import inference from "./routes/inference/index.js"; import settings from "./routes/settings.js"; import source from "./routes/source.js"; @@ -53,6 +54,7 @@ export function createApp( ); // All routes are modularized in the ./routes folder + app.route("/", auth); app.route("/", traces); app.route("/", inference); app.route("/", source); diff --git a/api/src/constants.ts b/api/src/constants.ts index 70b8a7ecc..e83e0115e 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -5,3 +5,11 @@ export const DEFAULT_DATABASE_URL = "file:fpx.db"; export const USER_PROJECT_ROOT_DIR = path.resolve( process.env.FPX_WATCH_DIR ?? process.cwd(), ); + +/** The port on which to run our ephemeral, local authentication server */ +export const FPX_AUTH_SERVER_PORT = 6174; + +export const FPX_PORT = +(process.env.FPX_PORT ?? 8788); + +export const FPX_AUTH_BASE_URL = + process.env.FPX_AUTH_BASE_URL || "http://localhost:3578"; diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 1d87298f9..088624924 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -180,3 +180,15 @@ export const settings = sqliteTable("settings", { export type Setting = typeof settings.$inferSelect; export type NewSetting = typeof settings.$inferInsert; + +// New schema for tokens +export const tokens = sqliteTable("tokens", { + id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), + value: text("value").notNull().unique(), + expiresAt: text("expires_at"), + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), +}); + +export type Token = typeof tokens.$inferSelect; +export type NewToken = typeof tokens.$inferInsert; diff --git a/api/src/index.node.ts b/api/src/index.node.ts index ab416c1bc..3a245ed38 100644 --- a/api/src/index.node.ts +++ b/api/src/index.node.ts @@ -7,9 +7,14 @@ import { drizzle } from "drizzle-orm/libsql"; import figlet from "figlet"; import type { WebSocket } from "ws"; import { createApp } from "./app.js"; -import { DEFAULT_DATABASE_URL, USER_PROJECT_ROOT_DIR } from "./constants.js"; +import { + DEFAULT_DATABASE_URL, + FPX_PORT, + USER_PROJECT_ROOT_DIR, +} from "./constants.js"; import * as schema from "./db/schema.js"; import { getTSServer } from "./lib/expand-function/tsserver/index.js"; +import { getAuthServer } from "./lib/fp-services/server.js"; import { setupRealtimeService } from "./lib/realtime/index.js"; import { getSetting } from "./lib/settings/index.js"; import { resolveWebhoncUrl } from "./lib/utils.js"; @@ -46,7 +51,7 @@ app.use("/*", staticServerMiddleware); app.get("*", frontendRoutesHandler); // Serve the API -const port = +(process.env.FPX_PORT ?? 8788); +const port = FPX_PORT; const server = serve({ fetch: app.fetch, port, @@ -72,6 +77,10 @@ server.on("error", (err) => { } }); +// We need to kick off another server in the background on a predictable port +// TODO - Implement a flow that kicks off and tears down this server ephemerally +getAuthServer(FPX_PORT); + // First, fire off an async probe to the service we want to monitor // - This will collect information on all routes that the service exposes // diff --git a/api/src/lib/ai/fp.ts b/api/src/lib/ai/fp.ts new file mode 100644 index 000000000..d5af24d2e --- /dev/null +++ b/api/src/lib/ai/fp.ts @@ -0,0 +1,63 @@ +import logger from "../../logger.js"; +import { makeFpAuthRequest } from "../fp-services/request.js"; + +type GenerateRequestOptions = { + fpApiKey: string; + persona: string; + method: string; + path: string; + handler: string; + handlerContext?: string; + history?: Array; + openApiSpec?: string; + middleware?: { + handler: string; + method: string; + path: string; + }[]; + middlewareContext?: string; +}; + +export async function generateRequestWithFp({ + fpApiKey, + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, +}: GenerateRequestOptions) { + logger.debug( + "Generating request data with FP", + `persona: ${persona}`, + `method: ${method}`, + `path: ${path}`, + // `handler: ${handler}`, + // `handlerContext: ${handlerContext}`, + // `openApiSpec: ${openApiSpec}`, + // `middleware: ${middleware}`, + // `middlewareContext: ${middlewareContext}`, + ); + + const response = await makeFpAuthRequest({ + token: fpApiKey, + method: "POST", + path: "/ai/request", + body: { + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, + }, + }); + + return response.json(); +} diff --git a/api/src/lib/ai/index.ts b/api/src/lib/ai/index.ts index 3eb99ead9..8aafec22a 100644 --- a/api/src/lib/ai/index.ts +++ b/api/src/lib/ai/index.ts @@ -4,6 +4,7 @@ import { createOpenAI } from "@ai-sdk/openai"; import type { Settings } from "@fiberplane/fpx-types"; import { generateObject } from "ai"; import logger from "../../logger.js"; +import { generateRequestWithFp } from "./fp.js"; import { invokeRequestGenerationPrompt } from "./prompts.js"; import { requestSchema } from "./tools.js"; @@ -42,6 +43,7 @@ function configureProvider( } export async function generateRequestWithAiProvider({ + fpApiKey, inferenceConfig, persona, method, @@ -53,6 +55,7 @@ export async function generateRequestWithAiProvider({ middleware, middlewareContext, }: { + fpApiKey?: string; inferenceConfig: Settings; persona: string; method: string; @@ -87,6 +90,27 @@ export async function generateRequestWithAiProvider({ const providerConfig = aiProviderConfigurations[aiProvider]; + if (aiProvider === "fp") { + if (!fpApiKey) { + return { + data: null, + error: { message: "Fiberplane token not found" }, + }; + } + return generateRequestWithFp({ + fpApiKey, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, + persona, + method, + path, + }); + } + const provider = configureProvider(aiProvider, providerConfig); logger.debug("Generating request with AI provider", { diff --git a/api/src/lib/fp-services/auth.ts b/api/src/lib/fp-services/auth.ts new file mode 100644 index 000000000..f04406269 --- /dev/null +++ b/api/src/lib/fp-services/auth.ts @@ -0,0 +1,86 @@ +import { URL } from "node:url"; +import { + ERROR_TYPE_INVALID_TOKEN, + ERROR_TYPE_TOKEN_EXPIRED, + ERROR_TYPE_UNAUTHORIZED, + InvalidTokenError, + TokenExpiredError, + UnauthorizedError, +} from "./errors.js"; +import { makeFpAuthRequest } from "./request.js"; + +export async function getUser(token: string) { + try { + const response = await makeFpAuthRequest({ + method: "GET", + path: "/user", + token, + }); + + // NOTE - The API will return 404 for no matching user for token + // TODO - Delete associated token on our end + if (response?.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const verifiedToken = await response.json(); + return verifiedToken; + } catch (error) { + console.error("Error verifying token:", error); + throw error; + } +} + +/** + * Verify a token with the authentication API + * @param token The token to verify + * @returns A promise that resolves to the verification result + * @throws {Error} with specific error type for unauthorized, invalid token, or expired token + */ +export async function verifyToken(token: string): Promise { + const baseUrl = process.env.FPX_AUTH_BASE_URL; + if (!baseUrl) { + throw new Error("FPX_AUTH_BASE_URL environment variable is not set"); + } + + const url = new URL("/verify", baseUrl); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (response.status === 401) { + const errorData = await response.json(); + switch (errorData?.errorType) { + case ERROR_TYPE_UNAUTHORIZED: + throw new UnauthorizedError(); + case ERROR_TYPE_INVALID_TOKEN: + throw new InvalidTokenError(); + case ERROR_TYPE_TOKEN_EXPIRED: + throw new TokenExpiredError(); + default: + throw new UnauthorizedError( + `Unauthorized: ${errorData?.errorType || "unknown"}`, + ); + } + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const verifiedToken = await response.json(); + return verifiedToken; + } catch (error) { + console.error("Error verifying token:", error); + throw error; + } +} diff --git a/api/src/lib/fp-services/errors.ts b/api/src/lib/fp-services/errors.ts new file mode 100644 index 000000000..2d23953b5 --- /dev/null +++ b/api/src/lib/fp-services/errors.ts @@ -0,0 +1,38 @@ +// NOTE - Copy-pasted from fp-services/src/constants.ts +export const ERROR_TYPE_UNAUTHORIZED = "UnauthorizedError"; +export const ERROR_TYPE_NOT_FOUND = "NotFoundError"; +export const ERROR_TYPE_INVALID_TOKEN = "InvalidTokenError"; +export const ERROR_TYPE_TOKEN_EXPIRED = "TokenExpiredError"; + +// New error classes +export class UnauthorizedError extends Error { + constructor(message = "Unauthorized") { + super(message); + this.name = ERROR_TYPE_UNAUTHORIZED; + Object.setPrototypeOf(this, UnauthorizedError.prototype); + } +} + +export class NotFoundError extends Error { + constructor(message = "Not Found") { + super(message); + this.name = ERROR_TYPE_NOT_FOUND; + Object.setPrototypeOf(this, NotFoundError.prototype); + } +} + +export class InvalidTokenError extends Error { + constructor(message = "Invalid Token") { + super(message); + this.name = ERROR_TYPE_INVALID_TOKEN; + Object.setPrototypeOf(this, InvalidTokenError.prototype); + } +} + +export class TokenExpiredError extends Error { + constructor(message = "Token Expired") { + super(message); + this.name = ERROR_TYPE_TOKEN_EXPIRED; + Object.setPrototypeOf(this, TokenExpiredError.prototype); + } +} diff --git a/api/src/lib/fp-services/request.ts b/api/src/lib/fp-services/request.ts new file mode 100644 index 000000000..51214aa11 --- /dev/null +++ b/api/src/lib/fp-services/request.ts @@ -0,0 +1,33 @@ +import { FPX_AUTH_BASE_URL } from "../../constants.js"; +import logger from "../../logger.js"; + +type FpAuthRequestOptions = { + token: string; + method: string; + path: string; + body?: Record; +}; + +export async function makeFpAuthRequest({ + token, + method, + path, + body, +}: FpAuthRequestOptions) { + const url = new URL(path, FPX_AUTH_BASE_URL); + + logger.debug("Making FP Auth request", `method: ${method}`, `path: ${path}`); + + const headers: HeadersInit = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + const requestOptions: RequestInit = { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }; + + return fetch(url, requestOptions); +} diff --git a/api/src/lib/fp-services/server.ts b/api/src/lib/fp-services/server.ts new file mode 100644 index 000000000..aba8c71e9 --- /dev/null +++ b/api/src/lib/fp-services/server.ts @@ -0,0 +1,178 @@ +import { createServer } from "node:http"; +import { serve } from "@hono/node-server"; +import { zValidator } from "@hono/zod-validator"; +import chalk from "chalk"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { FPX_AUTH_SERVER_PORT } from "../../constants.js"; +import logger from "../../logger.js"; +import { TokenPayloadSchema } from "./types.js"; + +type AuthServer = ReturnType; + +let currentAuthServer: null | AuthServer = null; + +/** + * Returns the currently active authentication server. + * Otherwise, initializes an authentication server. + * + * @INVESTIGATE - How can we recover if there's a clashing or crufty process? + */ +export async function getAuthServer(fpxStudioPort: number) { + if (!currentAuthServer) { + currentAuthServer = await serveAuth(fpxStudioPort); + } + + return currentAuthServer; +} + +/** + * Helper for closing the currently running authentication server + */ +export async function closeAuthServer() { + if (currentAuthServer) { + currentAuthServer.close(() => { + currentAuthServer = null; + }); + } +} + +/** + * Spin up a small authenitcation server on a known port, + * in order to receive the user's JWT from the external Fiberplane service. + * + * @param fpxStudioPort - The port on which the Studio API is running + */ +export function serveAuth(fpxStudioPort: number): Promise { + return new Promise((resolve, reject) => { + try { + const app = createAuthApp(fpxStudioPort); + + logger.debug(chalk("[auth-server] Initializing")); + + const server = serve({ + fetch: app.fetch, + port: FPX_AUTH_SERVER_PORT, + createServer, + }) as ReturnType; + + // Resolve the promise when the server is listening + server.on("listening", () => { + logger.debug( + chalk.dim( + `[auth-server] Auth server listening on http://localhost:${FPX_AUTH_SERVER_PORT}`, + ), + ); + resolve(server); + }); + + // Reject the promise if there's an error + server.on("error", (err) => { + currentAuthServer = null; + if ("code" in err && err.code === "EADDRINUSE") { + logger.error( + `[auth-server] Port ${FPX_AUTH_SERVER_PORT} is already in use. Please choose a different port for FPX.`, + ); + } else { + logger.error("[auth-server] Auth server error:", err); + } + + // FIXME - The server may have already been listening, so + // this could be bad news bears and lead to warnings in the console + // that the promise already resolved. + reject(err); + }); + + // Remove reference to `currentAuthServer` if the server closes + // TODO - Investigate if this is necessary + server.on("close", () => { + currentAuthServer = null; + }); + } catch (error) { + logger.error("Failed to create authentication server", error); + reject(error); + } + }); +} + +/** + * Factory that creates a Hono app that can handle auth success payloads + * and forward them to the main Studio API + * + * @param fpxPort - The port on which the Studio API is running + */ +function createAuthApp(fpxStudioPort: number) { + const app = new Hono(); + + // Set up CORS middleware + app.use( + "/v0/auth/success", + cors({ + // HACK - Trust the local auth service while testing + // TODO - Update origin to include Fiberplane services + origin: ["http://127.0.0.1:3578", "http://localhost:3578"], + allowMethods: ["GET", "POST", "OPTIONS"], + allowHeaders: ["Content-Type"], + exposeHeaders: ["Content-Length"], + }), + ); + + app.post( + "/v0/auth/success", + zValidator("json", TokenPayloadSchema), + async (c) => { + const { token, expiresAt } = c.req.valid("json"); + try { + const success = await reportTokenToStudio( + fpxStudioPort, + token, + expiresAt, + ); + if (success) { + return c.text("OK"); + } + return c.text("Unknown error", 500); + } catch (error) { + logger.error("Spooky error reporting token to Studio", error); + return c.text("Unknown error", 500); + } + }, + ); + + return app; +} + +/** + * Helper function for reporting the JWT to the Studio API. + * + * @param fpxPort - The port on which the Studio API is running + * @param token - The user's JWT + * @returns - true if we successfully reported the token to Studio, false otherwise + */ +async function reportTokenToStudio( + fpxStudioPort: number, + token: string, + expiresAt: string, +) { + const localApiUrl = `http://localhost:${fpxStudioPort}/v0/auth/success`; + const response = await fetch(localApiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token, expiresAt }), + }); + + if (!response.ok) { + logger.error( + "Failed to POST auth data to Studio API:", + response.statusText, + ); + return false; + } + + logger.debug( + chalk.green("Auth server successfully POSTed token to Studio API"), + ); + return true; +} diff --git a/api/src/lib/fp-services/types.ts b/api/src/lib/fp-services/types.ts new file mode 100644 index 000000000..b87359911 --- /dev/null +++ b/api/src/lib/fp-services/types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +// The payload we expect at `/v0/auth/success` +export const TokenPayloadSchema = z.object({ + token: z.string(), + expiresAt: z.string(), +}); diff --git a/api/src/lib/webhonc/index.ts b/api/src/lib/webhonc/index.ts index 307b90da8..aae9b4c44 100644 --- a/api/src/lib/webhonc/index.ts +++ b/api/src/lib/webhonc/index.ts @@ -168,6 +168,9 @@ const messageHandlers: { trace_created: async (_message, _config) => { logger.debug("trace_created message received, no action required"); }, + login_success: async () => { + logger.debug("login_success message received, this should never happen"); + }, connection_open: async (message, config) => { const { connectionId } = message.payload; logger.debug( diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts new file mode 100644 index 000000000..052351124 --- /dev/null +++ b/api/src/routes/auth.ts @@ -0,0 +1,114 @@ +import { zValidator } from "@hono/zod-validator"; +import { desc } from "drizzle-orm"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import * as schema from "../db/schema.js"; +import { getUser, verifyToken } from "../lib/fp-services/auth.js"; +import { TokenExpiredError } from "../lib/fp-services/errors.js"; +import { TokenPayloadSchema } from "../lib/fp-services/types.js"; +import type { Bindings, Variables } from "../lib/types.js"; +import logger from "../logger.js"; + +const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); + +/** + * Get user info (checks if there is a token for the user locally) + */ +app.get("/v0/auth/user", cors(), async (ctx) => { + const db = ctx.get("db"); + const [token] = await db + .select() + .from(schema.tokens) + .orderBy(desc(schema.tokens.createdAt)) + .limit(1); + + if (!token) { + return ctx.json(null); + } + + const user = await getUser(token.value); + + return ctx.json({ + ...user, + token: token.value, + expiresAt: token.expiresAt, + }); +}); + +/** + * Delete user info (effectively "logout") + * @TODO - Make an authenticated request to remove the user from Fiberplane Services. + * @NOTE - This won't delete the user from our OAuth app with GitHub. + */ +app.delete("/v0/auth/user", cors(), async (ctx) => { + logger.debug("Deleting user details"); + const db = ctx.get("db"); + await db.delete(schema.tokens); + // TODO - Make a request to Fiberplane Services to remove user from our D1 db + return ctx.body(null, 204); +}); + +/** + * Verify user JWT + */ +app.post("/v0/auth/verify", cors(), async (ctx) => { + const token = ctx.req.header("Authorization")?.split(" ")?.[1]; + + if (!token) { + return ctx.json({ error: "No token provided" }, 400); + } + + try { + await verifyToken(token); + logger.debug("Auth token verification successful"); + return ctx.json(true); + } catch (error) { + if (error instanceof TokenExpiredError) { + return ctx.json({ error: "Token expired", type: error.name }, 401); + } + logger.error("Verification failed", error); + return ctx.json({ error: "Verification failed" }, 401); + } +}); + +/** + * Handle successful authentication coming from our local background auth service + */ +app.post( + "/v0/auth/success", + cors(), + zValidator("json", TokenPayloadSchema), + async (ctx) => { + const { token, expiresAt } = ctx.req.valid("json"); + + const db = ctx.get("db"); + const wsConnections = ctx.get("wsConnections"); + + try { + await db.insert(schema.tokens).values({ + value: token, + expiresAt, + }); + + // Force the UI to refresh user information, + // effectively logging the user in. + if (wsConnections) { + for (const ws of wsConnections) { + ws.send( + JSON.stringify({ + event: "login_success", + payload: ["userInfo"], + }), + ); + } + } + + return ctx.text("OK"); + } catch (error) { + logger.error("Error handling auth success message:", error); + return ctx.text("Unknown error", 500); + } + }, +); + +export default app; diff --git a/api/src/routes/inference/inference.ts b/api/src/routes/inference/inference.ts index 139572558..283ceedab 100644 --- a/api/src/routes/inference/inference.ts +++ b/api/src/routes/inference/inference.ts @@ -1,8 +1,10 @@ import { zValidator } from "@hono/zod-validator"; +import { desc } from "drizzle-orm"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { z } from "zod"; import { USER_PROJECT_ROOT_DIR } from "../../constants.js"; +import * as schema from "../../db/schema.js"; import { generateRequestWithAiProvider } from "../../lib/ai/index.js"; import { expandFunction } from "../../lib/expand-function/index.js"; import { getInferenceConfig } from "../../lib/settings/index.js"; @@ -74,9 +76,17 @@ app.post( }); // console.timeEnd("Handler and Middleware Expansion"); + // HACK - Get latest token from db + const [token] = await db + .select() + .from(schema.tokens) + .orderBy(desc(schema.tokens.createdAt)) + .limit(1); + // Generate the request const { data: parsedArgs, error: generateError } = await generateRequestWithAiProvider({ + fpApiKey: token?.value, inferenceConfig, persona, method, diff --git a/biome.jsonc b/biome.jsonc index d1ce327b2..9ea846751 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -72,7 +72,8 @@ "packages/client-library-otel", "api", "cli", - "honc-code-gen" + "honc-code-gen", + "fp-services" ], "linter": { "enabled": true, diff --git a/fp-services/.dev.vars.example b/fp-services/.dev.vars.example new file mode 100644 index 000000000..1587545bd --- /dev/null +++ b/fp-services/.dev.vars.example @@ -0,0 +1,8 @@ +GITHUB_ID= +GITHUB_SECRET= +PUBLIC_KEY= +PRIVATE_KEY= + +OPENAI_API_KEY="" + +FPX_ENDPOINT="" \ No newline at end of file diff --git a/fp-services/.gitignore b/fp-services/.gitignore new file mode 100644 index 000000000..d64b7b6a8 --- /dev/null +++ b/fp-services/.gitignore @@ -0,0 +1,47 @@ +.wrangler +shared/dist +.env +.envrc + +.dev.vars + +# Fpx +.fpxconfig + +# Rust targets +**/target +target + +# Mac +.DS_Store + +# VS Code +.vscode +*.code-workspace + +# CLion +.idea + +# Package locks +**/package-lock.json +**/yarn.lock + +# TypeScript / Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +node_modules +yarn-error.log + +# Build tools +.parcel-cache* +.nx/cache +*.tsbuildinfo + +# Personal files +start-dev.sh +slam-geese.sh + diff --git a/fp-services/LICENSE b/fp-services/LICENSE new file mode 100644 index 000000000..27425a28d --- /dev/null +++ b/fp-services/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 [Fiberplane](https://fiberplane.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fp-services/README.md b/fp-services/README.md new file mode 100644 index 000000000..c0be1e6c9 --- /dev/null +++ b/fp-services/README.md @@ -0,0 +1,105 @@ +# Fiberplane Studio Services + +Makes use of GitHub OAuth and JWT-based authentication using Hono, Drizzle ORM, and Cloudflare Workers. + +## Features + +- GitHub OAuth authentication +- User management with SQLite database (using Cloudflare D1) +- JWT token generation and verification +- RSA key pair generation and management + +## Prerequisites + +- Node.js +- pnpm +- Cloudflare account with Workers and D1 +- GitHub OAuth App credentials + +## Setup + +1. Install dependencies: + ```sh + pnpm install + ``` + +2. Set up environment variables: + Create a `.dev.vars` file in the root directory with the following variables: + ``` + GITHUB_ID= + GITHUB_SECRET= + PUBLIC_KEY= + PRIVATE_KEY= + ``` + +3. Generate RSA key pair: + Use `pnpm keypair:generate` endpoint to generate a new RSA key pair. Save the public and private keys in your Cloudflare Worker's environment variables as `PUBLIC_KEY` and `PRIVATE_KEY` respectively. + +4. Set up Cloudflare D1 database: + Locally, you need to run `pnpm db:touch` + +## Running Locally + +1. Start the development server: + ```sh + pnpm db:touch + pnpm db:migrate + pnpm run dev + ``` + +2. The app will be available at `http://localhost:3578` + +### Testing + +- [Configure a GitHub OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) on your personal account, and grab the client id and secret. Put these in `.dev.vars` + * Use `http://127.0.0.1:3578/github` as the callback. + +- Launch the api in the repo with `pnpm dev` + +- Hit the URL: `http://127.0.0.1:3578/github` + * **USE `127.0.0.1` as the host**, do NOT use `localhost`. The hono GitHub OAuth middleware sets a cookie in order to verify the response. + +- Log in with GitHub + +## API Endpoints + +- `GET /github`: GitHub OAuth callback endpoint +- `GET /user`: Endpoint to verify JWT tokens and return the user +- `POST /ai/request`: AI Request Generation + +## Deployment + +Deploy the app to Cloudflare Workers using the following command: + +```sh +pnpm run deploy +``` + +### Setting up Secrets + +Generate a new key pair if you must: + +```sh +pnpx keypair:generate +``` + +Set the secrets from the commandline (although I preferred to set the keys in the Dashboard): + +```sh +pnpx wrangler secret put OPENAI_API_KEY +pnpx wrangler secret put GITHUB_ID +pnpx wrangler secret put GITHUB_SECRET +pnpx wrangler secret put PUBLIC_KEY +pnpx wrangler secret put PRIVATE_KEY +``` + +## Project Structure + +- `src/index.tsx`: Main api file with route handlers and core logic +- `src/lib/crypto.ts`: Cryptographic functions for key generation and imports +- `src/db/index.ts`: Database initialization and connection +- `src/db/schema.ts`: Database schema definition using Drizzle ORM + +## License + +[MIT License](LICENSE) diff --git a/fp-services/drizzle.config.ts b/fp-services/drizzle.config.ts new file mode 100644 index 000000000..95a2b9554 --- /dev/null +++ b/fp-services/drizzle.config.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import { config } from "dotenv"; +import { defineConfig } from "drizzle-kit"; + +let dbConfig: ReturnType; +if (process.env.AUTH_ENV === "production") { + config({ path: "./.prod.vars" }); + dbConfig = defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + driver: "d1-http", + dbCredentials: { + // biome-ignore lint/style/noNonNullAssertion: + accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, + // biome-ignore lint/style/noNonNullAssertion: + databaseId: process.env.CLOUDFLARE_DATABASE_ID!, + // biome-ignore lint/style/noNonNullAssertion: + token: process.env.CLOUDFLARE_D1_TOKEN!, + }, + }); +} else { + config({ path: "./.dev.vars" }); + const localD1DB = getLocalD1DB(); + console.log("localD1DB", localD1DB); + if (!localD1DB) { + process.exit(1); + } + + dbConfig = defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle/migrations", + dialect: "sqlite", + dbCredentials: { + url: localD1DB, + }, + }); +} + +export default dbConfig; + +// Modified from: https://github.com/drizzle-team/drizzle-orm/discussions/1545 +function getLocalD1DB() { + try { + const basePath = path.resolve(".wrangler"); + const files = fs + .readdirSync(basePath, { encoding: "utf-8", recursive: true }) + .filter((f) => f.endsWith(".sqlite")); + + // In case there are multiple .sqlite files, we want the most recent one. + files.sort((a, b) => { + const statA = fs.statSync(path.join(basePath, a)); + const statB = fs.statSync(path.join(basePath, b)); + return statB.mtime.getTime() - statA.mtime.getTime(); + }); + const dbFile = files[0]; + + if (!dbFile) { + throw new Error(`.sqlite file not found in ${basePath}`); + } + + const url = path.resolve(basePath, dbFile); + console.debug(`Resolved local D1 DB: ${url}`); + return url; + } catch (err) { + if (err instanceof Error) { + console.log(`Error resolving local D1 DB: ${err.message}`); + } else { + console.log(`Error resolving local D1 DB: ${err}`); + } + } +} diff --git a/fp-services/drizzle/migrations/0000_same_surge.sql b/fp-services/drizzle/migrations/0000_same_surge.sql new file mode 100644 index 000000000..d97767a47 --- /dev/null +++ b/fp-services/drizzle/migrations/0000_same_surge.sql @@ -0,0 +1,9 @@ +CREATE TABLE `users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `github_username` text NOT NULL, + `email` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_github_username_unique` ON `users` (`github_username`); \ No newline at end of file diff --git a/fp-services/drizzle/migrations/0001_unknown_ultron.sql b/fp-services/drizzle/migrations/0001_unknown_ultron.sql new file mode 100644 index 000000000..baa3f2564 --- /dev/null +++ b/fp-services/drizzle/migrations/0001_unknown_ultron.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `ai_request_credits` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/fp-services/drizzle/migrations/meta/0000_snapshot.json b/fp-services/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..aabb1ca19 --- /dev/null +++ b/fp-services/drizzle/migrations/meta/0000_snapshot.json @@ -0,0 +1,71 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "04cada49-6344-41cb-90e5-7ed10c447a01", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_github_username_unique": { + "name": "users_github_username_unique", + "columns": [ + "github_username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/fp-services/drizzle/migrations/meta/0001_snapshot.json b/fp-services/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..4061841fd --- /dev/null +++ b/fp-services/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,79 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "df94bbcc-6742-4411-a86f-f2f288eb3e68", + "prevId": "04cada49-6344-41cb-90e5-7ed10c447a01", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ai_request_credits": { + "name": "ai_request_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_github_username_unique": { + "name": "users_github_username_unique", + "columns": [ + "github_username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/fp-services/drizzle/migrations/meta/_journal.json b/fp-services/drizzle/migrations/meta/_journal.json new file mode 100644 index 000000000..e940e0c72 --- /dev/null +++ b/fp-services/drizzle/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1728470906993, + "tag": "0000_same_surge", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1728891767330, + "tag": "0001_unknown_ultron", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/fp-services/keypair-generate.ts b/fp-services/keypair-generate.ts new file mode 100644 index 000000000..2666467a3 --- /dev/null +++ b/fp-services/keypair-generate.ts @@ -0,0 +1,12 @@ +import { exportKey, generateKeyPair } from "./src/lib/crypto"; + +generateKeyPair().then(async (keys) => { + const publicKeyExport = await exportKey("public", keys.publicKey); + console.log("public key export\n\n", publicKeyExport); + const privateKeyExport = await exportKey("private", keys.privateKey); + console.log("\n\n\nprivate key export\n\n", privateKeyExport); + + console.log( + "!!! When adding to .dev.vars, use double quotes and write literal newlines !!!", + ); +}); diff --git a/fp-services/package.json b/fp-services/package.json new file mode 100644 index 000000000..75749d770 --- /dev/null +++ b/fp-services/package.json @@ -0,0 +1,40 @@ +{ + "name": "fp-services", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy --minify", + "cf-typegen": "wrangler types --env-interface CloudflareBindings", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:touch": "wrangler d1 execute auth --local --command='SELECT 1'", + "db:migrate:prod": "AUTH_ENV=production drizzle-kit migrate", + "db:studio:prod": "AUTH_ENV=production drizzle-kit studio", + "format": "biome check . --write", + "keypair:generate": "tsx keypair-generate.ts", + "lint": "biome lint .", + "test": "vitest --run" + }, + "dependencies": { + "@ai-sdk/openai": "^0.0.66", + "@fiberplane/hono-otel": "workspace:*", + "@hono/oauth-providers": "^0.6.1", + "@hono/zod-validator": "^0.4.1", + "@langchain/core": "^0.3.11", + "@libsql/client": "^0.14.0", + "ai": "^3.4.10", + "dotenv": "^16.4.5", + "drizzle-orm": "^0.34.1", + "hono": "^4.6.3", + "jose": "^5.9.3", + "tsx": "^4.19.1", + "vitest": "^2.1.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@biomejs/biome": "^1.8.3", + "@cloudflare/workers-types": "^4.20241004.0", + "drizzle-kit": "^0.25.0", + "wrangler": "^3.80.1" + } +} diff --git a/fp-services/src/constants.ts b/fp-services/src/constants.ts new file mode 100644 index 000000000..4220891f0 --- /dev/null +++ b/fp-services/src/constants.ts @@ -0,0 +1,4 @@ +export const ERROR_TYPE_UNAUTHORIZED = "UnauthorizedError"; +export const ERROR_TYPE_NOT_FOUND = "NotFoundError"; +export const ERROR_TYPE_INVALID_TOKEN = "InvalidTokenError"; +export const ERROR_TYPE_TOKEN_EXPIRED = "TokenExpiredError"; diff --git a/fp-services/src/db/index.ts b/fp-services/src/db/index.ts new file mode 100644 index 000000000..e693c7ac3 --- /dev/null +++ b/fp-services/src/db/index.ts @@ -0,0 +1,13 @@ +import { drizzle } from "drizzle-orm/d1"; +import * as schema from "./schema"; + +export * from "./schema"; + +export type DBSchema = typeof schema; + +/** + * Initializes a database connection using Drizzle ORM. + * @param {D1Database} env - The D1 database environment + * @returns {ReturnType} The initialized Drizzle ORM instance + */ +export const initDbConnect = (env: D1Database) => drizzle(env, { schema }); diff --git a/fp-services/src/db/schema.ts b/fp-services/src/db/schema.ts new file mode 100644 index 000000000..b39be2b57 --- /dev/null +++ b/fp-services/src/db/schema.ts @@ -0,0 +1,25 @@ +import { sql } from "drizzle-orm"; +import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; + +// Define the "users" table schema for D1 (SQLite) +export const usersTable = sqliteTable( + "users", + { + id: integer("id", { mode: "number" }).primaryKey({ autoIncrement: true }), + githubUsername: text("github_username").notNull(), // GitHub username + email: text("email").notNull(), // User email + aiRequestCredits: integer("ai_request_credits").notNull().default(0), // New column for AI credits + createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(CURRENT_TIMESTAMP)`) + .$onUpdateFn(() => sql`(CURRENT_TIMESTAMP)`), + }, + (table) => ({ + // Adding unique constraints for githubUsername and email + githubUsernameUnique: unique().on(table.githubUsername), + // emailUnique: unique().on(table.email), + }), +); + +export type User = typeof usersTable.$inferSelect; diff --git a/fp-services/src/index.tsx b/fp-services/src/index.tsx new file mode 100644 index 000000000..18b395b6c --- /dev/null +++ b/fp-services/src/index.tsx @@ -0,0 +1,48 @@ +import { instrument } from "@fiberplane/hono-otel"; +import { Hono } from "hono"; +import { ERROR_TYPE_NOT_FOUND, ERROR_TYPE_UNAUTHORIZED } from "./constants"; +import { fpAuthenticate } from "./lib"; +import ai from "./routes/ai"; +import github from "./routes/github"; +// import success from "./routes/success"; +import type { FpAuthApp } from "./types"; + +const app = new Hono(); + +/** GitHub auth routing */ +app.route("/github", github); + +/** Ai services */ +app.route("/ai", ai); + +// TODO - REMOVE ME! This is here for testing the hacky UI on the success page +// app.route("/success", success); + +/** + * Return currently logged in user. + * I.e., user associated with a given JWT. + */ +app.get("/user", fpAuthenticate, async (c) => { + const verifiedToken = c.get("verifiedToken"); + if (!verifiedToken) { + return c.json( + { errorType: ERROR_TYPE_UNAUTHORIZED, message: "Unauthorized" }, + 401, + ); + } + + const user = c.get("currentUser"); + if (!user) { + return c.json( + { errorType: ERROR_TYPE_NOT_FOUND, message: "Not found" }, + 404, + ); + } + + return c.json({ + ...user, + exp: verifiedToken.exp, + }); +}); + +export default instrument(app); diff --git a/fp-services/src/lib/authenticate.ts b/fp-services/src/lib/authenticate.ts new file mode 100644 index 000000000..a8ea8db45 --- /dev/null +++ b/fp-services/src/lib/authenticate.ts @@ -0,0 +1,69 @@ +import type { MiddlewareHandler } from "hono"; +import { createFactory } from "hono/factory"; +import * as jose from "jose"; +import { + ERROR_TYPE_INVALID_TOKEN, + ERROR_TYPE_TOKEN_EXPIRED, +} from "../constants"; +import { initDbConnect } from "../db"; +import type { FpAuthApp } from "../types"; +import { importKey } from "./crypto"; +import { getUserById } from "./users"; + +const factory = createFactory(); + +/** + * A piece of middleware that handles JWT parsing and user lookup based off of the `sub` of the JWT. + * + * Sets variables on the Hono context for the bearerToken and currentUser. + */ +export const fpAuthenticate: MiddlewareHandler = + factory.createMiddleware(async (c, next) => { + const authHeader = c.req.header("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Invalid or missing Authorization header" }, 401); + } + + const bearerToken = authHeader.split(" ")?.[1]?.trim(); + + if (!bearerToken) { + return c.json({ message: "Unauthorized" }, 401); + } + + c.set("bearerToken", bearerToken); + + const publicKey = await importKey("public", c.env.PUBLIC_KEY); + + try { + const verifiedToken = await jose.jwtVerify(bearerToken, publicKey); + c.set("verifiedToken", verifiedToken.payload); + const userId = Number.parseInt(verifiedToken?.payload?.sub ?? ""); + if (!userId) { + return c.json({ message: "Unauthorized" }, 401); + } + + const db = initDbConnect(c.env.DB); + const user = await getUserById(db, userId); + + if (!user) { + return c.json({ message: "User not found" }, 404); + } + + c.set("currentUser", user); + + await next(); + } catch (error) { + if (error instanceof jose.errors.JWTExpired) { + return c.json( + { errorType: ERROR_TYPE_TOKEN_EXPIRED, message: "Token expired" }, + 401, + ); + } + // Handle other JWT verification errors + return c.json( + { errorType: ERROR_TYPE_INVALID_TOKEN, message: "Invalid token" }, + 401, + ); + } + }); diff --git a/fp-services/src/lib/crypto.ts b/fp-services/src/lib/crypto.ts new file mode 100644 index 000000000..57076797f --- /dev/null +++ b/fp-services/src/lib/crypto.ts @@ -0,0 +1,81 @@ +import * as jose from "jose"; + +/** + * Generates an RSA key pair for signing and verifying. + */ +export function generateKeyPair() { + return crypto.subtle.generateKey( + { + name: "RSA-PSS", + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) as Promise; +} + +/** + * Imports a public or private key from a PEM-formatted string. + * + * @param {("public"|"private")} keyType - The type of key to import + * @param {string} key - The PEM-formatted key string + * @returns {Promise} The imported CryptoKey + */ +export function importKey( + keyType: "public" | "private", + key: string, +): Promise { + const format = keyType === "public" ? "spki" : "pkcs8"; + const keyUsage = keyType === "public" ? ["verify"] : ["sign"]; + + const replace = + /-----BEGIN (PUBLIC|PRIVATE) KEY-----|-----END (PUBLIC|PRIVATE) KEY-----|\n/g; + + const keyData = new Uint8Array( + atob(key.replace(replace, "")) + .split("") + .map((c) => c.charCodeAt(0)), + ); + + return crypto.subtle.importKey( + format, + keyData, + { + name: "RSA-PSS", + hash: "SHA-256", + }, + keyType === "public", + keyUsage, + ); +} + +export async function exportKey( + keyType: "public" | "private", + key: CryptoKey, +): Promise { + if (keyType === "public") { + return await jose.exportSPKI(key); + } + + return await jose.exportPKCS8(key); +} + +/** + * NOTE - Not in use, simply a backup way to export keys in case the jose helpers are not working + * + */ +export async function exportKeyAlt( + keyType: "public" | "private", + key: CryptoKey, +) { + const format = keyType === "public" ? "spki" : "pkcs8"; + return crypto.subtle.exportKey(format, key).then((keyData) => { + const keyString = btoa( + // @ts-expect-error - Works in practice + String.fromCharCode.apply(null, new Uint8Array(keyData)), + ); + return `-----BEGIN ${keyType.toUpperCase()} KEY-----\n${keyString}\n-----END ${keyType.toUpperCase()} KEY-----`; + }); +} diff --git a/fp-services/src/lib/index.ts b/fp-services/src/lib/index.ts new file mode 100644 index 000000000..cc562b122 --- /dev/null +++ b/fp-services/src/lib/index.ts @@ -0,0 +1,3 @@ +export * from "./users"; +export * from "./authenticate"; +export * from "./crypto"; diff --git a/fp-services/src/lib/users.ts b/fp-services/src/lib/users.ts new file mode 100644 index 000000000..55236597e --- /dev/null +++ b/fp-services/src/lib/users.ts @@ -0,0 +1,66 @@ +import { eq } from "drizzle-orm"; +import type { DrizzleD1Database } from "drizzle-orm/d1"; +import { type DBSchema, usersTable } from "../db"; + +/** + * Upserts a user in the database. + * If the user exists, it simply updates the email. + * If not, it inserts a new user and gives them 50 aiRequestCredits. + */ +export async function upsertUser( + db: DrizzleD1Database, + user: typeof usersTable.$inferInsert, +) { + return await db + .insert(usersTable) + .values({ + ...user, + aiRequestCredits: 50, + }) + .onConflictDoUpdate({ + target: usersTable.githubUsername, + set: { + email: user.email, + }, + }) + .returning(); +} + +export async function getUserById( + db: DrizzleD1Database, + userId: number, +) { + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, userId)); + + if (!user) { + console.warn("user not found, id:", userId); + } + + return user ?? null; +} + +export async function decrementAiCredits( + db: DrizzleD1Database, + userId: number, +) { + const user = await getUserById(db, userId); + + const decrementedCredits = user.aiRequestCredits - 1; + + return await db + .insert(usersTable) + .values({ + ...user, + aiRequestCredits: decrementedCredits, + }) + .onConflictDoUpdate({ + target: usersTable.githubUsername, + set: { + aiRequestCredits: decrementedCredits, + }, + }) + .returning(); +} diff --git a/fp-services/src/routes/ai/ai.ts b/fp-services/src/routes/ai/ai.ts new file mode 100644 index 000000000..c49038697 --- /dev/null +++ b/fp-services/src/routes/ai/ai.ts @@ -0,0 +1,47 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { initDbConnect } from "../../db"; +import { decrementAiCredits, fpAuthenticate } from "../../lib"; +import type { FpAuthApp } from "../../types"; +import { generateRequest } from "./service"; +import { GenerateRequestOptionsSchema } from "./types"; + +const app = new Hono(); + +app.post( + "/request", + fpAuthenticate, + zValidator("json", GenerateRequestOptionsSchema), + async (c) => { + const currentUser = c.get("currentUser"); + if (!currentUser) { + return c.json({ message: "Unauthorized" }, 401); + } + + const db = initDbConnect(c.env.DB); + + try { + const requestOptions = c.req.valid("json"); + const aiResult = await generateRequest({ + apiKey: c.env.OPENAI_API_KEY, + ...requestOptions, + }); + if (aiResult.error) { + return c.json({ message: "Error generating request " }, 500); + } + // NOTE - Enqueue the ai credit derementing promise for after the worker finishes + c.executionCtx.waitUntil(decrementAiCredits(db, currentUser.id)); + return c.json(aiResult.data); + } catch (error) { + console.error("Error processing request generation call", error); + return c.json( + { + message: "Unknown error", + }, + 500, + ); + } + }, +); + +export default app; diff --git a/fp-services/src/routes/ai/index.ts b/fp-services/src/routes/ai/index.ts new file mode 100644 index 000000000..a76744b97 --- /dev/null +++ b/fp-services/src/routes/ai/index.ts @@ -0,0 +1,3 @@ +import ai from "./ai"; + +export default ai; diff --git a/fp-services/src/routes/ai/service/index.ts b/fp-services/src/routes/ai/service/index.ts new file mode 100644 index 000000000..0a1fd2501 --- /dev/null +++ b/fp-services/src/routes/ai/service/index.ts @@ -0,0 +1,34 @@ +import type { GenerateRequestOptions } from "../types"; +import { generateRequestWithOpenAI } from "./openai"; + +export async function generateRequest({ + apiKey, + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, +}: GenerateRequestOptions & { apiKey: string }) { + return generateRequestWithOpenAI({ + apiKey, + model: "gpt-4o", + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, + }).catch((error) => { + if (error instanceof Error) { + return { data: null, error: { message: error.message } }; + } + return { data: null, error: { message: "Unknown error" } }; + }); +} diff --git a/fp-services/src/routes/ai/service/openai.ts b/fp-services/src/routes/ai/service/openai.ts new file mode 100644 index 000000000..461e802f2 --- /dev/null +++ b/fp-services/src/routes/ai/service/openai.ts @@ -0,0 +1,92 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { generateObject } from "ai"; +import type { GenerateRequestOptions } from "../types"; +import { getSystemPrompt, invokeRequestGenerationPrompt } from "./prompts"; +import { requestSchema } from "./schema"; + +const logger = { + debug: (...args: unknown[]) => console.debug(...args), + error: (...args: unknown[]) => console.error(...args), +}; + +/** + * Generates request data for a route handler + * - uses OpenAI's tool-calling feature. + * - returns the request data as JSON. + * + * See the JSON Schema definition for the request data in the `make_request` tool. + */ +export async function generateRequestWithOpenAI({ + apiKey, + model, + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, +}: GenerateRequestOptions & { model: string; apiKey: string }) { + logger.debug( + "Generating request data with OpenAI", + `model: ${model}`, + `persona: ${persona}`, + `method: ${method}`, + `path: ${path}`, + // `handler: ${handler}`, + // `handlerContext: ${handlerContext}`, + // `openApiSpec: ${openApiSpec}`, + // `middleware: ${middleware}`, + // `middlewareContext: ${middlewareContext}`, + ); + const openaiClient = createOpenAI({ + apiKey, + }); + + const userPrompt = await invokeRequestGenerationPrompt({ + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, + }); + + const openai = openaiClient(model, { + // NOTE - Later models (gpt-4o, gpt-4-turbo) should guarantee function calling to have json output + structuredOutputs: true, + }); + + const { + object: generatedObject, + warnings, + usage, + } = await generateObject({ + model: openai, + schema: requestSchema, + prompt: userPrompt, + system: getSystemPrompt(persona), + temperature: 0.12, + }); + + logger.debug("Generated object, warnings, usage", { + generatedObject, + warnings, + usage, + }); + + // Remove x-fpx-trace-id header from the generated object + const filteredHeaders = generatedObject?.headers?.filter( + (header) => header.key.toLowerCase() !== "x-fpx-trace-id", + ); + + return { + data: { ...generatedObject, headers: filteredHeaders }, + error: null, + }; +} diff --git a/fp-services/src/routes/ai/service/prompts.ts b/fp-services/src/routes/ai/service/prompts.ts new file mode 100644 index 000000000..2bd07c1e6 --- /dev/null +++ b/fp-services/src/routes/ai/service/prompts.ts @@ -0,0 +1,314 @@ +import { PromptTemplate } from "@langchain/core/prompts"; + +export const getSystemPrompt = (persona: string) => { + return persona === "QA" + ? QA_PARAMETER_GENERATION_SYSTEM_PROMPT + : FRIENDLY_PARAMETER_GENERATION_SYSTEM_PROMPT; +}; + +function formatMiddleware( + middleware?: { + handler: string; + method: string; + path: string; + }[], +) { + // HACK - Filter out react renderer middleware + const filteredMiddleware = middleware?.filter( + (m) => !/function reactRenderer/i.test(m?.handler), + ); + + if (!filteredMiddleware || filteredMiddleware.length === 0) { + return "NO MIDDLEWARE"; + } + + return filteredMiddleware + .map((m) => `${m.handler}`) + .join("\n"); +} + +export const invokeRequestGenerationPrompt = async ({ + persona, + method, + path, + handler, + handlerContext, + history, + openApiSpec, + middleware, + middlewareContext, +}: { + persona: string; + method: string; + path: string; + handler: string; + handlerContext?: string; + history?: Array; + openApiSpec?: string; + middleware?: { + handler: string; + method: string; + path: string; + }[]; + middlewareContext?: string; +}) => { + const promptTemplate = + persona === "QA" ? qaTesterPrompt : friendlyTesterPrompt; + const userPromptInterface = await promptTemplate.invoke({ + method, + path, + handler, + handlerContext: handlerContext ?? "NO HANDLER CONTEXT", + history: history?.join("\n") ?? "NO HISTORY", + openApiSpec: openApiSpec ?? "NO OPENAPI SPEC", + middleware: formatMiddleware(middleware), + middlewareContext: middlewareContext ?? "NO MIDDLEWARE CONTEXT", + }); + const userPrompt = userPromptInterface.value; + return userPrompt; +}; + +/** + * A friendly tester prompt. + * + * This prompt is used to generate requests for the API. + * It is a friendly tester, who tries to help you succeed. + */ +export const friendlyTesterPrompt = PromptTemplate.fromTemplate( + ` +I need to make a request to one of my Hono api handlers. + +Here are some recent requests/responses, which you can use as inspiration for future requests. +E.g., if we recently created a resource, you can look that resource up. + + +{history} + + +The request you make should be a {method} request to route: {path} + +Here is the OpenAPI spec for the handler: +{openApiSpec} + +Here is the middleware that will be applied to the request: +{middleware} + +Here is some additional context for the middleware that will be applied to the request: +{middlewareContext} + +Here is the code for the handler: +{handler} + +Here is some additional context for the handler source code, if you need it: +{handlerContext} + +`.trim(), +); + +// NOTE - We need to remind the QA tester not to generate long inputs, +// since that has (in the past) broken tool calling with gpt-4o +export const qaTesterPrompt = PromptTemplate.fromTemplate( + ` +I need to make a request to one of my Hono api handlers. + +Here are some recent requests and responses, which you can use as inspiration for future requests. + + +{history} + + +The request you make should be a {method} request to route: {path} + +Here is the OpenAPI spec for the handler: +{openApiSpec} + +Here is the middleware that will be applied to the request: +{middleware} + +Here is some additional context for the middleware that will be applied to the request: +{middlewareContext} + +Here is the code for the handler: +{handler} + +Here is some additional context for the handler source code, if you need it: +{handlerContext} + +REMEMBER YOU ARE A QA. MISUSE THE API. BUT DO NOT MISUSE YOURSELF. +Keep your responses short-ish. Including your random data. +`.trim(), +); + +export const FRIENDLY_PARAMETER_GENERATION_SYSTEM_PROMPT = cleanPrompt(` +You are a friendly, expert full-stack engineer and an API testing assistant for apps that use Hono, +a typescript web framework similar to express. + +You need to help craft requests to route handlers. + +You will be provided the source code of a route handler for an API route, and you should generate +query parameters, a request body, and headers that will test the request. + +Be clever and creative with test data. Avoid just writing things like "test". + +For example, if you get a route like \`/users/:id\`, you should return a URL like +\`/users/10\` and a pathParams parameter like this: + +{ "path": "/users/10", "pathParams": { "key": ":id", "value": "10" } } + +*Remember to keep the colon in the pathParam key!* + +If you get a route like \`POST /users/:id\` with a handler like: + +\`\`\`ts +async (c) => { + const token = c.req.headers.get("authorization")?.split(" ")[1] + + const auth = c.get("authService"); + const isAuthorized = await auth.isAuthorized(token) + if (!isAuthorized) { + return c.json({ message: "Unauthorized" }, 401) + } + + const db = c.get("db"); + + const id = c.req.param('id'); + const { email } = await c.req.json() + + const user = (await db.update(user).set({ email }).where(eq(user.id, +id)).returning())?.[0]; + + if (!user) { + return c.json({ message: 'User not found' }, 404); + } + + return c.json(user); +} +\`\`\` + +You should return a URL like: + +\`/users/64\` and a pathParams like: + +{ "path": "/users/64", "pathParams": { "key": ":id", "value": "64" } } + +and a header like: + +{ "headers": { "key": "authorization", "value": "Bearer " } } + +and a body like: + +{ email: "paul@beatles.music" } + +with a body type of "json" + +It is, however, possible that the body type is JSON, text, or form data. If the body type is a file stream, return an empty body. +Only return bodyType "file" for obvious, singular file uploads. + +If it appears that more fields are coming alongside a file, return a body type of "form-data" with isMultipart set to true. + +For form data, you can return a body type of "form-data". You can still return a JSON object like above, +I will handle converting it to form data. + +Never add the x-fpx-trace-id header to the request. + +=== + +Use the tool "make_request". Always respond in valid JSON. Help the user test the happy path. +`); + +/** + * A QA (hostile) tester prompt. + * + * This prompt is used to generate requests for the API. + * It is a QA tester, who tries to break your api. + * + * NOTE - I had to stop instructing the AI to create very long data in this prompt. + * It would end up repeating 9999999 ad infinitum and break JSON responses. + */ +export const QA_PARAMETER_GENERATION_SYSTEM_PROMPT = cleanPrompt(` +You are an expert QA Engineer, a thorough API tester, and a code debugging assistant for web APIs that use Hono, +a typescript web framework similar to express. You have a generally hostile disposition. + +You need to help craft requests to route handlers. + +You will be provided the source code of a route handler for an API route, and you should generate +query parameters, a request body, and headers that will test the request. + +Be clever and creative with test data. Avoid just writing things like "test". + +For example, if you get a route like \`/users/:id\`, you should return a filled-in "path" field, +like \`/users/1234567890\` and a "pathParams" field like: + +{ "path": "/users/1234567890", "pathParams": { "key": ":id", "value": "1234567890" } } + +*Remember to keep the colon in the pathParam key!* + +If you get a route like \`POST /users/:id\` with a handler like: + +\`\`\`ts +async (c) => { + const token = c.req.headers.get("authorization")?.split(" ")[1] + + const auth = c.get("authService"); + const isAuthorized = await auth.isAuthorized(token) + if (!isAuthorized) { + return c.json({ message: "Unauthorized" }, 401) + } + + const db = c.get("db"); + + const id = c.req.param('id'); + const { email } = await c.req.json() + + const user = (await db.update(user).set({ email }).where(eq(user.id, +id)).returning())?.[0]; + + if (!user) { + return c.json({ message: 'User not found' }, 404); + } + + return c.json(user); +} +\`\`\` + +You should return a filled-in "path" field like \`/users/1234567890\` and a "pathParams" field like: + +{ "path": "/users/1234567890", "pathParams": { "key": ":id", "value": "1234567890" } } + +and a header like: + +{ "headers": { "key": "authorization", "value": "Bearer admin" } } + +and a body like: + +{ "body": { "email": "" } } + +It is possible that the body type is JSON, text, or form data. You can use the wrong body type to see what happens. +But if the body type is a file stream, just return an empty body. + +For form data, you can return a body type of "form-data". You can still return a JSON object like above, +I will handle converting it to form data. + +You should focus on trying to break things. You are a QA. + +You are the enemy of bugs. To protect quality, you must find bugs. + +Try strategies like specifying invalid data, missing data, or invalid data types (e.g., using strings instead of numbers). + +Try to break the system. But do not break yourself! +Keep your responses to a reasonable length. Including your random data. + +Never add the x-fpx-trace-id header to the request. + +Use the tool "make_request". Always respond in valid JSON. +***Don't make your responses too long, otherwise we cannot parse your JSON response.*** +`); + +/** + * Clean a prompt by trimming whitespace for each line and joining the lines. + */ +export function cleanPrompt(prompt: string) { + return prompt + .trim() + .split("\n") + .map((l) => l.trim()) + .join("\n"); +} diff --git a/fp-services/src/routes/ai/service/schema.ts b/fp-services/src/routes/ai/service/schema.ts new file mode 100644 index 000000000..d7e2a9c6d --- /dev/null +++ b/fp-services/src/routes/ai/service/schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const requestSchema = z.object({ + path: z.string(), + pathParams: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .nullable(), + queryParams: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .nullable(), + body: z.string().nullable(), + bodyType: z + .object({ + type: z.enum(["json", "text", "form-data", "file"]), + isMultipart: z.boolean(), + }) + .nullable(), + headers: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .nullable(), +}); diff --git a/fp-services/src/routes/ai/service/tools.ts b/fp-services/src/routes/ai/service/tools.ts new file mode 100644 index 000000000..e05063150 --- /dev/null +++ b/fp-services/src/routes/ai/service/tools.ts @@ -0,0 +1,76 @@ +export const makeRequestTool = { + type: "function", + function: { + name: "make_request", + description: + "Generates some random data for an http request to an api backend", + // Describe parameters as json schema https://json-schema.org/understanding-json-schema/ + parameters: { + type: "object", + properties: { + path: { + type: "string", + }, + pathParams: { + type: "array", + items: { + type: "object", + properties: { + key: { + type: "string", + }, + value: { + type: "string", + }, + }, + }, + }, + queryParams: { + type: "array", + items: { + type: "object", + properties: { + key: { + type: "string", + }, + value: { + type: "string", + }, + }, + }, + }, + body: { + type: "string", + }, + bodyType: { + type: "object" as const, + properties: { + type: { + type: "string" as const, + enum: ["json", "text", "form-data", "file"], + }, + isMultipart: { + type: "boolean" as const, + }, + }, + }, + headers: { + type: "array", + items: { + type: "object", + properties: { + key: { + type: "string", + }, + value: { + type: "string", + }, + }, + }, + }, + }, + // TODO - Mark fields like `pathParams` as required based on the route definition? + required: ["path"], + }, + }, +} as const; diff --git a/fp-services/src/routes/ai/types.ts b/fp-services/src/routes/ai/types.ts new file mode 100644 index 000000000..f2b6a3dc1 --- /dev/null +++ b/fp-services/src/routes/ai/types.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const GenerateRequestOptionsSchema = z.object({ + persona: z.string(), + method: z.string(), + path: z.string(), + handler: z.string(), + handlerContext: z.string().optional(), + history: z.array(z.string()).optional(), + openApiSpec: z.string().optional(), + middleware: z + .array( + z.object({ + handler: z.string(), + method: z.string(), + path: z.string(), + }), + ) + .optional(), + middlewareContext: z.string().optional(), +}); + +export type GenerateRequestOptions = z.infer< + typeof GenerateRequestOptionsSchema +>; + +// ... rest of the existing code ... diff --git a/fp-services/src/routes/github.tsx b/fp-services/src/routes/github.tsx new file mode 100644 index 000000000..71cde7a5a --- /dev/null +++ b/fp-services/src/routes/github.tsx @@ -0,0 +1,83 @@ +import { githubAuth } from "@hono/oauth-providers/github"; +import { Hono } from "hono"; +import * as jose from "jose"; +import { initDbConnect } from "../db"; +import { importKey, upsertUser } from "../lib"; +import type { FpAuthApp } from "../types"; +import { SuccessPage, generateNonce } from "./success"; + +const app = new Hono(); + +/** + * Set up OAuth middleware for GitHub + */ +app.use("/", (c, next) => { + const handler = githubAuth({ + client_id: c.env.GITHUB_ID, + client_secret: c.env.GITHUB_SECRET, + scope: ["read:user", "user:email"], + oauthApp: true, + }); + const result = handler(c, next); + return result; +}); + +/** + * GitHub OAuth callback after logging in + */ +app.get("/", async (c) => { + const db = initDbConnect(c.env.DB); + + // Get OAuth tokens from the context (not used in this implementation) + const _token = c.get("token"); + const _refreshToken = c.get("refresh-token"); + + // Get the authenticated GitHub user information + const user = c.get("user-github"); + + // Check if we have the required user information + if (user?.login && user?.email) { + // Upsert the user in the database + const [userRecord] = await upsertUser(db, { + githubUsername: user.login, + email: user.email, + }); + + const privateKey = await importKey("private", c.env.PRIVATE_KEY); + + // Sign the JWT using the private key from environment variables + // Create a JWT payload + const userId = userRecord?.id; + const payload = { + // NOTE - Token expiration is set below + sub: userId?.toString() ?? "anon", // Subject (user identifier) + iat: Math.floor(Date.now() / 1000), // Issued at (current timestamp) + nbf: Math.floor(Date.now() / 1000), // Not before (current timestamp) + }; + + // HACK - Temporary workaround to communicate expiration to the client + // I am being lazy + const expiresAt = new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + + const token = await new jose.SignJWT(payload) + .setProtectedHeader({ alg: "PS256" }) + .setExpirationTime("7d") + .sign(privateKey); + + const nonce = generateNonce(); // Generate a unique nonce for each request + + // Set CSP header + c.header("Content-Security-Policy", `script-src 'nonce-${nonce}'`); + + return c.render( + , + ); + } + + // If no user information is available, return an error message + return c.text("Error: No user information", 500); +}); + +export default app; diff --git a/fp-services/src/routes/success.tsx b/fp-services/src/routes/success.tsx new file mode 100644 index 000000000..1e8ba8401 --- /dev/null +++ b/fp-services/src/routes/success.tsx @@ -0,0 +1,155 @@ +import { Hono } from "hono"; +import { Style, css } from "hono/css"; +import { html } from "hono/html"; + +const app = new Hono(); + +type SuccessPageProps = { + nonce: string; + token: string; + expiresAt: string; +}; + +// NOTE - I could not figure out the proper type for `children` on a JSX element. +// (Hono docs uses `any` for `children`) +// So we are using one big page component for now. +export const SuccessPage = ({ nonce, token, expiresAt }: SuccessPageProps) => { + return ( + + + + + Fiberplane Studio Auth + + + +
+
+ Loading... +
+

+ You can close this page and return to Studio. +

+

+ An error occurred authenticating with Studio. +

+
+ + + + ); +}; + +app.get("/test", async (c) => { + const token = `test-${crypto.randomUUID()}`; + const expiresAt = new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + // Generate a unique nonce for each request + const nonce = generateNonce(); + + // Set CSP header + c.header("Content-Security-Policy", `script-src 'nonce-${nonce}'`); + + return c.render( + , + ); +}); + +export default app; + +export function generateNonce(): string { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + // @ts-expect-error - works in practice + return btoa(String.fromCharCode.apply(null, array)); +} + +function ScriptPostToken({ token, nonce, expiresAt }: SuccessPageProps) { + return ( + <> + {html` + + `} + + ); +} diff --git a/fp-services/src/types.ts b/fp-services/src/types.ts new file mode 100644 index 000000000..b6b123420 --- /dev/null +++ b/fp-services/src/types.ts @@ -0,0 +1,19 @@ +import type { User } from "./db"; + +export type Bindings = CloudflareBindings & { + OPENAI_API_KEY: string; +}; + +export type Variables = { + bearerToken: string; + currentUser: User; + verifiedToken: { + sub: string; + exp: number; + }; +}; + +export type FpAuthApp = { + Bindings: Bindings; + Variables: Variables; +}; diff --git a/fp-services/tsconfig.json b/fp-services/tsconfig.json new file mode 100644 index 000000000..4a2963706 --- /dev/null +++ b/fp-services/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext" + ], + "types": [ + "vitest/globals", + "@cloudflare/workers-types/2023-07-01" + ], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + }, +} \ No newline at end of file diff --git a/fp-services/vitest.config.ts b/fp-services/vitest.config.ts new file mode 100644 index 000000000..a4cfc8656 --- /dev/null +++ b/fp-services/vitest.config.ts @@ -0,0 +1,10 @@ +// Used example Hono app to configure tests +// https://github.com/honojs/examples/blob/main/basic/package.json + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/fp-services/worker-configuration.d.ts b/fp-services/worker-configuration.d.ts new file mode 100644 index 000000000..d4c54c77d --- /dev/null +++ b/fp-services/worker-configuration.d.ts @@ -0,0 +1,12 @@ +// Generated by Wrangler by running `wrangler types --env-interface CloudflareBindings` + +interface CloudflareBindings { + GITHUB_ID: string; + GITHUB_SECRET: string; + CLOUDFLARE_ACCOUNT_ID: string; + CLOUDFLARE_DATABASE_ID: string; + CLOUDFLARE_D1_TOKEN: string; + PRIVATE_KEY: string; + PUBLIC_KEY: string; + DB: D1Database; +} diff --git a/fp-services/wrangler.toml b/fp-services/wrangler.toml new file mode 100644 index 000000000..1059d2b69 --- /dev/null +++ b/fp-services/wrangler.toml @@ -0,0 +1,125 @@ +#:schema node_modules/wrangler/config-schema.json +name = "fp-services" +main = "src/index.tsx" +compatibility_date = "2024-09-25" +compatibility_flags = [ "nodejs_compat" ] + +[dev] +port = 3578 + +# Workers Logs +# Docs: https://developers.cloudflare.com/workers/observability/logs/workers-logs/ +# Configuration: https://developers.cloudflare.com/workers/observability/logs/workers-logs/#enable-workers-logs +[observability] +enabled = true + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "DB" +# database_name = "authorization-development" +# database_id = "xxxx" +# migrations_dir = "drizzle/migrations" + +[[d1_databases]] +binding = "DB" # i.e. available in your Worker on env.DB +database_name = "fp-services" +# preview_database_id = "93c4319c-5689-4058-889e-07d356963e99" +database_id = "a834f249-3260-439c-b1a4-2ee40348347b" +migrations_dir = "drizzle/migrations" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model. +# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#durable-objects +# [[durable_objects.bindings]] +# name = "MY_DURABLE_OBJECT" +# class_name = "MyDurableObject" + +# Durable Object migrations. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#migrations +# [[migrations]] +# tag = "v1" +# new_classes = ["MyDurableObject"] + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/packages/types/src/settings.ts b/packages/types/src/settings.ts index a8f5b21f3..c71b21573 100644 --- a/packages/types/src/settings.ts +++ b/packages/types/src/settings.ts @@ -85,12 +85,14 @@ export type OpenAIModel = z.infer; export const ProviderOptions = { openai: "OpenAI", anthropic: "Anthropic", + fp: "Fiberplane", mistral: "Mistral", } as const; export const AiProviderTypeSchema = z.union([ z.literal("openai"), z.literal("anthropic"), + z.literal("fp"), z.literal("mistral"), ]); diff --git a/packages/types/src/ws.ts b/packages/types/src/ws.ts index cfd050aea..25fd07d22 100644 --- a/packages/types/src/ws.ts +++ b/packages/types/src/ws.ts @@ -6,6 +6,10 @@ export const WsMessageSchema = z.discriminatedUnion("event", [ // TODO: this should be an array of traces instead of the queryKeys to invalidate on the browser payload: z.array(z.literal("mizuTraces")), }), + z.object({ + event: z.literal("login_success"), + payload: z.array(z.literal("userInfo")), + }), z.object({ event: z.literal("connection_open"), payload: z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a5aa363b..d8dd24532 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,7 +189,7 @@ importers: version: 22.7.5 tsup: specifier: ^8.2.3 - version: 8.3.0(@swc/core@1.7.10)(jiti@1.21.6)(postcss@8.4.47)(tsx@4.17.0)(typescript@5.6.2)(yaml@2.5.0) + version: 8.3.0(@swc/core@1.7.10)(jiti@1.21.6)(postcss@8.4.47)(tsx@4.19.1)(typescript@5.6.2)(yaml@2.5.0) examples/ai-request-generation: dependencies: @@ -223,7 +223,7 @@ importers: version: 16.4.5 drizzle-orm: specifier: ^0.32.0 - version: 0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + version: 0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) hono: specifier: ^4.5.9 version: 4.5.9 @@ -251,7 +251,7 @@ importers: version: 16.4.5 drizzle-orm: specifier: ^0.32.0 - version: 0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + version: 0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) hono: specifier: ^4.5.9 version: 4.5.9 @@ -292,7 +292,7 @@ importers: version: link:../../packages/client-library-otel drizzle-orm: specifier: ^0.32.0 - version: 0.32.2(@cloudflare/workers-types@4.20240924.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + version: 0.32.2(@cloudflare/workers-types@4.20240924.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) hono: specifier: ^4.5.9 version: 4.6.2 @@ -307,6 +307,64 @@ importers: specifier: ^3.73.0 version: 3.78.5(@cloudflare/workers-types@4.20240924.0) + fp-services: + dependencies: + '@ai-sdk/openai': + specifier: ^0.0.66 + version: 0.0.66(zod@3.23.8) + '@fiberplane/hono-otel': + specifier: workspace:* + version: link:../packages/client-library-otel + '@hono/oauth-providers': + specifier: ^0.6.1 + version: 0.6.2(hono@4.6.5) + '@hono/zod-validator': + specifier: ^0.4.1 + version: 0.4.1(hono@4.6.5)(zod@3.23.8) + '@langchain/core': + specifier: ^0.3.11 + version: 0.3.13(openai@4.67.3(encoding@0.1.13)(zod@3.23.8)) + '@libsql/client': + specifier: ^0.14.0 + version: 0.14.0 + ai: + specifier: ^3.4.10 + version: 3.4.10(openai@4.67.3(encoding@0.1.13)(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.5.12(typescript@5.6.2))(zod@3.23.8) + dotenv: + specifier: ^16.4.5 + version: 16.4.5 + drizzle-orm: + specifier: ^0.34.1 + version: 0.34.1(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + hono: + specifier: ^4.6.3 + version: 4.6.5 + jose: + specifier: ^5.9.3 + version: 5.9.6 + tsx: + specifier: ^4.19.1 + version: 4.19.1 + vitest: + specifier: ^2.1.2 + version: 2.1.3(@types/node@22.7.5) + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@biomejs/biome': + specifier: ^1.8.3 + version: 1.8.3 + '@cloudflare/workers-types': + specifier: ^4.20241004.0 + version: 4.20241011.0 + drizzle-kit: + specifier: ^0.25.0 + version: 0.25.0 + wrangler: + specifier: ^3.80.1 + version: 3.80.4(@cloudflare/workers-types@4.20241011.0) + honc-code-gen: dependencies: '@anthropic-ai/sdk': @@ -338,10 +396,10 @@ importers: version: 0.24.2 drizzle-orm: specifier: ^0.33.0 - version: 0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + version: 0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) drizzle-zod: specifier: ^0.5.1 - version: 0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8) + version: 0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8) hono: specifier: ^4.6.2 version: 4.6.2 @@ -2070,12 +2128,24 @@ packages: peerDependencies: hono: ^4 + '@hono/oauth-providers@0.6.2': + resolution: {integrity: sha512-Uo2Lf+GnaPGebkC4GUQMRD9spIKonav/gWwSapEJhCKQjq1WVxTLIlCykNRhJtcizPCPc6OAV3A9RAYk0kX9XQ==} + engines: {node: '>=18.4.0'} + peerDependencies: + hono: '>=3.*' + '@hono/zod-validator@0.2.2': resolution: {integrity: sha512-dSDxaPV70Py8wuIU2QNpoVEIOSzSXZ/6/B/h4xA7eOMz7+AarKTSGV8E6QwrdcCbBLkpqfJ4Q2TmBO0eP1tCBQ==} peerDependencies: hono: '>=3.9.0' zod: ^3.19.1 + '@hono/zod-validator@0.4.1': + resolution: {integrity: sha512-I8LyfeJfvVmC5hPjZ2Iij7RjexlgSBT7QJudZ4JvNPLxn0JQ3sqclz2zydlwISAnw21D2n4LQ0nfZdoiv9fQQA==} + peerDependencies: + hono: '>=3.9.0' + zod: ^3.19.1 + '@hookform/resolvers@3.9.0': resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==} peerDependencies: @@ -2261,6 +2331,10 @@ packages: resolution: {integrity: sha512-qHLvScqERDeH7y2cLuJaSAlMwg3f/3Oc9nayRSXRU2UuaK/SOhI42cxiPLj1FnuHJSmN0rBQFkrLx02gI4mcVg==} engines: {node: '>=18'} + '@langchain/core@0.3.13': + resolution: {integrity: sha512-sHDlwyHhgeaYC+wfORrWO7sXxD6/GDtZZ5mqjY48YMwB58cVv8hTs8goR/9EwXapYt8fQi2uXTGUV87bHzvdZQ==} + engines: {node: '>=18'} + '@langchain/langgraph-checkpoint@0.0.10': resolution: {integrity: sha512-BMfJD5Eg39pM0iJmEv50qJL5dJJI5U2oHuNXixWlQ1BKsvtbSs713+EHc21uuvcJUct1MPiv7RdfvwXycLM/aQ==} engines: {node: '>=18'} @@ -2288,9 +2362,15 @@ packages: '@lezer/lr@1.4.2': resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + '@libsql/client@0.14.0': + resolution: {integrity: sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q==} + '@libsql/client@0.6.2': resolution: {integrity: sha512-xRNfRLv/dOCbV4qd+M0baQwGmvuZpMd2wG2UAPs8XmcdaPvu5ErkcaeITkxlm3hDEJVabQM1cFhMBxsugWW9fQ==} + '@libsql/core@0.14.0': + resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==} + '@libsql/core@0.6.2': resolution: {integrity: sha512-c2P4M+4u/4b2L02A0KjggO3UW51rGkhxr/7fzJO0fEAqsqrWGxuNj2YtRkina/oxfYvAof6xjp8RucNoIV/Odw==} @@ -2299,18 +2379,35 @@ packages: cpu: [arm64] os: [darwin] + '@libsql/darwin-arm64@0.4.6': + resolution: {integrity: sha512-45i604CJ2Lubbg7NqtDodjarF6VgST8rS5R8xB++MoRqixtDns9PZ6tocT9pRJDWuTWEiy2sjthPOFWMKwYAsg==} + cpu: [arm64] + os: [darwin] + '@libsql/darwin-x64@0.3.19': resolution: {integrity: sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw==} cpu: [x64] os: [darwin] + '@libsql/darwin-x64@0.4.6': + resolution: {integrity: sha512-dRKliflhfr5zOPSNgNJ6C2nZDd4YA8bTXF3MUNqNkcxQ8BffaH9uUwL9kMq89LkFIZQHcyP75bBgZctxfJ/H5Q==} + cpu: [x64] + os: [darwin] + '@libsql/hrana-client@0.6.2': resolution: {integrity: sha512-MWxgD7mXLNf9FXXiM0bc90wCjZSpErWKr5mGza7ERy2FJNNMXd7JIOv+DepBA1FQTIfI8TFO4/QDYgaQC0goNw==} + '@libsql/hrana-client@0.7.0': + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} + '@libsql/isomorphic-fetch@0.2.4': resolution: {integrity: sha512-FaL5BAaoEsBklY8SkvzOUzqnHH4n2MeabZhihviTrZDNDPR890XXryuHXk/arOcmpIAPbvds7GjnwVFVXZwh6w==} engines: {node: '>=18.0.0'} + '@libsql/isomorphic-fetch@0.3.1': + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} + engines: {node: '>=18.0.0'} + '@libsql/isomorphic-ws@0.1.5': resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} @@ -2319,26 +2416,51 @@ packages: cpu: [arm64] os: [linux] + '@libsql/linux-arm64-gnu@0.4.6': + resolution: {integrity: sha512-DMPavVyY6vYPAYcQR1iOotHszg+5xSjHSg6F9kNecPX0KKdGq84zuPJmORfKOPtaWvzPewNFdML/e+s1fu09XQ==} + cpu: [arm64] + os: [linux] + '@libsql/linux-arm64-musl@0.3.19': resolution: {integrity: sha512-VEZtxghyK6zwGzU9PHohvNxthruSxBEnRrX7BSL5jQ62tN4n2JNepJ6SdzXp70pdzTfwroOj/eMwiPt94gkVRg==} cpu: [arm64] os: [linux] + '@libsql/linux-arm64-musl@0.4.6': + resolution: {integrity: sha512-whuHSYAZyclGjM3L0mKGXyWqdAy7qYvPPn+J1ve7FtGkFlM0DiIPjA5K30aWSGJSRh72sD9DBZfnu8CpfSjT6w==} + cpu: [arm64] + os: [linux] + '@libsql/linux-x64-gnu@0.3.19': resolution: {integrity: sha512-2t/J7LD5w2f63wGihEO+0GxfTyYIyLGEvTFEsMO16XI5o7IS9vcSHrxsvAJs4w2Pf907uDjmc7fUfMg6L82BrQ==} cpu: [x64] os: [linux] + '@libsql/linux-x64-gnu@0.4.6': + resolution: {integrity: sha512-0ggx+5RwEbYabIlDBBAvavdfIJCZ757u6nDZtBeQIhzW99EKbWG3lvkXHM3qudFb/pDWSUY4RFBm6vVtF1cJGA==} + cpu: [x64] + os: [linux] + '@libsql/linux-x64-musl@0.3.19': resolution: {integrity: sha512-BLsXyJaL8gZD8+3W2LU08lDEd9MIgGds0yPy5iNPp8tfhXx3pV/Fge2GErN0FC+nzt4DYQtjL+A9GUMglQefXQ==} cpu: [x64] os: [linux] + '@libsql/linux-x64-musl@0.4.6': + resolution: {integrity: sha512-SWNrv7Hz72QWlbM/ZsbL35MPopZavqCUmQz2HNDZ55t0F+kt8pXuP+bbI2KvmaQ7wdsoqAA4qBmjol0+bh4ndw==} + cpu: [x64] + os: [linux] + '@libsql/win32-x64-msvc@0.3.19': resolution: {integrity: sha512-ay1X9AobE4BpzG0XPw1gplyLZPGHIgJOovvW23gUrukRegiUP62uzhpRbKNogLlUOynyXeq//prHgPXiebUfWg==} cpu: [x64] os: [win32] + '@libsql/win32-x64-msvc@0.4.6': + resolution: {integrity: sha512-Q0axn110zDNELfkEog3Nl8p9BU4eI/UvgaHevGyOiSDN7s0KPfj0j6jwVHk4oz3o/d/Gg3DRIxomZ4ftfTOy/g==} + cpu: [x64] + os: [win32] + '@mdx-js/mdx@3.0.1': resolution: {integrity: sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==} @@ -3949,18 +4071,48 @@ packages: '@vitest/expect@1.6.0': resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} + '@vitest/expect@2.1.3': + resolution: {integrity: sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==} + + '@vitest/mocker@2.1.3': + resolution: {integrity: sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==} + peerDependencies: + '@vitest/spy': 2.1.3 + msw: ^2.3.5 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.3': + resolution: {integrity: sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==} + '@vitest/runner@1.6.0': resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} + '@vitest/runner@2.1.3': + resolution: {integrity: sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==} + '@vitest/snapshot@1.6.0': resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} + '@vitest/snapshot@2.1.3': + resolution: {integrity: sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==} + '@vitest/spy@1.6.0': resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} + '@vitest/spy@2.1.3': + resolution: {integrity: sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==} + '@vitest/utils@1.6.0': resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} + '@vitest/utils@2.1.3': + resolution: {integrity: sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==} + '@volar/kit@2.4.0': resolution: {integrity: sha512-uqwtPKhrbnP+3f8hs+ltDYXLZ6Wdbs54IzkaPocasI4aBhqWLht5qXctE1MqpZU52wbH359E0u9nhxEFmyon+w==} peerDependencies: @@ -4156,6 +4308,10 @@ packages: assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astring@1.8.6: resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==} hasBin: true @@ -4353,6 +4509,10 @@ packages: resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} engines: {node: '>=4'} + chai@5.1.1: + resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} + engines: {node: '>=12'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -4383,6 +4543,10 @@ packages: check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} @@ -4664,6 +4828,10 @@ packages: resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} engines: {node: '>=6'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4793,6 +4961,10 @@ packages: resolution: {integrity: sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==} hasBin: true + drizzle-kit@0.25.0: + resolution: {integrity: sha512-Rcf0nYCAKizwjWQCY+d3zytyuTbDb81NcaPor+8NebESlUz1+9W3uGl0+r9FhU4Qal5Zv9j/7neXCSCe7DHzjA==} + hasBin: true + drizzle-orm@0.32.2: resolution: {integrity: sha512-3fXKzPzrgZIcnWCSLiERKN5Opf9Iagrag75snfFlKeKSYB1nlgPBshzW3Zn6dQymkyiib+xc4nIz0t8U+Xdpuw==} peerDependencies: @@ -4971,6 +5143,95 @@ packages: sqlite3: optional: true + drizzle-orm@0.34.1: + resolution: {integrity: sha512-t+zCwyWWt8xTqtYV4doE/xYmT7hpv1L8pQ66zddEz+3VWyedBBtctjMAp22mAJPfyWurRQXUJ1nrTtqLq+DqNA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=3' + '@electric-sql/pglite': '>=0.1.1' + '@libsql/client': '>=0.10.0' + '@neondatabase/serverless': '>=0.1' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=13.2.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + drizzle-zod@0.5.1: resolution: {integrity: sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A==} peerDependencies: @@ -5523,6 +5784,10 @@ packages: resolution: {integrity: sha512-v+39817TgAhetmHUEli8O0uHDmxp2Up3DnhS4oUZXOl5IQ9np9tYtldd42e5zgdLVS0wsOoXQNZ6mx+BGmEvCA==} engines: {node: '>=16.9.0'} + hono@4.6.5: + resolution: {integrity: sha512-qsmN3V5fgtwdKARGLgwwHvcdLKursMd+YOt69eGpl1dUCJb8mCd7hZfyZnBYjxCegBG7qkJRQRUy2oO25yHcyQ==} + engines: {node: '>=16.9.0'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -5762,6 +6027,9 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + jose@5.9.6: + resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -5877,6 +6145,10 @@ packages: resolution: {integrity: sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA==} os: [darwin, linux, win32] + libsql@0.4.6: + resolution: {integrity: sha512-F5M+ltteK6dCcpjMahrkgT96uFJvVI8aQ4r9f2AzHQjC7BkAYtvfMSTWGvRBezRgMUIU2h1Sy0pF9nOGOD5iyA==} + os: [darwin, linux, win32] + lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -5944,6 +6216,9 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.1.2: + resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -6673,6 +6948,10 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + peek-readable@5.1.4: resolution: {integrity: sha512-E7mY2VmKqw9jYuXrSWGHFuPCW2SLQenzXLF3amGaY6lXXg4/b3gj5HVM7h8ZjCO/nZS9ICs0Cz285+32FvNd/A==} engines: {node: '>=14.16'} @@ -6879,6 +7158,9 @@ packages: resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} engines: {node: '>=6'} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -7641,10 +7923,22 @@ packages: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} + tinypool@1.0.1: + resolution: {integrity: sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + tinyspy@2.2.1: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -7760,6 +8054,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.19.1: + resolution: {integrity: sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -7961,6 +8260,11 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-node@2.1.3: + resolution: {integrity: sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-plugin-svgr@4.2.0: resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==} peerDependencies: @@ -8069,6 +8373,31 @@ packages: jsdom: optional: true + vitest@2.1.3: + resolution: {integrity: sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.3 + '@vitest/ui': 2.1.3 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + volar-service-css@0.0.61: resolution: {integrity: sha512-Ct9L/w+IB1JU8F4jofcNCGoHy6TF83aiapfZq9A0qYYpq+Kk5dH+ONS+rVZSsuhsunq8UvAuF8Gk6B8IFLfniw==} peerDependencies: @@ -9610,6 +9939,10 @@ snapshots: dependencies: hono: 4.5.9 + '@hono/oauth-providers@0.6.2(hono@4.6.5)': + dependencies: + hono: 4.6.5 + '@hono/zod-validator@0.2.2(hono@4.5.5)(zod@3.23.8)': dependencies: hono: 4.5.5 @@ -9620,6 +9953,11 @@ snapshots: hono: 4.6.2 zod: 3.23.8 + '@hono/zod-validator@0.4.1(hono@4.6.5)(zod@3.23.8)': + dependencies: + hono: 4.6.5 + zod: 3.23.8 + '@hookform/resolvers@3.9.0(react-hook-form@7.53.0(react@18.3.1))': dependencies: react-hook-form: 7.53.0(react@18.3.1) @@ -9843,6 +10181,22 @@ snapshots: transitivePeerDependencies: - openai + '@langchain/core@0.3.13(openai@4.67.3(encoding@0.1.13)(zod@3.23.8))': + dependencies: + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.12 + langsmith: 0.1.66(openai@4.67.3(encoding@0.1.13)(zod@3.23.8)) + mustache: 4.2.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 10.0.0 + zod: 3.23.8 + zod-to-json-schema: 3.23.2(zod@3.23.8) + transitivePeerDependencies: + - openai + '@langchain/langgraph-checkpoint@0.0.10(@langchain/core@0.2.36(openai@4.67.3(encoding@0.1.13)(zod@3.23.8)))': dependencies: '@langchain/core': 0.2.36(openai@4.67.3(encoding@0.1.13)(zod@3.23.8)) @@ -9878,6 +10232,17 @@ snapshots: dependencies: '@lezer/common': 1.2.1 + '@libsql/client@0.14.0': + dependencies: + '@libsql/core': 0.14.0 + '@libsql/hrana-client': 0.7.0 + js-base64: 3.7.7 + libsql: 0.4.6 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@libsql/client@0.6.2': dependencies: '@libsql/core': 0.6.2 @@ -9888,6 +10253,10 @@ snapshots: - bufferutil - utf-8-validate + '@libsql/core@0.14.0': + dependencies: + js-base64: 3.7.7 + '@libsql/core@0.6.2': dependencies: js-base64: 3.7.7 @@ -9895,9 +10264,15 @@ snapshots: '@libsql/darwin-arm64@0.3.19': optional: true + '@libsql/darwin-arm64@0.4.6': + optional: true + '@libsql/darwin-x64@0.3.19': optional: true + '@libsql/darwin-x64@0.4.6': + optional: true + '@libsql/hrana-client@0.6.2': dependencies: '@libsql/isomorphic-fetch': 0.2.4 @@ -9908,8 +10283,20 @@ snapshots: - bufferutil - utf-8-validate + '@libsql/hrana-client@0.7.0': + dependencies: + '@libsql/isomorphic-fetch': 0.3.1 + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.7.7 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@libsql/isomorphic-fetch@0.2.4': {} + '@libsql/isomorphic-fetch@0.3.1': {} + '@libsql/isomorphic-ws@0.1.5': dependencies: '@types/ws': 8.5.12 @@ -9921,18 +10308,33 @@ snapshots: '@libsql/linux-arm64-gnu@0.3.19': optional: true + '@libsql/linux-arm64-gnu@0.4.6': + optional: true + '@libsql/linux-arm64-musl@0.3.19': optional: true + '@libsql/linux-arm64-musl@0.4.6': + optional: true + '@libsql/linux-x64-gnu@0.3.19': optional: true + '@libsql/linux-x64-gnu@0.4.6': + optional: true + '@libsql/linux-x64-musl@0.3.19': optional: true + '@libsql/linux-x64-musl@0.4.6': + optional: true + '@libsql/win32-x64-msvc@0.3.19': optional: true + '@libsql/win32-x64-msvc@0.4.6': + optional: true + '@mdx-js/mdx@3.0.1': dependencies: '@types/estree': 1.0.5 @@ -11528,22 +11930,56 @@ snapshots: '@vitest/utils': 1.6.0 chai: 4.5.0 + '@vitest/expect@2.1.3': + dependencies: + '@vitest/spy': 2.1.3 + '@vitest/utils': 2.1.3 + chai: 5.1.1 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.3(@vitest/spy@2.1.3)(vite@5.4.5(@types/node@22.7.5))': + dependencies: + '@vitest/spy': 2.1.3 + estree-walker: 3.0.3 + magic-string: 0.30.11 + optionalDependencies: + vite: 5.4.5(@types/node@22.7.5) + + '@vitest/pretty-format@2.1.3': + dependencies: + tinyrainbow: 1.2.0 + '@vitest/runner@1.6.0': dependencies: '@vitest/utils': 1.6.0 p-limit: 5.0.0 pathe: 1.1.2 + '@vitest/runner@2.1.3': + dependencies: + '@vitest/utils': 2.1.3 + pathe: 1.1.2 + '@vitest/snapshot@1.6.0': dependencies: magic-string: 0.30.11 pathe: 1.1.2 pretty-format: 29.7.0 + '@vitest/snapshot@2.1.3': + dependencies: + '@vitest/pretty-format': 2.1.3 + magic-string: 0.30.11 + pathe: 1.1.2 + '@vitest/spy@1.6.0': dependencies: tinyspy: 2.2.1 + '@vitest/spy@2.1.3': + dependencies: + tinyspy: 3.0.2 + '@vitest/utils@1.6.0': dependencies: diff-sequences: 29.6.3 @@ -11551,6 +11987,12 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 + '@vitest/utils@2.1.3': + dependencies: + '@vitest/pretty-format': 2.1.3 + loupe: 3.1.2 + tinyrainbow: 1.2.0 + '@volar/kit@2.4.0(typescript@5.6.2)': dependencies: '@volar/language-service': 2.4.0 @@ -11704,6 +12146,32 @@ snapshots: - solid-js - vue + ai@3.4.10(openai@4.67.3(encoding@0.1.13)(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.5.12(typescript@5.6.2))(zod@3.23.8): + dependencies: + '@ai-sdk/provider': 0.0.24 + '@ai-sdk/provider-utils': 1.0.20(zod@3.23.8) + '@ai-sdk/react': 0.0.62(react@18.3.1)(zod@3.23.8) + '@ai-sdk/solid': 0.0.49(zod@3.23.8) + '@ai-sdk/svelte': 0.0.51(svelte@4.2.19)(zod@3.23.8) + '@ai-sdk/ui-utils': 0.0.46(zod@3.23.8) + '@ai-sdk/vue': 0.0.54(vue@3.5.12(typescript@5.6.2))(zod@3.23.8) + '@opentelemetry/api': 1.9.0 + eventsource-parser: 1.1.2 + json-schema: 0.4.0 + jsondiffpatch: 0.6.0 + nanoid: 3.3.6 + secure-json-parse: 2.7.0 + zod-to-json-schema: 3.23.2(zod@3.23.8) + optionalDependencies: + openai: 4.67.3(encoding@0.1.13)(zod@3.23.8) + react: 18.3.1 + sswr: 2.1.0(svelte@4.2.19) + svelte: 4.2.19 + zod: 3.23.8 + transitivePeerDependencies: + - solid-js + - vue + ajv-draft-04@1.0.0(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -11780,6 +12248,8 @@ snapshots: assertion-error@1.1.0: {} + assertion-error@2.0.1: {} + astring@1.8.6: {} astro-expressive-code@0.35.6(astro@4.15.6(@types/node@22.7.5)(rollup@4.22.0)(typescript@5.6.2)): @@ -12097,6 +12567,14 @@ snapshots: pathval: 1.1.1 type-detect: 4.1.0 + chai@5.1.1: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.2 + pathval: 2.0.0 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -12124,6 +12602,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + check-error@2.1.1: {} + cheerio-select@2.1.0: dependencies: boolbase: 1.0.0 @@ -12406,6 +12886,8 @@ snapshots: dependencies: type-detect: 4.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deepmerge@4.3.1: {} @@ -12520,20 +13002,29 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + drizzle-kit@0.25.0: + dependencies: + '@drizzle-team/brocli': 0.10.1 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.19.12 + esbuild-register: 3.6.0(esbuild@0.19.12) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240821.1)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): optionalDependencies: '@cloudflare/workers-types': 4.20240821.1 - '@libsql/client': 0.6.2 + '@libsql/client': 0.14.0 '@neondatabase/serverless': 0.9.4 '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.6 '@types/react': 18.3.3 react: 18.3.1 - drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240924.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + drizzle-orm@0.32.2(@cloudflare/workers-types@4.20240924.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): optionalDependencies: '@cloudflare/workers-types': 4.20240924.0 - '@libsql/client': 0.6.2 + '@libsql/client': 0.14.0 '@neondatabase/serverless': 0.9.4 '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.6 @@ -12550,10 +13041,20 @@ snapshots: '@types/react': 18.3.3 react: 18.3.1 - drizzle-orm@0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + drizzle-orm@0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): optionalDependencies: '@cloudflare/workers-types': 4.20241011.0 - '@libsql/client': 0.6.2 + '@libsql/client': 0.14.0 + '@neondatabase/serverless': 0.9.4 + '@opentelemetry/api': 1.9.0 + '@types/pg': 8.11.6 + '@types/react': 18.3.3 + react: 18.3.1 + + drizzle-orm@0.34.1(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1): + optionalDependencies: + '@cloudflare/workers-types': 4.20241011.0 + '@libsql/client': 0.14.0 '@neondatabase/serverless': 0.9.4 '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.6 @@ -12565,9 +13066,9 @@ snapshots: drizzle-orm: 0.33.0(@cloudflare/workers-types@4.20240806.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) zod: 3.23.8 - drizzle-zod@0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8): + drizzle-zod@0.5.1(drizzle-orm@0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1))(zod@3.23.8): dependencies: - drizzle-orm: 0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.6.2)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) + drizzle-orm: 0.33.0(@cloudflare/workers-types@4.20241011.0)(@libsql/client@0.14.0)(@neondatabase/serverless@0.9.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.6)(@types/react@18.3.3)(react@18.3.1) zod: 3.23.8 dset@3.1.3: {} @@ -13368,6 +13869,8 @@ snapshots: hono@4.6.2: {} + hono@4.6.5: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -13565,6 +14068,8 @@ snapshots: jiti@1.21.6: {} + jose@5.9.6: {} + joycon@3.1.1: {} js-base64@3.7.7: {} @@ -13680,6 +14185,19 @@ snapshots: '@libsql/linux-x64-musl': 0.3.19 '@libsql/win32-x64-msvc': 0.3.19 + libsql@0.4.6: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.4.6 + '@libsql/darwin-x64': 0.4.6 + '@libsql/linux-arm64-gnu': 0.4.6 + '@libsql/linux-arm64-musl': 0.4.6 + '@libsql/linux-x64-gnu': 0.4.6 + '@libsql/linux-x64-musl': 0.4.6 + '@libsql/win32-x64-msvc': 0.4.6 + lilconfig@2.1.0: {} lilconfig@3.1.2: {} @@ -13740,6 +14258,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + loupe@3.1.2: {} + lower-case@2.0.2: dependencies: tslib: 2.6.3 @@ -14821,6 +15341,8 @@ snapshots: pathval@1.1.1: {} + pathval@2.0.0: {} + peek-readable@5.1.4: {} pend@1.2.0: {} @@ -14942,13 +15464,13 @@ snapshots: optionalDependencies: postcss: 8.4.47 - postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.47)(tsx@4.17.0)(yaml@2.5.0): + postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.4.47)(tsx@4.19.1)(yaml@2.5.0): dependencies: lilconfig: 3.1.2 optionalDependencies: jiti: 1.21.6 postcss: 8.4.47 - tsx: 4.17.0 + tsx: 4.19.1 yaml: 2.5.0 postcss-nested@6.2.0(postcss@8.4.41): @@ -15040,6 +15562,8 @@ snapshots: prismjs@1.29.0: {} + promise-limit@2.7.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -16004,8 +16528,14 @@ snapshots: tinypool@0.8.4: {} + tinypool@1.0.1: {} + + tinyrainbow@1.2.0: {} + tinyspy@2.2.1: {} + tinyspy@3.0.2: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -16087,7 +16617,7 @@ snapshots: tslib@2.6.3: {} - tsup@8.3.0(@swc/core@1.7.10)(jiti@1.21.6)(postcss@8.4.47)(tsx@4.17.0)(typescript@5.6.2)(yaml@2.5.0): + tsup@8.3.0(@swc/core@1.7.10)(jiti@1.21.6)(postcss@8.4.47)(tsx@4.19.1)(typescript@5.6.2)(yaml@2.5.0): dependencies: bundle-require: 5.0.0(esbuild@0.23.0) cac: 6.7.14 @@ -16098,7 +16628,7 @@ snapshots: execa: 5.1.1 joycon: 3.1.1 picocolors: 1.1.0 - postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.4.47)(tsx@4.17.0)(yaml@2.5.0) + postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.4.47)(tsx@4.19.1)(yaml@2.5.0) resolve-from: 5.0.0 rollup: 4.22.0 source-map: 0.8.0-beta.0 @@ -16127,6 +16657,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.19.1: + dependencies: + esbuild: 0.23.0 + get-tsconfig: 4.7.6 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -16371,6 +16908,23 @@ snapshots: - supports-color - terser + vite-node@2.1.3(@types/node@22.7.5): + dependencies: + cac: 6.7.14 + debug: 4.3.7 + pathe: 1.1.2 + vite: 5.4.5(@types/node@22.7.5) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-svgr@4.2.0(rollup@4.22.0)(typescript@5.6.2)(vite@5.4.0(@types/node@20.14.15)): dependencies: '@rollup/pluginutils': 5.1.0(rollup@4.22.0) @@ -16492,6 +17046,40 @@ snapshots: - supports-color - terser + vitest@2.1.3(@types/node@22.7.5): + dependencies: + '@vitest/expect': 2.1.3 + '@vitest/mocker': 2.1.3(@vitest/spy@2.1.3)(vite@5.4.5(@types/node@22.7.5)) + '@vitest/pretty-format': 2.1.3 + '@vitest/runner': 2.1.3 + '@vitest/snapshot': 2.1.3 + '@vitest/spy': 2.1.3 + '@vitest/utils': 2.1.3 + chai: 5.1.1 + debug: 4.3.7 + magic-string: 0.30.11 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.9.0 + tinyexec: 0.3.0 + tinypool: 1.0.1 + tinyrainbow: 1.2.0 + vite: 5.4.5(@types/node@22.7.5) + vite-node: 2.1.3(@types/node@22.7.5) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.7.5 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + volar-service-css@0.0.61(@volar/language-service@2.4.0): dependencies: vscode-css-languageservice: 6.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1205ad8bd..d43c04b67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "studio" - "api" + - "fp-services" - "cli" - "honc-code-gen" - "packages/*" diff --git a/studio/src/Layout/BottomBar/BottomBar.tsx b/studio/src/Layout/BottomBar/BottomBar.tsx index 17686b6e6..97f014bc4 100644 --- a/studio/src/Layout/BottomBar/BottomBar.tsx +++ b/studio/src/Layout/BottomBar/BottomBar.tsx @@ -15,6 +15,7 @@ import { } from "@radix-ui/react-tooltip"; import { useShallow } from "zustand/react/shallow"; import { Branding } from "../Branding"; +import { LoggedInUser } from "../LoggedInUser"; import { SettingsMenu, SettingsScreen } from "../Settings"; import { FloatingSidePanel } from "../SidePanel"; import { SidePanelTrigger } from "../SidePanel"; @@ -52,6 +53,7 @@ export function BottomBar() { setSettingsOpen={setSettingsOpen} /> +
diff --git a/studio/src/Layout/Layout.tsx b/studio/src/Layout/Layout.tsx index d2b64dc70..dd4648a8f 100644 --- a/studio/src/Layout/Layout.tsx +++ b/studio/src/Layout/Layout.tsx @@ -1,4 +1,6 @@ +import { useFetchUserInfo } from "@/queries"; import type React from "react"; +import { useEffect } from "react"; import { useWebsocketQueryInvalidation } from "../hooks"; import { cn } from "../utils"; import { BottomBar } from "./BottomBar"; @@ -6,6 +8,12 @@ import { BottomBar } from "./BottomBar"; export function Layout({ children }: { children?: React.ReactNode }) { useWebsocketQueryInvalidation(); + const { data: userInfo } = useFetchUserInfo(); + + useEffect(() => { + console.log("user info changed", userInfo); + }, [userInfo]); + return (
+ + + + + + @{user.githubUsername} + {user.aiRequestCredits ? ( + <> + + + {user.aiRequestCredits} AI Requests Remaining + + + ) : ( + <> + + + No AI credits remaining — Credits refreshed daily + + + )} + + +
+ ); +} diff --git a/studio/src/Layout/Settings/SettingsMenu.tsx b/studio/src/Layout/Settings/SettingsMenu.tsx index 919887fd5..656d17a69 100644 --- a/studio/src/Layout/Settings/SettingsMenu.tsx +++ b/studio/src/Layout/Settings/SettingsMenu.tsx @@ -1,3 +1,4 @@ +import { useLogout, useUserInfo } from "@/queries"; import { Icon } from "@iconify/react/dist/iconify.js"; import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; import { @@ -24,6 +25,8 @@ export function SettingsMenu({ } }); + const user = useUserInfo(); + return ( @@ -58,6 +61,8 @@ export function SettingsMenu({ Discord + {user ? : } + setSettingsOpen(true)} @@ -73,6 +78,35 @@ export function SettingsMenu({ ); } +function LogOut() { + const logout = useLogout(); + + return ( + !logout.isPending && logout.mutate()} + > +
+ + {logout.isPending ? "Logging you out" : "Log out"} +
+
+ ); +} + +function GitHubLogInLink() { + return ( + } + > + Log In + + ); +} + function MenuItemLink({ href, icon, diff --git a/studio/src/hooks/useRealtimeService.ts b/studio/src/hooks/useRealtimeService.ts index 0a7f550a3..5bc501c76 100644 --- a/studio/src/hooks/useRealtimeService.ts +++ b/studio/src/hooks/useRealtimeService.ts @@ -8,6 +8,10 @@ const FpxWebsocketMessageSchema = z.discriminatedUnion("event", [ event: z.literal("trace_created"), payload: z.array(z.enum([MIZU_TRACES_KEY, PROBED_ROUTES_KEY])), }), + z.object({ + event: z.literal("login_success"), + payload: z.array(z.literal("userInfo")), + }), z.object({ event: z.literal("connection_open"), payload: z.object({ diff --git a/studio/src/hooks/useWebsocketQueryInvalidation.ts b/studio/src/hooks/useWebsocketQueryInvalidation.ts index 2a0406a53..7f6765c64 100644 --- a/studio/src/hooks/useWebsocketQueryInvalidation.ts +++ b/studio/src/hooks/useWebsocketQueryInvalidation.ts @@ -21,6 +21,12 @@ export function useWebsocketQueryInvalidation() { break; } + case "login_success": { + console.debug("login_success"); + queryClient.invalidateQueries({ queryKey: wsMessage.payload }); + break; + } + case "connection_open": { console.debug("connection_open - invalidating webhonc id"); queryClient.invalidateQueries({ diff --git a/studio/src/queries/index.ts b/studio/src/queries/index.ts index 8d0d1a046..18c1d144a 100644 --- a/studio/src/queries/index.ts +++ b/studio/src/queries/index.ts @@ -20,3 +20,5 @@ export { } from "./traces-interop"; export { useOtelTrace, useOtelTraces } from "./traces-otel"; + +export * from "./user-info"; diff --git a/studio/src/queries/user-info.ts b/studio/src/queries/user-info.ts new file mode 100644 index 000000000..1cedaf632 --- /dev/null +++ b/studio/src/queries/user-info.ts @@ -0,0 +1,53 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +// Define a schema for user info +const UserInfoSchema = z.object({ + id: z.number(), + githubUsername: z.string(), + email: z.string().email(), + token: z.string(), + aiRequestCredits: z.number().optional(), +}); + +// type UserInfo = z.infer; + +const USER_INFO_QUERY_KEY = "userInfo"; + +export function useFetchUserInfo() { + return useQuery({ + queryKey: [USER_INFO_QUERY_KEY], + queryFn: async () => { + const response = await fetch("/v0/auth/user"); + const json = await response.json(); + const user = UserInfoSchema.parse(json); + return user; + }, + }); +} + +export function useUserInfo() { + const { data } = useFetchUserInfo(); + return data; +} + +export function useLogout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + const response = await fetch("/v0/auth/user", { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Logout failed"); + } + }, + onSuccess: () => { + // Clear the user info from the cache + queryClient.setQueryData([USER_INFO_QUERY_KEY], null); + // Optionally, invalidate the query to refetch + queryClient.invalidateQueries({ queryKey: [USER_INFO_QUERY_KEY] }); + }, + }); +}